Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions apps/admin/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 = '<adresse>'"
```
1 change: 1 addition & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/db/recette-seed-cli.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
235 changes: 235 additions & 0 deletions apps/api/src/db/recette-seed.ts
Original file line number Diff line number Diff line change
@@ -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<Parameters<Db['transaction']>[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<RecetteCounts> {
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,
})),
);
}
Loading