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>
401 lines
15 KiB
TypeScript
401 lines
15 KiB
TypeScript
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");
|
||
}
|