From 791311446143bdd38c898a7e06a5c1f5da919695 Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Wed, 21 Oct 2026 19:37:54 +0200 Subject: [PATCH 1/2] fix(shared): let the team republish a route and unpublish one to fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two moves of the admin were missing from the life cycle, for the moderator only: unpublished → published (Republier, A-04) and needs_fix → unpublished (Dépublier). Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/route-lifecycle.test.ts | 2 ++ packages/shared/src/route-lifecycle.ts | 3 +++ 2 files changed, 5 insertions(+) diff --git a/packages/shared/src/route-lifecycle.test.ts b/packages/shared/src/route-lifecycle.test.ts index 9dfc1246..8fa3b094 100644 --- a/packages/shared/src/route-lifecycle.test.ts +++ b/packages/shared/src/route-lifecycle.test.ts @@ -26,6 +26,8 @@ const allowed = new Set([ 'needs_fix>pending:author', 'published>unpublished:author', 'published>unpublished:moderator', + 'needs_fix>unpublished:moderator', + 'unpublished>published:moderator', 'rejected>draft:author', 'draft>published:editor', 'private>published:editor', diff --git a/packages/shared/src/route-lifecycle.ts b/packages/shared/src/route-lifecycle.ts index 56fe3383..17564169 100644 --- a/packages/shared/src/route-lifecycle.ts +++ b/packages/shared/src/route-lifecycle.ts @@ -25,6 +25,9 @@ export const routeTransitions: readonly Transition[] = [ { from: 'published', to: 'needs_fix', by: ['system', 'moderator'] }, { from: 'needs_fix', to: 'pending', by: ['author'] }, { from: 'published', to: 'unpublished', by: ['author', 'moderator'] }, + // The team takes down a route to fix (A-02, A-04) and puts an unpublished one back (A-04). + { from: 'needs_fix', to: 'unpublished', by: ['moderator'] }, + { from: 'unpublished', to: 'published', by: ['moderator'] }, { from: 'rejected', to: 'draft', by: ['author'] }, { from: 'draft', to: 'published', by: ['editor'] }, { from: 'private', to: 'published', by: ['editor'] }, From b6b0683f0b3795f0ffdef172d6677a490bc41191 Mon Sep 17 00:00:00 2001 From: Paul Thiberville Date: Wed, 21 Oct 2026 19:37:54 +0200 Subject: [PATCH 2/2] fix(api): move the status of a route from the admin through moveRoute Publish, request changes, reject and unpublish (A-02, A-04), republish (A-04) and the routes of a closed place (A-03) wrote routes.status directly. They now lock the whole row and call moveRoute as the moderator: a route that goes offline leaves the profiles that featured it, the first publication keeps its date, each withdrawal is dated and a republication clears it. The 409 already_decided stays checked before the move. Co-Authored-By: Claude Opus 5.5 --- apps/api/src/catalog/route-actions.ts | 9 ++- apps/api/src/moderation/decisions.ts | 14 ++-- apps/api/src/reports/place-signals.ts | 36 ++++++++--- apps/api/src/routes/admin-catalog.test.ts | 16 +++++ apps/api/src/routes/admin-moderation.test.ts | 68 +++++++++++++++++++- apps/api/src/routes/admin-signals.test.ts | 32 ++++++++- 6 files changed, 150 insertions(+), 25 deletions(-) diff --git a/apps/api/src/catalog/route-actions.ts b/apps/api/src/catalog/route-actions.ts index 708f6342..31fbcf30 100644 --- a/apps/api/src/catalog/route-actions.ts +++ b/apps/api/src/catalog/route-actions.ts @@ -3,6 +3,7 @@ import { eq } from 'drizzle-orm'; import { recordAdminAction } from '../admin/actions'; import type { Db } from '../db/client'; import { moderationActionsTable, routes } from '../db/schema'; +import { moveRoute } from '../editor/lifecycle'; import { httpError } from '../errors'; type RouteActionInput = { @@ -23,7 +24,7 @@ export async function republishRoute( ): Promise { return db.transaction(async (tx) => { const [route] = await tx - .select({ status: routes.status, isOfficial: routes.isOfficial }) + .select() .from(routes) .where(eq(routes.id, input.routeId)) .for('update'); @@ -35,10 +36,8 @@ export async function republishRoute( throw httpError(409, 'Route is not unpublished', 'already_decided', details); } - await tx - .update(routes) - .set({ status: 'published', publishedAt: now }) - .where(eq(routes.id, input.routeId)); + // Keeps the date of the first publication and clears the one of the withdrawal. + await moveRoute(tx, route, 'published', 'moderator', now); await tx.insert(moderationActionsTable).values({ routeId: input.routeId, actorId: input.actorId, diff --git a/apps/api/src/moderation/decisions.ts b/apps/api/src/moderation/decisions.ts index 8befc1be..4224bbcb 100644 --- a/apps/api/src/moderation/decisions.ts +++ b/apps/api/src/moderation/decisions.ts @@ -9,6 +9,7 @@ import { eq } from 'drizzle-orm'; import { recordAdminAction } from '../admin/actions'; import type { Db } from '../db/client'; import { moderationActionsTable, routes } from '../db/schema'; +import { moveRoute } from '../editor/lifecycle'; import { httpError } from '../errors'; type DecidableAction = Exclude; @@ -34,8 +35,8 @@ export type ModerationDecisionInput = { }; /** - * Applies a decision to a route: its new status, a row in its moderation history and a row in - * the journal of the admin, all in one transaction. The route is locked while the decision is + * Applies a decision to a route: its new status (by `moveRoute`), a row in its moderation history + * and a row in the journal of the admin, all in one transaction. The route is locked while the decision is * made: when two members of the team decide at once, the second one finds a route that no longer * waits and receives 409 `already_decided` with the status the first one gave it. */ @@ -48,7 +49,7 @@ export async function decideRoute( return db.transaction(async (tx) => { const [route] = await tx - .select({ status: routes.status }) + .select() .from(routes) .where(eq(routes.id, input.routeId)) .for('update'); @@ -64,10 +65,9 @@ export async function decideRoute( ? 'needs_fix' : transitions[input.action].to; - await tx - .update(routes) - .set({ status: to, ...(to === 'published' ? { publishedAt: now } : {}) }) - .where(eq(routes.id, input.routeId)); + // The first publication keeps its date, each withdrawal is dated, and a route that goes + // offline leaves the profiles that featured it. + await moveRoute(tx, route, to, 'moderator', now); await tx.insert(moderationActionsTable).values({ routeId: input.routeId, actorId: input.actorId, diff --git a/apps/api/src/reports/place-signals.ts b/apps/api/src/reports/place-signals.ts index 56caf513..00f08171 100644 --- a/apps/api/src/reports/place-signals.ts +++ b/apps/api/src/reports/place-signals.ts @@ -5,11 +5,12 @@ import { type PlaceActionResult, type PlaceAlreadyDecidedDetails, } from '@widoo/shared'; -import { eq, inArray, sql } from 'drizzle-orm'; +import { and, eq, exists, sql } from 'drizzle-orm'; import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; import { recordAdminAction } from '../admin/actions'; import type { Db } from '../db/client'; -import { moderationActionsTable, places, routes } from '../db/schema'; +import { moderationActionsTable, places, routes, steps } from '../db/schema'; +import { moveRoute } from '../editor/lifecycle'; import { httpError } from '../errors'; type PlaceRow = { @@ -122,12 +123,13 @@ export async function verifyPlace( /** * 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 + * become `needs_fix` by `moveRoute`, a decision of the team, 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 }, + now = new Date(), ): Promise { return db.transaction(async (tx) => { const [place] = await tx @@ -146,14 +148,28 @@ export async function closePlace( .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); + const concerned = await tx + .select() + .from(routes) + .where( + and( + eq(routes.status, 'published'), + exists( + tx + .select({ id: steps.id }) + .from(steps) + .where(and(eq(steps.routeId, routes.id), eq(steps.placeId, input.placeId))), + ), + ), + ) + .orderBy(routes.id) + .for('update'); + const routeIds = concerned.map((route) => route.id); + // A decision of the team, like « Demander des modifications » on a published route (A-02). + for (const route of concerned) { + await moveRoute(tx, route, 'needs_fix', 'moderator', now); + } 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, diff --git a/apps/api/src/routes/admin-catalog.test.ts b/apps/api/src/routes/admin-catalog.test.ts index dc133387..e3b62c2c 100644 --- a/apps/api/src/routes/admin-catalog.test.ts +++ b/apps/api/src/routes/admin-catalog.test.ts @@ -172,6 +172,22 @@ describe('POST /v1/admin/routes/:id/republish', () => { expect(journal).toEqual([]); }); + it('keeps the date of the first publication and clears the one of the withdrawal', async () => { + const admin = await signIn('admin'); + const id = await addRoute('Daté', { status: 'unpublished' }); + const firstPublishedAt = (await routeOf(id))?.publishedAt; + await app.db + .update(routes) + .set({ unpublishedAt: new Date('2026-10-01T08:00:00Z') }) + .where(eq(routes.id, id)); + + expect((await act(admin.headers, id, 'republish', body)).statusCode).toBe(200); + const route = await routeOf(id); + expect(firstPublishedAt).toBeInstanceOf(Date); + expect(route?.publishedAt).toEqual(firstPublishedAt); + expect(route?.unpublishedAt).toBeNull(); + }); + it('is for admins only, and needs a reason', async () => { const moderator = await signIn('moderator'); const admin = await signIn('admin'); diff --git a/apps/api/src/routes/admin-moderation.test.ts b/apps/api/src/routes/admin-moderation.test.ts index d27f652e..d7f77043 100644 --- a/apps/api/src/routes/admin-moderation.test.ts +++ b/apps/api/src/routes/admin-moderation.test.ts @@ -11,7 +11,13 @@ 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, moderationActionsTable, routes, users } from '../db/schema'; +import { + adminActions, + moderationActionsTable, + routes, + userFeaturedRoutes, + users, +} from '../db/schema'; import { seed, seedId } from '../db/seed'; import { uuidv7 } from '../db/uuid'; import { testConfig } from '../test-config'; @@ -81,6 +87,8 @@ async function addRoute(status: RouteStatus, hoursAgo = 1, title = 'Montmartre') return id; } +const routeOf = async (id: string) => + (await app.db.select().from(routes).where(eq(routes.id, id)))[0]; const statusOf = async (id: string) => (await app.db.select({ status: routes.status }).from(routes).where(eq(routes.id, id)))[0]?.status; const queue = (headers: Record, query = '') => @@ -255,14 +263,70 @@ describe('decisions', () => { expect(action).toMatchObject({ action: 'reject', ...refusal }); }); - it('unpublishes a published route', async () => { + it('unpublishes a published route: dated, and out of the profiles that featured it', async () => { const moderator = await signIn('moderator'); const id = await addRoute('published'); + const firstPublishedAt = new Date('2026-10-01T08:00:00Z'); + await app.db.update(routes).set({ publishedAt: firstPublishedAt }).where(eq(routes.id, id)); + await app.db.insert(userFeaturedRoutes).values({ userId: authorId, routeId: id, position: 1 }); + + const before = Date.now(); + expect((await decide(moderator.headers, id, 'unpublish', refusal)).statusCode).toBe(200); + const route = await routeOf(id); + expect(route?.status).toBe('unpublished'); + expect(route?.unpublishedAt?.getTime()).toBeGreaterThanOrEqual(before); + expect(route?.publishedAt).toEqual(firstPublishedAt); + const featured = await app.db + .select() + .from(userFeaturedRoutes) + .where(eq(userFeaturedRoutes.routeId, id)); + expect(featured).toEqual([]); + }); + + it('unpublishes a route to fix', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('needs_fix'); expect((await decide(moderator.headers, id, 'unpublish', refusal)).statusCode).toBe(200); expect(await statusOf(id)).toBe('unpublished'); }); + it('keeps the date of the first publication when a route is published again', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending'); + const firstPublishedAt = new Date('2026-10-01T08:00:00Z'); + await app.db.update(routes).set({ publishedAt: firstPublishedAt }).where(eq(routes.id, id)); + + expect((await decide(moderator.headers, id, 'publish')).statusCode).toBe(200); + expect((await routeOf(id))?.publishedAt).toEqual(firstPublishedAt); + }); + + it('dates the first publication', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending'); + + const before = Date.now(); + expect((await decide(moderator.headers, id, 'publish')).statusCode).toBe(200); + expect((await routeOf(id))?.publishedAt?.getTime()).toBeGreaterThanOrEqual(before); + }); + + it.each<[string, RouteStatus, Record | undefined]>([ + ['publish', 'unpublished', undefined], + ['publish', 'draft', undefined], + ['reject', 'published', refusal], + ['request-changes', 'rejected', { message: 'À reprendre.' }], + ['unpublish', 'pending', refusal], + ['unpublish', 'private', refusal], + ])('refuses to %s a route %s, outside its life cycle', async (action, status, payload) => { + const moderator = await signIn('moderator'); + const id = await addRoute(status); + + const response = await decide(moderator.headers, id, action, payload); + expect(response.statusCode).toBe(409); + expect(ApiError.parse(response.json()).code).toBe('already_decided'); + expect(await statusOf(id)).toBe(status); + }); + it.each([ ['a missing message', 'request-changes', {}], ['markup in the message', 'request-changes', { message: 'non' }], diff --git a/apps/api/src/routes/admin-signals.test.ts b/apps/api/src/routes/admin-signals.test.ts index f7de6f82..4faaed60 100644 --- a/apps/api/src/routes/admin-signals.test.ts +++ b/apps/api/src/routes/admin-signals.test.ts @@ -12,7 +12,15 @@ 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, moderationActionsTable, places, routes, steps, users } from '../db/schema'; +import { + adminActions, + moderationActionsTable, + places, + routes, + steps, + userFeaturedRoutes, + users, +} from '../db/schema'; import { seed, seedId } from '../db/seed'; import { uuidv7 } from '../db/uuid'; import { testConfig } from '../test-config'; @@ -244,6 +252,28 @@ describe('POST /v1/admin/places/:id/close', () => { }); }); + it('moves the routes by their life cycle: out of the profiles that featured them', async () => { + const moderator = await signIn('moderator'); + const { placeId, routeId } = await addPlace('Mis en avant', 'flagged', 'published'); + await app.db + .insert(userFeaturedRoutes) + .values({ userId: adminId, routeId: routeId ?? '', position: 1 }); + + const response = await act(moderator.headers, placeId, 'close', { reason: 'Fermé.' }); + expect(response.statusCode).toBe(200); + const [route] = await app.db + .select() + .from(routes) + .where(eq(routes.id, routeId ?? '')); + expect(route?.status).toBe('needs_fix'); + expect(route?.unpublishedAt).toBeNull(); + const featured = await app.db + .select() + .from(userFeaturedRoutes) + .where(eq(userFeaturedRoutes.routeId, routeId ?? '')); + expect(featured).toEqual([]); + }); + 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');