From 3146af836e769f958391881462867bd0eee6d939 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:11:46 +0700 Subject: [PATCH 01/13] feat(shared): add the schemas of the reports, signals and ratings of the admin (A-03) Co-Authored-By: Claude Sonnet 5.5 --- packages/shared/src/index.ts | 2 + .../shared/src/schemas/admin-reports.test.ts | 27 ++++++ packages/shared/src/schemas/admin-reports.ts | 72 ++++++++++++++ packages/shared/src/schemas/admin-signals.ts | 93 +++++++++++++++++++ 4 files changed, 194 insertions(+) create mode 100644 packages/shared/src/schemas/admin-reports.test.ts create mode 100644 packages/shared/src/schemas/admin-reports.ts create mode 100644 packages/shared/src/schemas/admin-signals.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 016065e9..6fd94e48 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -20,6 +20,8 @@ export * from './thresholds'; export { hasRole } from './roles'; export { zoneMinSpanM, zoneSearchBox } from './zone'; export * from './schemas/admin'; +export * from './schemas/admin-reports'; +export * from './schemas/admin-signals'; export * from './schemas/catalog'; export * from './schemas/catalog-place'; export * from './schemas/common'; diff --git a/packages/shared/src/schemas/admin-reports.test.ts b/packages/shared/src/schemas/admin-reports.test.ts new file mode 100644 index 00000000..0516e371 --- /dev/null +++ b/packages/shared/src/schemas/admin-reports.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { AdminReportsQuery, ResolveReportBody } from './admin-reports'; + +describe('ResolveReportBody', () => { + it('takes an outcome, with an optional note', () => { + expect(ResolveReportBody.parse({ outcome: 'accepted' })).toEqual({ outcome: 'accepted' }); + expect(ResolveReportBody.parse({ outcome: 'rejected', note: ' Déjà corrigé ' })).toEqual({ + outcome: 'rejected', + note: 'Déjà corrigé', + }); + }); + + it('refuses an open report as an outcome, markup and unknown fields', () => { + expect(ResolveReportBody.safeParse({ outcome: 'open' }).success).toBe(false); + expect(ResolveReportBody.safeParse({ outcome: 'accepted', note: 'x' }).success).toBe( + false, + ); + expect(ResolveReportBody.safeParse({ outcome: 'accepted', status: 'x' }).success).toBe(false); + }); +}); + +describe('AdminReportsQuery', () => { + it('knows the reasons of a report, the system one included', () => { + expect(AdminReportsQuery.safeParse({ reason: 'low_rating' }).success).toBe(true); + expect(AdminReportsQuery.safeParse({ reason: 'spam' }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/schemas/admin-reports.ts b/packages/shared/src/schemas/admin-reports.ts new file mode 100644 index 00000000..6a34564e --- /dev/null +++ b/packages/shared/src/schemas/admin-reports.ts @@ -0,0 +1,72 @@ +import { z } from 'zod'; +import { reportStatuses, routeReportReasons } from '../reports'; +import { taxonomies } from '../taxonomies'; +import { plainText } from './common'; + +/** Rows of a page of the reports of the admin. */ +export const adminReportsPageSize = 50; + +/** Query of `GET /admin/reports`: the open reports of routes, oldest first (A-03). */ +export const AdminReportsQuery = z.strictObject({ + reason: z.enum(routeReportReasons).optional(), + cursor: z.string().max(200).optional(), +}); +export type AdminReportsQuery = z.infer; + +/** + * An open report of a route, as the team reads it (A-03, D-056). `low_rating` has no reporter. The + * step is the one of the place that changed, for `place_changed` only. + */ +export const AdminRouteReport = z.object({ + id: z.uuid(), + reason: z.enum(routeReportReasons), + /** Written by the reporter, 80 characters at most; read by the team, never published. */ + comment: z.string().nullable(), + createdAt: z.iso.datetime(), + route: z.object({ + id: z.uuid(), + title: z.string(), + status: z.enum(taxonomies.routeStatuses), + /** « Par Widoo »: a route of the team, which has no author account. */ + isOfficial: z.boolean(), + cityName: z.string(), + }), + /** Null for a route of the team, or once the account of the author is purged. */ + author: z.object({ id: z.uuid(), firstName: z.string().nullable() }).nullable(), + /** Null for `low_rating`, and once the account of the reporter is purged. */ + reporter: z.object({ id: z.uuid(), firstName: z.string().nullable() }).nullable(), + step: z.object({ position: z.number().int().nonnegative(), placeName: z.string() }).nullable(), + /** Reports still open on the same route, this one included. */ + openOnRoute: z.number().int().positive(), +}); +export type AdminRouteReport = z.infer; + +/** Body of `GET /admin/reports`. */ +export const AdminRouteReports = z.object({ + items: z.array(AdminRouteReport), + total: z.number().int().nonnegative(), + /** Rows before this page: the first row shown is number `offset + 1`. */ + offset: z.number().int().nonnegative(), + previousCursor: z.string().nullable(), + nextCursor: z.string().nullable(), +}); +export type AdminRouteReports = z.infer; + +/** Body of `POST /admin/reports/:id/resolve`: `accepted` leads to a correction, `rejected` to none. */ +export const ResolveReportBody = z.strictObject({ + outcome: z.enum(['accepted', 'rejected']), + /** Kept in the journal of the admin, read by the team only. */ + note: plainText(500).optional(), +}); +export type ResolveReportBody = z.infer; + +/** Answer of `POST /admin/reports/:id/resolve`: the report as it stands afterwards. */ +export const ResolvedReport = z.object({ + id: z.uuid(), + status: z.enum(['accepted', 'rejected']), +}); +export type ResolvedReport = z.infer; + +/** `details` of a 409 `already_decided` on a report: the status another member gave it. */ +export const ReportAlreadyDecidedDetails = z.object({ status: z.enum(reportStatuses) }); +export type ReportAlreadyDecidedDetails = z.infer; diff --git a/packages/shared/src/schemas/admin-signals.ts b/packages/shared/src/schemas/admin-signals.ts new file mode 100644 index 00000000..84471f49 --- /dev/null +++ b/packages/shared/src/schemas/admin-signals.ts @@ -0,0 +1,93 @@ +import { z } from 'zod'; +import { taxonomies } from '../taxonomies'; +import { plainText } from './common'; + +/** Rows of a page of the signals and ratings lists of the admin. */ +export const adminSignalsPageSize = 50; + +/** Query of `GET /admin/signals`: the places the community flagged, oldest check first (A-03). */ +export const AdminSignalsQuery = z.strictObject({ cursor: z.string().max(200).optional() }); +export type AdminSignalsQuery = z.infer; + +/** + * A place waiting for the team in A-03, with the routes that go through it. The signals + * themselves (their number, their reasons, the recent confirmations) come with `place_signals` + * (wiki Modele-de-Donnees): until then they read as none. + */ +export const AdminSignaledPlace = z.object({ + id: z.uuid(), + name: z.string(), + address: z.string(), + cityName: z.string(), + verificationStatus: z.enum(taxonomies.verificationStatuses), + /** Null for a place never verified. */ + verifiedAt: z.iso.datetime().nullable(), + /** Open signals on the place. */ + signalCount: z.number().int().nonnegative(), + /** Confirmations since the last verification, weighted by trust. */ + confirmationCount: z.number().nonnegative(), + /** Published routes that go through the place: they pass to `needs_fix` when it is closed. */ + routes: z.array( + z.object({ id: z.uuid(), title: z.string(), status: z.enum(taxonomies.routeStatuses) }), + ), +}); +export type AdminSignaledPlace = z.infer; + +/** Body of `GET /admin/signals`. */ +export const AdminSignaledPlaces = z.object({ + items: z.array(AdminSignaledPlace), + total: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + previousCursor: z.string().nullable(), + nextCursor: z.string().nullable(), +}); +export type AdminSignaledPlaces = z.infer; + +/** Body of `POST /admin/places/:id/close`: why the place is closed, kept in the journal. */ +export const ClosePlaceBody = z.strictObject({ reason: plainText(500) }); +export type ClosePlaceBody = z.infer; + +/** Answer of `verify` and `close`: the place as it stands, and the routes marked to correct. */ +export const PlaceActionResult = z.object({ + placeId: z.uuid(), + verificationStatus: z.enum(taxonomies.verificationStatuses), + /** Routes passed to `needs_fix` by a closing; 0 for a verification. */ + routesToFix: z.number().int().nonnegative(), +}); +export type PlaceActionResult = z.infer; + +/** `details` of a 409 `already_decided` on a place: the status it already has. */ +export const PlaceAlreadyDecidedDetails = z.object({ + status: z.enum(taxonomies.verificationStatuses), +}); +export type PlaceAlreadyDecidedDetails = z.infer; + +/** Query of `GET /admin/ratings`: ratings with a comment, filtered by note and by route (A-03). */ +export const AdminRatingsQuery = z.strictObject({ + rating: z.coerce.number().int().min(1).max(5).optional(), + routeId: z.uuid().optional(), + cursor: z.string().max(200).optional(), +}); +export type AdminRatingsQuery = z.infer; + +/** A rating with a comment, as the team reads it. Ratings come with `route_ratings` (E-15). */ +export const AdminRating = z.object({ + id: z.uuid(), + rating: z.number().int().min(1).max(5), + comment: z.string(), + isHidden: z.boolean(), + createdAt: z.iso.datetime(), + route: z.object({ id: z.uuid(), title: z.string() }), + author: z.object({ id: z.uuid(), firstName: z.string().nullable() }).nullable(), +}); +export type AdminRating = z.infer; + +/** Body of `GET /admin/ratings`. */ +export const AdminRatings = z.object({ + items: z.array(AdminRating), + total: z.number().int().nonnegative(), + offset: z.number().int().nonnegative(), + previousCursor: z.string().nullable(), + nextCursor: z.string().nullable(), +}); +export type AdminRatings = z.infer; From 852efa5299c7184cccd6583d3800639dd92d4919 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:12:21 +0700 Subject: [PATCH 02/13] feat(api): list the open reports of routes for the team (A-03) Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/app.ts | 2 + apps/api/src/reports/admin-reports.ts | 92 ++++++++++++ apps/api/src/routes/admin-reports.test.ts | 168 ++++++++++++++++++++++ apps/api/src/routes/admin-reports.ts | 31 ++++ 4 files changed, 293 insertions(+) create mode 100644 apps/api/src/reports/admin-reports.ts create mode 100644 apps/api/src/routes/admin-reports.test.ts create mode 100644 apps/api/src/routes/admin-reports.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 2aced356..2335c695 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -19,6 +19,7 @@ import { parseQueryString } from './query-string'; import { adminRoutes } from './routes/admin'; import { adminCatalogRoutes } from './routes/admin-catalog'; import { adminModerationRoutes } from './routes/admin-moderation'; +import { adminReportsRoutes } from './routes/admin-reports'; import { configRoutes } from './routes/config'; import { editorRoutes } from './routes/editor'; import { geocodeRoutes } from './routes/geocode'; @@ -125,6 +126,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(adminRoutes, { prefix: '/v1' }); await app.register(adminModerationRoutes, { prefix: '/v1' }); await app.register(adminCatalogRoutes, { prefix: '/v1' }); + await app.register(adminReportsRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(usersRoutes, { prefix: '/v1' }); await app.register(editorRoutes, { prefix: '/v1' }); diff --git a/apps/api/src/reports/admin-reports.ts b/apps/api/src/reports/admin-reports.ts new file mode 100644 index 00000000..0c67f370 --- /dev/null +++ b/apps/api/src/reports/admin-reports.ts @@ -0,0 +1,92 @@ +import { + adminReportsPageSize, + type AdminReportsQuery, + type AdminRouteReports, +} from '@widoo/shared'; +import { sql } from 'drizzle-orm'; +import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; +import type { Db } from '../db/client'; + +type ReportRow = { + id: string; + reason: AdminRouteReports['items'][number]['reason']; + comment: string | null; + created_at: Date; + route_id: string; + route_title: string; + route_status: AdminRouteReports['items'][number]['route']['status']; + is_official: boolean; + city_name: string; + author_id: string | null; + author_first_name: string | null; + reporter_id: string | null; + reporter_first_name: string | null; + step_position: number | null; + place_name: string | null; + open_on_route: number; +}; + +const toReport = (row: ReportRow): AdminRouteReports['items'][number] => ({ + id: row.id, + reason: row.reason, + comment: row.comment, + createdAt: new Date(row.created_at).toISOString(), + route: { + id: row.route_id, + title: row.route_title, + status: row.route_status, + isOfficial: row.is_official, + cityName: row.city_name, + }, + author: row.author_id ? { id: row.author_id, firstName: row.author_first_name } : null, + reporter: row.reporter_id ? { id: row.reporter_id, firstName: row.reporter_first_name } : null, + step: + row.step_position === null || row.place_name === null + ? null + : { position: row.step_position, placeName: row.place_name }, + openOnRoute: row.open_on_route, +}); + +/** + * A page of the open reports of routes (A-03), the oldest first. Deleted accounts are left out of + * the names: the report stays, its author or reporter reads as unknown. + */ +export async function readOpenRouteReports( + db: Db, + query: AdminReportsQuery, +): Promise { + const offset = offsetOfCursor(query.cursor); + const where = query.reason + ? sql`rr.status = 'open' and rr.reason = ${query.reason}` + : sql`rr.status = 'open'`; + const from = sql`from route_reports rr + join routes r on r.id = rr.route_id + join cities c on c.id = r.city_id + left join users a on a.id = r.author_id and a.deleted_at is null + left join users rp on rp.id = rr.user_id and rp.deleted_at is null + left join steps s on s.id = rr.step_id + left join places p on p.id = s.place_id`; + + const rows = await db.execute(sql` + select rr.id, rr.reason, rr.comment, rr.created_at, + r.id as route_id, r.title as route_title, r.status as route_status, r.is_official, c.name as city_name, + a.id as author_id, a.first_name as author_first_name, + rp.id as reporter_id, rp.first_name as reporter_first_name, + s.position as step_position, coalesce(s.title, p.name) as place_name, + (select count(*)::int from route_reports o + where o.route_id = rr.route_id and o.status = 'open') as open_on_route + ${from} + where ${where} + order by rr.created_at asc, rr.id + limit ${adminReportsPageSize} offset ${offset}`); + const [counted] = await db.execute<{ total: number }>( + sql`select count(*)::int as total ${from} where ${where}`, + ); + const total = counted?.total ?? 0; + return { + items: rows.map(toReport), + total, + offset, + ...neighbourCursors(offset, total, adminReportsPageSize), + }; +} diff --git a/apps/api/src/routes/admin-reports.test.ts b/apps/api/src/routes/admin-reports.test.ts new file mode 100644 index 00000000..885dd7ca --- /dev/null +++ b/apps/api/src/routes/admin-reports.test.ts @@ -0,0 +1,168 @@ +import { + AdminRouteReports, + ApiError, + type RouteReportReason, + type RouteStatus, + type UserRole, +} from '@widoo/shared'; +import { eq, inArray } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { TestTokenVerifier } from '../auth/test-verifier'; +import { adminActions, routeReports, routes, steps, users } from '../db/schema'; +import { seed, seedId } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// Shared database, parallel files: unique users and routes, and only those are listed or deleted. +const verifier = new TestTokenVerifier(); +const createdUids: string[] = []; +const createdRouteIds: string[] = []; +const createdReportIds: string[] = []; +const marker = `reporttest-${uuidv7()}`; +const hour = 60 * 60 * 1000; +const templateId = seedId('route:marais-gourmand'); +const farAway = { lat: 33, lng: 33 }; +const farAwayBounds = { west: 33, south: 33, east: 33.001, north: 33.001 }; + +let app: Awaited>; +let authorId: string; +let reporterId: string; +let templateStepId: string; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); + await seed(app.db); + const create = async (firstName: string) => { + const uid = `test-${uuidv7()}`; + createdUids.push(uid); + const [user] = await app.db.insert(users).values({ firebaseUid: uid, firstName }).returning(); + if (!user) throw new Error('User not created'); + return user.id; + }; + authorId = await create('Camille'); + reporterId = await create('Lina'); + const [step] = await app.db + .select({ id: steps.id }) + .from(steps) + .where(eq(steps.routeId, templateId)) + .limit(1); + if (!step) throw new Error('Demo step missing from the seed'); + templateStepId = step.id; +}); +afterAll(async () => { + await app.db.delete(adminActions).where(inArray(adminActions.targetId, createdReportIds)); + await app.db.delete(routes).where(inArray(routes.id, createdRouteIds)); + await app.db.delete(users).where(inArray(users.firebaseUid, createdUids)); + await app.close(); +}); + +async function signIn(role: UserRole) { + const { token, uid } = verifier.issue({ secondFactor: 'totp' }); + createdUids.push(uid); + const [user] = await app.db.insert(users).values({ firebaseUid: uid, role }).returning(); + if (!user) throw new Error('User not created'); + return { id: user.id, headers: { authorization: `Bearer ${token}` } }; +} + +/** A route copied from the seed, far from every zone the search tests look at. */ +async function addRoute(status: RouteStatus, title: string) { + const [template] = await app.db.select().from(routes).where(eq(routes.id, templateId)); + if (!template) throw new Error('Demo route missing from the seed'); + const id = uuidv7(); + createdRouteIds.push(id); + await app.db.insert(routes).values({ + ...template, + id, + authorId, + shareToken: null, + startLocation: farAway, + bounds: farAwayBounds, + status, + title: `${title} ${marker}`, + }); + return id; +} + +async function addReport( + routeId: string, + reason: RouteReportReason, + hoursAgo = 1, + extra: { comment?: string; stepId?: string } = {}, +) { + const id = uuidv7(); + createdReportIds.push(id); + await app.db.insert(routeReports).values({ + id, + routeId, + userId: reason === 'low_rating' ? null : reporterId, + reason, + comment: extra.comment ?? null, + stepId: extra.stepId ?? null, + createdAt: new Date(Date.now() - hoursAgo * hour), + }); + return id; +} + +const list = (headers: Record, query = '') => + app.inject({ url: `/v1/admin/reports${query}`, headers }); + +describe('GET /v1/admin/reports', () => { + it('is for moderators and admins, not for an editor', async () => { + const editor = await signIn('editor'); + const response = await list(editor.headers); + expect(response.statusCode).toBe(403); + expect(ApiError.parse(response.json()).code).toBe('forbidden'); + }); + + it('lists the open reports, the oldest first, with route, author, reporter and step', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'Signalé'); + const other = await addRoute('published', 'Autre'); + const recent = await addReport(route, 'inappropriate', 2); + const old = await addReport(other, 'place_changed', 30, { + comment: 'Fermé depuis mars', + stepId: templateStepId, + }); + await addReport(route, 'other', 5, { comment: 'Photo trompeuse' }); + + const response = await list(moderator.headers); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + const body = AdminRouteReports.parse(response.json()); + const mine = body.items.filter((item) => item.route.title.includes(marker)); + expect(mine.map((item) => item.id)[0]).toBe(old); + expect(mine.map((item) => item.id)).toContain(recent); + expect(mine[0]).toMatchObject({ + reason: 'place_changed', + comment: 'Fermé depuis mars', + author: { id: authorId, firstName: 'Camille' }, + reporter: { id: reporterId, firstName: 'Lina' }, + openOnRoute: 1, + }); + expect(mine[0]?.step?.placeName).toEqual(expect.any(String)); + expect(mine[0]?.route.isOfficial).toEqual(expect.any(Boolean)); + const twice = mine.find((item) => item.id === recent); + expect(twice?.openOnRoute).toBe(2); + }); + + it('shows a low rating without a reporter, and filters by reason', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'Note basse'); + const low = await addReport(route, 'low_rating', 3); + await addReport(route, 'false_info', 2); + + const body = AdminRouteReports.parse( + (await list(moderator.headers, '?reason=low_rating')).json(), + ); + + const item = body.items.find((report) => report.id === low); + expect(item).toMatchObject({ reason: 'low_rating', reporter: null, step: null }); + expect(body.items.every((report) => report.reason === 'low_rating')).toBe(true); + }); + + it('refuses a reason it does not know', async () => { + const moderator = await signIn('moderator'); + expect((await list(moderator.headers, '?reason=spam')).statusCode).toBe(400); + }); +}); diff --git a/apps/api/src/routes/admin-reports.ts b/apps/api/src/routes/admin-reports.ts new file mode 100644 index 00000000..c315152b --- /dev/null +++ b/apps/api/src/routes/admin-reports.ts @@ -0,0 +1,31 @@ +import { AdminReportsQuery, AdminRouteReports } from '@widoo/shared'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { readOpenRouteReports } from '../reports/admin-reports'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; + +/** + * Reports of the admin (A-03, wiki API › Admin): the open reports of routes. Moderators and + * admins only. Never cached. + */ +export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAdminSection('reports')); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/admin/reports', + { + schema: { + tags, + summary: 'Open reports of routes', + security, + querystring: AdminReportsQuery, + response: { 200: AdminRouteReports }, + }, + }, + async (request) => readOpenRouteReports(app.db, request.query), + ); +}; From c564e1b728eef6703a5d9b22c0132d69b038e395 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:12:49 +0700 Subject: [PATCH 03/13] feat(api): accept or reject a report, and ask changes of a published route (A-03) Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/moderation/decisions.ts | 10 +- apps/api/src/reports/admin-reports.ts | 69 +++++++++- apps/api/src/routes/admin-reports.test.ts | 158 +++++++++++++++++++++- apps/api/src/routes/admin-reports.ts | 46 ++++++- 4 files changed, 274 insertions(+), 9 deletions(-) diff --git a/apps/api/src/moderation/decisions.ts b/apps/api/src/moderation/decisions.ts index 23f05626..8befc1be 100644 --- a/apps/api/src/moderation/decisions.ts +++ b/apps/api/src/moderation/decisions.ts @@ -16,7 +16,9 @@ type DecidableAction = Exclude; /** What each decision asks of the route, and the status it gives it (wiki, cycle de vie). */ const transitions: Record = { publish: { from: ['pending'], to: 'published' }, - request_changes: { from: ['pending'], to: 'changes_requested' }, + // A route waiting for its first look goes back to its author; one already online stays online + // and is marked « à corriger » (a low rating or a report, A-03): see `statusAfter`. + request_changes: { from: ['pending', 'published'], to: 'changes_requested' }, reject: { from: ['pending'], to: 'rejected' }, unpublish: { from: ['published', 'needs_fix'], to: 'unpublished' }, }; @@ -42,7 +44,7 @@ export async function decideRoute( input: ModerationDecisionInput, now = new Date(), ): Promise { - const { from, to } = transitions[input.action]; + const { from } = transitions[input.action]; return db.transaction(async (tx) => { const [route] = await tx @@ -57,6 +59,10 @@ export async function decideRoute( const details: AlreadyDecidedDetails = { status: route.status }; throw httpError(409, 'Route already decided', 'already_decided', details); } + const to: RouteStatus = + input.action === 'request_changes' && route.status === 'published' + ? 'needs_fix' + : transitions[input.action].to; await tx .update(routes) diff --git a/apps/api/src/reports/admin-reports.ts b/apps/api/src/reports/admin-reports.ts index 0c67f370..573e1888 100644 --- a/apps/api/src/reports/admin-reports.ts +++ b/apps/api/src/reports/admin-reports.ts @@ -2,10 +2,15 @@ import { adminReportsPageSize, type AdminReportsQuery, type AdminRouteReports, + type ReportAlreadyDecidedDetails, + type ResolvedReport, } from '@widoo/shared'; -import { sql } from 'drizzle-orm'; +import { eq, sql } from 'drizzle-orm'; import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; +import { recordAdminAction } from '../admin/actions'; import type { Db } from '../db/client'; +import { notifications, routeReports } from '../db/schema'; +import { httpError } from '../errors'; type ReportRow = { id: string; @@ -90,3 +95,65 @@ export async function readOpenRouteReports( ...neighbourCursors(offset, total, adminReportsPageSize), }; } + +type ResolveInput = { + reportId: string; + /** The member of the team who decides. */ + actorId: string; + outcome: 'accepted' | 'rejected'; + note?: string; +}; + +/** + * Decides an open report (A-03, D-056): `accepted` thanks the reporter with a notification + * (`report_resolved`), `rejected` sends nothing. The note stays in the journal of the admin. The + * report is locked while it is decided: a report that is no longer open answers 409 + * `already_decided` with the status the first decision gave it. What happens to the route itself + * (unpublishing, a request for changes) is its own decision, made with the moderation actions. + */ +export async function resolveRouteReport( + db: Db, + input: ResolveInput, + now = new Date(), +): Promise { + return db.transaction(async (tx) => { + const [report] = await tx + .select({ + status: routeReports.status, + userId: routeReports.userId, + routeId: routeReports.routeId, + }) + .from(routeReports) + .where(eq(routeReports.id, input.reportId)) + .for('update'); + if (!report) { + throw httpError(404, 'Report not found'); + } + if (report.status !== 'open') { + const details: ReportAlreadyDecidedDetails = { status: report.status }; + throw httpError(409, 'Report already decided', 'already_decided', details); + } + + await tx + .update(routeReports) + .set({ status: input.outcome, reviewedBy: input.actorId, reviewedAt: now }) + .where(eq(routeReports.id, input.reportId)); + if (input.outcome === 'accepted' && report.userId) { + await tx.insert(notifications).values({ + userId: report.userId, + kind: 'report_resolved', + payload: { reportId: input.reportId, routeId: report.routeId }, + }); + } + await recordAdminAction(tx, { + actorId: input.actorId, + action: 'report.resolve', + targetType: 'route_report', + targetId: input.reportId, + before: { status: 'open' }, + after: { status: input.outcome }, + reason: input.note, + }); + return { id: input.reportId, status: input.outcome }; + }); +} diff --git a/apps/api/src/routes/admin-reports.test.ts b/apps/api/src/routes/admin-reports.test.ts index 885dd7ca..541a25cc 100644 --- a/apps/api/src/routes/admin-reports.test.ts +++ b/apps/api/src/routes/admin-reports.test.ts @@ -1,15 +1,17 @@ import { AdminRouteReports, ApiError, + ReportAlreadyDecidedDetails, + ResolvedReport, type RouteReportReason, type RouteStatus, type UserRole, } from '@widoo/shared'; -import { eq, inArray } from 'drizzle-orm'; +import { and, eq, inArray } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; import { TestTokenVerifier } from '../auth/test-verifier'; -import { adminActions, routeReports, routes, steps, users } from '../db/schema'; +import { adminActions, notifications, routeReports, routes, steps, users } from '../db/schema'; import { seed, seedId } from '../db/seed'; import { uuidv7 } from '../db/uuid'; import { testConfig } from '../test-config'; @@ -105,6 +107,10 @@ async function addReport( const list = (headers: Record, query = '') => app.inject({ url: `/v1/admin/reports${query}`, headers }); +const resolve = (headers: Record, id: string, payload: object) => + app.inject({ method: 'POST', url: `/v1/admin/reports/${id}/resolve`, headers, payload }); +const titlesOf = (body: AdminRouteReports) => + body.items.map((item) => item.route.title).filter((title) => title.includes(marker)); describe('GET /v1/admin/reports', () => { it('is for moderators and admins, not for an editor', async () => { @@ -161,8 +167,156 @@ describe('GET /v1/admin/reports', () => { expect(body.items.every((report) => report.reason === 'low_rating')).toBe(true); }); + it('leaves out the reports the team already decided', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'Décidé'); + const report = await addReport(route, 'inappropriate'); + await resolve(moderator.headers, report, { outcome: 'rejected' }); + + const body = AdminRouteReports.parse((await list(moderator.headers)).json()); + + expect(titlesOf(body)).not.toContain(`Décidé ${marker}`); + }); + it('refuses a reason it does not know', async () => { const moderator = await signIn('moderator'); expect((await list(moderator.headers, '?reason=spam')).statusCode).toBe(400); }); }); + +describe('POST /v1/admin/reports/:id/resolve', () => { + it('accepts a report, thanks the reporter and writes the note in the journal', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'Accepté'); + const report = await addReport(route, 'inappropriate'); + + const response = await resolve(moderator.headers, report, { + outcome: 'accepted', + note: 'Photo retirée', + }); + + expect(response.statusCode).toBe(200); + expect(ResolvedReport.parse(response.json())).toEqual({ id: report, status: 'accepted' }); + const [row] = await app.db.select().from(routeReports).where(eq(routeReports.id, report)); + expect(row).toMatchObject({ status: 'accepted', reviewedBy: moderator.id }); + expect(row?.reviewedAt).toBeInstanceOf(Date); + const sent = await app.db + .select() + .from(notifications) + .where(and(eq(notifications.userId, reporterId), eq(notifications.kind, 'report_resolved'))); + expect(sent.some((item) => (item.payload as { reportId?: string }).reportId === report)).toBe( + true, + ); + const [entry] = await app.db + .select() + .from(adminActions) + .where(eq(adminActions.targetId, report)); + expect(entry).toMatchObject({ + actorId: moderator.id, + action: 'report.resolve', + targetType: 'route_report', + reason: 'Photo retirée', + }); + }); + + it('rejects a report without telling the reporter', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'Rejeté'); + const report = await addReport(route, 'inappropriate'); + + const response = await resolve(moderator.headers, report, { outcome: 'rejected' }); + + expect(response.statusCode).toBe(200); + const sent = await app.db + .select() + .from(notifications) + .where(eq(notifications.userId, reporterId)); + expect(sent.some((item) => (item.payload as { reportId?: string }).reportId === report)).toBe( + false, + ); + }); + + it('closes a low rating without a reporter to thank', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'Gardé'); + const report = await addReport(route, 'low_rating'); + + const response = await resolve(moderator.headers, report, { outcome: 'accepted' }); + + expect(response.statusCode).toBe(200); + }); + + it('answers 409 already_decided with the status the first decision gave', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'Deux fois'); + const report = await addReport(route, 'inappropriate'); + await resolve(moderator.headers, report, { outcome: 'rejected' }); + + const response = await resolve(moderator.headers, report, { outcome: 'accepted' }); + + expect(response.statusCode).toBe(409); + const error = ApiError.parse(response.json()); + expect(error.code).toBe('already_decided'); + expect(ReportAlreadyDecidedDetails.parse(error.details)).toEqual({ status: 'rejected' }); + }); + + it('lets only one of two simultaneous decisions through', async () => { + const [first, second] = await Promise.all([signIn('moderator'), signIn('admin')]); + const route = await addRoute('published', 'Course'); + const report = await addReport(route, 'inappropriate'); + + const answers = await Promise.all([ + resolve(first.headers, report, { outcome: 'accepted' }), + resolve(second.headers, report, { outcome: 'rejected' }), + ]); + + expect(answers.map((answer) => answer.statusCode).sort()).toEqual([200, 409]); + const journal = await app.db + .select() + .from(adminActions) + .where(eq(adminActions.targetId, report)); + expect(journal).toHaveLength(1); + }); + + it('answers 404 to a report that does not exist, and 422 to an invalid body', async () => { + const moderator = await signIn('moderator'); + expect((await resolve(moderator.headers, uuidv7(), { outcome: 'accepted' })).statusCode).toBe( + 404, + ); + const route = await addRoute('published', 'Invalide'); + const report = await addReport(route, 'inappropriate'); + expect((await resolve(moderator.headers, report, { outcome: 'open' })).statusCode).toBe(422); + expect( + (await resolve(moderator.headers, report, { outcome: 'accepted', note: '' })).statusCode, + ).toBe(422); + }); + + it('is refused to an editor and leaves the report open', async () => { + const editor = await signIn('editor'); + const route = await addRoute('published', 'Éditeur'); + const report = await addReport(route, 'inappropriate'); + + expect((await resolve(editor.headers, report, { outcome: 'accepted' })).statusCode).toBe(403); + const [row] = await app.db.select().from(routeReports).where(eq(routeReports.id, report)); + expect(row?.status).toBe('open'); + }); +}); + +describe('POST /v1/admin/routes/:id/request-changes on a published route', () => { + it('marks the route as to correct without taking it offline', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'À corriger'); + + const response = await app.inject({ + method: 'POST', + url: `/v1/admin/routes/${route}/request-changes`, + headers: moderator.headers, + payload: { message: 'La note moyenne est trop basse : reprenez les étapes 2 et 3.' }, + }); + + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ action: 'request_changes', status: 'needs_fix' }); + const [row] = await app.db.select().from(routes).where(eq(routes.id, route)); + expect(row?.status).toBe('needs_fix'); + }); +}); diff --git a/apps/api/src/routes/admin-reports.ts b/apps/api/src/routes/admin-reports.ts index c315152b..8a43b530 100644 --- a/apps/api/src/routes/admin-reports.ts +++ b/apps/api/src/routes/admin-reports.ts @@ -1,13 +1,30 @@ -import { AdminReportsQuery, AdminRouteReports } from '@widoo/shared'; +import { + AdminReportsQuery, + AdminRouteReports, + ResolvedReport, + ResolveReportBody, +} from '@widoo/shared'; +import type { FastifyRequest } from 'fastify'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; -import { readOpenRouteReports } from '../reports/admin-reports'; +import { z } from 'zod'; +import { httpError } from '../errors'; +import { readOpenRouteReports, resolveRouteReport } from '../reports/admin-reports'; const security = [{ firebaseIdToken: [] }]; const tags = ['admin']; +const ReportParams = z.object({ id: z.uuid() }); + +/** Set by the guard of the section, which every route of this plugin runs first. */ +function actorIdOf(request: FastifyRequest): string { + if (!request.user) { + throw httpError(401, 'Missing bearer token'); + } + return request.user.id; +} /** - * Reports of the admin (A-03, wiki API › Admin): the open reports of routes. Moderators and - * admins only. Never cached. + * Reports of the admin (A-03, wiki API › Admin): the open reports of routes and their resolution. + * Moderators and admins only; each decision is written in the journal of the admin. Never cached. */ export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => { app.addHook('onRequest', app.requireAdminSection('reports')); @@ -28,4 +45,25 @@ export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => { }, async (request) => readOpenRouteReports(app.db, request.query), ); + + app.post( + '/admin/reports/:id/resolve', + { + schema: { + tags, + summary: 'Accept or reject a report of a route', + security, + params: ReportParams, + body: ResolveReportBody, + response: { 200: ResolvedReport }, + }, + }, + async (request) => + resolveRouteReport(app.db, { + reportId: request.params.id, + actorId: actorIdOf(request), + outcome: request.body.outcome, + note: request.body.note, + }), + ); }; From 5430b545d49b08b27ff962f9345a9857ee99290c Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:13:37 +0700 Subject: [PATCH 04/13] feat(api): list the places the community flagged and mark one verified (A-03) Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/reports/place-signals.ts | 121 ++++++++++++++ apps/api/src/routes/admin-reports.ts | 39 ++++- apps/api/src/routes/admin-signals.test.ts | 185 ++++++++++++++++++++++ 3 files changed, 343 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/reports/place-signals.ts create mode 100644 apps/api/src/routes/admin-signals.test.ts diff --git a/apps/api/src/reports/place-signals.ts b/apps/api/src/reports/place-signals.ts new file mode 100644 index 00000000..b8d13d3d --- /dev/null +++ b/apps/api/src/reports/place-signals.ts @@ -0,0 +1,121 @@ +import { + adminSignalsPageSize, + type AdminSignaledPlaces, + type AdminSignalsQuery, + type PlaceActionResult, + type PlaceAlreadyDecidedDetails, +} from '@widoo/shared'; +import { eq, sql } from 'drizzle-orm'; +import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; +import { recordAdminAction } from '../admin/actions'; +import type { Db } from '../db/client'; +import { places } from '../db/schema'; +import { httpError } from '../errors'; + +type PlaceRow = { + id: string; + name: string; + address: string; + city_name: string; + verification_status: AdminSignaledPlaces['items'][number]['verificationStatus']; + verified_at: Date | null; + confirmations: number; + routes: AdminSignaledPlaces['items'][number]['routes']; +}; + +// The signals of a place are not stored yet (`place_signals`, ticket #49): while no table holds +// them, none is open. One expression to replace when the table exists. +const openSignalCount = 0; + +/** + * The places the community flagged (A-03), the least recently verified first, with the published + * routes that go through each. Without the signals table, the list holds the places whose status + * is `flagged`, each with no signal counted. + */ +export async function readSignaledPlaces( + db: Db, + query: AdminSignalsQuery, +): Promise { + const offset = offsetOfCursor(query.cursor); + const from = sql`from places p join cities c on c.id = p.city_id + where p.verification_status = 'flagged'`; + const rows = await db.execute(sql` + select p.id, p.name, p.address, c.name as city_name, p.verification_status, p.verified_at, + p.confirmations_since_verified::float as confirmations, + coalesce(( + select jsonb_agg(jsonb_build_object('id', r.id, 'title', r.title, 'status', r.status) + order by r.title) + from routes r + where r.status = 'published' + and exists (select 1 from steps s where s.route_id = r.id and s.place_id = p.id) + ), '[]'::jsonb) as routes + ${from} + order by p.verified_at asc nulls first, p.id + limit ${adminSignalsPageSize} offset ${offset}`); + const [counted] = await db.execute<{ total: number }>(sql`select count(*)::int as total ${from}`); + const total = counted?.total ?? 0; + return { + items: rows.map((row) => ({ + id: row.id, + name: row.name, + address: row.address, + cityName: row.city_name, + verificationStatus: row.verification_status, + verifiedAt: row.verified_at ? new Date(row.verified_at).toISOString() : null, + signalCount: openSignalCount, + confirmationCount: row.confirmations, + routes: row.routes, + })), + total, + offset, + ...neighbourCursors(offset, total, adminSignalsPageSize), + }; +} + +type PlaceActionInput = { + placeId: string; + /** The member of the team who acts. */ + actorId: string; +}; + +function alreadyIs(status: PlaceActionResult['verificationStatus']) { + const details: PlaceAlreadyDecidedDetails = { status }; + return httpError(409, `Place is already ${status}`, 'already_decided', details); +} + +/** + * Marks a place verified by the team (A-03): the confirmations count from zero again. A place + * that is already verified answers 409 `already_decided`. + */ +export async function verifyPlace( + db: Db, + input: PlaceActionInput, + now = new Date(), +): Promise { + return db.transaction(async (tx) => { + const [place] = await tx + .select({ status: places.verificationStatus }) + .from(places) + .where(eq(places.id, input.placeId)) + .for('update'); + if (!place) { + throw httpError(404, 'Place not found'); + } + if (place.status === 'verified') { + throw alreadyIs(place.status); + } + await tx + .update(places) + .set({ verificationStatus: 'verified', verifiedAt: now, confirmationsSinceVerified: 0 }) + .where(eq(places.id, input.placeId)); + await recordAdminAction(tx, { + actorId: input.actorId, + action: 'place.verify', + targetType: 'place', + targetId: input.placeId, + before: { status: place.status }, + after: { status: 'verified' }, + }); + return { placeId: input.placeId, verificationStatus: 'verified', routesToFix: 0 }; + }); +} diff --git a/apps/api/src/routes/admin-reports.ts b/apps/api/src/routes/admin-reports.ts index 8a43b530..1e778146 100644 --- a/apps/api/src/routes/admin-reports.ts +++ b/apps/api/src/routes/admin-reports.ts @@ -1,6 +1,9 @@ import { AdminReportsQuery, AdminRouteReports, + AdminSignaledPlaces, + AdminSignalsQuery, + PlaceActionResult, ResolvedReport, ResolveReportBody, } from '@widoo/shared'; @@ -9,10 +12,12 @@ import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; import { httpError } from '../errors'; import { readOpenRouteReports, resolveRouteReport } from '../reports/admin-reports'; +import { readSignaledPlaces, verifyPlace } from '../reports/place-signals'; const security = [{ firebaseIdToken: [] }]; const tags = ['admin']; const ReportParams = z.object({ id: z.uuid() }); +const PlaceParams = z.object({ id: z.uuid() }); /** Set by the guard of the section, which every route of this plugin runs first. */ function actorIdOf(request: FastifyRequest): string { @@ -23,8 +28,9 @@ function actorIdOf(request: FastifyRequest): string { } /** - * Reports of the admin (A-03, wiki API › Admin): the open reports of routes and their resolution. - * Moderators and admins only; each decision is written in the journal of the admin. Never cached. + * Signals and reports of the admin (A-03, wiki API › Admin): the places the community flagged and + * what the team does with them, the open reports of routes and their resolution. Moderators and + * admins only; each decision is written in the journal of the admin. Never cached. */ export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => { app.addHook('onRequest', app.requireAdminSection('reports')); @@ -66,4 +72,33 @@ export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => { note: request.body.note, }), ); + + app.get( + '/admin/signals', + { + schema: { + tags, + summary: 'Places flagged by the community', + security, + querystring: AdminSignalsQuery, + response: { 200: AdminSignaledPlaces }, + }, + }, + async (request) => readSignaledPlaces(app.db, request.query), + ); + + app.post( + '/admin/places/:id/verify', + { + schema: { + tags, + summary: 'Mark a place verified', + security, + params: PlaceParams, + response: { 200: PlaceActionResult }, + }, + }, + async (request) => + verifyPlace(app.db, { placeId: request.params.id, actorId: actorIdOf(request) }), + ); }; diff --git a/apps/api/src/routes/admin-signals.test.ts b/apps/api/src/routes/admin-signals.test.ts new file mode 100644 index 00000000..e03d2304 --- /dev/null +++ b/apps/api/src/routes/admin-signals.test.ts @@ -0,0 +1,185 @@ +import { + AdminSignaledPlaces, + ApiError, + PlaceActionResult, + PlaceAlreadyDecidedDetails, + type CatalogPlaceInput, + type RouteStatus, + type UserRole, +} from '@widoo/shared'; +import { and, eq, inArray, like } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { TestTokenVerifier } from '../auth/test-verifier'; +import { createCatalogPlace } from '../catalog/place-writes'; +import { adminActions, places, routes, steps, users } from '../db/schema'; +import { seed, seedId } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// Shared database, parallel files: unique users, and places and routes found by a marker. +const verifier = new TestTokenVerifier(); +const createdUids: string[] = []; +const createdRouteIds: string[] = []; +const marker = `signaltest-${uuidv7()}`; +const templateId = seedId('route:marais-gourmand'); +const farAway = { lat: 33.5, lng: 33.5 }; +const farAwayBounds = { west: 33.5, south: 33.5, east: 33.501, north: 33.501 }; + +let app: Awaited>; +let adminId: string; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); + await seed(app.db); + adminId = (await signIn('admin')).id; +}); +afterAll(async () => { + const created = await app.db + .select({ id: places.id }) + .from(places) + .where(like(places.name, `%${marker}%`)); + const placeIds = created.map((place) => place.id); + await app.db + .delete(adminActions) + .where(inArray(adminActions.targetId, [...placeIds, ...createdRouteIds])); + await app.db.delete(routes).where(inArray(routes.id, createdRouteIds)); + if (placeIds.length > 0) { + await app.db.delete(places).where(inArray(places.id, placeIds)); + } + await app.db.delete(users).where(inArray(users.firebaseUid, createdUids)); + await app.close(); +}); + +async function signIn(role: UserRole) { + const { token, uid } = verifier.issue({ secondFactor: 'totp' }); + createdUids.push(uid); + const [user] = await app.db.insert(users).values({ firebaseUid: uid, role }).returning(); + if (!user) throw new Error('User not created'); + return { id: user.id, headers: { authorization: `Bearer ${token}` } }; +} + +// Inside the bounds of Paris of the seed. +const sheet = (name: string): CatalogPlaceInput => ({ + name: `${name} ${marker}`, + category: 'cafe', + address: '12 rue des Martyrs, 75009 Paris', + location: { lat: 48.8785, lng: 2.3397 }, + isIndoor: true, + priceRange: null, + websiteUrl: null, + phone: null, + hours: [], +}); + +/** A place of the catalog in `status`, with a route in `routeStatus` that goes through it. */ +async function addPlace( + name: string, + status: 'flagged' | 'verified' | 'stale', + routeStatus: RouteStatus | null = null, +) { + const placeId = await createCatalogPlace(app.db, sheet(name), adminId); + await app.db + .update(places) + .set({ verificationStatus: status, confirmationsSinceVerified: 3.5 }) + .where(eq(places.id, placeId)); + let routeId: string | null = null; + if (routeStatus) { + const [template] = await app.db.select().from(routes).where(eq(routes.id, templateId)); + const [step] = await app.db.select().from(steps).where(eq(steps.routeId, templateId)).limit(1); + if (!template || !step) throw new Error('Demo route missing from the seed'); + routeId = uuidv7(); + createdRouteIds.push(routeId); + await app.db.insert(routes).values({ + ...template, + id: routeId, + shareToken: null, + startLocation: farAway, + bounds: farAwayBounds, + status: routeStatus, + title: `${name} ${marker}`, + }); + await app.db.insert(steps).values({ ...step, id: uuidv7(), routeId, placeId }); + } + return { placeId, routeId }; +} + +const list = (headers: Record) => app.inject({ url: '/v1/admin/signals', headers }); +const act = ( + headers: Record, + placeId: string, + action: 'verify' | 'close', + payload?: object, +) => app.inject({ method: 'POST', url: `/v1/admin/places/${placeId}/${action}`, headers, payload }); + +describe('GET /v1/admin/signals', () => { + it('is for moderators and admins, not for an editor', async () => { + const editor = await signIn('editor'); + expect((await list(editor.headers)).statusCode).toBe(403); + }); + + it('lists the flagged places with the published routes that go through them', async () => { + const moderator = await signIn('moderator'); + const flagged = await addPlace('Signalé', 'flagged', 'published'); + await addPlace('Vérifié', 'verified'); + const unpublished = await addPlace('Hors ligne', 'flagged', 'unpublished'); + + const response = await list(moderator.headers); + + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + const body = AdminSignaledPlaces.parse(response.json()); + const mine = body.items.filter((item) => item.name.includes(marker)); + expect(mine.map((item) => item.name.split(' ')[0]).sort()).toEqual(['Hors', 'Signalé']); + const item = mine.find((place) => place.id === flagged.placeId); + expect(item).toMatchObject({ + verificationStatus: 'flagged', + cityName: 'Paris', + signalCount: 0, + confirmationCount: 3.5, + }); + expect(item?.routes.map((route) => route.id)).toEqual([flagged.routeId]); + const noRoute = mine.find((place) => place.id === unpublished.placeId); + expect(noRoute?.routes).toEqual([]); + }); +}); + +describe('POST /v1/admin/places/:id/verify', () => { + it('marks the place verified, from zero, and writes it in the journal', async () => { + const moderator = await signIn('moderator'); + const { placeId } = await addPlace('À vérifier', 'flagged'); + + const response = await act(moderator.headers, placeId, 'verify'); + + expect(response.statusCode).toBe(200); + expect(PlaceActionResult.parse(response.json())).toEqual({ + placeId, + verificationStatus: 'verified', + routesToFix: 0, + }); + const [row] = await app.db.select().from(places).where(eq(places.id, placeId)); + expect(row).toMatchObject({ verificationStatus: 'verified', confirmationsSinceVerified: 0 }); + expect(row?.verifiedAt).toBeInstanceOf(Date); + const [entry] = await app.db + .select() + .from(adminActions) + .where(and(eq(adminActions.targetId, placeId), eq(adminActions.action, 'place.verify'))); + expect(entry).toMatchObject({ actorId: moderator.id, action: 'place.verify' }); + }); + + it('answers 409 already_decided to a place that is already verified', async () => { + const moderator = await signIn('moderator'); + const { placeId } = await addPlace('Déjà', 'verified'); + + const response = await act(moderator.headers, placeId, 'verify'); + + expect(response.statusCode).toBe(409); + const error = ApiError.parse(response.json()); + expect(error.code).toBe('already_decided'); + expect(PlaceAlreadyDecidedDetails.parse(error.details)).toEqual({ status: 'verified' }); + }); + + it('answers 404 to a place that does not exist', async () => { + const moderator = await signIn('moderator'); + expect((await act(moderator.headers, uuidv7(), 'verify')).statusCode).toBe(404); + }); +}); From 66f5cbc8fcf285360d10729ba3a0a4fa7f3336a0 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:13:52 +0700 Subject: [PATCH 05/13] feat(api): close a place and mark the routes that go through it to correct (A-03) Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/reports/place-signals.ts | 64 ++++++++++++++++++- apps/api/src/routes/admin-reports.ts | 23 ++++++- apps/api/src/routes/admin-signals.test.ts | 77 ++++++++++++++++++++++- 3 files changed, 160 insertions(+), 4 deletions(-) diff --git a/apps/api/src/reports/place-signals.ts b/apps/api/src/reports/place-signals.ts index b8d13d3d..56caf513 100644 --- a/apps/api/src/reports/place-signals.ts +++ b/apps/api/src/reports/place-signals.ts @@ -5,11 +5,11 @@ import { type PlaceActionResult, type PlaceAlreadyDecidedDetails, } from '@widoo/shared'; -import { eq, sql } from 'drizzle-orm'; +import { eq, inArray, sql } from 'drizzle-orm'; import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; import { recordAdminAction } from '../admin/actions'; import type { Db } from '../db/client'; -import { places } from '../db/schema'; +import { moderationActionsTable, places, routes } from '../db/schema'; import { httpError } from '../errors'; type PlaceRow = { @@ -119,3 +119,63 @@ export async function verifyPlace( return { placeId: input.placeId, verificationStatus: 'verified', routesToFix: 0 }; }); } + +/** + * Closes a place (A-03): its status becomes `closed`, and the published routes that go through it + * become `needs_fix`, each with a line in its history. The reason is required. A place that is + * already closed answers 409 `already_decided`. + */ +export async function closePlace( + db: Db, + input: PlaceActionInput & { reason: string }, +): Promise { + return db.transaction(async (tx) => { + const [place] = await tx + .select({ status: places.verificationStatus, name: places.name }) + .from(places) + .where(eq(places.id, input.placeId)) + .for('update'); + if (!place) { + throw httpError(404, 'Place not found'); + } + if (place.status === 'closed') { + throw alreadyIs(place.status); + } + await tx + .update(places) + .set({ verificationStatus: 'closed' }) + .where(eq(places.id, input.placeId)); + + const concerned = await tx.execute<{ id: string }>(sql` + select r.id from routes r + where r.status = 'published' + and exists (select 1 from steps s where s.route_id = r.id and s.place_id = ${input.placeId}) + for update`); + const routeIds = concerned.map((row) => row.id); + if (routeIds.length > 0) { + await tx.update(routes).set({ status: 'needs_fix' }).where(inArray(routes.id, routeIds)); + await tx.insert(moderationActionsTable).values( + routeIds.map((routeId) => ({ + routeId, + actorId: input.actorId, + action: 'request_changes' as const, + message: `Lieu fermé : ${place.name}. ${input.reason}`, + })), + ); + } + await recordAdminAction(tx, { + actorId: input.actorId, + action: 'place.close', + targetType: 'place', + targetId: input.placeId, + before: { status: place.status }, + after: { status: 'closed', routesToFix: routeIds }, + reason: input.reason, + }); + return { + placeId: input.placeId, + verificationStatus: 'closed', + routesToFix: routeIds.length, + }; + }); +} diff --git a/apps/api/src/routes/admin-reports.ts b/apps/api/src/routes/admin-reports.ts index 1e778146..c574f0b3 100644 --- a/apps/api/src/routes/admin-reports.ts +++ b/apps/api/src/routes/admin-reports.ts @@ -3,6 +3,7 @@ import { AdminRouteReports, AdminSignaledPlaces, AdminSignalsQuery, + ClosePlaceBody, PlaceActionResult, ResolvedReport, ResolveReportBody, @@ -12,7 +13,7 @@ import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; import { httpError } from '../errors'; import { readOpenRouteReports, resolveRouteReport } from '../reports/admin-reports'; -import { readSignaledPlaces, verifyPlace } from '../reports/place-signals'; +import { closePlace, readSignaledPlaces, verifyPlace } from '../reports/place-signals'; const security = [{ firebaseIdToken: [] }]; const tags = ['admin']; @@ -101,4 +102,24 @@ export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => { async (request) => verifyPlace(app.db, { placeId: request.params.id, actorId: actorIdOf(request) }), ); + + app.post( + '/admin/places/:id/close', + { + schema: { + tags, + summary: 'Close a place, and mark the routes that go through it to correct', + security, + params: PlaceParams, + body: ClosePlaceBody, + response: { 200: PlaceActionResult }, + }, + }, + async (request) => + closePlace(app.db, { + placeId: request.params.id, + actorId: actorIdOf(request), + reason: request.body.reason, + }), + ); }; diff --git a/apps/api/src/routes/admin-signals.test.ts b/apps/api/src/routes/admin-signals.test.ts index e03d2304..9b318a39 100644 --- a/apps/api/src/routes/admin-signals.test.ts +++ b/apps/api/src/routes/admin-signals.test.ts @@ -12,7 +12,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; import { TestTokenVerifier } from '../auth/test-verifier'; import { createCatalogPlace } from '../catalog/place-writes'; -import { adminActions, places, routes, steps, users } from '../db/schema'; +import { adminActions, moderationActionsTable, places, routes, steps, users } from '../db/schema'; import { seed, seedId } from '../db/seed'; import { uuidv7 } from '../db/uuid'; import { testConfig } from '../test-config'; @@ -110,6 +110,8 @@ const act = ( action: 'verify' | 'close', payload?: object, ) => app.inject({ method: 'POST', url: `/v1/admin/places/${placeId}/${action}`, headers, payload }); +const routeStatusOf = async (id: string) => + (await app.db.select({ status: routes.status }).from(routes).where(eq(routes.id, id)))[0]?.status; describe('GET /v1/admin/signals', () => { it('is for moderators and admins, not for an editor', async () => { @@ -183,3 +185,76 @@ describe('POST /v1/admin/places/:id/verify', () => { expect((await act(moderator.headers, uuidv7(), 'verify')).statusCode).toBe(404); }); }); + +describe('POST /v1/admin/places/:id/close', () => { + it('closes the place and marks the published routes that go through it to correct', async () => { + const moderator = await signIn('moderator'); + const online = await addPlace('Fermé', 'flagged', 'published'); + const offline = await addPlace('Fermé aussi', 'flagged', 'unpublished'); + // A second route through the first place, already out of the catalog: left as it is. + expect(offline.routeId).not.toBeNull(); + + const response = await act(moderator.headers, online.placeId, 'close', { + reason: 'Fermé définitivement depuis septembre.', + }); + + expect(response.statusCode).toBe(200); + expect(PlaceActionResult.parse(response.json())).toEqual({ + placeId: online.placeId, + verificationStatus: 'closed', + routesToFix: 1, + }); + expect(await routeStatusOf(online.routeId ?? '')).toBe('needs_fix'); + expect(await routeStatusOf(offline.routeId ?? '')).toBe('unpublished'); + const history = await app.db + .select() + .from(moderationActionsTable) + .where(eq(moderationActionsTable.routeId, online.routeId ?? '')); + expect(history).toHaveLength(1); + expect(history[0]).toMatchObject({ action: 'request_changes', actorId: moderator.id }); + expect(history[0]?.message).toContain('Fermé définitivement'); + const [entry] = await app.db + .select() + .from(adminActions) + .where( + and(eq(adminActions.targetId, online.placeId), eq(adminActions.action, 'place.close')), + ); + expect(entry).toMatchObject({ + action: 'place.close', + reason: 'Fermé définitivement depuis septembre.', + }); + }); + + it('needs a reason, and leaves the place and its routes as they were', async () => { + const moderator = await signIn('moderator'); + const { placeId, routeId } = await addPlace('Sans motif', 'flagged', 'published'); + + expect((await act(moderator.headers, placeId, 'close', {})).statusCode).toBe(422); + expect( + (await act(moderator.headers, placeId, 'close', { reason: 'x' })).statusCode, + ).toBe(422); + + const [row] = await app.db.select().from(places).where(eq(places.id, placeId)); + expect(row?.verificationStatus).toBe('flagged'); + expect(await routeStatusOf(routeId ?? '')).toBe('published'); + }); + + it('answers 409 already_decided to a place that is already closed', async () => { + const moderator = await signIn('moderator'); + const { placeId } = await addPlace('Deux fois', 'flagged'); + await act(moderator.headers, placeId, 'close', { reason: 'Fermé.' }); + + const response = await act(moderator.headers, placeId, 'close', { reason: 'Encore.' }); + + expect(response.statusCode).toBe(409); + expect(PlaceAlreadyDecidedDetails.parse(ApiError.parse(response.json()).details)).toEqual({ + status: 'closed', + }); + }); + + it('is refused to an editor', async () => { + const editor = await signIn('editor'); + const { placeId } = await addPlace('Éditeur', 'flagged'); + expect((await act(editor.headers, placeId, 'close', { reason: 'Non.' })).statusCode).toBe(403); + }); +}); From 02a3ec69dd6d84e31e28e52d666a0a5a0e8de4b9 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:13:58 +0700 Subject: [PATCH 06/13] feat(api): list the ratings with a comment, empty until they are stored (A-03) Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/reports/ratings.ts | 18 ++++++++++++++++ apps/api/src/routes/admin-reports.ts | 24 ++++++++++++++++++--- apps/api/src/routes/admin-signals.test.ts | 26 +++++++++++++++++++++++ 3 files changed, 65 insertions(+), 3 deletions(-) create mode 100644 apps/api/src/reports/ratings.ts diff --git a/apps/api/src/reports/ratings.ts b/apps/api/src/reports/ratings.ts new file mode 100644 index 00000000..5d60128c --- /dev/null +++ b/apps/api/src/reports/ratings.ts @@ -0,0 +1,18 @@ +import { adminSignalsPageSize, type AdminRatings, type AdminRatingsQuery } from '@widoo/shared'; +import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; + +/** + * The ratings with a comment (A-03). Ratings are not stored yet (`route_ratings`, ticket #64): + * until the table exists, none was ever given, and the list is empty. The query is read all the + * same, so that its filters are validated now. + */ +export async function readRatings(query: AdminRatingsQuery): Promise { + const offset = offsetOfCursor(query.cursor); + const total = 0; + return { + items: [], + total, + offset, + ...neighbourCursors(offset, total, adminSignalsPageSize), + }; +} diff --git a/apps/api/src/routes/admin-reports.ts b/apps/api/src/routes/admin-reports.ts index c574f0b3..ed005aa8 100644 --- a/apps/api/src/routes/admin-reports.ts +++ b/apps/api/src/routes/admin-reports.ts @@ -1,4 +1,6 @@ import { + AdminRatings, + AdminRatingsQuery, AdminReportsQuery, AdminRouteReports, AdminSignaledPlaces, @@ -14,6 +16,7 @@ import { z } from 'zod'; import { httpError } from '../errors'; import { readOpenRouteReports, resolveRouteReport } from '../reports/admin-reports'; import { closePlace, readSignaledPlaces, verifyPlace } from '../reports/place-signals'; +import { readRatings } from '../reports/ratings'; const security = [{ firebaseIdToken: [] }]; const tags = ['admin']; @@ -29,9 +32,10 @@ function actorIdOf(request: FastifyRequest): string { } /** - * Signals and reports of the admin (A-03, wiki API › Admin): the places the community flagged and - * what the team does with them, the open reports of routes and their resolution. Moderators and - * admins only; each decision is written in the journal of the admin. Never cached. + * Signals, reports and ratings of the admin (A-03, wiki API › Admin): the places the community + * flagged and what the team does with them, the open reports of routes and their resolution, and + * the ratings. Moderators and admins only; each decision is written in the journal of the admin. + * Never cached. */ export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => { app.addHook('onRequest', app.requireAdminSection('reports')); @@ -122,4 +126,18 @@ export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => { reason: request.body.reason, }), ); + + app.get( + '/admin/ratings', + { + schema: { + tags, + summary: 'Ratings with a comment', + security, + querystring: AdminRatingsQuery, + response: { 200: AdminRatings }, + }, + }, + async (request) => readRatings(request.query), + ); }; diff --git a/apps/api/src/routes/admin-signals.test.ts b/apps/api/src/routes/admin-signals.test.ts index 9b318a39..0b2707fd 100644 --- a/apps/api/src/routes/admin-signals.test.ts +++ b/apps/api/src/routes/admin-signals.test.ts @@ -1,4 +1,5 @@ import { + AdminRatings, AdminSignaledPlaces, ApiError, PlaceActionResult, @@ -258,3 +259,28 @@ describe('POST /v1/admin/places/:id/close', () => { expect((await act(editor.headers, placeId, 'close', { reason: 'Non.' })).statusCode).toBe(403); }); }); + +describe('GET /v1/admin/ratings', () => { + it('is empty until ratings exist, and validates its filters', async () => { + const moderator = await signIn('moderator'); + + const response = await app.inject({ + url: `/v1/admin/ratings?rating=2&routeId=${uuidv7()}`, + headers: moderator.headers, + }); + + expect(response.statusCode).toBe(200); + expect(AdminRatings.parse(response.json())).toEqual({ + items: [], + total: 0, + offset: 0, + previousCursor: null, + nextCursor: null, + }); + const tooHigh = await app.inject({ + url: '/v1/admin/ratings?rating=6', + headers: moderator.headers, + }); + expect(tooHigh.statusCode).toBe(400); + }); +}); From 1623b5ce5bbe6f3e8ea8abf862056b342cbc81f4 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:14:10 +0700 Subject: [PATCH 07/13] feat(api): count the open reports and the flagged places in the navigation and the dashboard Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/counters.ts | 33 ++++++++++++++++++++--- apps/api/src/admin/stats.ts | 20 +++++++++++--- apps/api/src/routes/admin-reports.test.ts | 24 +++++++++++++++++ apps/api/src/routes/admin-signals.test.ts | 19 +++++++++++++ 4 files changed, 90 insertions(+), 6 deletions(-) diff --git a/apps/api/src/admin/counters.ts b/apps/api/src/admin/counters.ts index 95cc4f41..09231be8 100644 --- a/apps/api/src/admin/counters.ts +++ b/apps/api/src/admin/counters.ts @@ -7,7 +7,7 @@ import { } from '@widoo/shared'; import { eq, sql } from 'drizzle-orm'; import type { Db } from '../db/client'; -import { routes } from '../db/schema'; +import { places, routeReports, routes } from '../db/schema'; /** * Routes waiting for the team. Late after 72 hours without a change: until the history of a @@ -27,8 +27,32 @@ async function countPendingRoutes(db: Db, now: Date): Promise { } /** - * Counters of the navigation of the admin, for the sections `role` opens. Signals, support, AI - * drafts and translations join as their tables arrive with their sections. + * What waits in A-03: the open reports of routes, late after 72 hours, and the places the + * community flagged. A flagged place has no date until its signals are stored (`place_signals`), + * so it is never late. + */ +async function countOpenReports(db: Db, now: Date): Promise { + const lateBefore = new Date(now.getTime() - adminLateAfterHours * 60 * 60 * 1000); + const [reports] = await db + .select({ + count: sql`count(*)::int`, + lateCount: sql`(count(*) filter (where ${routeReports.createdAt} < ${lateBefore.toISOString()}::timestamptz))::int`, + }) + .from(routeReports) + .where(eq(routeReports.status, 'open')); + const [flagged] = await db + .select({ count: sql`count(*)::int` }) + .from(places) + .where(eq(places.verificationStatus, 'flagged')); + return { + count: (reports?.count ?? 0) + (flagged?.count ?? 0), + lateCount: reports?.lateCount ?? 0, + }; +} + +/** + * Counters of the navigation of the admin, for the sections `role` opens. Support, AI drafts and + * translations join as their tables arrive with their sections. */ export async function readAdminCounters( db: Db, @@ -39,5 +63,8 @@ export async function readAdminCounters( if (canOpenAdminSection(role, 'moderation')) { counters.moderation = await countPendingRoutes(db, now); } + if (canOpenAdminSection(role, 'reports')) { + counters.reports = await countOpenReports(db, now); + } return counters; } diff --git a/apps/api/src/admin/stats.ts b/apps/api/src/admin/stats.ts index 08ceff7f..7bd78b66 100644 --- a/apps/api/src/admin/stats.ts +++ b/apps/api/src/admin/stats.ts @@ -14,6 +14,10 @@ type StatsRow = { pending: number; pending_late: number; pending_oldest: Date | null; + reports: number; + reports_late: number; + reports_oldest: Date | null; + flagged_places: number; published_routes: number; premium_routes: number; official_routes: number; @@ -45,6 +49,11 @@ export async function readAdminStats( (select count(*)::int from routes where status = 'pending' and updated_at < ${lateBefore}::timestamptz) as pending_late, (select min(updated_at) from routes where status = 'pending') as pending_oldest, + (select count(*)::int from route_reports where status = 'open') as reports, + (select count(*)::int from route_reports + where status = 'open' and created_at < ${lateBefore}::timestamptz) as reports_late, + (select min(created_at) from route_reports where status = 'open') as reports_oldest, + (select count(*)::int from places where verification_status = 'flagged') as flagged_places, (select count(*)::int from routes where status = 'published') as published_routes, (select count(*)::int from routes where status = 'published' and access = 'premium') as premium_routes, @@ -66,8 +75,9 @@ export async function readAdminStats( throw new Error('Dashboard figures not read'); } - // What waits in each section. Reports, support messages, AI drafts and failed translations - // have no table yet: nothing can wait there, and each one gets its query with its section. + // What waits in each section. Support messages, AI drafts and failed translations have no + // table yet: nothing can wait there, and each one gets its query with its section. The signals + // of places join the reports with `place_signals`. const nothing: AdminToHandle = { count: 0, lateCount: 0, oldestSince: null }; const waiting: Record = { moderation: { @@ -75,7 +85,11 @@ export async function readAdminStats( lateCount: row.pending_late, oldestSince: row.pending_oldest ? new Date(row.pending_oldest).toISOString() : null, }, - reports: nothing, + reports: { + count: row.reports + row.flagged_places, + lateCount: row.reports_late, + oldestSince: row.reports_oldest ? new Date(row.reports_oldest).toISOString() : null, + }, support: nothing, aiDrafts: nothing, translations: nothing, diff --git a/apps/api/src/routes/admin-reports.test.ts b/apps/api/src/routes/admin-reports.test.ts index 541a25cc..649f6abd 100644 --- a/apps/api/src/routes/admin-reports.test.ts +++ b/apps/api/src/routes/admin-reports.test.ts @@ -1,5 +1,7 @@ import { + AdminCounters, AdminRouteReports, + AdminStats, ApiError, ReportAlreadyDecidedDetails, ResolvedReport, @@ -184,6 +186,28 @@ describe('GET /v1/admin/reports', () => { }); }); +describe('what waits in the navigation and on the dashboard', () => { + it('counts the open reports, and the ones waiting for more than 72 hours', async () => { + const moderator = await signIn('moderator'); + const route = await addRoute('published', 'Compteur'); + await addReport(route, 'inappropriate', 80); + await addReport(route, 'false_info', 1); + + const counters = AdminCounters.parse( + (await app.inject({ url: '/v1/admin/counters', headers: moderator.headers })).json(), + ); + const stats = AdminStats.parse( + (await app.inject({ url: '/v1/admin/stats', headers: moderator.headers })).json(), + ); + + expect(counters.reports?.count).toBeGreaterThanOrEqual(2); + expect(counters.reports?.lateCount).toBeGreaterThanOrEqual(1); + expect(stats.toHandle.reports?.count).toBeGreaterThanOrEqual(2); + const oldest = new Date(stats.toHandle.reports?.oldestSince ?? '').getTime(); + expect(Date.now() - oldest).toBeGreaterThanOrEqual(80 * hour - 60_000); + }); +}); + describe('POST /v1/admin/reports/:id/resolve', () => { it('accepts a report, thanks the reporter and writes the note in the journal', async () => { const moderator = await signIn('moderator'); diff --git a/apps/api/src/routes/admin-signals.test.ts b/apps/api/src/routes/admin-signals.test.ts index 0b2707fd..a0fd48be 100644 --- a/apps/api/src/routes/admin-signals.test.ts +++ b/apps/api/src/routes/admin-signals.test.ts @@ -146,6 +146,25 @@ describe('GET /v1/admin/signals', () => { }); }); +describe('what waits in the navigation', () => { + it('counts a flagged place with the reports, until it is verified', async () => { + const moderator = await signIn('moderator'); + const counted = async () => + ( + (await app.inject({ url: '/v1/admin/counters', headers: moderator.headers })).json() as { + reports?: { count: number }; + } + ).reports?.count ?? 0; + const before = await counted(); + const { placeId } = await addPlace('Compté', 'flagged'); + + expect(await counted()).toBe(before + 1); + + await act(moderator.headers, placeId, 'verify'); + expect(await counted()).toBe(before); + }); +}); + describe('POST /v1/admin/places/:id/verify', () => { it('marks the place verified, from zero, and writes it in the journal', async () => { const moderator = await signIn('moderator'); From 339b4c7a5a229b883f66f94bc1a0203f50dbff01 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:14:17 +0700 Subject: [PATCH 08/13] feat(api-client): read the reports, signals and ratings and decide on them (A-03) Co-Authored-By: Claude Sonnet 5.5 --- packages/api-client/src/client.ts | 70 +++++++++++++++++++++++++ packages/api-client/src/reports.test.ts | 25 +++++++++ packages/api-client/src/reports.ts | 28 ++++++++++ 3 files changed, 123 insertions(+) create mode 100644 packages/api-client/src/reports.test.ts create mode 100644 packages/api-client/src/reports.ts diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 437904c7..2b9d9dae 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,4 +1,9 @@ import { + AdminRatings, + AdminRouteReports, + AdminSignaledPlaces, + PlaceActionResult, + ResolvedReport, PlaceImportResult, CatalogPlaces, CatalogPlaceDetail, @@ -34,7 +39,12 @@ import { type RequestChangesBody, type CatalogRoutesQuery, type RepublishBody, + type AdminRatingsQuery, + type AdminReportsQuery, + type AdminSignalsQuery, type CatalogPlaceInput, + type ClosePlaceBody, + type ResolveReportBody, type CatalogPlacesQuery, type PlaceImportBody, } from '@widoo/shared'; @@ -50,6 +60,7 @@ import { removedOf, routePath, type GetRouteOptions, type RouteSheetAnswer } fro import { createTransport, type ApiClientOptions } from './transport'; import { moderationQueryString, type ModerationQueueParams } from './moderation'; import { catalogPlacesQueryString, catalogQueryString } from './catalog'; +import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports'; /** * Typed client of the API, one method per route, answers validated by the `shared` schemas. @@ -406,6 +417,65 @@ export function createApiClient(options: ApiClientOptions) { auth: true, }), + /** `GET /v1/admin/reports`: the open reports of routes, oldest first (A-03). */ + getAdminReports: (params: Partial, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/reports?${reportsQueryString(params)}`, + schema: AdminRouteReports, + auth: true, + signal, + }), + + /** `POST /v1/admin/reports/:id/resolve`: accepts or rejects a report, with a note. */ + resolveReport: (reportId: string, body: ResolveReportBody) => + request({ + method: 'POST', + path: `/v1/admin/reports/${reportId}/resolve`, + body, + schema: ResolvedReport, + auth: true, + }), + + /** `GET /v1/admin/signals`: the places the community flagged, with their routes (A-03). */ + getAdminSignals: (params: Partial, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/signals?${signalsQueryString(params)}`, + schema: AdminSignaledPlaces, + auth: true, + signal, + }), + + /** `POST /v1/admin/places/:id/verify`: marks a place verified by the team. */ + verifyPlace: (placeId: string) => + request({ + method: 'POST', + path: `/v1/admin/places/${placeId}/verify`, + schema: PlaceActionResult, + auth: true, + }), + + /** `POST /v1/admin/places/:id/close`: closes a place, its routes become `needs_fix`. */ + closePlace: (placeId: string, body: ClosePlaceBody) => + request({ + method: 'POST', + path: `/v1/admin/places/${placeId}/close`, + body, + schema: PlaceActionResult, + auth: true, + }), + + /** `GET /v1/admin/ratings`: the ratings with a comment, filtered by note and route (A-03). */ + getAdminRatings: (params: Partial, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/ratings?${ratingsQueryString(params)}`, + schema: AdminRatings, + auth: true, + signal, + }), + /** `DELETE /v1/me`: anonymizes the account; its token is refused afterwards. */ deleteMe: () => requestEmpty({ method: 'DELETE', path: '/v1/me', auth: true }), }; diff --git a/packages/api-client/src/reports.test.ts b/packages/api-client/src/reports.test.ts new file mode 100644 index 00000000..2ac21337 --- /dev/null +++ b/packages/api-client/src/reports.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports'; + +describe('reportsQueryString', () => { + it('is empty for the whole list, and writes the reason and the page', () => { + expect(reportsQueryString({})).toBe(''); + expect(reportsQueryString({ reason: 'low_rating', cursor: 'NTA' })).toBe( + 'reason=low_rating&cursor=NTA', + ); + }); +}); + +describe('signalsQueryString', () => { + it('writes the page only', () => { + expect(signalsQueryString({ cursor: 'NTA' })).toBe('cursor=NTA'); + }); +}); + +describe('ratingsQueryString', () => { + it('writes the note as a number and the route', () => { + expect(ratingsQueryString({ rating: 2, routeId: '0198c1de-0000-7000-8000-000000000001' })).toBe( + 'rating=2&routeId=0198c1de-0000-7000-8000-000000000001', + ); + }); +}); diff --git a/packages/api-client/src/reports.ts b/packages/api-client/src/reports.ts new file mode 100644 index 00000000..c43ee040 --- /dev/null +++ b/packages/api-client/src/reports.ts @@ -0,0 +1,28 @@ +import type { AdminRatingsQuery, AdminReportsQuery, AdminSignalsQuery } from '@widoo/shared'; + +/** A query string from the values that are set, in the order of `keys`. */ +function queryStringOf( + params: Record, + keys: readonly string[], +): string { + const query = new URLSearchParams(); + for (const key of keys) { + const value = params[key]; + if (value !== undefined && value !== '') { + query.set(key, String(value)); + } + } + return query.toString(); +} + +/** Query string of the open reports of routes, as the API reads it. */ +export const reportsQueryString = (params: Partial) => + queryStringOf(params, ['reason', 'cursor']); + +/** Query string of the flagged places, as the API reads it. */ +export const signalsQueryString = (params: Partial) => + queryStringOf(params, ['cursor']); + +/** Query string of the ratings with a comment, as the API reads it. */ +export const ratingsQueryString = (params: Partial) => + queryStringOf(params, ['rating', 'routeId', 'cursor']); From 016d4cf88653f3af5ac68364900fdcd48f124c54 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:14:19 +0700 Subject: [PATCH 09/13] feat(admin): share the section tabs and the texts of the reports and ratings (A-03) Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/catalog/CatalogTabs.tsx | 28 +--- .../src/components/navigation/SectionTabs.tsx | 36 ++++ .../src/components/reports/ReportsTabs.tsx | 27 +++ apps/admin/src/hooks/useRouteAction.ts | 22 ++- apps/admin/src/lib/conflict-status.test.ts | 15 ++ apps/admin/src/lib/conflict-status.ts | 24 +++ apps/admin/src/messages/fr.ts | 155 ++++++++++++++++++ 7 files changed, 276 insertions(+), 31 deletions(-) create mode 100644 apps/admin/src/components/navigation/SectionTabs.tsx create mode 100644 apps/admin/src/components/reports/ReportsTabs.tsx create mode 100644 apps/admin/src/lib/conflict-status.test.ts create mode 100644 apps/admin/src/lib/conflict-status.ts diff --git a/apps/admin/src/components/catalog/CatalogTabs.tsx b/apps/admin/src/components/catalog/CatalogTabs.tsx index 79491940..7138cd90 100644 --- a/apps/admin/src/components/catalog/CatalogTabs.tsx +++ b/apps/admin/src/components/catalog/CatalogTabs.tsx @@ -1,7 +1,6 @@ +import { SectionTabs } from '@/components/navigation/SectionTabs'; import { sectionPaths } from '@/config/navigation'; import { messages } from '@/messages/fr'; -import { cn } from '@widoo/ui'; -import Link from 'next/link'; const texts = messages.catalog.tabs; @@ -14,22 +13,13 @@ export const catalogPaths = { /** The two lists of the catalog (A-04), as tabs above them: places, then routes. */ export function CatalogTabs({ current }: { current: keyof typeof catalogPaths }) { return ( - + ); } diff --git a/apps/admin/src/components/navigation/SectionTabs.tsx b/apps/admin/src/components/navigation/SectionTabs.tsx new file mode 100644 index 00000000..6935f57e --- /dev/null +++ b/apps/admin/src/components/navigation/SectionTabs.tsx @@ -0,0 +1,36 @@ +import { cn } from '@widoo/ui'; +import Link from 'next/link'; + +type SectionTabsProps = { + /** Name of the navigation for screen readers. */ + label: string; + tabs: readonly { id: TabId; label: string; href: string }[]; + current: TabId; +}; + +/** The lists of a section as tabs above them, each one its own address. */ +export function SectionTabs({ + label, + tabs, + current, +}: SectionTabsProps) { + return ( + + ); +} diff --git a/apps/admin/src/components/reports/ReportsTabs.tsx b/apps/admin/src/components/reports/ReportsTabs.tsx new file mode 100644 index 00000000..2022f434 --- /dev/null +++ b/apps/admin/src/components/reports/ReportsTabs.tsx @@ -0,0 +1,27 @@ +import { SectionTabs } from '@/components/navigation/SectionTabs'; +import { sectionPaths } from '@/config/navigation'; +import { messages } from '@/messages/fr'; + +const texts = messages.reports.tabs; + +/** Addresses of the three lists of the reports (A-03). */ +export const reportsPaths = { + places: `${sectionPaths.reports}/lieux`, + routes: `${sectionPaths.reports}/parcours`, + ratings: `${sectionPaths.reports}/avis`, +} as const; + +/** The three lists of A-03 as tabs: places, routes, ratings. */ +export function ReportsTabs({ current }: { current: keyof typeof reportsPaths }) { + return ( + + ); +} diff --git a/apps/admin/src/hooks/useRouteAction.ts b/apps/admin/src/hooks/useRouteAction.ts index 714f1f2f..3909dcbe 100644 --- a/apps/admin/src/hooks/useRouteAction.ts +++ b/apps/admin/src/hooks/useRouteAction.ts @@ -1,7 +1,7 @@ import { notifyCountersStale } from '@/hooks/useAdminCounters'; import { messages } from '@/messages/fr'; +import { conflictStatusLabel } from '@/lib/conflict-status'; import { ApiRequestError } from '@widoo/api-client'; -import { AlreadyDecidedDetails, labels } from '@widoo/shared'; import { showToast } from '@widoo/ui'; import { useState } from 'react'; @@ -11,10 +11,10 @@ const texts = messages.moderation.decisions.toasts; export type RouteActionOutcome = 'published' | 'other'; /** - * Sends an action of the team on a route and tells its result in a toast. `onSettled` is called - * once the route is settled, by this action or by the one another member of the team took first - * (409 `already_decided`, told with the status the route was given): the screen then moves on. - * The counters of the navigation are read again at once. + * Sends an action of the team on a route, a report or a place and tells its result in a toast. + * `onSettled` is called once it is settled, by this action or by the one another member of the + * team took first (409 `already_decided`, told with the status it was given): the screen then + * moves on. The counters of the navigation are read again at once. */ export function useRouteAction(onSettled: (outcome: RouteActionOutcome) => void) { const [isBusy, setIsBusy] = useState(false); @@ -31,14 +31,12 @@ export function useRouteAction(onSettled: (outcome: RouteActionOutcome) => void) notifyCountersStale(); onSettled(outcome); } catch (error) { - const details = + const conflict = error instanceof ApiRequestError && error.code === 'already_decided' - ? AlreadyDecidedDetails.safeParse(error.body?.details) - : undefined; - if (details?.success) { - showToast('info', { - title: texts.alreadyDecided(labels.fr.routeStatuses[details.data.status]), - }); + ? conflictStatusLabel(error.body?.details) + : null; + if (conflict) { + showToast('info', { title: texts.alreadyDecided(conflict) }); notifyCountersStale(); onSettled('other'); } else { diff --git a/apps/admin/src/lib/conflict-status.test.ts b/apps/admin/src/lib/conflict-status.test.ts new file mode 100644 index 00000000..624fa6f6 --- /dev/null +++ b/apps/admin/src/lib/conflict-status.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vitest'; +import { conflictStatusLabel } from './conflict-status'; + +describe('conflictStatusLabel', () => { + it('names the status of a route, of a report and of a place', () => { + expect(conflictStatusLabel({ status: 'published' })).toBe('Publié'); + expect(conflictStatusLabel({ status: 'accepted' })).toBe('Accepté'); + expect(conflictStatusLabel({ status: 'closed' })).toBe('Fermé'); + }); + + it('gives nothing for other details', () => { + expect(conflictStatusLabel({ status: 'nope' })).toBeNull(); + expect(conflictStatusLabel(undefined)).toBeNull(); + }); +}); diff --git a/apps/admin/src/lib/conflict-status.ts b/apps/admin/src/lib/conflict-status.ts new file mode 100644 index 00000000..2b71e012 --- /dev/null +++ b/apps/admin/src/lib/conflict-status.ts @@ -0,0 +1,24 @@ +import { + AlreadyDecidedDetails, + labels, + PlaceAlreadyDecidedDetails, + ReportAlreadyDecidedDetails, +} from '@widoo/shared'; +import { messages } from '@/messages/fr'; + +/** + * The status another member of the team gave first, as a label, from the `details` of a 409 + * `already_decided`: of a route, of a report or of a place. Null when the details are none of them. + */ +export function conflictStatusLabel(details: unknown): string | null { + const route = AlreadyDecidedDetails.safeParse(details); + if (route.success) { + return labels.fr.routeStatuses[route.data.status]; + } + const report = ReportAlreadyDecidedDetails.safeParse(details); + if (report.success) { + return messages.reports.routes.statuses[report.data.status]; + } + const place = PlaceAlreadyDecidedDetails.safeParse(details); + return place.success ? labels.fr.verificationStatuses[place.data.status] : null; +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index b8133d6a..740dc68e 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -609,6 +609,161 @@ export const messages = { title: "Vous n'avez pas accès à cette page", text: 'Votre rôle ne permet pas cette action. Adressez-vous à un admin si vous en avez besoin.', }, + reports: { + tabs: { label: 'Signalements et avis', places: 'Lieux', routes: 'Parcours', ratings: 'Avis' }, + reasons: { + place_changed: 'Lieu modifié', + inappropriate: 'Contenu inapproprié', + dangerous_place: 'Lieu dangereux', + hidden_ad: 'Publicité cachée', + copied_content: 'Contenu copié', + false_info: 'Informations fausses', + other: 'Autre', + low_rating: 'Note basse', + }, + routes: { + filters: { label: 'Filtres des signalements', reason: 'Motif' }, + total: (count: number) => + count > 1 ? `${count} signalements ouverts` : `${count} signalement ouvert`, + columns: { + route: 'Parcours', + reason: 'Motif', + reporter: 'Signalé par', + author: 'Auteur', + since: 'Depuis', + }, + stepOf: (position: number, placeName: string) => `Étape ${position + 1}, ${placeName}`, + noReporter: 'Note moyenne basse', + team: 'Équipe Widoo', + unknown: 'Compte supprimé', + otherOpen: (count: number) => + count > 1 ? `${count - 1} autres signalements ouverts` : '1 autre signalement ouvert', + empty: { title: 'Aucun signalement ouvert', text: 'Rien à traiter pour le moment.' }, + panel: { + title: (routeTitle: string) => routeTitle, + reportedBy: 'Signalé par', + author: 'Auteur', + city: 'Ville', + comment: 'Commentaire', + step: 'Étape concernée', + noComment: 'Aucun commentaire', + openRoute: 'Voir le parcours', + lowRatingHint: + 'La note moyenne du parcours est passée sous 2,5 avec au moins 5 avis. Gardez-le, demandez des modifications ou dépubliez-le.', + }, + actions: { + accept: 'Accepter', + reject: 'Rejeter', + keep: 'Garder', + requestChanges: 'Demander des modifications', + unpublish: 'Dépublier', + }, + dialogs: { + accept: { + title: 'Accepter ce signalement ?', + description: + 'Le signaleur est remercié. Le parcours lui-même ne change pas : corrigez-le ou dépubliez-le depuis sa fiche si besoin.', + confirm: 'Accepter le signalement', + }, + reject: { + title: 'Rejeter ce signalement ?', + description: "Le signalement est classé sans suite. Le signaleur n'est pas prévenu.", + confirm: 'Rejeter le signalement', + }, + keep: { + title: 'Garder ce parcours ?', + description: + 'Le parcours reste en ligne. Un nouveau signalement ne reviendra qu’après cinq nouveaux avis.', + confirm: 'Garder le parcours', + }, + requestChanges: { + title: 'Demander des modifications ?', + description: + "Le parcours reste en ligne et passe en « À corriger ». Votre message est gardé dans son historique ; l'auteur n'est pas encore averti automatiquement.", + message: "Message à l'auteur", + confirm: 'Demander les modifications', + }, + unpublish: { + title: 'Dépublier ce parcours ?', + description: + "Le parcours n'est plus visible dans l'app. Le motif et votre message sont gardés dans son historique.", + message: "Message à l'auteur", + confirm: 'Dépublier le parcours', + }, + note: 'Note', + noteHint: 'Facultative. Gardée dans le journal, visible par l’équipe.', + }, + toasts: { + accepted: 'Signalement accepté', + rejected: 'Signalement rejeté', + kept: 'Parcours gardé', + changesRequested: 'Modifications demandées', + unpublished: 'Parcours dépublié', + failed: "La décision n'a pas pu être enregistrée.", + alreadyDecided: (status: string) => + `Un autre membre de l'équipe a déjà tranché : ${status.toLowerCase()}.`, + }, + statuses: { accepted: 'Accepté', rejected: 'Rejeté', open: 'Ouvert' }, + error: 'Les signalements ne se sont pas chargés.', + }, + places: { + total: (count: number) => (count > 1 ? `${count} lieux signalés` : `${count} lieu signalé`), + empty: { + title: 'Aucun lieu signalé', + text: 'Les lieux signalés par la communauté apparaissent ici. Le décompte des signalements arrive avec leur enregistrement.', + }, + columns: { + place: 'Lieu', + status: 'Statut', + signals: 'Signalements', + confirmations: 'Confirmations', + routes: 'Parcours concernés', + verified: 'Dernière vérification', + }, + neverVerified: 'Jamais', + noRoute: 'Aucun', + actions: { verify: 'Marquer vérifié', fix: 'Corriger le lieu', close: 'Fermer le lieu' }, + closeDialog: { + title: 'Fermer ce lieu ?', + description: (count: number) => + count === 0 + ? "Le lieu est marqué fermé. Aucun parcours publié n'y passe." + : count === 1 + ? 'Le lieu est marqué fermé. Le parcours publié qui y passe passe en « À corriger ».' + : `Le lieu est marqué fermé. Les ${count} parcours publiés qui y passent passent en « À corriger ».`, + reason: 'Motif', + reasonHint: 'Gardé dans le journal et dans l’historique des parcours concernés.', + confirm: 'Fermer le lieu', + }, + toasts: { + verified: 'Lieu marqué vérifié', + closed: 'Lieu fermé', + failed: "L'action n'a pas pu être enregistrée.", + alreadyDecided: (status: string) => + `Un autre membre de l'équipe a déjà tranché : ${status.toLowerCase()}.`, + }, + error: 'Les lieux signalés ne se sont pas chargés.', + }, + ratings: { + filters: { label: 'Filtres des avis', rating: 'Note' }, + ratingOption: (value: number) => (value > 1 ? `${value} étoiles` : `${value} étoile`), + total: (count: number) => `${count} avis`, + columns: { + rating: 'Note', + comment: 'Commentaire', + route: 'Parcours', + author: 'Auteur', + date: 'Date', + }, + empty: { + title: 'Aucun avis avec commentaire', + text: 'Les avis laissés par les utilisateurs après une sortie apparaissent ici.', + }, + hidden: 'Masqué', + unknownAuthor: 'Compte supprimé', + error: 'Les avis ne se sont pas chargés.', + }, + }, notFound: { title: 'Page introuvable', text: "Cette adresse n'existe pas dans l'admin.", From dd859804efb106e7761766dea4a1ae16504884f5 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Thu, 15 Oct 2026 17:14:28 +0700 Subject: [PATCH 10/13] feat(admin): decide on a report of a route, with a note or a message (A-03) Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/reports/ReportActions.tsx | 52 +++++++++++ .../components/reports/ReportNoteDialog.tsx | 51 +++++++++++ .../containers/ReportDecisionsContainer.tsx | 88 +++++++++++++++++++ apps/admin/src/hooks/useRouteReportActions.ts | 41 +++++++++ 4 files changed, 232 insertions(+) create mode 100644 apps/admin/src/components/reports/ReportActions.tsx create mode 100644 apps/admin/src/components/reports/ReportNoteDialog.tsx create mode 100644 apps/admin/src/containers/ReportDecisionsContainer.tsx create mode 100644 apps/admin/src/hooks/useRouteReportActions.ts diff --git a/apps/admin/src/components/reports/ReportActions.tsx b/apps/admin/src/components/reports/ReportActions.tsx new file mode 100644 index 00000000..d735f689 --- /dev/null +++ b/apps/admin/src/components/reports/ReportActions.tsx @@ -0,0 +1,52 @@ +import { messages } from '@/messages/fr'; +import { Button } from '@widoo/ui'; +import Link from 'next/link'; + +const texts = messages.reports.routes; + +export type ReportActionName = 'accept' | 'reject' | 'keep' | 'requestChanges' | 'unpublish'; + +type ReportActionsProps = { + /** A note-based report offers « Garder », « Demander des modifications » and « Dépublier ». */ + isLowRating: boolean; + /** Address of the sheet of the route, to read it before deciding. */ + routePath: string; + onAct: (action: ReportActionName) => void; + isBusy: boolean; +}; + +/** + * The decisions on a report (A-03). A report of a reader is accepted or rejected, with a note; a + * low rating, which has no reader to thank, is kept, sent back for changes, or taken offline. + */ +export function ReportActions({ isLowRating, routePath, onAct, isBusy }: ReportActionsProps) { + return ( + <> + + {isLowRating ? ( + <> + + + + + ) : ( + <> + + + + )} + + ); +} diff --git a/apps/admin/src/components/reports/ReportNoteDialog.tsx b/apps/admin/src/components/reports/ReportNoteDialog.tsx new file mode 100644 index 00000000..c63e78a8 --- /dev/null +++ b/apps/admin/src/components/reports/ReportNoteDialog.tsx @@ -0,0 +1,51 @@ +import { messages } from '@/messages/fr'; +import { ConfirmDialog, Field, Textarea } from '@widoo/ui'; + +const texts = messages.reports.routes.dialogs; + +type ReportNoteDialogProps = { + isOpen: boolean; + /** What the window says: its question, the effect of the decision, and its button. */ + dialog: { title: string; description: string; confirm: string }; + note: string; + onNoteChange: (note: string) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** A decision on a report with an optional note for the team, kept in the journal. */ +export function ReportNoteDialog({ + isOpen, + dialog, + note, + onNoteChange, + onCancel, + onConfirm, + isBusy, +}: ReportNoteDialogProps) { + return ( + + +