From 3e66fda1a9ecc6a0d9f7dffb9a953d57cbe1139a Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Wed, 21 Oct 2026 12:21:53 +0700 Subject: [PATCH 1/2] feat(shared): describe the body and the answer of the report of a route (D-056) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The body of POST /routes/:id/reports follows the rules of reports.ts: a step for « place_changed » and no other reason, a comment of 80 characters for « place_changed » (optional) and « other » (required), never « low_rating ». The answer holds the report and nothing of the reporter. Co-Authored-By: Claude Sonnet 5.5 --- packages/shared/src/index.ts | 1 + .../shared/src/schemas/route-report.test.ts | 53 ++++++++++++++ packages/shared/src/schemas/route-report.ts | 69 +++++++++++++++++++ 3 files changed, 123 insertions(+) create mode 100644 packages/shared/src/schemas/route-report.test.ts create mode 100644 packages/shared/src/schemas/route-report.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 0d488f4b..4f90f8d7 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -49,6 +49,7 @@ export * from './schemas/rating'; export * from './schemas/recommendation'; export * from './schemas/route'; export * from './schemas/route-detail'; +export * from './schemas/route-report'; export * from './schemas/route-editor'; export * from './schemas/search'; export * from './schemas/support'; diff --git a/packages/shared/src/schemas/route-report.test.ts b/packages/shared/src/schemas/route-report.test.ts new file mode 100644 index 00000000..48e50bd2 --- /dev/null +++ b/packages/shared/src/schemas/route-report.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from 'vitest'; +import { RouteReportBody } from './route-report'; + +const stepId = '0198c1de-0000-7000-8000-000000000001'; + +describe('RouteReportBody', () => { + it('takes a reason that takes nothing else', () => { + for (const reason of [ + 'inappropriate', + 'dangerous_place', + 'hidden_ad', + 'copied_content', + 'false_info', + ]) { + expect(RouteReportBody.safeParse({ reason }).success).toBe(true); + } + }); + + it('asks the step of the place for « place_changed », and a comment for « other »', () => { + expect(RouteReportBody.safeParse({ reason: 'place_changed' }).success).toBe(false); + expect(RouteReportBody.safeParse({ reason: 'place_changed', stepId }).success).toBe(true); + expect( + RouteReportBody.safeParse({ reason: 'place_changed', stepId, comment: 'Fermé depuis lundi' }) + .success, + ).toBe(true); + expect(RouteReportBody.safeParse({ reason: 'other' }).success).toBe(false); + expect(RouteReportBody.safeParse({ reason: 'other', comment: 'Autre chose' }).success).toBe( + true, + ); + }); + + it('refuses a step or a comment on a reason that takes none', () => { + expect(RouteReportBody.safeParse({ reason: 'hidden_ad', stepId }).success).toBe(false); + expect(RouteReportBody.safeParse({ reason: 'hidden_ad', comment: 'Une pub' }).success).toBe( + false, + ); + expect(RouteReportBody.safeParse({ reason: 'other', stepId, comment: 'Autre' }).success).toBe( + false, + ); + }); + + it('refuses the reason of the system, markup, a long comment and an unknown key', () => { + expect(RouteReportBody.safeParse({ reason: 'low_rating' }).success).toBe(false); + expect(RouteReportBody.safeParse({ reason: 'other', comment: 'x' }).success).toBe(false); + expect(RouteReportBody.safeParse({ reason: 'other', comment: 'x'.repeat(81) }).success).toBe( + false, + ); + expect(RouteReportBody.safeParse({ reason: 'other', comment: 'x'.repeat(80) }).success).toBe( + true, + ); + expect(RouteReportBody.safeParse({ reason: 'inappropriate', extra: 1 }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/schemas/route-report.ts b/packages/shared/src/schemas/route-report.ts new file mode 100644 index 00000000..1b9be37e --- /dev/null +++ b/packages/shared/src/schemas/route-report.ts @@ -0,0 +1,69 @@ +import { z } from 'zod'; +import { + reportStatuses, + routeReportCommentMaxLength, + routeReportCommentRuleOf, + routeReportNeedsStep, + userRouteReportReasons, +} from '../reports'; +import { plainText } from './common'; + +/** + * Body of `POST /routes/:id/reports` (E-05, « Signaler ce parcours », D-056). The reason is one + * the user picks, never `low_rating`. `stepId` is for `place_changed` and for no other reason; the + * comment, of 80 characters, is for `place_changed` (optional) and `other` (required). Strict: an + * unknown key is refused. A step that is not one of the route is refused by the API, which knows + * the route. + */ +export const RouteReportBody = z + .strictObject({ + reason: z.enum(userRouteReportReasons), + stepId: z.uuid().optional(), + comment: plainText(routeReportCommentMaxLength).optional(), + }) + .superRefine((body, context) => { + const needsStep = routeReportNeedsStep(body.reason); + if (needsStep && body.stepId === undefined) { + context.addIssue({ + code: 'custom', + path: ['stepId'], + message: 'The step of the place is required for this reason', + }); + } + if (!needsStep && body.stepId !== undefined) { + context.addIssue({ + code: 'custom', + path: ['stepId'], + message: 'This reason takes no step', + }); + } + const commentRule = routeReportCommentRuleOf(body.reason); + if (commentRule === 'required' && body.comment === undefined) { + context.addIssue({ + code: 'custom', + path: ['comment'], + message: 'A comment is required for this reason', + }); + } + if (commentRule === 'none' && body.comment !== undefined) { + context.addIssue({ + code: 'custom', + path: ['comment'], + message: 'This reason takes no comment', + }); + } + }); +export type RouteReportBody = z.infer; + +/** + * Answer of `POST /routes/:id/reports` (201): the report as it stands, open until the team + * decides (A-03). Nothing of the reporter beyond the route and the reason they gave. + */ +export const RouteReportReceipt = z.object({ + id: z.uuid(), + reason: z.enum(userRouteReportReasons), + status: z.enum(reportStatuses), + /** The time of the report: `myReport.reportedAt` of the route sheet says the same. */ + reportedAt: z.iso.datetime(), +}); +export type RouteReportReceipt = z.infer; From 581552ecba9eb28d5fc637c66d94f71fbba03182 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Wed, 21 Oct 2026 12:21:53 +0700 Subject: [PATCH 2/2] feat(api): let a reader report an online route, one report by route over 7 days (D-056) POST /routes/:id/reports answers 201 with the open report, 409 with reportedAt inside the window, 403 on the route of the caller, 404 for a route that is not online or unknown. It rests on createRouteReport: the rule of the 7 days, the check of the step and the lock against two reports at once are its own. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/app.ts | 2 + apps/api/src/reports/submit.ts | 57 ++++++ apps/api/src/routes/route-reports.test.ts | 226 ++++++++++++++++++++++ apps/api/src/routes/route-reports.ts | 36 ++++ 4 files changed, 321 insertions(+) create mode 100644 apps/api/src/reports/submit.ts create mode 100644 apps/api/src/routes/route-reports.test.ts create mode 100644 apps/api/src/routes/route-reports.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index ea04ecfb..deb22d7e 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -40,6 +40,7 @@ import { meRoutes } from './routes/me'; import { meExportRoutes } from './routes/me-export'; import { plannedRoutesPlugin } from './routes/planned'; import { routeRatingsPlugin } from './routes/ratings'; +import { routeReportsRoutes } from './routes/route-reports'; import { routeSheetRoutes } from './routes/route-sheet'; import { searchRoutesPlugin } from './routes/search'; import { supportRoutes } from './routes/support'; @@ -173,6 +174,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(goRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(routeRatingsPlugin, { prefix: '/v1' }); + await app.register(routeReportsRoutes, { prefix: '/v1' }); await app.register(routeSheetRoutes, { prefix: '/v1', directions: diff --git a/apps/api/src/reports/submit.ts b/apps/api/src/reports/submit.ts new file mode 100644 index 00000000..3ad4d725 --- /dev/null +++ b/apps/api/src/reports/submit.ts @@ -0,0 +1,57 @@ +import { + onlineRouteStatuses, + type RouteReportBody, + type RouteReportReceipt, + type UserRouteReportReason, +} from '@widoo/shared'; +import { eq } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { routes } from '../db/schema'; +import { httpError } from '../errors'; +import type { User } from '../users/repository'; +import { createRouteReport } from './repository'; + +/** + * Writes the report a reader makes of an online route (wiki API › Lieux, D-056). 404 for a route + * that does not exist or is not online (a draft, a rejected or a withdrawn one: the answer never + * tells them apart), 403 for the route of the caller. The rule of the 7 days, the check of the + * step and the lock against two reports at once are those of `createRouteReport`: 409 with + * `details.reportedAt`, and 422 for a step that is not of the route. + */ +export async function submitRouteReport( + db: Db, + user: User, + routeId: string, + body: RouteReportBody, + now: Date = new Date(), +): Promise { + const [route] = await db + .select({ status: routes.status, authorId: routes.authorId }) + .from(routes) + .where(eq(routes.id, routeId)); + if (!route || !(onlineRouteStatuses as readonly string[]).includes(route.status)) { + throw httpError(404, 'Route not found'); + } + if (route.authorId === user.id) { + throw httpError(403, 'A route cannot be reported by its author'); + } + const created = await db.transaction((tx) => + createRouteReport( + tx, + { + routeId, + userId: user.id, + reason: body.reason satisfies UserRouteReportReason, + stepId: body.stepId, + comment: body.comment, + }, + now, + ), + ); + return { + id: created.id, + reason: body.reason, + status: 'open', + reportedAt: created.createdAt.toISOString(), + }; +} diff --git a/apps/api/src/routes/route-reports.test.ts b/apps/api/src/routes/route-reports.test.ts new file mode 100644 index 00000000..6698948b --- /dev/null +++ b/apps/api/src/routes/route-reports.test.ts @@ -0,0 +1,226 @@ +import { + ApiError, + RouteDetail, + RouteReportReceipt, + type UserRouteReportReason, +} from '@widoo/shared'; +import { and, eq } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { TestTokenVerifier } from '../auth/test-verifier'; +import { routeReports, steps } from '../db/schema'; +import { uuidv7 } from '../db/uuid'; +import { plannedFixtures } from '../planned/test-fixtures'; +import { testConfig } from '../test-config'; + +// On copies of the demo route, one per case: shared database, parallel files. Fictitious accounts +// are deleted afterwards with their reports. +const verifier = new TestTokenVerifier(); +let app: Awaited>; +const fixtures = plannedFixtures(() => app, verifier); +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier, directions: null }); +}); +afterAll(async () => { + await fixtures.cleanUp(); + await app.close(); +}); + +type Headers = Record; + +/** A signed-in account and its id. */ +async function account() { + const headers = await fixtures.signIn(); + const me = await app.inject({ url: '/v1/me', headers }); + return { headers, id: me.json<{ id: string }>().id }; +} + +const report = (routeId: string, headers: Headers, body: object) => + app.inject({ method: 'POST', url: `/v1/routes/${routeId}/reports`, headers, payload: body }); +const firstStepOf = async (routeId: string) => { + const [step] = await app.db.select().from(steps).where(eq(steps.routeId, routeId)).limit(1); + if (!step) throw new Error('Step missing'); + return step.id; +}; +const reportsOf = (routeId: string, userId: string) => + app.db + .select() + .from(routeReports) + .where(and(eq(routeReports.routeId, routeId), eq(routeReports.userId, userId))); + +describe('POST /v1/routes/:id/reports', () => { + it('writes an open report and answers 201 with it', async () => { + const reader = await account(); + const routeId = await fixtures.copyDemoRoute(); + + const response = await report(routeId, reader.headers, { reason: 'inappropriate' }); + + expect(response.statusCode).toBe(201); + const receipt = RouteReportReceipt.parse(response.json()); + expect(receipt).toMatchObject({ reason: 'inappropriate', status: 'open' }); + expect(response.headers['cache-control']).toBe('no-store'); + const [row] = await reportsOf(routeId, reader.id); + expect(row).toMatchObject({ + id: receipt.id, + reason: 'inappropriate', + status: 'open', + stepId: null, + comment: null, + }); + expect(Object.keys(receipt).sort()).toEqual(['id', 'reason', 'reportedAt', 'status']); + }); + + it('keeps the step and the comment of « place_changed », and the comment of « other »', async () => { + const reader = await account(); + const routeId = await fixtures.copyDemoRoute(); + const stepId = await firstStepOf(routeId); + + const changed = await report(routeId, reader.headers, { + reason: 'place_changed', + stepId, + comment: 'Fermé depuis lundi', + }); + expect(changed.statusCode).toBe(201); + const other = await report(await fixtures.copyDemoRoute(), reader.headers, { + reason: 'other', + comment: 'Une autre raison', + }); + expect(other.statusCode).toBe(201); + + const [row] = await reportsOf(routeId, reader.id); + expect(row).toMatchObject({ stepId, comment: 'Fermé depuis lundi' }); + }); + + it('answers 409 with the time of the first report, inside 7 days, and shows it on the sheet', async () => { + const reader = await account(); + const routeId = await fixtures.copyDemoRoute(); + const first = RouteReportReceipt.parse( + (await report(routeId, reader.headers, { reason: 'hidden_ad' })).json(), + ); + + const again = await report(routeId, reader.headers, { reason: 'false_info' }); + + expect(again.statusCode).toBe(409); + expect(ApiError.parse(again.json()).details).toEqual({ reportedAt: first.reportedAt }); + expect(await reportsOf(routeId, reader.id)).toHaveLength(1); + const sheet = RouteDetail.parse( + (await app.inject({ url: `/v1/routes/${routeId}`, headers: reader.headers })).json(), + ); + expect(sheet.myReport).toEqual({ reportedAt: first.reportedAt, status: 'open' }); + }); + + it('lets the reader report again once the team decided, and another reader report the route', async () => { + const reader = await account(); + const other = await account(); + const routeId = await fixtures.copyDemoRoute(); + await report(routeId, reader.headers, { reason: 'copied_content' }); + await app.db + .update(routeReports) + .set({ status: 'rejected', reviewedAt: new Date() }) + .where(and(eq(routeReports.routeId, routeId), eq(routeReports.userId, reader.id))); + + expect((await report(routeId, reader.headers, { reason: 'copied_content' })).statusCode).toBe( + 201, + ); + expect((await report(routeId, other.headers, { reason: 'copied_content' })).statusCode).toBe( + 201, + ); + }); + + it('answers 403 on the own route of the reader', async () => { + const author = await account(); + const routeId = await fixtures.copyDemoRoute({ authorId: author.id }); + + const response = await report(routeId, author.headers, { reason: 'inappropriate' }); + + expect(response.statusCode).toBe(403); + expect(await reportsOf(routeId, author.id)).toHaveLength(0); + }); + + it('answers 404 for a route that is not online, withdrawn or unknown, and 401 without a token', async () => { + const reader = await account(); + const draft = await fixtures.copyDemoRoute({ status: 'draft' }); + const unpublished = await fixtures.copyDemoRoute({ status: 'unpublished' }); + const online = await fixtures.copyDemoRoute({ status: 'needs_fix' }); + + expect( + (await report(draft, reader.headers, { reason: 'other', comment: 'x' })).statusCode, + ).toBe(404); + expect( + (await report(unpublished, reader.headers, { reason: 'other', comment: 'x' })).statusCode, + ).toBe(404); + expect( + (await report(uuidv7(), reader.headers, { reason: 'other', comment: 'x' })).statusCode, + ).toBe(404); + // A route to correct stays online: it can be reported. + expect((await report(online, reader.headers, { reason: 'inappropriate' })).statusCode).toBe( + 201, + ); + expect( + ( + await app.inject({ + method: 'POST', + url: `/v1/routes/${online}/reports`, + payload: { reason: 'inappropriate' }, + }) + ).statusCode, + ).toBe(401); + }); + + it('answers 422 for a body that breaks the rules of the reasons, and writes nothing', async () => { + const reader = await account(); + const routeId = await fixtures.copyDemoRoute(); + const otherRoute = await fixtures.copyDemoRoute(); + const stepOfOther = await firstStepOf(otherRoute); + const stepId = await firstStepOf(routeId); + const bodies: object[] = [ + { reason: 'place_changed' }, + { reason: 'place_changed', stepId: stepOfOther }, + { reason: 'other' }, + { reason: 'other', comment: 'x'.repeat(81) }, + { reason: 'inappropriate', stepId }, + { reason: 'inappropriate', comment: 'Un mot' }, + { reason: 'low_rating' }, + { reason: 'inappropriate', unknown: true }, + {}, + ]; + + for (const body of bodies) { + const response = await report(routeId, reader.headers, body); + expect(response.statusCode, JSON.stringify(body)).toBe(422); + } + expect(await reportsOf(routeId, reader.id)).toHaveLength(0); + expect( + (await report('not-an-id', reader.headers, { reason: 'inappropriate' })).statusCode, + ).toBe(400); + }); + + it('writes one report when the same report is sent twice at once', async () => { + const reader = await account(); + const routeId = await fixtures.copyDemoRoute(); + + const answers = await Promise.all( + Array.from({ length: 4 }, () => report(routeId, reader.headers, { reason: 'inappropriate' })), + ); + + expect(answers.map((response) => response.statusCode).sort()).toEqual([201, 409, 409, 409]); + expect(await reportsOf(routeId, reader.id)).toHaveLength(1); + }); +}); + +const everyReason: UserRouteReportReason[] = [ + 'inappropriate', + 'dangerous_place', + 'hidden_ad', + 'copied_content', + 'false_info', +]; + +describe('the reasons that take no step and no comment', () => { + it.each(everyReason)('takes « %s » alone', async (reason) => { + const reader = await account(); + const routeId = await fixtures.copyDemoRoute(); + + expect((await report(routeId, reader.headers, { reason })).statusCode).toBe(201); + }); +}); diff --git a/apps/api/src/routes/route-reports.ts b/apps/api/src/routes/route-reports.ts new file mode 100644 index 00000000..12c487e7 --- /dev/null +++ b/apps/api/src/routes/route-reports.ts @@ -0,0 +1,36 @@ +import { RouteReportBody, RouteReportReceipt } from '@widoo/shared'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { submitRouteReport } from '../reports/submit'; +import { currentUser } from './me'; + +/** + * `POST /routes/:id/reports` (wiki API › Lieux, E-05 « Signaler ce parcours », D-056): a signed-in + * reader reports an online route; one report by user and by route over 7 sliding days. Never + * cached. + */ +export const routeReportsRoutes: FastifyPluginAsyncZod = async (app) => { + app.post( + '/routes/:id/reports', + { + onRequest: app.requireAuth, + onSend: async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }, + schema: { + tags: ['discovery'], + summary: 'Report an online route: a place that changed, an abuse, a wrong information', + security: [{ firebaseIdToken: [] }], + params: z.strictObject({ id: z.uuid() }), + body: RouteReportBody, + response: { 201: RouteReportReceipt }, + }, + }, + async (request, reply) => + reply + .status(201) + .send( + await submitRouteReport(app.db, currentUser(request), request.params.id, request.body), + ), + ); +};