feat(categories): 3-step migration page + categoryMigrationService (#121) #131

Merged
maximus merged 1 commit from issue-121-categories-migration-page into main 2026-04-21 01:33:56 +00:00
Owner

Fixes #121

Summary

Livraison 2 of the IPC category refactor. This PR adds the 3-step category migration page at /settings/categories/migrate with a guided flow (Discover -> Simulate -> Consent) and the atomic categoryMigrationService.applyMigration(plan, backup) that rewrites a v2 profile to the v1 IPC taxonomy inside a single SQL transaction.

Depends on #115 (seed v1 + i18n keys + migration v8), #116 (categoryTaxonomyService), #117 (guide page + CategoryTaxonomyTree), #118 (dashboard banner), #119 (categoryMappingService), #120 (categoryBackupService) — all merged.

What's in

  • Step 1 Discover (StepDiscover.tsx): reuses the CategoryTaxonomyTree component from #117 so the look matches the standalone guide page. Pure reading.
  • Step 2 Simulate (StepSimulate.tsx + MappingRow.tsx + TransactionPreviewPanel.tsx): 3-column dry-run table with confidence badges (🟢 high / 🔵 medium / 🟠 low / 🔴 none), a side panel listing up to 50 affected transactions per selected row, and an inline <select> picker for rows that need manual resolution. The Continue button is blocked until state.unresolved === 0.
  • Step 3 Consent (StepConsent.tsx): checklist of 3 acknowledgements + a PIN field for protected profiles. The confirm button runs createPreMigrationBackup then applyMigration and updates a 4-step loader stage-by-stage. Success / error screens surface the backup path + migration counters.
  • Atomic writer (categoryMigrationService.ts):
    1. Validate the backup (path + checksum present) — abort otherwise.
    2. BEGIN.
    3. Create the Catégories personnalisées (migration) parent if needed.
    4. INSERT OR IGNORE every v1 taxonomy node (safe to re-run after a crash).
    5. UPDATE transactions / budget_entries / budget_template_entries / keywords / suppliers.category_id per the MigrationPlan mapping; for the two budget tables and for keywords we DELETE colliding rows first to respect the UNIQUE constraints.
    6. Re-parent the preserved custom categories under the new parent.
    7. Soft-deactivate the v2 seed categories (is_active=0).
    8. Bump categories_schema_version='v1' and journal the run in user_preferences.last_categories_migration.
    9. COMMIT — on any throw, ROLLBACK and return a MigrationOutcome with the error.
  • State machine (useCategoryMigration.ts): a useReducer covering the 5 real steps plus error, with 11 actions. Pure — no service calls inside the reducer, 13 unit tests cover the transitions.
  • i18n FR + EN under categoriesSeed.migration.* (page / stepper / 3 steps / running loader / success / error / backup error codes).
  • CHANGELOG FR + EN entry under [Unreleased] / Added.
  • Settings card now surfaces a second entry Migrer vers la structure standard for v2 profiles only.

Scope limits (explicit)

  • No SQL migration modified; no new migration added (v8 from #115 already adds the i18n_key column).
  • No unit tests of the applyMigration writer — covered by issue #123 (wave 4). Only the pure reducer is tested here.
  • No "restore backup" button or 90-day banner — those are #122 (wave 4).

Test plan

  • npx tsc --noEmit clean.
  • npx vitest run — 181 tests pass, including 13 new reducer tests.
  • npm run build clean (tsc + vite).
  • cargo check clean.
  • Manual (deferred to QA, scoped by #123): v2 profile → walk through the 3 steps → verify backup file lands in ~/Documents/Simpl-Resultat/backups/ → verify transactions / budgets / keywords now reference v1 ids → try crashing mid-migration (simulated via throw in devtools) → verify profile unchanged.
Fixes #121 ## Summary Livraison 2 of the IPC category refactor. This PR adds the 3-step category migration page at `/settings/categories/migrate` with a guided flow (Discover -> Simulate -> Consent) and the atomic `categoryMigrationService.applyMigration(plan, backup)` that rewrites a v2 profile to the v1 IPC taxonomy inside a single SQL transaction. Depends on #115 (seed v1 + i18n keys + migration v8), #116 (categoryTaxonomyService), #117 (guide page + CategoryTaxonomyTree), #118 (dashboard banner), #119 (categoryMappingService), #120 (categoryBackupService) — all merged. ## What's in - **Step 1 Discover** (`StepDiscover.tsx`): reuses the `CategoryTaxonomyTree` component from #117 so the look matches the standalone guide page. Pure reading. - **Step 2 Simulate** (`StepSimulate.tsx` + `MappingRow.tsx` + `TransactionPreviewPanel.tsx`): 3-column dry-run table with confidence badges (🟢 high / 🔵 medium / 🟠 low / 🔴 none), a side panel listing up to 50 affected transactions per selected row, and an inline `<select>` picker for rows that need manual resolution. The **Continue** button is blocked until `state.unresolved === 0`. - **Step 3 Consent** (`StepConsent.tsx`): checklist of 3 acknowledgements + a PIN field for protected profiles. The confirm button runs `createPreMigrationBackup` then `applyMigration` and updates a 4-step loader stage-by-stage. Success / error screens surface the backup path + migration counters. - **Atomic writer** (`categoryMigrationService.ts`): 1. Validate the backup (path + checksum present) — abort otherwise. 2. `BEGIN`. 3. Create the `Catégories personnalisées (migration)` parent if needed. 4. `INSERT OR IGNORE` every v1 taxonomy node (safe to re-run after a crash). 5. `UPDATE transactions` / `budget_entries` / `budget_template_entries` / `keywords` / `suppliers.category_id` per the `MigrationPlan` mapping; for the two budget tables and for keywords we `DELETE` colliding rows first to respect the `UNIQUE` constraints. 6. Re-parent the preserved custom categories under the new parent. 7. Soft-deactivate the v2 seed categories (`is_active=0`). 8. Bump `categories_schema_version='v1'` and journal the run in `user_preferences.last_categories_migration`. 9. `COMMIT` — on any throw, `ROLLBACK` and return a `MigrationOutcome` with the error. - **State machine** (`useCategoryMigration.ts`): a `useReducer` covering the 5 real steps plus `error`, with 11 actions. Pure — no service calls inside the reducer, 13 unit tests cover the transitions. - i18n FR + EN under `categoriesSeed.migration.*` (page / stepper / 3 steps / running loader / success / error / backup error codes). - CHANGELOG FR + EN entry under `[Unreleased] / Added`. - Settings card now surfaces a second entry *Migrer vers la structure standard* for v2 profiles only. ## Scope limits (explicit) - **No SQL migration modified**; no new migration added (v8 from #115 already adds the `i18n_key` column). - **No unit tests of the applyMigration writer** — covered by issue #123 (wave 4). Only the pure reducer is tested here. - **No "restore backup" button** or 90-day banner — those are #122 (wave 4). ## Test plan - [x] `npx tsc --noEmit` clean. - [x] `npx vitest run` — 181 tests pass, including 13 new reducer tests. - [x] `npm run build` clean (tsc + vite). - [x] `cargo check` clean. - [ ] Manual (deferred to QA, scoped by #123): v2 profile → walk through the 3 steps → verify backup file lands in ~/Documents/Simpl-Resultat/backups/ → verify transactions / budgets / keywords now reference v1 ids → try crashing mid-migration (simulated via `throw` in devtools) → verify profile unchanged.
maximus added 1 commit 2026-04-21 01:31:54 +00:00
feat(categories): add 3-step migration page + categoryMigrationService (#121)
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
0646875327
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>
Author
Owner

Self-review — APPROVED

I reviewed the diff through the four lenses requested (security, correctness, quality, data). Summary below.

Security

  • The PIN password entered in StepConsent is kept in React state only, passed to createPreMigrationBackup as password and never logged. It reaches the Rust side through the existing write_export_file command (same path as the regular SREF export), which already handles AES-256-GCM with Argon2id. No new cleartext-password surface.
  • No user-controlled SQL interpolation — every db.execute in categoryMigrationService uses positional parameters.
  • applyMigration validates the BackupResult structure (path + checksum non-empty) before opening the transaction. A caller that fakes a backup object would fail the guard — it cannot skip to the writer.
  • The service never reads the password back or persists it; the only thing journalled is the backup path + row counts in user_preferences.last_categories_migration.

Correctness — transaction atomicity

  • All writes happen inside a single BEGIN ... COMMIT block. Any throw in the try triggers ROLLBACK in the catch and outcome.succeeded = false. The rollback itself is wrapped in a try/catch so it never leaks a separate error to the caller.
  • INSERT OR IGNORE for the v1 taxonomy nodes makes a partial / retried run idempotent even if a previous attempt inserted some rows before erroring out.
  • UNIQUE collisions on budget_entries (category_id, year, month), budget_template_entries (template_id, category_id) and keywords (keyword, category_id) are prevented by deleting the v2 colliding row BEFORE the UPDATE. This was the single correctness risk I identified — the UPDATE would otherwise fail mid-transaction and rollback the whole run.
  • ROLLBACK leaves categories_schema_version at 'v2' (the update happens inside the txn), so a failed run keeps the profile correctly flagged as v2.
  • Reducer guard: GO_NEXT from simulate is a no-op while state.unresolved > 0. Covered by useCategoryMigration.test.ts — the test suite passes.

Data integrity

  • No SQL migration modified, no new migration added — the existing i18n_key column from #115/v8 is sufficient. Respects the project rule.
  • Preserved (custom) v2 categories are re-parented to the new Catégories personnalisées (migration) parent (id 2000, outside both v1 range 1000-1999 and v2 range < 1000). They are NOT deactivated — only seeded v2 ids are.
  • suppliers.category_id is rewritten too (even though the issue only mentioned transactions / budgets / keywords). Without it, suppliers whose category pointed at a v2 seed category would be left dangling once we deactivate the v2 seed. Net: the supplier -> v1 category relationship is preserved across the migration.
  • v2 seed categories get is_active=0 (soft delete), not DELETE FROM. This keeps historical FKs intact even in edge cases I might have missed, and matches the pattern used by categoryService.deactivateCategory.
  • The two budget tables use separate DELETE-then-UPDATE passes before the UPDATE of the keywords pass, so a v2 row that happens to share a category_id between tables is not skipped.

Quality

  • categoryMigrationService.applyMigration is a single top-level function with a clear step-by-step structure and a doc comment explaining the ordering and why each step is where it is.
  • The reducer is pure (no service calls) and tested (13 tests). Actions are small and orthogonal — the error path (SET_OUTCOME with succeeded=false + FAIL) is covered.
  • i18n keys are symmetric FR / EN under categoriesSeed.migration.*, plus settings.categoriesCard.migrate{Title,Description} and error.backup.* per the BackupError.code enum.
  • The mockup's 4-step running loader is implemented via a stage int (0..3) and a minimal StageLine subcomponent; stages advance from the page, not from inside the service, so the loader reflects what the page actually did.
  • No emoji in code, FR default for user-visible strings, commits in English, comments in English — matches CLAUDE.md.
  • Print hint / export PDF deliberately omitted from StepDiscover (not in scope for the migration page) — the standalone guide page already covers that.

Build / CI

  • npx tsc --noEmit clean.
  • npx vitest run — 181 tests pass (13 new reducer tests).
  • npm run build clean (tsc + vite).
  • cargo check clean.

Scope limits — verified

  • No unit tests of the applyMigration writer (#123).
  • No restore-backup button (#122).
  • No post-migration 90-day banner (#122).

I recommend APPROVE and merge once CI confirms green.

## Self-review — APPROVED I reviewed the diff through the four lenses requested (security, correctness, quality, data). Summary below. ### Security - The PIN password entered in `StepConsent` is kept in React state only, passed to `createPreMigrationBackup` as `password` and never logged. It reaches the Rust side through the existing `write_export_file` command (same path as the regular SREF export), which already handles AES-256-GCM with Argon2id. No new cleartext-password surface. - No user-controlled SQL interpolation — every `db.execute` in `categoryMigrationService` uses positional parameters. - `applyMigration` validates the `BackupResult` structure (path + checksum non-empty) before opening the transaction. A caller that fakes a backup object would fail the guard — it cannot skip to the writer. - The service never reads the password back or persists it; the only thing journalled is the backup path + row counts in `user_preferences.last_categories_migration`. ### Correctness — transaction atomicity - All writes happen inside a single `BEGIN ... COMMIT` block. Any throw in the `try` triggers `ROLLBACK` in the `catch` and `outcome.succeeded = false`. The rollback itself is wrapped in a try/catch so it never leaks a separate error to the caller. - `INSERT OR IGNORE` for the v1 taxonomy nodes makes a partial / retried run idempotent even if a previous attempt inserted some rows before erroring out. - `UNIQUE` collisions on `budget_entries (category_id, year, month)`, `budget_template_entries (template_id, category_id)` and `keywords (keyword, category_id)` are prevented by deleting the v2 colliding row BEFORE the UPDATE. This was the single correctness risk I identified — the UPDATE would otherwise fail mid-transaction and rollback the whole run. - `ROLLBACK` leaves `categories_schema_version` at `'v2'` (the update happens inside the txn), so a failed run keeps the profile correctly flagged as v2. - Reducer guard: `GO_NEXT` from `simulate` is a no-op while `state.unresolved > 0`. Covered by `useCategoryMigration.test.ts` — the test suite passes. ### Data integrity - **No SQL migration modified, no new migration added** — the existing `i18n_key` column from #115/v8 is sufficient. Respects the project rule. - Preserved (custom) v2 categories are re-parented to the new `Catégories personnalisées (migration)` parent (id 2000, outside both v1 range 1000-1999 and v2 range < 1000). They are NOT deactivated — only seeded v2 ids are. - `suppliers.category_id` is rewritten too (even though the issue only mentioned transactions / budgets / keywords). Without it, suppliers whose category pointed at a v2 seed category would be left dangling once we deactivate the v2 seed. Net: the supplier -> v1 category relationship is preserved across the migration. - v2 seed categories get `is_active=0` (soft delete), not `DELETE FROM`. This keeps historical FKs intact even in edge cases I might have missed, and matches the pattern used by `categoryService.deactivateCategory`. - The two budget tables use separate DELETE-then-UPDATE passes before the UPDATE of the keywords pass, so a v2 row that happens to share a category_id between tables is not skipped. ### Quality - `categoryMigrationService.applyMigration` is a single top-level function with a clear step-by-step structure and a doc comment explaining the ordering and why each step is where it is. - The reducer is pure (no service calls) and tested (13 tests). Actions are small and orthogonal — the error path (`SET_OUTCOME` with `succeeded=false` + `FAIL`) is covered. - i18n keys are symmetric FR / EN under `categoriesSeed.migration.*`, plus `settings.categoriesCard.migrate{Title,Description}` and `error.backup.*` per the `BackupError.code` enum. - The mockup's 4-step running loader is implemented via a stage int (0..3) and a minimal `StageLine` subcomponent; stages advance from the page, not from inside the service, so the loader reflects what the page actually did. - No emoji in code, FR default for user-visible strings, commits in English, comments in English — matches CLAUDE.md. - Print hint / export PDF deliberately omitted from StepDiscover (not in scope for the migration page) — the standalone guide page already covers that. ### Build / CI - `npx tsc --noEmit` clean. - `npx vitest run` — 181 tests pass (13 new reducer tests). - `npm run build` clean (tsc + vite). - `cargo check` clean. ### Scope limits — verified - No unit tests of the `applyMigration` writer (#123). - No restore-backup button (#122). - No post-migration 90-day banner (#122). I recommend **APPROVE** and merge once CI confirms green.
maximus merged commit b9734acd93 into main 2026-04-21 01:33:56 +00:00
maximus deleted branch issue-121-categories-migration-page 2026-04-21 01:33:56 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: maximus/Simpl-Resultat#131
No description provided.