// Fully type-checked (tsconfig.server.json); leading import type keeps Node type-stripping active. import type { Req, Res, Next } from '../types/http'; ('use strict'); const express = require('express'); const router = express.Router(); const { getDb } = require('../db/database.cts'); const { seedDemoData } = require('../scripts/seedDemoData.cts'); const { demoDataLimiter } = require('../middleware/rateLimiter.cts'); const { eraseUserData, clearSeededDemoData } = require('../services/userDataService.cts'); const { ApiError, ValidationError } = require('../utils/apiError.cts'); // 4xx service errors keep their message/code on the wire; anything else is // rethrown raw so the terminal handler masks + logs it as a 5xx. function toUserOpError(err: any, fallbackCode: string) { if (err.status && err.status < 500) { return new ApiError(err.code || fallbackCode, err.message, err.status); } return err; } // GET /api/user/seeded-status — whether the current user has demo data, with counts router.get('/seeded-status', (req: Req, res: Res) => { try { const db = getDb(); const userId = req.user.id; const one = (sql: string) => db.prepare(sql).get(userId).count as number; const seededBills = one( 'SELECT COUNT(*) as count FROM bills WHERE user_id = ? AND is_seeded = 1', ); const seededCategories = one( 'SELECT COUNT(*) as count FROM categories WHERE user_id = ? AND is_seeded = 1', ); // Bill-children (cascade with the seeded bill) counted via their bill. const seededPayments = one( 'SELECT COUNT(*) as count FROM payments WHERE bill_id IN (SELECT id FROM bills WHERE user_id = ? AND is_seeded = 1)', ); const seededTransactions = one( 'SELECT COUNT(*) as count FROM transactions WHERE user_id = ? AND is_seeded = 1', ); res.json({ seeded: seededBills > 0 || seededCategories > 0, seededBills, seededCategories, seededPayments, seededTransactions, }); } catch (err) { throw toUserOpError(err, 'SEEDED_STATUS_ERROR'); } }); // POST /api/user/clear-demo-data — surgically remove ONLY seeded demo data for the // requesting user (in one transaction, child → parent). Marker tables are removed // by is_seeded; bill-children cascade with their seeded bill. A seeded category is // removed only if no remaining (non-seeded) bill references it, so a user's own bill // that reused a demo category keeps its category. Mirrors eraseUserData(). router.post('/clear-demo-data', demoDataLimiter, (req: Req, res: Res) => { try { const result = clearSeededDemoData(getDb(), req.user.id); res.json({ success: true, removed: result.removed, billsDeleted: result.counts.bills || 0, categoriesDeleted: result.counts.categories || 0, counts: result.counts, }); } catch (err) { throw toUserOpError(err, 'CLEAR_DEMO_ERROR'); } }); // POST /api/user/seed-demo-data — seed the full demo dataset for the requesting user router.post('/seed-demo-data', demoDataLimiter, (req: Req, res: Res) => { try { const db = getDb(); const result = seedDemoData(req.user.id); db.prepare( `INSERT INTO import_history (user_id, imported_at, source_filename, file_type, rows_parsed, rows_created, rows_updated, rows_skipped, rows_ambiguous, rows_errored, options_json, summary_json) VALUES (?, datetime('now'), 'seed-demo-data', 'seed-demo', ?, ?, 0, 0, 0, 0, ?, ?)`, ).run( req.user.id, result.billsCreated + result.paymentsCreated + result.transactionsCreated, result.billsCreated, JSON.stringify({ action: 'seed-demo-data' }), JSON.stringify(result.counts), ); res.json({ success: true, message: `Created ${result.billsCreated} bills, ${result.paymentsCreated} payments and ${result.transactionsCreated} transactions`, billsCreated: result.billsCreated, categoriesCreated: result.categoriesCreated, paymentsCreated: result.paymentsCreated, transactionsCreated: result.transactionsCreated, counts: result.counts, }); } catch (err) { throw toUserOpError(err, 'SEED_ERROR'); } }); // POST /api/user/erase-data — permanently wipe the requesting user's financial + // imported data (bills, payments, transactions, connections, categories, imports). // Preserves the account, login, 2FA, and preferences. Requires a type-to-confirm token. router.post('/erase-data', demoDataLimiter, (req: Req, res: Res) => { const confirm = String(req.body?.confirm ?? '') .trim() .toUpperCase(); if (confirm !== 'ERASE') { throw ValidationError('Type ERASE to confirm.', 'confirm', 'ERASE_CONFIRM_REQUIRED'); } try { const result = eraseUserData(getDb(), req.user.id); res.json({ success: true, ...result }); } catch (err) { throw toUserOpError(err, 'ERASE_ERROR'); } }); module.exports = router;