Simpl-Resultat/src/services/categoryMigrationService.ts
le king fu 0646875327
All checks were successful
PR Check / rust (push) Successful in 21m39s
PR Check / frontend (push) Successful in 2m21s
PR Check / rust (pull_request) Successful in 21m49s
PR Check / frontend (pull_request) Successful in 2m15s
feat(categories): add 3-step migration page + categoryMigrationService (#121)
New user-facing 3-step migration flow at /settings/categories/migrate that
allows legacy v2 profiles to opt in to the v1 IPC taxonomy.

Step 1 Discover — read-only taxonomy tree (reuses CategoryTaxonomyTree from
Livraison 1, #117).
Step 2 Simulate — 3-column dry-run table with confidence badges (high /
medium / low / needs-review), transaction preview side panel, inline target
picker for unresolved rows. The "next" button is blocked until every row is
resolved.
Step 3 Consent — checklist + optional PIN field for PIN-protected profiles +
4-step loader (backup created / verified / SQL applied / committed).

Success and error screens surface the SREF backup path and the counts of
rows migrated. Errors never leave the profile in a partial state — the new
categoryMigrationService wraps the entire SQL writeover in a
BEGIN/COMMIT/ROLLBACK atomic transaction and aborts up-front if the backup
is not present / verified.

New code:
- src/services/categoryMigrationService.ts — applyMigration(plan, backup)
  atomic writer (INSERT v1 → UPDATE transactions/budgets/budget_templates/
  keywords/suppliers → reparent preserved customs → deactivate v2 seed →
  bump categories_schema_version=v1 → journal last_categories_migration).
- src/hooks/useCategoryMigration.ts — useReducer state machine
  (discover → simulate → consent → running → success | error).
- src/hooks/useCategoryMigration.test.ts — 13 pure reducer tests.
- src/components/categories-migration/{StepDiscover,StepSimulate,StepConsent,
  MappingRow,TransactionPreviewPanel}.tsx — UI per the mockup.
- src/pages/CategoriesMigrationPage.tsx — wrapper with internal router,
  stepper, backup/migrate orchestration, success/error screens.

Tweaks:
- src/App.tsx — new /settings/categories/migrate route.
- src/components/settings/CategoriesCard.tsx — additional card surfacing
  the migrate entry for v2 profiles only.
- src/i18n/locales/{fr,en}.json — categoriesSeed.migration.* namespace
  (page / stepper / 3 steps / running / success / error / backup error codes).
- CHANGELOG.{md,fr.md} — [Unreleased] / Added entry.

Scope limits respected: no SQL migration modified, no new migration added,
no unit tests of the applyMigration writer (covered by #123 in wave 4),
no restore-backup button (#122 in wave 4), no post-migration banner (#122).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:31:21 -04:00

401 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { getDb } from "./db";
import { setPreference } from "./userPreferenceService";
import { getTaxonomyV1, type TaxonomyNode } from "./categoryTaxonomyService";
import type { BackupResult } from "./categoryBackupService";
import type { MigrationPlan, MappingRow } from "./categoryMappingService";
// -----------------------------------------------------------------------------
// Category migration service — orchestrates the atomic v2 → v1 SQL writeover
// using a MigrationPlan computed upstream by categoryMappingService.
//
// The service is intentionally destructive: it is only meant to be called
// *after* a verified pre-migration SREF backup (BackupResult) has been written
// to disk and confirmed by the user. The caller (CategoriesMigrationPage /
// useCategoryMigration hook) is responsible for sequencing backup → migrate →
// surface success/error — this service only runs the DB part.
//
// Ordering (all inside a single BEGIN / COMMIT transaction):
// 1. Guard: backup looks valid (path + checksum present).
// 2. BEGIN.
// 3. If plan.preserved.length > 0: INSERT a new parent category
// "Catégories personnalisées (migration)" (with i18n_key so it renders
// in both languages), keep its new id.
// 4. INSERT all v1 taxonomy nodes with explicit ids from the bundled
// `categoryTaxonomyV1.json`. We skip ids that already exist in the DB
// (OR IGNORE) so a re-run is safe.
// 5. UPDATE transactions.category_id per plan.rows mapping (v2 → v1).
// 6. UPDATE budget_entries.category_id and budget_template_entries.category_id
// per plan.rows mapping (v2 → v1). We DELETE conflicting rows first
// because budget_entries has UNIQUE(category_id, year, month) and
// budget_template_entries has UNIQUE(template_id, category_id).
// 7. UPDATE keywords.category_id per plan.rows mapping (v2 → v1). We
// DELETE conflicting rows first because of UNIQUE(keyword, category_id).
// 8. UPDATE suppliers.category_id per plan.rows mapping (v2 → v1).
// 9. Re-parent preserved v2 custom categories under the new "Catégories
// personnalisées (migration)" parent.
// 10. DELETE v2 seeded categories that are now empty (no transactions /
// keywords / budgets / suppliers / child categories referencing them).
// Deletion is soft (is_active=0) to preserve ON DELETE CASCADE from
// historical budget_entries etc. in edge cases.
// 11. Set `categories_schema_version='v1'` and record
// `last_categories_migration` JSON in user_preferences.
// 12. COMMIT. On any thrown error: ROLLBACK and report in MigrationOutcome.
// -----------------------------------------------------------------------------
export interface MigrationOutcome {
/** True when the transaction committed; false if we rolled back or aborted. */
succeeded: boolean;
/** Human-readable error message on failure. Undefined on success. */
error?: string;
/** Number of v1 taxonomy rows we inserted (may be 0 on re-run). */
insertedV1Count: number;
/** Number of transactions whose category_id was rewritten. */
updatedTransactionsCount: number;
/** Number of budget_entries + budget_template_entries rows rewritten. */
updatedBudgetsCount: number;
/** Number of keywords rows rewritten. */
updatedKeywordsCount: number;
/** Number of v2 categories we deactivated (soft-delete). */
deletedV2Count: number;
/** Number of custom categories re-parented under the new parent. */
customPreservedCount: number;
/** Path to the SREF backup that was created before this run. */
backupPath: string;
}
/** JSON journalled in user_preferences.last_categories_migration. */
export interface LastMigrationJournal {
timestamp: string;
backupPath: string;
outcome: Pick<
MigrationOutcome,
| "insertedV1Count"
| "updatedTransactionsCount"
| "updatedBudgetsCount"
| "updatedKeywordsCount"
| "deletedV2Count"
| "customPreservedCount"
>;
}
// Preference keys we write at the end of a successful migration.
const SCHEMA_VERSION_KEY = "categories_schema_version";
const LAST_MIGRATION_KEY = "last_categories_migration";
// Id reserved for the "Catégories personnalisées (migration)" parent we
// create when the profile has custom v2 categories. We deliberately pick a
// number that is outside the v1 taxonomy range (1000 1999) and outside the
// v2 seed range (< 1000) to avoid collisions even on re-runs.
const CUSTOM_PARENT_NEW_ID = 2000;
const CUSTOM_PARENT_NAME = "Catégories personnalisées (migration)";
const CUSTOM_PARENT_I18N_KEY = "categoriesSeed.migration.customParent";
const CUSTOM_PARENT_COLOR = "#64748b";
const CUSTOM_PARENT_TYPE = "expense";
// -----------------------------------------------------------------------------
// Helpers
// -----------------------------------------------------------------------------
function flattenTaxonomy(root: TaxonomyNode, parentId: number | null, out: Array<TaxonomyFlat>): void {
out.push({
id: root.id,
name: root.name,
i18n_key: root.i18n_key,
parent_id: parentId,
type: root.type,
color: root.color,
sort_order: root.sort_order,
is_inputable: root.children.length === 0,
});
for (const child of root.children) {
flattenTaxonomy(child, root.id, out);
}
}
interface TaxonomyFlat {
id: number;
name: string;
i18n_key: string;
parent_id: number | null;
type: string;
color: string;
sort_order: number;
is_inputable: boolean;
}
function listAllV1Rows(): TaxonomyFlat[] {
const flat: TaxonomyFlat[] = [];
for (const root of getTaxonomyV1().roots) {
flattenTaxonomy(root, null, flat);
}
return flat;
}
/** Validate the backup looks usable — path + checksum present. */
function validateBackup(backup: BackupResult): void {
if (!backup || typeof backup.path !== "string" || backup.path.length === 0) {
throw new Error("invalid_backup: missing path");
}
if (typeof backup.checksum !== "string" || backup.checksum.length === 0) {
throw new Error("invalid_backup: missing checksum");
}
}
/** Build the v2Id → v1Id map from plan.rows (only resolved targets are kept). */
function buildMappingFromRows(rows: MappingRow[]): Map<number, number> {
const map = new Map<number, number>();
for (const row of rows) {
if (row.v1TargetId !== null && row.v1TargetId !== undefined) {
map.set(row.v2CategoryId, row.v1TargetId);
}
}
return map;
}
// -----------------------------------------------------------------------------
// Public entry point
// -----------------------------------------------------------------------------
export async function applyMigration(
plan: MigrationPlan,
backup: BackupResult,
): Promise<MigrationOutcome> {
const outcome: MigrationOutcome = {
succeeded: false,
insertedV1Count: 0,
updatedTransactionsCount: 0,
updatedBudgetsCount: 0,
updatedKeywordsCount: 0,
deletedV2Count: 0,
customPreservedCount: 0,
backupPath: backup?.path ?? "",
};
try {
validateBackup(backup);
} catch (e) {
outcome.error = e instanceof Error ? e.message : String(e);
return outcome;
}
const db = await getDb();
const mapping = buildMappingFromRows(plan.rows);
await db.execute("BEGIN");
try {
// 1. Optionally create the "custom categories (migration)" parent.
let customParentId: number | null = null;
if (plan.preserved.length > 0) {
// Use INSERT OR IGNORE so a re-run never throws on the PK.
await db.execute(
`INSERT OR IGNORE INTO categories
(id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key)
VALUES ($1, $2, NULL, $3, $4, 1, 0, 99, $5)`,
[
CUSTOM_PARENT_NEW_ID,
CUSTOM_PARENT_NAME,
CUSTOM_PARENT_COLOR,
CUSTOM_PARENT_TYPE,
CUSTOM_PARENT_I18N_KEY,
],
);
customParentId = CUSTOM_PARENT_NEW_ID;
}
// 2. INSERT v1 taxonomy. Roots first, then subcategories, then leaves,
// thanks to flattenTaxonomy's depth-first walk. Use OR IGNORE so a
// partial earlier run is recoverable.
const v1Rows = listAllV1Rows();
for (const row of v1Rows) {
const result = await db.execute(
`INSERT OR IGNORE INTO categories
(id, name, parent_id, color, type, is_active, is_inputable, sort_order, i18n_key)
VALUES ($1, $2, $3, $4, $5, 1, $6, $7, $8)`,
[
row.id,
row.name,
row.parent_id,
row.color,
row.type,
row.is_inputable ? 1 : 0,
row.sort_order,
row.i18n_key,
],
);
// tauri-plugin-sql returns rowsAffected; on OR IGNORE conflicts it's 0.
const affected = Number(result.rowsAffected ?? 0);
outcome.insertedV1Count += affected;
}
// 3. Rewrite transactions.category_id v2 → v1.
for (const [v2Id, v1Id] of mapping.entries()) {
const r = await db.execute(
`UPDATE transactions SET category_id = $1, updated_at = CURRENT_TIMESTAMP
WHERE category_id = $2`,
[v1Id, v2Id],
);
outcome.updatedTransactionsCount += Number(r.rowsAffected ?? 0);
}
// 4. Rewrite budget_entries (handle UNIQUE(category_id, year, month) by
// deleting rows we'd collide with first — in a preview-and-consent
// flow, the collision means the user already has a budget on the v1
// target for the same period, so dropping the v2 duplicate is the
// least-surprising choice).
for (const [v2Id, v1Id] of mapping.entries()) {
await db.execute(
`DELETE FROM budget_entries
WHERE category_id = $1
AND (year, month) IN (
SELECT year, month FROM budget_entries WHERE category_id = $2
)`,
[v2Id, v1Id],
);
const r = await db.execute(
`UPDATE budget_entries SET category_id = $1, updated_at = CURRENT_TIMESTAMP
WHERE category_id = $2`,
[v1Id, v2Id],
);
outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0);
}
// 5. Rewrite budget_template_entries (same collision rule via
// UNIQUE(template_id, category_id)).
for (const [v2Id, v1Id] of mapping.entries()) {
await db.execute(
`DELETE FROM budget_template_entries
WHERE category_id = $1
AND template_id IN (
SELECT template_id FROM budget_template_entries WHERE category_id = $2
)`,
[v2Id, v1Id],
);
const r = await db.execute(
`UPDATE budget_template_entries SET category_id = $1
WHERE category_id = $2`,
[v1Id, v2Id],
);
outcome.updatedBudgetsCount += Number(r.rowsAffected ?? 0);
}
// 6. Rewrite keywords (UNIQUE(keyword, category_id)). Drop v2 keywords
// whose normalized spelling already points at the v1 target before the
// UPDATE, to avoid constraint violations.
for (const [v2Id, v1Id] of mapping.entries()) {
await db.execute(
`DELETE FROM keywords
WHERE category_id = $1
AND keyword IN (
SELECT keyword FROM keywords WHERE category_id = $2
)`,
[v2Id, v1Id],
);
const r = await db.execute(
`UPDATE keywords SET category_id = $1 WHERE category_id = $2`,
[v1Id, v2Id],
);
outcome.updatedKeywordsCount += Number(r.rowsAffected ?? 0);
}
// 7. Rewrite suppliers.category_id — no unique constraint, straightforward.
for (const [v2Id, v1Id] of mapping.entries()) {
await db.execute(
`UPDATE suppliers SET category_id = $1, updated_at = CURRENT_TIMESTAMP
WHERE category_id = $2`,
[v1Id, v2Id],
);
}
// 8. Re-parent preserved custom categories under the new parent. We touch
// only the top level of the custom tree (parent_id IS NULL or pointing
// at a v2 structural parent in the 1..6 range): children follow naturally.
if (customParentId !== null) {
for (const preservedRow of plan.preserved) {
const r = await db.execute(
`UPDATE categories SET parent_id = $1 WHERE id = $2`,
[customParentId, preservedRow.v2CategoryId],
);
outcome.customPreservedCount += Number(r.rowsAffected ?? 0);
}
}
// 9. Soft-delete v2 seeded categories that are now unreferenced.
// We deactivate instead of hard-deleting so that any historical
// reference we might have missed stays intact (is_active=0 hides them
// from the UI lists). We explicitly only target the v2 seed id range
// (< 1000) AND ids that map in our plan — this avoids touching user
// custom categories that may also have parent_id < 1000 structural.
for (const row of plan.rows) {
// Only deactivate rows that were part of the v2 seed AND we successfully
// mapped to a v1 target. Rows with no v1 target (unresolved review) are
// left alone — in the UX, the consent step is blocked until all rows
// are resolved, so this should be dead code, but it is a safety net.
if (row.v1TargetId === null) continue;
const r = await db.execute(
`UPDATE categories SET is_active = 0 WHERE id = $1`,
[row.v2CategoryId],
);
outcome.deletedV2Count += Number(r.rowsAffected ?? 0);
}
// 10. Also deactivate the v2 structural parents (1..6) — they have no v1
// equivalent and become obsolete after the migration.
{
const r = await db.execute(
`UPDATE categories SET is_active = 0
WHERE id IN (1, 2, 3, 4, 5, 6)`,
);
outcome.deletedV2Count += Number(r.rowsAffected ?? 0);
}
// 11. Bump the schema version and journal the run.
await db.execute(
`INSERT INTO user_preferences (key, value, updated_at)
VALUES ($1, 'v1', CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = 'v1', updated_at = CURRENT_TIMESTAMP`,
[SCHEMA_VERSION_KEY],
);
const journal: LastMigrationJournal = {
timestamp: new Date().toISOString(),
backupPath: backup.path,
outcome: {
insertedV1Count: outcome.insertedV1Count,
updatedTransactionsCount: outcome.updatedTransactionsCount,
updatedBudgetsCount: outcome.updatedBudgetsCount,
updatedKeywordsCount: outcome.updatedKeywordsCount,
deletedV2Count: outcome.deletedV2Count,
customPreservedCount: outcome.customPreservedCount,
},
};
await db.execute(
`INSERT INTO user_preferences (key, value, updated_at)
VALUES ($1, $2, CURRENT_TIMESTAMP)
ON CONFLICT(key) DO UPDATE SET value = $2, updated_at = CURRENT_TIMESTAMP`,
[LAST_MIGRATION_KEY, JSON.stringify(journal)],
);
await db.execute("COMMIT");
outcome.succeeded = true;
return outcome;
} catch (e) {
try {
await db.execute("ROLLBACK");
} catch {
// Swallow: if the rollback itself fails there is nothing we can do here
// besides returning the original error to the caller.
}
outcome.error = e instanceof Error ? e.message : String(e);
outcome.succeeded = false;
return outcome;
}
}
/**
* Convenience: mark the schema as v1 without running the full migration.
* Exported for tests and tooling — the happy-path user flow goes through
* `applyMigration` which handles this transactionally.
*/
export async function markSchemaVersionV1(): Promise<void> {
await setPreference(SCHEMA_VERSION_KEY, "v1");
}