import { getDb, withTransaction } from "./db"; import { deletePreference, getImportFolder, getPreference, setPreference, } from "./userPreferenceService"; import { logInfo } from "./logService"; import { CHECKLIST_ITEM_KEYS, type ChecklistItemKey, type OnboardingCounters, } from "../shared/types"; /** * Getting-started path data (#351, #396). * * `getOnboardingSnapshot` reads everything the path needs in one round of * parallel reads, and writes nothing: the counters, the import folder, the * "hidden" preference and the skipped steps. The writes are separate, and the * onboarding provider is their only caller: `initializeGuidedPath` (rule D9), * `hideChecklist` / `showChecklist`, `skipStep` / `resumeStep`. None of them is * announced on the data channel: the provider updates its own state. No UI * lives here; `computeChecklist` turns the snapshot into rows and * `useOnboardingChecklist` holds the state. */ /** `user_preferences` key set to "1" when the path is hidden (D8: one state for the banner and the list). */ export const ONBOARDING_CHECKLIST_HIDDEN_KEY = "onboarding_checklist_hidden"; /** `user_preferences` key set to "1" once rule D9 ran for the profile. */ export const ONBOARDING_GUIDED_INITIALIZED_KEY = "onboarding_guided_initialized"; /** `user_preferences` key of a skipped step (D3): `onboarding_skipped_`, value "1". */ export function skippedStepKey(key: ChecklistItemKey): string { return `onboarding_skipped_${key}`; } export interface OnboardingSnapshot { counters: OnboardingCounters; /** True when an import folder has been chosen. */ importFolderSet: boolean; /** True when the path is hidden (by the user, or by rule D9). */ hidden: boolean; /** Skipped steps, in checklist order: only the six keys, only the value "1". */ skipped: ChecklistItemKey[]; } interface CountersRow { sources: number; imports: number; manual_categorized: number; budget_entries: number; transactions: number; } // `imports` counts a `completed` file only once it brought a transaction: // `executeImport` writes the row `completed` before inserting, and a file whose // rows were all in error (or that holds only its header) keeps that row with // no transaction. The sub-query is covered by `idx_transactions_file`. const COUNTERS_SQL = ` SELECT (SELECT COUNT(*) FROM import_sources) AS sources, (SELECT COUNT(*) FROM imported_files f WHERE f.status = 'completed' AND EXISTS (SELECT 1 FROM transactions t WHERE t.file_id = f.id)) AS imports, (SELECT COUNT(*) FROM transactions WHERE is_manually_categorized = 1) AS manual_categorized, (SELECT COUNT(*) FROM budget_entries) AS budget_entries, (SELECT COUNT(*) FROM transactions) AS transactions `; // `_` is a LIKE wildcard: the pattern may match more than the prefix, and the // exact-key filter of `readSkippedSteps` is what decides. const SKIPPED_SQL = "SELECT key, value FROM user_preferences WHERE key LIKE 'onboarding_skipped_%'"; const UPSERT_PREFERENCE_SQL = `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`; async function readCounters(): Promise { const db = await getDb(); const rows = await db.select(COUNTERS_SQL); const row = rows[0]; return { sources: row?.sources ?? 0, imports: row?.imports ?? 0, manualCategorized: row?.manual_categorized ?? 0, budgetEntries: row?.budget_entries ?? 0, transactions: row?.transactions ?? 0, }; } /** Skipped steps, read in one query and kept only for a valid key holding "1". */ async function readSkippedSteps(): Promise { const db = await getDb(); const rows = await db.select>(SKIPPED_SQL); const marked = new Set(rows.filter((row) => row.value === "1").map((row) => row.key)); return CHECKLIST_ITEM_KEYS.filter((key) => marked.has(skippedStepKey(key))); } /** * Reads the counters, the import folder, the hidden flag and the skipped steps * in parallel. A pure read: it never writes (rule D9 lives in * `initializeGuidedPath`). */ export async function getOnboardingSnapshot(): Promise { const [counters, importFolder, hiddenPref, skipped] = await Promise.all([ readCounters(), getImportFolder(), getPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY), readSkippedSteps(), ]); return { counters, importFolderSet: importFolder !== null && importFolder !== "", hidden: hiddenPref === "1", skipped, }; } /** * Rule D9, once per profile: at the first opening under this version, a * profile that already has transactions gets the path hidden (same preference * as "Hide", so Settings → Users shows it again); an empty or new profile sees * it. * * The flag is read first, outside any transaction: once D9 ran, every later * opening costs that one queued read and opens no transaction. Without the * flag, D9 runs as one transaction under the DB lock, flag * re-read inside it, so two concurrent first loads cannot apply it twice * (CWE-362): the hidden preference is written first, then the flag, then * COMMIT; any failure rolls both back and leaves the flag unset, so the next * opening tries again. Only the value "1" counts as set, on both reads. * * Returns `{ hidden: true }` only when this call hid the path, which it also * writes to the in-app log. Not announced on the data channel. */ export async function initializeGuidedPath(): Promise<{ hidden: boolean }> { if ((await getPreference(ONBOARDING_GUIDED_INITIALIZED_KEY)) === "1") { return { hidden: false }; } const hidden = await withTransaction(async (db) => { await db.execute("BEGIN"); try { const flag = await db.select>( "SELECT value FROM user_preferences WHERE key = $1", [ONBOARDING_GUIDED_INITIALIZED_KEY], ); if (flag[0]?.value === "1") { await db.execute("COMMIT"); return false; } const rows = await db.select>( "SELECT COUNT(*) AS transactions FROM transactions", ); const hasTransactions = (rows[0]?.transactions ?? 0) > 0; if (hasTransactions) { await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"]); } await db.execute(UPSERT_PREFERENCE_SQL, [ONBOARDING_GUIDED_INITIALIZED_KEY, "1"]); await db.execute("COMMIT"); return hasTransactions; } catch (e) { try { await db.execute("ROLLBACK"); } catch { // Keep the original error: it is the one that explains the failure. } throw e; } }); if (hidden) { logInfo( "Onboarding: the profile already had transactions, getting-started path hidden (Settings → Users shows it again)", ); } return { hidden }; } /** Hides the path until `showChecklist()` is called. */ export async function hideChecklist(): Promise { await setPreference(ONBOARDING_CHECKLIST_HIDDEN_KEY, "1"); logInfo("Onboarding: checklist hidden"); } /** Shows the path again (removes the hidden preference). */ export async function showChecklist(): Promise { await deletePreference(ONBOARDING_CHECKLIST_HIDDEN_KEY); logInfo("Onboarding: checklist shown"); } /** Marks a step as skipped. Idempotent: an UPSERT, no read before the write. */ export async function skipStep(key: ChecklistItemKey): Promise { await setPreference(skippedStepKey(key), "1"); logInfo(`Onboarding: step ${key} skipped`); } /** Cancels a skip. Idempotent: deleting a missing preference changes nothing. */ export async function resumeStep(key: ChecklistItemKey): Promise { await deletePreference(skippedStepKey(key)); logInfo(`Onboarding: step ${key} resumed`); }