From 7bb7ecb72efbfb378e8517bf9bc250b7fd6c3aeb Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Wed, 21 Oct 2026 19:32:34 +0200 Subject: [PATCH] chore(api): seed fictitious data for a local review of the admin `pnpm --filter api db:seed:recette`, on top of `db:seed`: three copies of demo routes waiting for moderation, three open reports, two flagged places, two comments of ratings and two support messages, from fictitious users who cannot sign in. Resets its own rows on every run; refused with NODE_ENV=production. The admin notes give the local commands with the Auth emulator. Co-Authored-By: Claude Opus 5.5 --- apps/admin/AGENTS.md | 9 ++ apps/api/package.json | 1 + apps/api/src/db/recette-seed-cli.ts | 16 ++ apps/api/src/db/recette-seed.ts | 235 ++++++++++++++++++++++++++++ 4 files changed, 261 insertions(+) create mode 100644 apps/api/src/db/recette-seed-cli.ts create mode 100644 apps/api/src/db/recette-seed.ts diff --git a/apps/admin/AGENTS.md b/apps/admin/AGENTS.md index 03684c86..362e23be 100644 --- a/apps/admin/AGENTS.md +++ b/apps/admin/AGENTS.md @@ -34,3 +34,12 @@ pnpm --filter @widoo/admin dev # http://localhost:5173 pnpm --filter @widoo/admin build && pnpm --filter @widoo/admin start pnpm --filter @widoo/admin lint && pnpm --filter @widoo/admin typecheck && pnpm --filter @widoo/admin test ``` + +En local, avec l'émulateur Auth (sans second facteur) et des données fictives à traiter : + +```bash +firebase emulators:start --only auth --project demo-widoo # liens de connexion dans sa sortie +FIREBASE_PROJECT_ID=demo-widoo FIREBASE_AUTH_EMULATOR_HOST=127.0.0.1:9099 pnpm --filter api dev +pnpm --filter api db:seed && pnpm --filter api db:seed:recette # parcours en attente, signalements, support +docker compose exec db psql -U widoo -d widoo -c "update users set role = 'admin' where email = ''" +``` diff --git a/apps/api/package.json b/apps/api/package.json index 203bbe5a..a709f3dd 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -13,6 +13,7 @@ "db:migrate": "tsx --env-file-if-exists=.env src/migrate.ts", "db:reset": "tsx --env-file-if-exists=.env src/db/reset.ts && pnpm db:migrate && pnpm db:seed", "db:seed": "tsx --env-file-if-exists=.env src/db/seed-cli.ts", + "db:seed:recette": "tsx --env-file-if-exists=.env src/db/recette-seed-cli.ts", "legal:copies": "tsx src/legal/copies-cli.ts && prettier --write ../mobile/src/legal/generated", "fixtures:route-sheets": "tsx src/db/route-sheets-cli.ts && prettier --write ../mobile/src/route/generated/route-sheets.json", "dev": "tsx watch --env-file-if-exists=.env src/server.ts", diff --git a/apps/api/src/db/recette-seed-cli.ts b/apps/api/src/db/recette-seed-cli.ts new file mode 100644 index 00000000..0eea105c --- /dev/null +++ b/apps/api/src/db/recette-seed-cli.ts @@ -0,0 +1,16 @@ +/** `pnpm db:seed:recette`: fictitious data for a local review of the admin. Refused in production. */ +import { loadConfig } from '../config'; +import { createDb, createSql } from './client'; +import { seedAdminRecette } from './recette-seed'; + +const config = loadConfig(process.env); +if (config.isProduction) { + throw new Error('db:seed:recette inserts fictitious data: refused with NODE_ENV=production'); +} +const sql = createSql(config.databaseUrl); +try { + const counts = await seedAdminRecette(createDb(sql)); + console.info(`Admin review data: ${JSON.stringify(counts)}`); +} finally { + await sql.end({ timeout: 5 }); +} diff --git a/apps/api/src/db/recette-seed.ts b/apps/api/src/db/recette-seed.ts new file mode 100644 index 00000000..e721bee8 --- /dev/null +++ b/apps/api/src/db/recette-seed.ts @@ -0,0 +1,235 @@ +/** + * Fictitious data for a local review of the admin (A-02, A-03, A-13), on top of `pnpm db:seed`: + * routes waiting for moderation, open reports, flagged places, comments of ratings and support + * messages, from fictitious users who cannot sign in. Never on staging or production. + * + * Resets on every run: the rows it owns have fixed ids and are rewritten, so a review can be + * replayed. The places it flags belong to the demo dataset, which `pnpm db:seed` sets verified + * again. + */ +import { eq, inArray } from 'drizzle-orm'; +import type { Db } from './client'; +import { + media, + places, + routeRatings, + routeReports, + routes, + steps, + supportMessages, + users, +} from './schema'; +import { seedId } from './seed'; + +type Tx = Parameters[0]>[0]; + +const dayMs = 24 * 60 * 60 * 1000; +const recetteId = (name: string) => seedId(`recette:${name}`); + +/** A fictitious Firebase uid, which no sign-in can produce: these users cannot log in. */ +const recetteUsers = [ + { key: 'ines', firstName: 'Inès', email: 'ines.recette@example.com' }, + { key: 'hugo', firstName: 'Hugo', email: 'hugo.recette@example.com' }, + { key: 'lea', firstName: 'Léa', email: 'lea.recette@example.com' }, +] as const; + +/** Copies of demo routes submitted by Inès, the oldest first in the queue. */ +const pendingCopies = [ + { source: 'marais-a-l-abri', daysAgo: 4 }, + { source: 'montmartre-a-deux', daysAgo: 2 }, + { source: 'canal-saint-martin-a-velo', daysAgo: 1 }, +] as const; + +const flaggedPlaces = ['rue-denoyez', 'la-maison-rose'] as const; + +export type RecetteCounts = { pending: number; reports: number; flagged: number; support: number }; + +export async function seedAdminRecette(db: Db, now = new Date()): Promise { + return db.transaction(async (tx) => { + const ago = (days: number) => new Date(now.getTime() - days * dayMs); + await seedUsers(tx, ago(30)); + for (const copy of pendingCopies) { + await copyAsPending(tx, copy.source, ago(copy.daysAgo)); + } + await seedReports(tx, ago); + await tx + .update(places) + .set({ verificationStatus: 'flagged', updatedAt: ago(1) }) + .where( + inArray( + places.id, + flaggedPlaces.map((key) => seedId(`place:${key}`)), + ), + ); + await seedSupport(tx, ago); + return { + pending: pendingCopies.length, + reports: 3, + flagged: flaggedPlaces.length, + support: 2, + }; + }); +} + +async function seedUsers(tx: Tx, createdAt: Date) { + for (const { key, firstName, email } of recetteUsers) { + const row = { firebaseUid: `seed-recette-${key}`, firstName, email, createdAt }; + await tx + .insert(users) + .values({ id: recetteId(`user:${key}`), ...row }) + .onConflictDoUpdate({ target: users.id, set: { ...row, suspendedAt: null, role: 'user' } }); + } +} + +/** A copy of a demo route, its steps and their photos, waiting in A-02 since `submittedAt`. */ +async function copyAsPending(tx: Tx, source: string, submittedAt: Date) { + const id = recetteId(`route:${source}`); + const oldStepIds = tx.select({ id: steps.id }).from(steps).where(eq(steps.routeId, id)); + await tx.delete(media).where(inArray(media.ownerId, oldStepIds)); + await tx.delete(routes).where(eq(routes.id, id)); + + const [original] = await tx + .select() + .from(routes) + .where(eq(routes.id, seedId(`route:${source}`))); + if (!original) throw new Error(`Demo route ${source} missing: run pnpm db:seed first`); + await tx.insert(routes).values({ + ...original, + id, + authorId: recetteId('user:ines'), + isOfficial: false, + status: 'pending', + shareToken: null, + publishedAt: null, + unpublishedAt: null, + stats: {}, + recommendation: null, + createdAt: submittedAt, + updatedAt: submittedAt, + }); + + const originalSteps = await tx.select().from(steps).where(eq(steps.routeId, original.id)); + for (const step of originalSteps) { + const stepId = recetteId(`step:${source}:${step.position}`); + await tx.insert(steps).values({ ...step, id: stepId, routeId: id }); + const photos = await tx.select().from(media).where(eq(media.ownerId, step.id)); + if (photos.length > 0) { + await tx.insert(media).values( + photos.map((photo) => ({ + ...photo, + id: recetteId(`media:${source}:${step.position}:${photo.position}`), + ownerId: stepId, + uploadedBy: recetteId('user:ines'), + })), + ); + } + } +} + +async function seedReports(tx: Tx, ago: (days: number) => Date) { + const reports = [ + { + key: 'belleville', + routeId: seedId('route:belleville-street-art'), + userId: recetteId('user:hugo'), + reason: 'false_info' as const, + stepId: null, + comment: null, + createdAt: ago(3), + }, + { + key: 'marais', + routeId: seedId('route:marais-gourmand'), + userId: recetteId('user:lea'), + reason: 'other' as const, + stepId: null, + comment: 'Le parcours passe deux fois par la même rue.', + createdAt: ago(2), + }, + { + key: 'butte', + routeId: seedId('route:butte-aux-cailles-village'), + userId: recetteId('user:hugo'), + reason: 'place_changed' as const, + stepId: seedId('step:butte-aux-cailles-village:0'), + comment: "L'entrée a changé de côté.", + createdAt: ago(1), + }, + ]; + const ratings = [ + { + key: 'marais', + routeId: seedId('route:marais-gourmand'), + userId: recetteId('user:hugo'), + rating: 4, + comment: 'Très bonne balade, les étapes gourmandes valaient le détour.', + }, + { + key: 'montmartre', + routeId: seedId('route:montmartre-a-deux'), + userId: recetteId('user:lea'), + rating: 2, + comment: 'Trop de monde au Sacré-Cœur, à faire tôt le matin.', + }, + ]; + await tx.delete(routeReports).where( + inArray( + routeReports.id, + reports.map((report) => recetteId(`report:${report.key}`)), + ), + ); + await tx + .insert(routeReports) + .values(reports.map(({ key, ...report }) => ({ id: recetteId(`report:${key}`), ...report }))); + await tx.delete(routeRatings).where( + inArray( + routeRatings.id, + ratings.map((rating) => recetteId(`rating:${rating.key}`)), + ), + ); + await tx.insert(routeRatings).values( + ratings.map(({ key, ...rating }) => ({ + id: recetteId(`rating:${key}`), + ...rating, + createdAt: ago(2), + updatedAt: ago(2), + })), + ); +} + +async function seedSupport(tx: Tx, ago: (days: number) => Date) { + const messages = [ + { + key: 'hugo', + userId: recetteId('user:hugo'), + replyEmail: 'hugo.recette@example.com', + topic: 'problem' as const, + message: "La carte reste grise quand j'ouvre un parcours hors connexion.", + createdAt: ago(2), + }, + { + key: 'visitor', + userId: null, + replyEmail: 'visiteur.recette@example.com', + topic: 'question' as const, + message: 'Peut-on proposer un parcours à Lyon ?', + createdAt: ago(1), + }, + ]; + await tx.delete(supportMessages).where( + inArray( + supportMessages.id, + messages.map((message) => recetteId(`support:${message.key}`)), + ), + ); + await tx.insert(supportMessages).values( + messages.map(({ key, ...message }) => ({ + id: recetteId(`support:${key}`), + ...message, + appVersion: '1.0.0', + device: 'iPhone 16 (iOS 26.4)', + locale: 'fr' as const, + handledAt: null, + })), + ); +}