/** * Petite base in-memory pour les mocks MSW. * Persiste dans sessionStorage pour survivre aux reload pendant le dev, * mais reste isolée par onglet (pas d'interférence entre devs). */ import type { Client, Invoice, Organization, Plan, User } from "@rubis/shared"; import { SEED_CLIENTS, SEED_INVOICES, SEED_PLANS } from "./seed"; const STORAGE_KEY = "rubis.mocks.db.v2"; type StoredUser = User & { passwordHash: string }; /** Forme enrichie des invoices stockée localement (avec dénormalisations * pratiques pour les listes : clientName, planName, statusLabel). */ export type StoredInvoice = Invoice & { clientName: string; planName: string | null; statusLabel?: string; }; /** Champs extraits par l'OCR (et leur version éditée par l'utilisateur). */ export type DraftFields = { /** Si l'OCR matche un client existant (case-insensitive sur le nom), * on pré-remplit clientId pour que l'utilisateur n'ait pas à re-sélectionner. * Sinon null = nouveau client à créer à la validation. */ clientId: string | null; clientName: string; clientEmail: string | null; numero: string; amountTtcCents: number; issueDate: string; dueDate: string; planId: string | null; }; /** Brouillon d'une facture en cours de review. */ export type StoredImportDraft = { id: string; filename: string; /** Champs initiaux extraits par "l'OCR" (notre mock). */ extracted: DraftFields; /** Champs édités par l'utilisateur. Initialement = extracted. */ edited: DraftFields; /** Confiance OCR par champ (0-1). Sert à signaler les champs douteux. */ confidence: Partial>; status: "pending" | "validated" | "skipped"; /** Si validé, l'id de l'invoice créée. */ invoiceId?: string; }; export type StoredImportBatch = { id: string; organizationId: string; drafts: StoredImportDraft[]; createdAt: string; }; type Db = { users: StoredUser[]; organizations: Organization[]; clients: Client[]; plans: Plan[]; invoices: StoredInvoice[]; importBatches: StoredImportBatch[]; }; const seedDb = (): Db => ({ users: [ { id: "usr_demo", email: "demo@rubis.fr", fullName: "Arthur Démo", organizationId: "org_demo", signature: "Cordialement,\nArthur — Rubis Démo", createdAt: new Date("2026-01-01").toISOString(), updatedAt: new Date().toISOString(), passwordHash: "demo1234", }, ], organizations: [ { id: "org_demo", name: "Rubis Démo", siret: null, monthlyVolumeBucket: "10-50", rubisCount: 124, createdAt: new Date("2026-01-01").toISOString(), updatedAt: new Date().toISOString(), }, ], clients: SEED_CLIENTS, plans: SEED_PLANS, invoices: SEED_INVOICES, importBatches: [], }); /** * Migration douce : si un champ top-level a été ajouté au schema entre * deux runs (ex. importBatches arrivé tardivement), on patche le snapshot * stocké avec le défaut du seed plutôt que de tout perdre. Évite les * `TypeError: Cannot read properties of undefined` pendant le dev. */ function migrate(stored: Partial): Db { const fresh = seedDb(); // Les batches d'import seedés avant l'ajout de clientId sur DraftFields // sont incompatibles avec le combobox (no clientId). Plus simple de les // jeter à la migration : ils sont éphémères de toute façon (drafts // pending qu'on ne validera plus). const importBatches = (stored.importBatches ?? []).map((b) => ({ ...b, drafts: b.drafts.map((d) => ({ ...d, // Spread des champs stockés AVANT clientId, pour qu'on n'écrase pas // un clientId déjà présent dans un nouveau snapshot. La défaut `null` // ne s'applique que pour les vieux snapshots qui n'avaient pas le champ. extracted: { ...d.extracted, clientId: d.extracted.clientId ?? null }, edited: { ...d.edited, clientId: d.edited.clientId ?? null }, })), })); return { users: stored.users ?? fresh.users, organizations: stored.organizations ?? fresh.organizations, clients: stored.clients ?? fresh.clients, plans: stored.plans ?? fresh.plans, invoices: stored.invoices ?? fresh.invoices, importBatches, }; } function load(): Db { if (typeof sessionStorage === "undefined") return seedDb(); const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) { const fresh = seedDb(); sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)); return fresh; } try { const stored = JSON.parse(raw) as Partial; const migrated = migrate(stored); // Persiste la version migrée pour ne pas refaire le merge à chaque load. sessionStorage.setItem(STORAGE_KEY, JSON.stringify(migrated)); return migrated; } catch { const fresh = seedDb(); sessionStorage.setItem(STORAGE_KEY, JSON.stringify(fresh)); return fresh; } } function save(db: Db): void { if (typeof sessionStorage !== "undefined") { sessionStorage.setItem(STORAGE_KEY, JSON.stringify(db)); } } function stripHash(user: StoredUser): User { const { passwordHash: _ph, ...publicUser } = user; return publicUser; } export const mockDb = { // === Users === findUserByEmail(email: string): StoredUser | undefined { return load().users.find((u) => u.email.toLowerCase() === email.toLowerCase()); }, findUserById(id: string): StoredUser | undefined { return load().users.find((u) => u.id === id); }, createUser(input: { email: string; password: string; fullName: string }): User { const db = load(); const now = new Date().toISOString(); const orgId = `org_${crypto.randomUUID()}`; const userId = `usr_${crypto.randomUUID()}`; const org: Organization = { id: orgId, name: "", siret: null, monthlyVolumeBucket: null, rubisCount: 0, createdAt: now, updatedAt: now, }; const user: StoredUser = { id: userId, email: input.email, fullName: input.fullName, organizationId: orgId, signature: null, createdAt: now, updatedAt: now, passwordHash: input.password, }; db.users.push(user); db.organizations.push(org); save(db); return stripHash(user); }, updateUser( id: string, patch: Partial>, ): User | undefined { const db = load(); const idx = db.users.findIndex((u) => u.id === id); if (idx === -1) return undefined; const existing = db.users[idx]!; const updated: StoredUser = { ...existing, ...patch, updatedAt: new Date().toISOString() }; db.users[idx] = updated; save(db); return stripHash(updated); }, // === Organizations === findOrgById(id: string): Organization | undefined { return load().organizations.find((o) => o.id === id); }, updateOrg( id: string, patch: Partial>, ): Organization | undefined { const db = load(); const idx = db.organizations.findIndex((o) => o.id === id); if (idx === -1) return undefined; const existing = db.organizations[idx]!; const updated: Organization = { ...existing, ...patch, updatedAt: new Date().toISOString() }; db.organizations[idx] = updated; save(db); return updated; }, // === Clients === findClientById(orgId: string, id: string): Client | undefined { return load().clients.find((c) => c.organizationId === orgId && c.id === id); }, listClientsForOrg(orgId: string): Client[] { return load().clients.filter((c) => c.organizationId === orgId); }, createClient( orgId: string, input: Omit, ): Client { const db = load(); const now = new Date().toISOString(); const client: Client = { ...input, id: `cli_${crypto.randomUUID()}`, organizationId: orgId, createdAt: now, updatedAt: now, }; db.clients.push(client); save(db); return client; }, updateClient( orgId: string, id: string, patch: Partial>, ): Client | undefined { const db = load(); const idx = db.clients.findIndex( (c) => c.organizationId === orgId && c.id === id, ); if (idx === -1) return undefined; const existing = db.clients[idx]!; const updated: Client = { ...existing, ...patch, updatedAt: new Date().toISOString(), }; db.clients[idx] = updated; save(db); return updated; }, // === Plans === findPlanById(orgId: string, id: string): Plan | undefined { return load().plans.find((p) => p.organizationId === orgId && p.id === id); }, findPlanBySlug(orgId: string, slug: string): Plan | undefined { return load().plans.find( (p) => p.organizationId === orgId && p.slug === slug, ); }, listPlansForOrg(orgId: string): Plan[] { return load().plans.filter((p) => p.organizationId === orgId); }, updatePlan( orgId: string, id: string, patch: Partial>, ): Plan | undefined { const db = load(); const idx = db.plans.findIndex( (p) => p.organizationId === orgId && p.id === id, ); if (idx === -1) return undefined; const existing = db.plans[idx]!; const updated: Plan = { ...existing, ...patch, updatedAt: new Date().toISOString(), }; db.plans[idx] = updated; save(db); return updated; }, // === Invoices === listInvoicesForOrg(orgId: string): StoredInvoice[] { return load().invoices.filter((i) => i.organizationId === orgId); }, createInvoice(orgId: string, input: Omit): StoredInvoice { const db = load(); const now = new Date().toISOString(); const invoice: StoredInvoice = { ...input, id: `inv_${crypto.randomUUID()}`, organizationId: orgId, createdAt: now, updatedAt: now, }; db.invoices.push(invoice); save(db); return invoice; }, findInvoiceById(orgId: string, id: string): StoredInvoice | undefined { return load().invoices.find((i) => i.organizationId === orgId && i.id === id); }, updateInvoice( orgId: string, id: string, patch: Partial, ): StoredInvoice | undefined { const db = load(); const idx = db.invoices.findIndex( (i) => i.organizationId === orgId && i.id === id, ); if (idx === -1) return undefined; const existing = db.invoices[idx]!; const updated: StoredInvoice = { ...existing, ...patch, updatedAt: new Date().toISOString(), }; db.invoices[idx] = updated; save(db); return updated; }, // === Import batches (OCR review flow) === createImportBatch( orgId: string, drafts: Omit[], ): StoredImportBatch { const db = load(); const batch: StoredImportBatch = { id: `batch_${crypto.randomUUID()}`, organizationId: orgId, createdAt: new Date().toISOString(), drafts: drafts.map((d) => ({ ...d, id: `draft_${crypto.randomUUID()}`, edited: { ...d.extracted }, status: "pending" as const, })), }; db.importBatches.push(batch); save(db); return batch; }, findImportBatch(orgId: string, id: string): StoredImportBatch | undefined { return load().importBatches.find( (b) => b.organizationId === orgId && b.id === id, ); }, updateImportDraft( orgId: string, batchId: string, draftId: string, patch: Partial, ): StoredImportDraft | undefined { const db = load(); const batch = db.importBatches.find( (b) => b.organizationId === orgId && b.id === batchId, ); if (!batch) return undefined; const idx = batch.drafts.findIndex((d) => d.id === draftId); if (idx === -1) return undefined; const updated = { ...batch.drafts[idx]!, ...patch }; batch.drafts[idx] = updated; save(db); return updated; }, deleteImportBatch(orgId: string, id: string): boolean { const db = load(); const before = db.importBatches.length; db.importBatches = db.importBatches.filter( (b) => !(b.organizationId === orgId && b.id === id), ); if (db.importBatches.length < before) { save(db); return true; } return false; }, reset(): void { if (typeof sessionStorage !== "undefined") { sessionStorage.removeItem(STORAGE_KEY); } }, };