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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions apps/api/src/catalog/route-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -23,7 +24,7 @@ export async function republishRoute(
): Promise<CatalogRouteAction> {
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');
Expand All @@ -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,
Expand Down
14 changes: 7 additions & 7 deletions apps/api/src/moderation/decisions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<ModerationAction, 'edit'>;
Expand All @@ -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.
*/
Expand All @@ -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');
Expand All @@ -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,
Expand Down
36 changes: 26 additions & 10 deletions apps/api/src/reports/place-signals.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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<PlaceActionResult> {
return db.transaction(async (tx) => {
const [place] = await tx
Expand All @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions apps/api/src/routes/admin-catalog.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
68 changes: 66 additions & 2 deletions apps/api/src/routes/admin-moderation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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<string, string>, query = '') =>
Expand Down Expand Up @@ -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<string, unknown> | 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: '<b>non</b>' }],
Expand Down
32 changes: 31 additions & 1 deletion apps/api/src/routes/admin-signals.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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');
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/route-lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
3 changes: 3 additions & 0 deletions packages/shared/src/route-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'] },
Expand Down
Loading