Simpl-Resultat/src/services/categoryRestoreService.ts
le king fu 0132e6e164
All checks were successful
PR Check / rust (push) Successful in 21m45s
PR Check / frontend (push) Successful in 2m17s
PR Check / rust (pull_request) Successful in 21m1s
PR Check / frontend (pull_request) Successful in 2m13s
feat(categories): add restore backup banner and permanent restore action (#122)
Surfaces the pre-migration SREF backup to the user so they can roll back a
category migration without digging into the filesystem:

- 90-day dismissable banner at the top of Settings > Categories pointing to
  the automatic backup (hidden once reverted, once dismissed, or past 90d).
- Permanent "Restore a backup" entry in Settings > Categories, available as
  long as a migration journal exists (even past the 90-day window).
- Confirmation modal with two-step consent, red Restore button, fallback
  file picker when the recorded path is missing, PIN prompt for encrypted
  SREF files, full-page reload on success.

Internals:
- New `categoryRestoreService` wrapping `read_import_file` +
  `importTransactionsWithCategories` with stable error codes
  (file_missing, read_failed, parse_failed, wrong_envelope_type,
  needs_password, wrong_password, import_failed).
- New `file_exists` Tauri command for the pre-flight presence check.
- On success: `categories_schema_version=v2` + merge `reverted_at` into
  `last_categories_migration`.
- Pure `shouldShowBanner` / `isWithinBannerWindow` helpers with tests.
- FR/EN i18n keys under `settings.categoriesCard.restore*`.
- CHANGELOG entries in both locales.

Closes #122
2026-04-20 21:47:43 -04:00

270 lines
9.9 KiB
TypeScript

import { invoke } from "@tauri-apps/api/core";
import {
parseImportedJson,
importTransactionsWithCategories,
} from "./dataExportService";
import { getPreference, setPreference } from "./userPreferenceService";
import type { LastMigrationJournal } from "./categoryMigrationService";
// -----------------------------------------------------------------------------
// Category restore service — rolls back the v2 → v1 categories migration by
// importing the pre-migration SREF backup created by categoryBackupService.
//
// The service is destructive by design: it wipes and re-inserts the entire
// profile from the backup payload (same semantics as the full-profile
// "transactions_with_categories" import). Calling it is the user's explicit
// choice, gated by a confirmation modal in the UI layer.
//
// Flow:
// 1. Resolve the backup file path — either the one recorded in
// `last_categories_migration.backupPath`, or a user-picked file when the
// recorded path no longer exists on disk.
// 2. Read it via `read_import_file` (Rust transparently handles the SREF
// AES-256-GCM decryption when the file starts with the magic bytes).
// 3. Parse it, assert `export_type === "transactions_with_categories"`.
// 4. Call `importTransactionsWithCategories` to wipe + restore everything.
// 5. Reset `categories_schema_version` back to `v2` and merge a `reverted_at`
// timestamp into `last_categories_migration`.
//
// The service never swallows errors — the caller (modal) is responsible for
// surfacing them via i18n-mapped messages.
// -----------------------------------------------------------------------------
export const CATEGORIES_SCHEMA_VERSION_KEY = "categories_schema_version";
export const LAST_CATEGORIES_MIGRATION_KEY = "last_categories_migration";
export const CATEGORIES_MIGRATION_BANNER_DISMISSED_KEY =
"categories_migration_banner_dismissed";
/** Extended journal shape: the base written by applyMigration plus the
* optional `reverted_at` stamped by a successful restore. */
export interface RestorableMigrationJournal extends LastMigrationJournal {
reverted_at?: string;
}
export interface RestoreResult {
/** Path that was actually restored from (may differ from the recorded one
* when the user picked a fallback file). */
filePath: string;
/** ISO-8601 timestamp recorded on `last_categories_migration.reverted_at`. */
revertedAt: string;
}
export type RestoreErrorCode =
| "file_missing"
| "read_failed"
| "parse_failed"
| "wrong_envelope_type"
| "no_recorded_migration"
| "needs_password"
| "wrong_password"
| "import_failed";
export class RestoreError extends Error {
public readonly code: RestoreErrorCode;
public readonly detail: string;
constructor(code: RestoreErrorCode, detail: string) {
super(`restore_failed:${code}:${detail}`);
this.name = "RestoreError";
this.code = code;
this.detail = detail;
}
}
// -----------------------------------------------------------------------------
// Journal helpers — safe JSON parsing with best-effort typing
// -----------------------------------------------------------------------------
/**
* Read and parse the journal written by applyMigration. Returns `null` when
* no migration has ever been run, or when the stored value is not valid JSON
* (we never throw on that — a corrupted journal is equivalent to "no record"
* for UI purposes). Shape is validated loosely: we only require `timestamp`
* and `backupPath` strings, which are the fields the restore flow actually
* consumes.
*/
export async function readLastMigrationJournal(): Promise<
RestorableMigrationJournal | null
> {
const raw = await getPreference(LAST_CATEGORIES_MIGRATION_KEY);
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<RestorableMigrationJournal>;
if (
typeof parsed.timestamp !== "string" ||
typeof parsed.backupPath !== "string"
) {
return null;
}
return parsed as RestorableMigrationJournal;
} catch {
return null;
}
}
/**
* Compute whether a migration is within the 90-day banner window. Pure
* function — exported for tests.
*/
export function isWithinBannerWindow(
journalTimestamp: string,
nowMs: number = Date.now(),
windowDays: number = 90,
): boolean {
const ts = Date.parse(journalTimestamp);
if (Number.isNaN(ts)) return false;
const ageMs = nowMs - ts;
const windowMs = windowDays * 24 * 60 * 60 * 1000;
return ageMs >= 0 && ageMs <= windowMs;
}
/**
* Check whether the banner should be shown given the three inputs we read
* from user_preferences:
* - journal: the parsed `last_categories_migration` JSON (null ⇒ never);
* - dismissedPref: the raw value of `categories_migration_banner_dismissed`;
* - now: the current clock (injectable for tests).
*
* Pure function — exported so the UI and tests share the same rules.
*/
export function shouldShowBanner(
journal: RestorableMigrationJournal | null,
dismissedPref: string | null,
now: number = Date.now(),
): boolean {
if (!journal) return false;
if (journal.reverted_at) return false;
if (dismissedPref === "1") return false;
return isWithinBannerWindow(journal.timestamp, now);
}
/**
* Merge a `reverted_at` stamp into the existing journal and persist it.
* Leaves the rest of the object intact. No-op when there is no recorded
* journal — the caller must have one (the restore flow is only reachable
* when we successfully read one upstream).
*/
export async function markMigrationReverted(
revertedAt: string = new Date().toISOString(),
): Promise<void> {
const journal = await readLastMigrationJournal();
if (!journal) return;
const updated: RestorableMigrationJournal = {
...journal,
reverted_at: revertedAt,
};
await setPreference(LAST_CATEGORIES_MIGRATION_KEY, JSON.stringify(updated));
}
// -----------------------------------------------------------------------------
// Tauri wrappers
// -----------------------------------------------------------------------------
/** Check whether a file is still on disk. Returns false when the path was
* moved or removed. */
export async function backupFileExists(path: string): Promise<boolean> {
try {
return await invoke<boolean>("file_exists", { filePath: path });
} catch {
// If even the stat call fails we treat the file as missing so the UI
// falls back to the manual file picker rather than hanging.
return false;
}
}
/** Detect whether a file is SREF-encrypted (has the `SREF` magic prefix). */
export async function isFileEncrypted(path: string): Promise<boolean> {
return invoke<boolean>("is_file_encrypted", { filePath: path });
}
/** Open the native file picker filtered to SREF/JSON. Returns null when the
* user cancels. */
export async function pickBackupFile(): Promise<string | null> {
return invoke<string | null>("pick_import_file", {
filters: [["Simpl'Result Backup", ["sref", "json"]]],
});
}
// -----------------------------------------------------------------------------
// Core restore flow — wipes the profile and re-imports the SREF payload
// -----------------------------------------------------------------------------
/**
* Restore a profile from a pre-migration SREF backup and mark the migration
* as reverted. This is the destructive write performed after confirmation.
*
* @param filePath Absolute path to the backup SREF/JSON file.
* @param password Clear-text PIN — required when the file is encrypted
* (SREF magic detected). Pass `null` for plaintext JSON
* backups or when the profile had no PIN.
*
* Throws a `RestoreError` on any failure; on success updates
* `categories_schema_version=v2` and stamps `reverted_at` on the journal.
*/
export async function restoreFromBackup(
filePath: string,
password: string | null,
): Promise<RestoreResult> {
// 1. Read the file — decryption happens inside read_import_file when the
// magic prefix is present.
let content: string;
try {
content = await invoke<string>("read_import_file", {
filePath,
password,
});
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
const lower = message.toLowerCase();
if (lower.includes("password is required")) {
throw new RestoreError("needs_password", message);
}
if (
lower.includes("decryption failed") ||
lower.includes("wrong password")
) {
throw new RestoreError("wrong_password", message);
}
throw new RestoreError("read_failed", message);
}
// 2. Parse the envelope. The export_type MUST be
// `transactions_with_categories` — anything else would be a user error
// (they picked the wrong file).
let envelope;
try {
envelope = parseImportedJson(content).envelope;
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new RestoreError("parse_failed", message);
}
if (envelope.export_type !== "transactions_with_categories") {
throw new RestoreError(
"wrong_envelope_type",
`got ${envelope.export_type}, expected transactions_with_categories`,
);
}
// 3. Perform the destructive re-import. importTransactionsWithCategories
// wipes transactions/imported_files/import_sources/keywords/suppliers/
// categories and re-inserts them from the envelope.
const filename = filePath.split(/[/\\]/).pop() ?? "backup.sref";
try {
await importTransactionsWithCategories(envelope.data, filename);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
throw new RestoreError("import_failed", message);
}
// 4. Reset schema version and stamp the journal. Both are best-effort in the
// sense that the DB restore has already succeeded by this point — we
// still surface a preference write failure because stale prefs would
// make the banner misleading.
const revertedAt = new Date().toISOString();
await setPreference(CATEGORIES_SCHEMA_VERSION_KEY, "v2");
await markMigrationReverted(revertedAt);
return { filePath, revertedAt };
}