From 01a0defaff9a3092a1ed12a0612ef924ba027e47 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:08:28 +0700 Subject: [PATCH 01/23] feat(shared): add the refusal reasons and the schemas of the moderation queue Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/index.ts | 2 + packages/shared/src/labels/fr.ts | 9 ++ .../shared/src/labels/rejection-reasons.ts | 20 +++ packages/shared/src/schemas/error.ts | 2 + packages/shared/src/schemas/moderation.ts | 131 ++++++++++++++++++ packages/shared/src/taxonomies.ts | 11 ++ 6 files changed, 175 insertions(+) create mode 100644 packages/shared/src/labels/rejection-reasons.ts create mode 100644 packages/shared/src/schemas/moderation.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 94a2be57..fc15c90a 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -13,6 +13,7 @@ export * from './moderation'; export * from './notifications'; export * from './reports'; export * from './route-lifecycle'; +export { rejectionReasonLabels } from './labels/rejection-reasons'; export * from './monitoring'; export * from './taxonomies'; export * from './thresholds'; @@ -26,6 +27,7 @@ export * from './schemas/export'; export * from './schemas/geocode'; export * from './schemas/legal'; export * from './schemas/me'; +export * from './schemas/moderation'; export * from './schemas/place'; export * from './schemas/planned'; export * from './schemas/profile'; diff --git a/packages/shared/src/labels/fr.ts b/packages/shared/src/labels/fr.ts index 515bda10..582fef0d 100644 --- a/packages/shared/src/labels/fr.ts +++ b/packages/shared/src/labels/fr.ts @@ -98,4 +98,13 @@ export const fr = { activity: 'Activité', other: 'Autre', }, + rejectionReasons: { + not_a_route: 'Pas un vrai parcours', + place_invalid: 'Lieu invalide', + description_insufficient: 'Description insuffisante', + photos_invalid: 'Photos non conformes', + promotional: 'Contenu promotionnel', + policy_violation: 'Contenu interdit', + duplicate: 'Doublon', + }, } as const satisfies TaxonomyLabels; diff --git a/packages/shared/src/labels/rejection-reasons.ts b/packages/shared/src/labels/rejection-reasons.ts new file mode 100644 index 00000000..cc946f01 --- /dev/null +++ b/packages/shared/src/labels/rejection-reasons.ts @@ -0,0 +1,20 @@ +import type { RejectionReason } from '../taxonomies'; +import { fr } from './fr'; + +/** + * Labels of the refusal reasons in both languages (wiki Creation-et-Moderation, D-087): the app + * shows them to the author in its language (E-08), the admin in French (A-02). The other labels + * only exist in French at the MVP. + */ +export const rejectionReasonLabels: Record<'fr' | 'en', Record> = { + fr: fr.rejectionReasons, + en: { + not_a_route: 'Not a real route', + place_invalid: 'Invalid place', + description_insufficient: 'Description too thin', + photos_invalid: 'Photos not allowed', + promotional: 'Promotional content', + policy_violation: 'Prohibited content', + duplicate: 'Duplicate', + }, +}; diff --git a/packages/shared/src/schemas/error.ts b/packages/shared/src/schemas/error.ts index b72afc2a..11d09f60 100644 --- a/packages/shared/src/schemas/error.ts +++ b/packages/shared/src/schemas/error.ts @@ -16,6 +16,8 @@ export const apiErrorCodes = [ 'mfa_required', /** 403 everywhere: the account was suspended by the team (wiki Admin-Web › A-06). */ 'account_suspended', + /** 409 on a moderation decision: another member of the team decided first (D-087). */ + 'already_decided', 'internal_error', ] as const; diff --git a/packages/shared/src/schemas/moderation.ts b/packages/shared/src/schemas/moderation.ts new file mode 100644 index 00000000..c7818259 --- /dev/null +++ b/packages/shared/src/schemas/moderation.ts @@ -0,0 +1,131 @@ +import { z } from 'zod'; +import { taxonomies } from '../taxonomies'; + +/** Statuses of the moderation queue (A-02): waiting for the team, or for the author's fix. */ +export const moderationQueueStatuses = ['pending', 'changes_requested'] as const; +export type ModerationQueueStatus = (typeof moderationQueueStatuses)[number]; + +/** Decisions of a moderator, as `moderation_actions.action` keeps them. */ +export const moderationActions = [ + 'publish', + 'request_changes', + 'reject', + 'unpublish', + 'edit', +] as const; +export type ModerationAction = (typeof moderationActions)[number]; + +/** Columns the queue sorts by; `waiting` (oldest first) is the default. */ +export const moderationQueueSorts = ['waiting', 'title', 'author', 'city', 'warnings'] as const; +export type ModerationQueueSort = (typeof moderationQueueSorts)[number]; + +/** Rows of a page of the queue. */ +export const moderationQueuePageSize = 50; + +/** Query of `GET /admin/moderation`: filters, search, sort and page of the queue. */ +export const ModerationQueueQuery = z.strictObject({ + status: z.enum(moderationQueueStatuses).optional(), + /** Only the routes of this city. */ + city: z.uuid().optional(), + /** `true`: only what has been waiting for more than 72 hours. */ + late: z.enum(['true']).optional(), + /** `true`: only the revisions of routes already published. */ + revision: z.enum(['true']).optional(), + /** Text searched in the title and in the first name of the author. */ + q: z.string().trim().min(1).max(100).optional(), + sort: z.enum(moderationQueueSorts).default('waiting'), + direction: z.enum(['asc', 'desc']).default('asc'), + /** `nextCursor` or `previousCursor` of a page already read. */ + cursor: z.string().max(200).optional(), +}); +export type ModerationQueueQuery = z.infer; + +/** A route of the moderation queue, as its row shows it (A-02). */ +export const ModerationQueueItem = z.object({ + id: z.uuid(), + title: z.string(), + status: z.enum(moderationQueueStatuses), + /** + * True for the revision of a route already published, false for a first submission. Always + * false until revisions exist (`route_revisions`, with the creation of a route). + */ + isRevision: z.boolean(), + /** Null once the account of the author is purged. */ + author: z + .object({ + id: z.uuid(), + firstName: z.string().nullable(), + /** + * « Créateur certifié » (D-023). Always false until the certification exists (columns of + * the profile and its thresholds). + */ + isCertified: z.boolean(), + }) + .nullable(), + cityName: z.string(), + stepCount: z.number().int().nonnegative(), + /** Null until the orchestration computed the route. */ + durationMin: z.number().int().nonnegative().nullable(), + /** Coherence warnings of the orchestration still standing. */ + warningCount: z.number().int().nonnegative(), + /** Places of the route that no visitor has verified yet. */ + unverifiedPlaceCount: z.number().int().nonnegative(), + /** Since when the route waits in the queue. */ + waitingSince: z.iso.datetime(), +}); +export type ModerationQueueItem = z.infer; + +/** Body of `GET /admin/moderation`. */ +export const ModerationQueue = z.object({ + items: z.array(ModerationQueueItem), + /** Routes matching the filters and the search, over all pages. */ + total: z.number().int().nonnegative(), + /** Size of each filter over the whole queue, shown in its chip. */ + counts: z.object({ + pending: z.number().int().nonnegative(), + changesRequested: z.number().int().nonnegative(), + late: z.number().int().nonnegative(), + revisions: z.number().int().nonnegative(), + }), + /** Cities that have a route in the queue, by name: the choices of the city filter. */ + cities: z.array(z.object({ id: z.uuid(), name: z.string() })), + /** 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 ModerationQueue = z.infer; + +/** + * A message of the team to an author: several lines of plain text, without markup or control + * characters (wiki Securite-et-RGPD). + */ +export const MessageToAuthor = z + .string() + .trim() + .min(1) + .max(1000) + .regex(/^(?:[^<>\p{Cc}]|\n)*$/u, 'Plain text only, without < > or control characters'); + +/** Body of `POST /admin/routes/:id/request-changes`. */ +export const RequestChangesBody = z.strictObject({ message: MessageToAuthor }); +export type RequestChangesBody = z.infer; + +/** Body of `POST /admin/routes/:id/reject` and `/unpublish`: a codified reason and a message. */ +export const RejectionBody = z.strictObject({ + reasonCode: z.enum(taxonomies.rejectionReasons), + message: MessageToAuthor, +}); +export type RejectionBody = z.infer; + +/** Answer of a decision: the route in its new status. */ +export const ModerationDecision = z.object({ + routeId: z.uuid(), + action: z.enum(moderationActions), + status: z.enum(taxonomies.routeStatuses), +}); +export type ModerationDecision = z.infer; + +/** `details` of a 409 `already_decided`: the status another member of the team gave the route. */ +export const AlreadyDecidedDetails = z.object({ status: z.enum(taxonomies.routeStatuses) }); +export type AlreadyDecidedDetails = z.infer; diff --git a/packages/shared/src/taxonomies.ts b/packages/shared/src/taxonomies.ts index db4fa59d..55b7a263 100644 --- a/packages/shared/src/taxonomies.ts +++ b/packages/shared/src/taxonomies.ts @@ -59,6 +59,16 @@ export const taxonomies = { userRoles: ['user', 'editor', 'moderator', 'admin'], plans: ['free', 'premium'], bookingProviders: ['restaurant', 'ticketing', 'activity', 'other'], + /** Codified reasons of a refusal in moderation (wiki Creation-et-Moderation, D-087). */ + rejectionReasons: [ + 'not_a_route', + 'place_invalid', + 'description_insufficient', + 'photos_invalid', + 'promotional', + 'policy_violation', + 'duplicate', + ], } as const; export type Taxonomy = keyof typeof taxonomies; @@ -78,3 +88,4 @@ export type SignalReason = TaxonomyValue<'signalReasons'>; export type UserRole = TaxonomyValue<'userRoles'>; export type Plan = TaxonomyValue<'plans'>; export type BookingProvider = TaxonomyValue<'bookingProviders'>; +export type RejectionReason = TaxonomyValue<'rejectionReasons'>; From e1e9d204d9be356cf2bb13c710af840933ec34c2 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:08:28 +0700 Subject: [PATCH 02/23] feat(api): add the moderation history of a route and details to API errors Co-Authored-By: Claude Opus 5.5 --- .../0006_add_moderation_actions.sql | 14 + .../0016_add_moderation_actions.sql | 14 + .../migrations/meta/0016_snapshot.json | 3249 +++++++++++++++++ .../generated/migrations/meta/_journal.json | 7 + apps/api/src/db/schema.ts | 24 + apps/api/src/errors.ts | 5 +- apps/api/src/users/export.test.ts | 2 + 7 files changed, 3313 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/db/generated/migrations/0006_add_moderation_actions.sql create mode 100644 apps/api/src/db/generated/migrations/0016_add_moderation_actions.sql create mode 100644 apps/api/src/db/generated/migrations/meta/0016_snapshot.json diff --git a/apps/api/src/db/generated/migrations/0006_add_moderation_actions.sql b/apps/api/src/db/generated/migrations/0006_add_moderation_actions.sql new file mode 100644 index 00000000..c0c028fe --- /dev/null +++ b/apps/api/src/db/generated/migrations/0006_add_moderation_actions.sql @@ -0,0 +1,14 @@ +CREATE TYPE "public"."moderation_action" AS ENUM('publish', 'request_changes', 'reject', 'unpublish', 'edit');--> statement-breakpoint +CREATE TABLE "moderation_actions" ( + "id" uuid PRIMARY KEY NOT NULL, + "route_id" uuid NOT NULL, + "actor_id" uuid, + "action" "moderation_action" NOT NULL, + "reason_code" text, + "message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "moderation_actions" ADD CONSTRAINT "moderation_actions_route_id_routes_id_fk" FOREIGN KEY ("route_id") REFERENCES "public"."routes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "moderation_actions" ADD CONSTRAINT "moderation_actions_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "moderation_actions_route_idx" ON "moderation_actions" USING btree ("route_id","created_at"); \ No newline at end of file diff --git a/apps/api/src/db/generated/migrations/0016_add_moderation_actions.sql b/apps/api/src/db/generated/migrations/0016_add_moderation_actions.sql new file mode 100644 index 00000000..c0c028fe --- /dev/null +++ b/apps/api/src/db/generated/migrations/0016_add_moderation_actions.sql @@ -0,0 +1,14 @@ +CREATE TYPE "public"."moderation_action" AS ENUM('publish', 'request_changes', 'reject', 'unpublish', 'edit');--> statement-breakpoint +CREATE TABLE "moderation_actions" ( + "id" uuid PRIMARY KEY NOT NULL, + "route_id" uuid NOT NULL, + "actor_id" uuid, + "action" "moderation_action" NOT NULL, + "reason_code" text, + "message" text, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "moderation_actions" ADD CONSTRAINT "moderation_actions_route_id_routes_id_fk" FOREIGN KEY ("route_id") REFERENCES "public"."routes"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "moderation_actions" ADD CONSTRAINT "moderation_actions_actor_id_users_id_fk" FOREIGN KEY ("actor_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "moderation_actions_route_idx" ON "moderation_actions" USING btree ("route_id","created_at"); \ No newline at end of file diff --git a/apps/api/src/db/generated/migrations/meta/0016_snapshot.json b/apps/api/src/db/generated/migrations/meta/0016_snapshot.json new file mode 100644 index 00000000..e4c6b5be --- /dev/null +++ b/apps/api/src/db/generated/migrations/meta/0016_snapshot.json @@ -0,0 +1,3249 @@ +{ + "id": "13aae6fb-5275-4ed2-8be2-1deed87ac237", + "prevId": "fccdd842-f347-49a9-b60b-684251765015", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.admin_actions": { + "name": "admin_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "before": { + "name": "before", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "after": { + "name": "after", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "admin_actions_created_at_idx": { + "name": "admin_actions_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_actions_target_idx": { + "name": "admin_actions_target_idx", + "columns": [ + { + "expression": "target_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "admin_actions_actor_idx": { + "name": "admin_actions_actor_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "admin_actions_actor_id_users_id_fk": { + "name": "admin_actions_actor_id_users_id_fk", + "tableFrom": "admin_actions", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.anecdote_questions": { + "name": "anecdote_questions", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "anecdote_questions_key_check": { + "name": "anecdote_questions_key_check", + "value": "\"anecdote_questions\".\"key\" ~ '^[a-z0-9]+(_[a-z0-9]+)*$'" + } + }, + "isRLSEnabled": false + }, + "public.cities": { + "name": "cities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "center": { + "name": "center", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "bounds": { + "name": "bounds", + "type": "geography(Polygon,4326)", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "cities_slug_unique": { + "name": "cities_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.devices": { + "name": "devices", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "push_token": { + "name": "push_token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "devices_user_updated_idx": { + "name": "devices_user_updated_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "devices_user_id_users_id_fk": { + "name": "devices_user_id_users_id_fk", + "tableFrom": "devices", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "devices_push_token_unique": { + "name": "devices_push_token_unique", + "nullsNotDistinct": false, + "columns": [ + "push_token" + ] + } + }, + "policies": {}, + "checkConstraints": { + "devices_locale_check": { + "name": "devices_locale_check", + "value": "(\"devices\".\"locale\" in ('fr', 'en'))" + } + }, + "isRLSEnabled": false + }, + "public.direction_cache": { + "name": "direction_cache", + "schema": "", + "columns": { + "profile": { + "name": "profile", + "type": "directions_profile", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "from_lat_e5": { + "name": "from_lat_e5", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "from_lng_e5": { + "name": "from_lng_e5", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "to_lat_e5": { + "name": "to_lat_e5", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "to_lng_e5": { + "name": "to_lng_e5", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "distance_m": { + "name": "distance_m", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "duration_s": { + "name": "duration_s", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "geometry": { + "name": "geometry", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "direction_cache_from_idx": { + "name": "direction_cache_from_idx", + "columns": [ + { + "expression": "from_lat_e5", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "from_lng_e5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "direction_cache_to_idx": { + "name": "direction_cache_to_idx", + "columns": [ + { + "expression": "to_lat_e5", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "to_lng_e5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "direction_cache_pkey": { + "name": "direction_cache_pkey", + "columns": [ + "profile", + "from_lat_e5", + "from_lng_e5", + "to_lat_e5", + "to_lng_e5" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "direction_cache_measures_check": { + "name": "direction_cache_measures_check", + "value": "\"direction_cache\".\"distance_m\" >= 0 and \"direction_cache\".\"duration_s\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.media": { + "name": "media", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "owner_type": { + "name": "owner_type", + "type": "media_owner_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "owner_id": { + "name": "owner_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "storage_path": { + "name": "storage_path", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "media_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'upload'" + }, + "author": { + "name": "author", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "license": { + "name": "license", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "media_owner_idx": { + "name": "media_owner_idx", + "columns": [ + { + "expression": "owner_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "media_step_position_idx": { + "name": "media_step_position_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"media\".\"owner_type\" not in ('route', 'place')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "media_uploaded_by_users_id_fk": { + "name": "media_uploaded_by_users_id_fk", + "tableFrom": "media", + "tableTo": "users", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "media_step_position_check": { + "name": "media_step_position_check", + "value": "\"media\".\"owner_type\" in ('route', 'place')\n or \"media\".\"position\" between 0 and 4" + }, + "media_credit_check": { + "name": "media_credit_check", + "value": "(\"media\".\"source\" = 'upload'\n and \"media\".\"author\" is null and \"media\".\"license\" is null and \"media\".\"source_url\" is null)\n or (\"media\".\"source\" = 'commons'\n and coalesce(\"media\".\"author\", '') <> '' and coalesce(\"media\".\"license\", '') <> ''\n and coalesce(\"media\".\"source_url\", '') like 'https://%')" + } + }, + "isRLSEnabled": false + }, + "public.moderation_actions": { + "name": "moderation_actions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "moderation_action", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "reason_code": { + "name": "reason_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "moderation_actions_route_idx": { + "name": "moderation_actions_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "moderation_actions_route_id_routes_id_fk": { + "name": "moderation_actions_route_id_routes_id_fk", + "tableFrom": "moderation_actions", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "moderation_actions_actor_id_users_id_fk": { + "name": "moderation_actions_actor_id_users_id_fk", + "tableFrom": "moderation_actions", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "notification_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "read_at": { + "name": "read_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_push_at": { + "name": "sent_push_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "sent_email_at": { + "name": "sent_email_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "notifications_user_created_idx": { + "name": "notifications_user_created_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.place_hours": { + "name": "place_hours", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "weekday": { + "name": "weekday", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "opens": { + "name": "opens", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "closes": { + "name": "closes", + "type": "time", + "primaryKey": false, + "notNull": true + }, + "valid_from": { + "name": "valid_from", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "valid_to": { + "name": "valid_to", + "type": "date", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "place_hours_place_idx": { + "name": "place_hours_place_idx", + "columns": [ + { + "expression": "place_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "place_hours_place_id_places_id_fk": { + "name": "place_hours_place_id_places_id_fk", + "tableFrom": "place_hours", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "place_hours_weekday_check": { + "name": "place_hours_weekday_check", + "value": "\"place_hours\".\"weekday\" between 0 and 6" + } + }, + "isRLSEnabled": false + }, + "public.places": { + "name": "places", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "city_id": { + "name": "city_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "place_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": true + }, + "address": { + "name": "address", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "address_components": { + "name": "address_components", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "is_indoor": { + "name": "is_indoor", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "price_range": { + "name": "price_range", + "type": "price_range", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "website_url": { + "name": "website_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_refs": { + "name": "external_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "verification_status": { + "name": "verification_status", + "type": "verification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "always_open": { + "name": "always_open", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "confirmations_since_verified": { + "name": "confirmations_since_verified", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_by": { + "name": "created_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "places_location_idx": { + "name": "places_location_idx", + "columns": [ + { + "expression": "location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "places_city_category_idx": { + "name": "places_city_category_idx", + "columns": [ + { + "expression": "city_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "category", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "places_name_trgm_idx": { + "name": "places_name_trgm_idx", + "columns": [ + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "gin_trgm_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "places_city_id_cities_id_fk": { + "name": "places_city_id_cities_id_fk", + "tableFrom": "places", + "tableTo": "cities", + "columnsFrom": [ + "city_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "places_created_by_users_id_fk": { + "name": "places_created_by_users_id_fk", + "tableFrom": "places", + "tableTo": "users", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.planned_routes": { + "name": "planned_routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "route_revision_snapshot": { + "name": "route_revision_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "starts_at": { + "name": "starts_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "planned_route_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'planned'" + }, + "timeline": { + "name": "timeline", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "checklist": { + "name": "checklist", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "closed_by": { + "name": "closed_by", + "type": "planned_route_closer", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "planned_routes_user_starts_idx": { + "name": "planned_routes_user_starts_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "starts_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planned_routes_route_status_idx": { + "name": "planned_routes_route_status_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "planned_routes_one_in_progress_idx": { + "name": "planned_routes_one_in_progress_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"planned_routes\".\"status\" = 'in_progress'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "planned_routes_user_id_users_id_fk": { + "name": "planned_routes_user_id_users_id_fk", + "tableFrom": "planned_routes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "planned_routes_route_id_routes_id_fk": { + "name": "planned_routes_route_id_routes_id_fk", + "tableFrom": "planned_routes", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "planned_routes_closed_by_check": { + "name": "planned_routes_closed_by_check", + "value": "\"planned_routes\".\"closed_by\" is null or \"planned_routes\".\"status\" in ('completed', 'stopped')" + }, + "planned_routes_completed_at_check": { + "name": "planned_routes_completed_at_check", + "value": "\"planned_routes\".\"completed_at\" is null or \"planned_routes\".\"status\" = 'completed'" + } + }, + "isRLSEnabled": false + }, + "public.planned_steps": { + "name": "planned_steps", + "schema": "", + "columns": { + "planned_route_id": { + "name": "planned_route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "arrived_at": { + "name": "arrived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "done_at": { + "name": "done_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "skipped_at": { + "name": "skipped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "planned_steps_planned_route_id_planned_routes_id_fk": { + "name": "planned_steps_planned_route_id_planned_routes_id_fk", + "tableFrom": "planned_steps", + "tableTo": "planned_routes", + "columnsFrom": [ + "planned_route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "planned_steps_pkey": { + "name": "planned_steps_pkey", + "columns": [ + "planned_route_id", + "step_id" + ] + } + }, + "uniqueConstraints": { + "planned_steps_route_position_unique": { + "name": "planned_steps_route_position_unique", + "nullsNotDistinct": false, + "columns": [ + "planned_route_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": { + "planned_steps_done_or_skipped_check": { + "name": "planned_steps_done_or_skipped_check", + "value": "\"planned_steps\".\"done_at\" is null or \"planned_steps\".\"skipped_at\" is null" + } + }, + "isRLSEnabled": false + }, + "public.route_reports": { + "name": "route_reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reason": { + "name": "reason", + "type": "route_report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "step_id": { + "name": "step_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "comment": { + "name": "comment", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "route_reports_route_user_idx": { + "name": "route_reports_route_user_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "route_reports_open_idx": { + "name": "route_reports_open_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"route_reports\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "route_reports_one_open_low_rating_idx": { + "name": "route_reports_one_open_low_rating_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"route_reports\".\"reason\" = 'low_rating' and \"route_reports\".\"status\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "route_reports_route_id_routes_id_fk": { + "name": "route_reports_route_id_routes_id_fk", + "tableFrom": "route_reports", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_reports_user_id_users_id_fk": { + "name": "route_reports_user_id_users_id_fk", + "tableFrom": "route_reports", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_reports_reviewed_by_users_id_fk": { + "name": "route_reports_reviewed_by_users_id_fk", + "tableFrom": "route_reports", + "tableTo": "users", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "route_reports_reporter_check": { + "name": "route_reports_reporter_check", + "value": "(\"route_reports\".\"user_id\" is null) = (\"route_reports\".\"reason\" = 'low_rating')" + }, + "route_reports_step_check": { + "name": "route_reports_step_check", + "value": "(\"route_reports\".\"step_id\" is not null) = (\"route_reports\".\"reason\" in ('place_changed'))" + }, + "route_reports_comment_check": { + "name": "route_reports_comment_check", + "value": "\"route_reports\".\"comment\" is null or ((\"route_reports\".\"reason\" in ('place_changed', 'other')) and btrim(\"route_reports\".\"comment\") <> '' and char_length(\"route_reports\".\"comment\") <= 80)" + }, + "route_reports_comment_required_check": { + "name": "route_reports_comment_required_check", + "value": "\"route_reports\".\"comment\" is not null or not (\"route_reports\".\"reason\" in ('other'))" + }, + "route_reports_review_check": { + "name": "route_reports_review_check", + "value": "(\"route_reports\".\"status\" = 'open') = (\"route_reports\".\"reviewed_at\" is null)" + } + }, + "isRLSEnabled": false + }, + "public.route_revisions": { + "name": "route_revisions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "route_revision_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "submitted_at": { + "name": "submitted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "route_revisions_one_open_idx": { + "name": "route_revisions_one_open_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"route_revisions\".\"status\" in ('draft', 'pending', 'changes_requested')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "route_revisions_route_idx": { + "name": "route_revisions_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "route_revisions_route_id_routes_id_fk": { + "name": "route_revisions_route_id_routes_id_fk", + "tableFrom": "route_revisions", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_revisions_author_id_users_id_fk": { + "name": "route_revisions_author_id_users_id_fk", + "tableFrom": "route_revisions", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.route_unlocks": { + "name": "route_unlocks", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "store_transaction_id": { + "name": "store_transaction_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "route_unlocks_route_idx": { + "name": "route_unlocks_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "route_unlocks_user_id_users_id_fk": { + "name": "route_unlocks_user_id_users_id_fk", + "tableFrom": "route_unlocks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "route_unlocks_route_id_routes_id_fk": { + "name": "route_unlocks_route_id_routes_id_fk", + "tableFrom": "route_unlocks", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "route_unlocks_pkey": { + "name": "route_unlocks_pkey", + "columns": [ + "user_id", + "route_id" + ] + } + }, + "uniqueConstraints": { + "route_unlocks_store_transaction_id_unique": { + "name": "route_unlocks_store_transaction_id_unique", + "nullsNotDistinct": false, + "columns": [ + "store_transaction_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.routes": { + "name": "routes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "city_id": { + "name": "city_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_official": { + "name": "is_official", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "status": { + "name": "status", + "type": "route_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'draft'" + }, + "access": { + "name": "access", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "sponsored": { + "name": "sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "share_token": { + "name": "share_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "moods": { + "name": "moods", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "audiences": { + "name": "audiences", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "conditions": { + "name": "conditions", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "transport": { + "name": "transport", + "type": "transport", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'walk'" + }, + "suggested_start": { + "name": "suggested_start", + "type": "time", + "primaryKey": false, + "notNull": false + }, + "tips": { + "name": "tips", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "tips_hidden": { + "name": "tips_hidden", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "start_location": { + "name": "start_location", + "type": "geography(Point,4326)", + "primaryKey": false, + "notNull": false + }, + "bounds": { + "name": "bounds", + "type": "geography(Polygon,4326)", + "primaryKey": false, + "notNull": false + }, + "computed": { + "name": "computed", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "duration_bucket": { + "name": "duration_bucket", + "type": "duration_bucket", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "budget_bucket": { + "name": "budget_bucket", + "type": "budget_bucket", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stats": { + "name": "stats", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "recommendation": { + "name": "recommendation", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "routes_bounds_idx": { + "name": "routes_bounds_idx", + "columns": [ + { + "expression": "bounds", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "routes_start_location_idx": { + "name": "routes_start_location_idx", + "columns": [ + { + "expression": "start_location", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gist", + "with": {} + }, + "routes_city_status_idx": { + "name": "routes_city_status_idx", + "columns": [ + { + "expression": "city_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routes_moods_idx": { + "name": "routes_moods_idx", + "columns": [ + { + "expression": "moods", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "routes_audiences_idx": { + "name": "routes_audiences_idx", + "columns": [ + { + "expression": "audiences", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "routes_conditions_idx": { + "name": "routes_conditions_idx", + "columns": [ + { + "expression": "conditions", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "routes_duration_bucket_idx": { + "name": "routes_duration_bucket_idx", + "columns": [ + { + "expression": "duration_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "routes_budget_bucket_idx": { + "name": "routes_budget_bucket_idx", + "columns": [ + { + "expression": "budget_bucket", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "routes_city_id_cities_id_fk": { + "name": "routes_city_id_cities_id_fk", + "tableFrom": "routes", + "tableTo": "cities", + "columnsFrom": [ + "city_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "routes_author_id_users_id_fk": { + "name": "routes_author_id_users_id_fk", + "tableFrom": "routes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "routes_share_token_unique": { + "name": "routes_share_token_unique", + "nullsNotDistinct": false, + "columns": [ + "share_token" + ] + } + }, + "policies": {}, + "checkConstraints": { + "routes_moods_check": { + "name": "routes_moods_check", + "value": "\"routes\".\"moods\" <@ ARRAY['culture', 'nature', 'shopping', 'food', 'romantic', 'unusual', 'instagrammable', 'relax', 'sport']::text[]" + }, + "routes_audiences_check": { + "name": "routes_audiences_check", + "value": "\"routes\".\"audiences\" <@ ARRAY['solo', 'couple', 'family', 'friends', 'dog_friendly', 'kids_friendly']::text[]" + }, + "routes_conditions_check": { + "name": "routes_conditions_check", + "value": "\"routes\".\"conditions\" <@ ARRAY['no_booking', 'wheelchair', 'indoor', 'outdoor', 'sunny', 'rainy']::text[]" + }, + "routes_title_length_check": { + "name": "routes_title_length_check", + "value": "char_length(\"routes\".\"title\") <= 60" + }, + "routes_tips_check": { + "name": "routes_tips_check", + "value": "(cardinality(\"routes\".\"tips\") = 0 or (array_ndims(\"routes\".\"tips\") = 1\n and array_position(\"routes\".\"tips\", null) is null\n and array_position(\"routes\".\"tips\", '') is null)) and cardinality(\"routes\".\"tips\") <= 3" + }, + "routes_tips_hidden_check": { + "name": "routes_tips_hidden_check", + "value": "(cardinality(\"routes\".\"tips_hidden\") = 0 or (array_ndims(\"routes\".\"tips_hidden\") = 1\n and array_position(\"routes\".\"tips_hidden\", null) is null\n and array_position(\"routes\".\"tips_hidden\", '') is null))" + } + }, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.steps": { + "name": "steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "place_id": { + "name": "place_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "duration_min": { + "name": "duration_min", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "cost_per_person": { + "name": "cost_per_person", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "booking_required": { + "name": "booking_required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "booking_url": { + "name": "booking_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "booking_provider": { + "name": "booking_provider", + "type": "booking_provider", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "booking_note": { + "name": "booking_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "booking_is_affiliate": { + "name": "booking_is_affiliate", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ends_on": { + "name": "ends_on", + "type": "date", + "primaryKey": false, + "notNull": false + }, + "transition_note": { + "name": "transition_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transition": { + "name": "transition", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "highlights": { + "name": "highlights", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "audio_path": { + "name": "audio_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "audio_duration_s": { + "name": "audio_duration_s", + "type": "smallint", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "steps_place_idx": { + "name": "steps_place_idx", + "columns": [ + { + "expression": "place_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "steps_route_id_routes_id_fk": { + "name": "steps_route_id_routes_id_fk", + "tableFrom": "steps", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "steps_place_id_places_id_fk": { + "name": "steps_place_id_places_id_fk", + "tableFrom": "steps", + "tableTo": "places", + "columnsFrom": [ + "place_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "steps_route_position_unique": { + "name": "steps_route_position_unique", + "nullsNotDistinct": false, + "columns": [ + "route_id", + "position" + ] + } + }, + "policies": {}, + "checkConstraints": { + "steps_highlights_check": { + "name": "steps_highlights_check", + "value": "\"steps\".\"highlights\" is null or (array_ndims(\"steps\".\"highlights\") = 1\n and cardinality(\"steps\".\"highlights\") between 1 and 3\n and array_position(\"steps\".\"highlights\", null) is null\n and array_position(\"steps\".\"highlights\", '') is null)" + }, + "steps_audio_check": { + "name": "steps_audio_check", + "value": "(\"steps\".\"audio_path\" is null and \"steps\".\"audio_duration_s\" is null)\n or (\"steps\".\"audio_path\" is not null and \"steps\".\"audio_duration_s\" is not null and \"steps\".\"audio_duration_s\" > 0)" + }, + "steps_description_length_check": { + "name": "steps_description_length_check", + "value": "char_length(\"steps\".\"description\") <= 600" + }, + "steps_booking_note_length_check": { + "name": "steps_booking_note_length_check", + "value": "char_length(\"steps\".\"booking_note\") <= 80" + }, + "steps_booking_affiliate_check": { + "name": "steps_booking_affiliate_check", + "value": "not \"steps\".\"booking_is_affiliate\" or \"steps\".\"booking_url\" is not null" + } + }, + "isRLSEnabled": false + }, + "public.support_messages": { + "name": "support_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "reply_email": { + "name": "reply_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic": { + "name": "topic", + "type": "support_topic", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "app_version": { + "name": "app_version", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device": { + "name": "device", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "handled_at": { + "name": "handled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "support_messages_user_idx": { + "name": "support_messages_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "support_messages_unhandled_idx": { + "name": "support_messages_unhandled_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"support_messages\".\"handled_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "support_messages_user_id_users_id_fk": { + "name": "support_messages_user_id_users_id_fk", + "tableFrom": "support_messages", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "support_messages_message_length_check": { + "name": "support_messages_message_length_check", + "value": "char_length(\"support_messages\".\"message\") between 1 and 1000" + }, + "support_messages_locale_check": { + "name": "support_messages_locale_check", + "value": "(\"support_messages\".\"locale\" in ('fr', 'en'))" + } + }, + "isRLSEnabled": false + }, + "public.user_anecdotes": { + "name": "user_anecdotes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_key": { + "name": "question_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "answer": { + "name": "answer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_anecdotes_user_id_users_id_fk": { + "name": "user_anecdotes_user_id_users_id_fk", + "tableFrom": "user_anecdotes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_anecdotes_question_key_anecdote_questions_key_fk": { + "name": "user_anecdotes_question_key_anecdote_questions_key_fk", + "tableFrom": "user_anecdotes", + "tableTo": "anecdote_questions", + "columnsFrom": [ + "question_key" + ], + "columnsTo": [ + "key" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_anecdotes_pkey": { + "name": "user_anecdotes_pkey", + "columns": [ + "user_id", + "position" + ] + } + }, + "uniqueConstraints": { + "user_anecdotes_user_question_unique": { + "name": "user_anecdotes_user_question_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "question_key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_anecdotes_position_check": { + "name": "user_anecdotes_position_check", + "value": "\"user_anecdotes\".\"position\" between 1 and 3" + }, + "user_anecdotes_answer_length_check": { + "name": "user_anecdotes_answer_length_check", + "value": "char_length(\"user_anecdotes\".\"answer\") between 1 and 150" + } + }, + "isRLSEnabled": false + }, + "public.user_featured_routes": { + "name": "user_featured_routes", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "route_id": { + "name": "route_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "smallint", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "user_featured_routes_route_idx": { + "name": "user_featured_routes_route_idx", + "columns": [ + { + "expression": "route_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_featured_routes_user_id_users_id_fk": { + "name": "user_featured_routes_user_id_users_id_fk", + "tableFrom": "user_featured_routes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_featured_routes_route_id_routes_id_fk": { + "name": "user_featured_routes_route_id_routes_id_fk", + "tableFrom": "user_featured_routes", + "tableTo": "routes", + "columnsFrom": [ + "route_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "user_featured_routes_pkey": { + "name": "user_featured_routes_pkey", + "columns": [ + "user_id", + "position" + ] + } + }, + "uniqueConstraints": { + "user_featured_routes_user_route_unique": { + "name": "user_featured_routes_user_route_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "route_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "user_featured_routes_position_check": { + "name": "user_featured_routes_position_check", + "value": "\"user_featured_routes\".\"position\" between 1 and 3" + } + }, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "firebase_uid": { + "name": "firebase_uid", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "plan": { + "name": "plan", + "type": "plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "plan_expires_at": { + "name": "plan_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revenuecat_app_user_id": { + "name": "revenuecat_app_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "neighborhood": { + "name": "neighborhood", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_certified": { + "name": "is_certified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "certified_override": { + "name": "certified_override", + "type": "certified_override", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "certified_at": { + "name": "certified_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "trust_score": { + "name": "trust_score", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "notification_prefs": { + "name": "notification_prefs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspended_at": { + "name": "suspended_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "suspension_reason": { + "name": "suspension_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "users_firebase_uid_active_idx": { + "name": "users_firebase_uid_active_idx", + "columns": [ + { + "expression": "firebase_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"users\".\"deleted_at\" is null", + "concurrently": false, + "method": "btree", + "with": {} + }, + "users_firebase_uid_deleted_idx": { + "name": "users_firebase_uid_deleted_idx", + "columns": [ + { + "expression": "firebase_uid", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "users_bio_length_check": { + "name": "users_bio_length_check", + "value": "char_length(\"users\".\"bio\") <= 150" + }, + "users_locale_check": { + "name": "users_locale_check", + "value": "\"users\".\"locale\" is null or (\"users\".\"locale\" in ('fr', 'en'))" + } + }, + "isRLSEnabled": false + } + }, + "enums": { + "public.booking_provider": { + "name": "booking_provider", + "schema": "public", + "values": [ + "restaurant", + "ticketing", + "activity", + "other" + ] + }, + "public.budget_bucket": { + "name": "budget_bucket", + "schema": "public", + "values": [ + "free", + "low", + "medium", + "high" + ] + }, + "public.certified_override": { + "name": "certified_override", + "schema": "public", + "values": [ + "none", + "granted", + "revoked" + ] + }, + "public.directions_profile": { + "name": "directions_profile", + "schema": "public", + "values": [ + "walking", + "cycling", + "driving" + ] + }, + "public.duration_bucket": { + "name": "duration_bucket", + "schema": "public", + "values": [ + "1_2h", + "half_day", + "full_day", + "weekend" + ] + }, + "public.media_owner_type": { + "name": "media_owner_type", + "schema": "public", + "values": [ + "route", + "place", + "step" + ] + }, + "public.media_source": { + "name": "media_source", + "schema": "public", + "values": [ + "upload", + "commons" + ] + }, + "public.moderation_action": { + "name": "moderation_action", + "schema": "public", + "values": [ + "publish", + "request_changes", + "reject", + "unpublish", + "edit" + ] + }, + "public.notification_kind": { + "name": "notification_kind", + "schema": "public", + "values": [ + "go_closed", + "report_resolved" + ] + }, + "public.place_category": { + "name": "place_category", + "schema": "public", + "values": [ + "restaurant", + "cafe", + "bar", + "bakery", + "museum", + "gallery", + "monument", + "park", + "viewpoint", + "shop", + "activity", + "event_venue", + "walk", + "other" + ] + }, + "public.plan": { + "name": "plan", + "schema": "public", + "values": [ + "free", + "premium" + ] + }, + "public.planned_route_closer": { + "name": "planned_route_closer", + "schema": "public", + "values": [ + "user", + "auto" + ] + }, + "public.planned_route_status": { + "name": "planned_route_status", + "schema": "public", + "values": [ + "planned", + "in_progress", + "completed", + "stopped", + "cancelled", + "missed" + ] + }, + "public.price_range": { + "name": "price_range", + "schema": "public", + "values": [ + "free", + "low", + "medium", + "high" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "open", + "accepted", + "rejected" + ] + }, + "public.route_report_reason": { + "name": "route_report_reason", + "schema": "public", + "values": [ + "place_changed", + "inappropriate", + "dangerous_place", + "hidden_ad", + "copied_content", + "false_info", + "other", + "low_rating" + ] + }, + "public.route_revision_status": { + "name": "route_revision_status", + "schema": "public", + "values": [ + "draft", + "pending", + "changes_requested", + "rejected", + "published" + ] + }, + "public.route_status": { + "name": "route_status", + "schema": "public", + "values": [ + "draft", + "private", + "pending", + "changes_requested", + "published", + "needs_fix", + "rejected", + "unpublished" + ] + }, + "public.support_topic": { + "name": "support_topic", + "schema": "public", + "values": [ + "question", + "problem", + "data", + "other" + ] + }, + "public.transport": { + "name": "transport", + "schema": "public", + "values": [ + "walk", + "bike", + "metro", + "car" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "editor", + "moderator", + "admin" + ] + }, + "public.verification_status": { + "name": "verification_status", + "schema": "public", + "values": [ + "unverified", + "verified", + "stale", + "flagged", + "closed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/api/src/db/generated/migrations/meta/_journal.json b/apps/api/src/db/generated/migrations/meta/_journal.json index 32557b69..59758622 100644 --- a/apps/api/src/db/generated/migrations/meta/_journal.json +++ b/apps/api/src/db/generated/migrations/meta/_journal.json @@ -113,6 +113,13 @@ "when": 1792121438641, "tag": "0015_add_admin_actions", "breakpoints": true + }, + { + "idx": 16, + "version": "7", + "when": 1792121607808, + "tag": "0016_add_moderation_actions", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/api/src/db/schema.ts b/apps/api/src/db/schema.ts index e177458b..25506dbb 100644 --- a/apps/api/src/db/schema.ts +++ b/apps/api/src/db/schema.ts @@ -36,6 +36,7 @@ import { type Audience, type Condition, type Mood, + moderationActions, } from '@widoo/shared'; import { sql, type SQL } from 'drizzle-orm'; import { @@ -97,6 +98,7 @@ export const reportStatus = pgEnum('report_status', reportStatuses); export const notificationKind = pgEnum('notification_kind', notificationKinds); /** Subject of a message to the support (E-19, D-063); its labels belong to the app and the admin. */ export const supportTopic = pgEnum('support_topic', supportTopics); +export const moderationAction = pgEnum('moderation_action', moderationActions); /** UUID v7 primary key, generated by the API (Postgres 16 has no `uuidv7()`). */ const id = () => @@ -673,6 +675,28 @@ export const routeUnlocks = pgTable( ], ); +/** + * History of a route in moderation: each decision of the team, with its codified reason and its + * message to the author. Rows are only ever inserted (wiki Modele-de-Donnees). + */ +export const moderationActionsTable = pgTable( + 'moderation_actions', + { + id: id(), + routeId: uuid('route_id') + .notNull() + .references(() => routes.id, { onDelete: 'cascade' }), + /** Kept when the account of the moderator is purged. */ + actorId: uuid('actor_id').references(() => users.id, { onDelete: 'set null' }), + action: moderationAction('action').notNull(), + /** One of `taxonomies.rejectionReasons`, for a refusal or an unpublication. */ + reasonCode: text('reason_code'), + message: text('message'), + createdAt: createdAt(), + }, + (t) => [index('moderation_actions_route_idx').on(t.routeId, t.createdAt)], +); + /** * Journal of every write made from the admin (A-12, D-085): who, what, on what, the state before * and after, and why. Rows are only ever inserted. `moderation_actions` stays the history of a diff --git a/apps/api/src/errors.ts b/apps/api/src/errors.ts index 9b26b2b1..72c2d651 100644 --- a/apps/api/src/errors.ts +++ b/apps/api/src/errors.ts @@ -17,14 +17,15 @@ const codeByStatus: Partial> = { /** * A client error the handler sends as is: `statusCode`, its `ApiError` code and `message` * (400 is `validation_error`). `code` replaces the code of the status when the wiki names a - * finer one (`mfa_required`, `account_suspended`). + * finer one (`mfa_required`, `account_suspended`, `already_decided`), with its `details`. */ export function httpError( statusCode: 400 | 401 | 403 | 404 | 409, message: string, code?: ApiErrorCode, + details?: Record, ) { - return Object.assign(new Error(message), { statusCode, apiCode: code }); + return Object.assign(new Error(message), { statusCode, apiCode: code, details }); } /** diff --git a/apps/api/src/users/export.test.ts b/apps/api/src/users/export.test.ts index 673e4692..4b568b87 100644 --- a/apps/api/src/users/export.test.ts +++ b/apps/api/src/users/export.test.ts @@ -171,6 +171,8 @@ describe('exportUserData', () => { // Journal of the writes of the team, kept when an account is purged (A-12): the work of a // member of the team, not data of the person. 'admin_actions.actor_id', + // Decisions of a member of the team on a route: their work, not data of the person. + 'moderation_actions.actor_id', // Photos of the caller's routes and steps are exported with them. 'media.uploaded_by', // Places belong to the shared catalog, not to the person who added them. From b65af040fd76e096bf24edeb409f639e38f2e376 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:08:28 +0700 Subject: [PATCH 03/23] feat(api): serve the moderation queue and the decisions of the team Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 2 + apps/api/src/moderation/decisions.ts | 84 +++++++++++ apps/api/src/moderation/queue.ts | 186 ++++++++++++++++++++++++ apps/api/src/routes/admin-moderation.ts | 118 +++++++++++++++ 4 files changed, 390 insertions(+) create mode 100644 apps/api/src/moderation/decisions.ts create mode 100644 apps/api/src/moderation/queue.ts create mode 100644 apps/api/src/routes/admin-moderation.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index a6cd9fec..ea493747 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -17,6 +17,7 @@ import { createMapboxGeocoder } from './geocode/mapbox'; import { loggerOptions, requestIdOf, type LogStream } from './logger'; import { parseQueryString } from './query-string'; import { adminRoutes } from './routes/admin'; +import { adminModerationRoutes } from './routes/admin-moderation'; import { configRoutes } from './routes/config'; import { editorRoutes } from './routes/editor'; import { geocodeRoutes } from './routes/geocode'; @@ -121,6 +122,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(meExportRoutes, { prefix: '/v1' }); await app.register(plannedRoutesPlugin, { prefix: '/v1' }); await app.register(adminRoutes, { prefix: '/v1' }); + await app.register(adminModerationRoutes, { 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/moderation/decisions.ts b/apps/api/src/moderation/decisions.ts new file mode 100644 index 00000000..23f05626 --- /dev/null +++ b/apps/api/src/moderation/decisions.ts @@ -0,0 +1,84 @@ +import type { + AlreadyDecidedDetails, + ModerationAction, + ModerationDecision, + RejectionReason, + RouteStatus, +} from '@widoo/shared'; +import { eq } from 'drizzle-orm'; +import { recordAdminAction } from '../admin/actions'; +import type { Db } from '../db/client'; +import { moderationActionsTable, routes } from '../db/schema'; +import { httpError } from '../errors'; + +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' }, + reject: { from: ['pending'], to: 'rejected' }, + unpublish: { from: ['published', 'needs_fix'], to: 'unpublished' }, +}; + +export type ModerationDecisionInput = { + routeId: string; + /** The member of the team who decides. */ + actorId: string; + action: DecidableAction; + reasonCode?: RejectionReason; + /** Message to the author, required for everything but a publication. */ + message?: string; +}; + +/** + * 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 + * 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. + */ +export async function decideRoute( + db: Db, + input: ModerationDecisionInput, + now = new Date(), +): Promise { + const { from, to } = transitions[input.action]; + + return db.transaction(async (tx) => { + const [route] = await tx + .select({ status: routes.status }) + .from(routes) + .where(eq(routes.id, input.routeId)) + .for('update'); + if (!route) { + throw httpError(404, 'Route not found'); + } + if (!from.includes(route.status)) { + const details: AlreadyDecidedDetails = { status: route.status }; + throw httpError(409, 'Route already decided', 'already_decided', details); + } + + await tx + .update(routes) + .set({ status: to, ...(to === 'published' ? { publishedAt: now } : {}) }) + .where(eq(routes.id, input.routeId)); + await tx.insert(moderationActionsTable).values({ + routeId: input.routeId, + actorId: input.actorId, + action: input.action, + reasonCode: input.reasonCode ?? null, + message: input.message ?? null, + }); + await recordAdminAction(tx, { + actorId: input.actorId, + action: `route.${input.action}`, + targetType: 'route', + targetId: input.routeId, + before: { status: route.status }, + after: { status: to }, + reason: [input.reasonCode, input.message].filter(Boolean).join(' — ') || undefined, + }); + + return { routeId: input.routeId, action: input.action, status: to }; + }); +} diff --git a/apps/api/src/moderation/queue.ts b/apps/api/src/moderation/queue.ts new file mode 100644 index 00000000..45d5ae9f --- /dev/null +++ b/apps/api/src/moderation/queue.ts @@ -0,0 +1,186 @@ +import { + adminLateAfterHours, + moderationQueuePageSize, + moderationQueueStatuses, + type ModerationQueue, + type ModerationQueueItem, + type ModerationQueueQuery, + type ModerationQueueSort, + type ModerationQueueStatus, +} from '@widoo/shared'; +import { sql, type SQL } from 'drizzle-orm'; +import type { Db } from '../db/client'; + +type QueueRow = { + id: string; + title: string; + status: ModerationQueueStatus; + author_id: string | null; + is_revision: boolean; + author_first_name: string | null; + author_is_certified: boolean; + city_name: string; + step_count: number; + duration_min: number | null; + warning_count: number; + unverified_place_count: number; + waiting_since: Date; +}; + +const statusList = sql.join( + moderationQueueStatuses.map((status) => sql`${status}`), + sql`, `, +); + +// Until a route records its submission (`moderation_actions`, with the creation of a route), its +// last change stands for the moment it entered the queue. +const waitingSince = sql`r.updated_at`; + +// A revision is the new version of a route already published. Revisions do not exist yet +// (`route_revisions`, with the creation of a route): no route of the queue is one. The filter, +// the count and the rows all read this one expression, the only thing to change then. +const isRevision = sql`false`; + +// « Créateur certifié » (D-023) is computed from the routes and the ratings of an author, or set +// by the team: neither exists yet, so no author is certified. One expression, as for revisions. +const isCertifiedAuthor = sql`false`; + +const sortExpressions: Record = { + waiting: waitingSince, + title: sql`lower(r.title)`, + author: sql`lower(coalesce(u.first_name, ''))`, + city: sql`lower(c.name)`, + warnings: sql`jsonb_array_length(coalesce(r.computed -> 'warnings', '[]'::jsonb))`, +}; + +const lateBefore = (now: Date) => + new Date(now.getTime() - adminLateAfterHours * 60 * 60 * 1000).toISOString(); + +/** `%` and `_` typed in the search are searched as they are, not read as wildcards. */ +const likePattern = (text: string) => `%${text.replaceAll(/[\\%_]/g, '\\$&')}%`; + +function filtersOf(query: ModerationQueueQuery, now: Date): SQL { + const filters: SQL[] = [sql`r.status in (${statusList})`]; + if (query.status) { + filters.push(sql`r.status = ${query.status}`); + } + if (query.city) { + filters.push(sql`r.city_id = ${query.city}`); + } + if (query.late) { + filters.push(sql`${waitingSince} < ${lateBefore(now)}::timestamptz`); + } + if (query.revision) { + filters.push(isRevision); + } + if (query.q) { + const pattern = likePattern(query.q); + filters.push(sql`(r.title ilike ${pattern} or u.first_name ilike ${pattern})`); + } + return sql.join(filters, sql` and `); +} + +const queueFrom = sql`from routes r + join cities c on c.id = r.city_id + left join users u on u.id = r.author_id and u.deleted_at is null`; + +/** + * The cursor of a page is the number of rows before it, kept opaque for the client. The queue is + * short and sorted by several columns: an offset is enough, and a row decided meanwhile only + * shifts the next page by one. + */ +const encodeCursor = (offset: number) => Buffer.from(String(offset)).toString('base64url'); + +export function decodeCursor(cursor: string | undefined): number { + if (!cursor) { + return 0; + } + const offset = Number(Buffer.from(cursor, 'base64url').toString()); + return Number.isSafeInteger(offset) && offset > 0 ? offset : 0; +} + +const toItem = (row: QueueRow): ModerationQueueItem => ({ + id: row.id, + title: row.title, + status: row.status, + isRevision: row.is_revision, + author: row.author_id + ? { + id: row.author_id, + firstName: row.author_first_name, + isCertified: row.author_is_certified, + } + : null, + cityName: row.city_name, + stepCount: row.step_count, + durationMin: row.duration_min, + warningCount: row.warning_count, + unverifiedPlaceCount: row.unverified_place_count, + waitingSince: new Date(row.waiting_since).toISOString(), +}); + +/** A page of the moderation queue (A-02), with the size of each filter over the whole queue. */ +export async function readModerationQueue( + db: Db, + query: ModerationQueueQuery, + now = new Date(), +): Promise { + const offset = decodeCursor(query.cursor); + const where = filtersOf(query, now); + const direction = query.direction === 'desc' ? sql`desc` : sql`asc`; + + const rows = await db.execute(sql` + select r.id, r.title, r.status, ${isRevision} as is_revision, u.id as author_id, + ${isCertifiedAuthor} as author_is_certified, u.first_name as author_first_name, + c.name as city_name, + (select count(*)::int from steps s where s.route_id = r.id) as step_count, + (r.computed ->> 'duration_min')::int as duration_min, + ${sortExpressions.warnings} as warning_count, + (select count(distinct p.id)::int from steps s join places p on p.id = s.place_id + where s.route_id = r.id and p.verification_status <> 'verified') as unverified_place_count, + ${waitingSince} as waiting_since + ${queueFrom} + where ${where} + order by ${sortExpressions[query.sort]} ${direction}, r.id + limit ${moderationQueuePageSize} offset ${offset}`); + + const [totals] = await db.execute<{ + total: number; + pending: number; + changes_requested: number; + late: number; + revisions: number; + }>(sql` + select + (select count(*)::int ${queueFrom} where ${where}) as total, + count(*) filter (where r.status = 'pending')::int as pending, + count(*) filter (where r.status = 'changes_requested')::int as changes_requested, + count(*) filter (where ${waitingSince} < ${lateBefore(now)}::timestamptz)::int as late, + count(*) filter (where ${isRevision})::int as revisions + from routes r + where r.status in (${statusList})`); + + const cities = await db.execute<{ id: string; name: string }>(sql` + select distinct c.id, c.name from routes r join cities c on c.id = r.city_id + where r.status in (${statusList}) + order by c.name`); + + const total = totals?.total ?? 0; + return { + items: rows.map(toItem), + total, + counts: { + pending: totals?.pending ?? 0, + changesRequested: totals?.changes_requested ?? 0, + late: totals?.late ?? 0, + revisions: totals?.revisions ?? 0, + }, + cities: cities.map(({ id, name }) => ({ id, name })), + offset, + previousCursor: offset > 0 ? encodeCursor(Math.max(0, offset - moderationQueuePageSize)) : null, + nextCursor: + offset + moderationQueuePageSize < total + ? encodeCursor(offset + moderationQueuePageSize) + : null, + }; +} diff --git a/apps/api/src/routes/admin-moderation.ts b/apps/api/src/routes/admin-moderation.ts new file mode 100644 index 00000000..a574a3b1 --- /dev/null +++ b/apps/api/src/routes/admin-moderation.ts @@ -0,0 +1,118 @@ +import { + ModerationDecision, + ModerationQueue, + ModerationQueueQuery, + RejectionBody, + RequestChangesBody, +} from '@widoo/shared'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { httpError } from '../errors'; +import { decideRoute } from '../moderation/decisions'; +import { readModerationQueue } from '../moderation/queue'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; +const RouteParams = 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; +} + +/** + * Moderation of the routes (A-02, wiki API › Admin): the queue, and the decisions of the team. + * Moderators and admins only; every decision is written in `moderation_actions` and in the + * journal of the admin. Never cached. + */ +export const adminModerationRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAdminSection('moderation')); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/admin/moderation', + { + schema: { + tags, + summary: 'Moderation queue', + security, + querystring: ModerationQueueQuery, + response: { 200: ModerationQueue }, + }, + }, + async (request) => readModerationQueue(app.db, request.query), + ); + + app.post( + '/admin/routes/:id/publish', + { + schema: { + tags, + summary: 'Publish a pending route', + security, + params: RouteParams, + response: { 200: ModerationDecision }, + }, + }, + async (request) => + decideRoute(app.db, { + routeId: request.params.id, + actorId: actorIdOf(request), + action: 'publish', + }), + ); + + app.post( + '/admin/routes/:id/request-changes', + { + schema: { + tags, + summary: 'Ask the author to change a pending route', + security, + params: RouteParams, + body: RequestChangesBody, + response: { 200: ModerationDecision }, + }, + }, + async (request) => + decideRoute(app.db, { + routeId: request.params.id, + actorId: actorIdOf(request), + action: 'request_changes', + message: request.body.message, + }), + ); + + for (const [path, action, summary] of [ + ['reject', 'reject', 'Refuse a pending route'], + ['unpublish', 'unpublish', 'Unpublish a published route'], + ] as const) { + app.post( + `/admin/routes/:id/${path}`, + { + schema: { + tags, + summary, + security, + params: RouteParams, + body: RejectionBody, + response: { 200: ModerationDecision }, + }, + }, + async (request) => + decideRoute(app.db, { + routeId: request.params.id, + actorId: actorIdOf(request), + action, + reasonCode: request.body.reasonCode, + message: request.body.message, + }), + ); + } +}; From 835f6b5a3160d97daf1c7ec8240422dcda08fb54 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:08:28 +0700 Subject: [PATCH 04/23] test(api): cover the moderation queue, its filters and concurrent decisions Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin-moderation.test.ts | 292 +++++++++++++++++++ 1 file changed, 292 insertions(+) create mode 100644 apps/api/src/routes/admin-moderation.test.ts diff --git a/apps/api/src/routes/admin-moderation.test.ts b/apps/api/src/routes/admin-moderation.test.ts new file mode 100644 index 00000000..c67d364c --- /dev/null +++ b/apps/api/src/routes/admin-moderation.test.ts @@ -0,0 +1,292 @@ +import { + AlreadyDecidedDetails, + ApiError, + ModerationDecision, + ModerationQueue, + 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, moderationActionsTable, routes, users } from '../db/schema'; +import { seed } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// Shared database, parallel files: unique users and routes, searched by a marker in their title. +const verifier = new TestTokenVerifier(); +const createdUids: string[] = []; +const createdRouteIds: string[] = []; +const marker = `modtest-${uuidv7()}`; +const hour = 60 * 60 * 1000; + +let app: Awaited>; +let authorId: string; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); + await seed(app.db); + const uid = `test-${uuidv7()}`; + createdUids.push(uid); + const [author] = await app.db + .insert(users) + .values({ firebaseUid: uid, firstName: 'Camille' }) + .returning(); + if (!author) throw new Error('Author not created'); + authorId = author.id; +}); +afterAll(async () => { + await app.db.delete(adminActions).where(inArray(adminActions.targetId, createdRouteIds)); + 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, in `status`, last changed `hoursAgo` hours ago. */ +async function addRoute(status: RouteStatus, hoursAgo = 1, title = 'Montmartre') { + const [template] = await app.db.select().from(routes).limit(1); + if (!template) throw new Error('No route in the seed'); + const id = uuidv7(); + createdRouteIds.push(id); + await app.db.insert(routes).values({ + ...template, + id, + authorId, + shareToken: null, + status, + publishedAt: null, + title: `${title} ${marker}`, + updatedAt: new Date(Date.now() - hoursAgo * hour), + }); + return id; +} + +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 = '') => + app.inject({ url: `/v1/admin/moderation?q=${marker}${query}`, headers }); +const decide = ( + headers: Record, + id: string, + action: string, + payload?: Record, +) => app.inject({ method: 'POST', url: `/v1/admin/routes/${id}/${action}`, headers, payload }); + +const refusal = { reasonCode: 'duplicate', message: 'Déjà publié sous un autre titre.' }; + +describe('GET /v1/admin/moderation', () => { + it('refuses an editor, who does not open the moderation', async () => { + const editor = await signIn('editor'); + const response = await queue(editor.headers); + expect(response.statusCode).toBe(403); + expect(ApiError.parse(response.json()).code).toBe('forbidden'); + }); + + it('lists what waits, the oldest first, with the author and the content', async () => { + const moderator = await signIn('moderator'); + const recent = await addRoute('pending', 2, 'Récent'); + const old = await addRoute('pending', 80, 'Ancien'); + const toFix = await addRoute('changes_requested', 30, 'À corriger'); + await addRoute('published', 5, 'Publié'); + await addRoute('draft', 5, 'Brouillon'); + + const response = await queue(moderator.headers); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + const body = ModerationQueue.parse(response.json()); + expect(body.items.map((item) => item.id)).toEqual([old, toFix, recent]); + expect(body.total).toBe(3); + expect(body.items[0]).toMatchObject({ + status: 'pending', + isRevision: false, + author: { id: authorId, firstName: 'Camille', isCertified: false }, + }); + expect(body.items[0]?.stepCount).toBeGreaterThanOrEqual(0); + expect(body.offset).toBe(0); + expect(body.cities.length).toBeGreaterThanOrEqual(1); + expect(body.nextCursor).toBeNull(); + expect(body.previousCursor).toBeNull(); + }); + + it('filters by status and by lateness, and sorts the other way', async () => { + const moderator = await signIn('moderator'); + const ids = async (query: string) => + ModerationQueue.parse((await queue(moderator.headers, query)).json()).items.map( + (item) => item.title.split(' ')[0], + ); + + expect(await ids('&status=changes_requested')).toEqual(['À']); + expect(await ids('&late=true')).toEqual(['Ancien']); + expect(await ids('&sort=title&direction=desc')).toEqual(['Récent', 'Ancien', 'À']); + }); + + it('filters by city', async () => { + const moderator = await signIn('moderator'); + const all = ModerationQueue.parse((await queue(moderator.headers)).json()); + const [city] = all.cities; + if (!city) throw new Error('No city in the queue'); + + const inCity = ModerationQueue.parse( + (await queue(moderator.headers, `&city=${city.id}`)).json(), + ); + expect(inCity.total).toBeGreaterThanOrEqual(1); + expect(inCity.items.every((item) => item.cityName === city.name)).toBe(true); + + const elsewhere = await queue(moderator.headers, `&city=${uuidv7()}`); + expect(ModerationQueue.parse(elsewhere.json()).items).toEqual([]); + }); + + it('finds no revision while revisions do not exist', async () => { + const moderator = await signIn('moderator'); + const body = ModerationQueue.parse((await queue(moderator.headers, '&revision=true')).json()); + expect(body.items).toEqual([]); + expect(body.total).toBe(0); + expect(body.counts.revisions).toBe(0); + }); + + it('counts each filter over the whole queue, whatever the search', async () => { + const moderator = await signIn('moderator'); + const { counts } = ModerationQueue.parse((await queue(moderator.headers)).json()); + expect(counts.pending).toBeGreaterThanOrEqual(2); + expect(counts.changesRequested).toBeGreaterThanOrEqual(1); + expect(counts.late).toBeGreaterThanOrEqual(1); + }); + + it('refuses a filter it does not know', async () => { + const moderator = await signIn('moderator'); + const response = await queue(moderator.headers, '&role=admin'); + expect(response.statusCode).toBe(400); + }); +}); + +describe('decisions', () => { + it('publishes a pending route and keeps who did it, in both journals', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending'); + + const response = await decide(moderator.headers, id, 'publish'); + expect(response.statusCode).toBe(200); + expect(ModerationDecision.parse(response.json())).toEqual({ + routeId: id, + action: 'publish', + status: 'published', + }); + const [route] = await app.db.select().from(routes).where(eq(routes.id, id)); + expect(route?.status).toBe('published'); + expect(route?.publishedAt).toBeInstanceOf(Date); + + const history = await app.db + .select() + .from(moderationActionsTable) + .where(eq(moderationActionsTable.routeId, id)); + expect(history).toMatchObject([{ action: 'publish', actorId: moderator.id }]); + const journal = await app.db.select().from(adminActions).where(eq(adminActions.targetId, id)); + expect(journal).toMatchObject([ + { + action: 'route.publish', + actorId: moderator.id, + targetType: 'route', + before: { status: 'pending' }, + after: { status: 'published' }, + }, + ]); + }); + + it('asks for changes with a message to the author', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending'); + const message = 'Ajoutez une photo de chaque étape.\nMerci !'; + + const response = await decide(moderator.headers, id, 'request-changes', { message }); + expect(response.statusCode).toBe(200); + expect(await statusOf(id)).toBe('changes_requested'); + const [action] = await app.db + .select() + .from(moderationActionsTable) + .where(eq(moderationActionsTable.routeId, id)); + expect(action).toMatchObject({ action: 'request_changes', message }); + }); + + it('refuses with a codified reason and a message', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending'); + + expect((await decide(moderator.headers, id, 'reject', refusal)).statusCode).toBe(200); + expect(await statusOf(id)).toBe('rejected'); + const [action] = await app.db + .select() + .from(moderationActionsTable) + .where(eq(moderationActionsTable.routeId, id)); + expect(action).toMatchObject({ action: 'reject', ...refusal }); + }); + + it('unpublishes a published route', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('published'); + + expect((await decide(moderator.headers, id, 'unpublish', refusal)).statusCode).toBe(200); + expect(await statusOf(id)).toBe('unpublished'); + }); + + it.each([ + ['a missing message', 'request-changes', {}], + ['markup in the message', 'request-changes', { message: 'non' }], + ['an unknown reason', 'reject', { reasonCode: 'ugly', message: 'Non.' }], + ['a refusal without message', 'reject', { reasonCode: 'duplicate' }], + ])('refuses %s and leaves the route as it was', async (_, action, payload) => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending'); + + // An invalid body is a 422 (wiki API › Conventions, D-071); an invalid query stays a 400. + expect((await decide(moderator.headers, id, action, payload)).statusCode).toBe(422); + expect(await statusOf(id)).toBe('pending'); + }); + + it('answers 404 for a route that does not exist', async () => { + const moderator = await signIn('moderator'); + expect((await decide(moderator.headers, uuidv7(), 'publish')).statusCode).toBe(404); + }); + + it('refuses an editor, and writes nothing', async () => { + const editor = await signIn('editor'); + const id = await addRoute('pending'); + + expect((await decide(editor.headers, id, 'publish')).statusCode).toBe(403); + expect(await statusOf(id)).toBe('pending'); + }); + + it('tells the second moderator what the first one decided, and writes nothing twice', async () => { + const first = await signIn('moderator'); + const second = await signIn('admin'); + const id = await addRoute('pending'); + + const answers = await Promise.all([ + decide(first.headers, id, 'publish'), + decide(second.headers, id, 'reject', refusal), + ]); + const statuses = answers.map((answer) => answer.statusCode).sort(); + expect(statuses).toEqual([200, 409]); + + const late = answers.find((answer) => answer.statusCode === 409); + const error = ApiError.parse(late?.json()); + expect(error.code).toBe('already_decided'); + expect(AlreadyDecidedDetails.parse(error.details).status).toBe(await statusOf(id)); + + const history = await app.db + .select() + .from(moderationActionsTable) + .where(eq(moderationActionsTable.routeId, id)); + expect(history).toHaveLength(1); + const journal = await app.db.select().from(adminActions).where(eq(adminActions.targetId, id)); + expect(journal).toHaveLength(1); + }); +}); From 5ba444db8098f34f338a1d6194ded007baf5ab06 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:08:28 +0700 Subject: [PATCH 05/23] feat(api-client): read the moderation queue and send the decisions Co-Authored-By: Claude Opus 5.5 --- packages/api-client/src/client.ts | 51 ++++++++++++++++++++++ packages/api-client/src/index.ts | 1 + packages/api-client/src/moderation.test.ts | 25 +++++++++++ packages/api-client/src/moderation.ts | 28 ++++++++++++ 4 files changed, 105 insertions(+) create mode 100644 packages/api-client/src/moderation.test.ts create mode 100644 packages/api-client/src/moderation.ts diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index c846e22d..adc3ebd1 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,4 +1,6 @@ import { + ModerationQueue, + ModerationDecision, AdminCounters, AppConfig, GeocodeResult, @@ -21,6 +23,8 @@ import { type SupportMessageInput, type UpdateMe, type UpdatePlannedRoute, + type RejectionBody, + type RequestChangesBody, } from '@widoo/shared'; import { plannedPath, plannedQueryString, type PlannedListParams } from './planned'; import { @@ -32,6 +36,7 @@ import { import { isProfileUnavailable, publicProfilePath, type PublicProfileAnswer } from './profile'; import { removedOf, routePath, type GetRouteOptions, type RouteSheetAnswer } from './route'; import { createTransport, type ApiClientOptions } from './transport'; +import { moderationQueryString, type ModerationQueueParams } from './moderation'; /** * Typed client of the API, one method per route, answers validated by the `shared` schemas. @@ -237,6 +242,52 @@ export function createApiClient(options: ApiClientOptions) { signal, }), + /** `GET /v1/admin/moderation`: a page of the moderation queue (A-02). Moderators and admins. */ + getModerationQueue: (params: ModerationQueueParams, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/moderation?${moderationQueryString(params)}`, + schema: ModerationQueue, + auth: true, + signal, + }), + + /** + * Decisions of a moderator on a route. A route another member of the team already decided + * answers 409 `already_decided`, with the status it was given in `details`. + */ + publishRoute: (routeId: string) => + request({ + method: 'POST', + path: `/v1/admin/routes/${routeId}/publish`, + schema: ModerationDecision, + auth: true, + }), + requestRouteChanges: (routeId: string, body: RequestChangesBody) => + request({ + method: 'POST', + path: `/v1/admin/routes/${routeId}/request-changes`, + body, + schema: ModerationDecision, + auth: true, + }), + rejectRoute: (routeId: string, body: RejectionBody) => + request({ + method: 'POST', + path: `/v1/admin/routes/${routeId}/reject`, + body, + schema: ModerationDecision, + auth: true, + }), + unpublishRoute: (routeId: string, body: RejectionBody) => + request({ + method: 'POST', + path: `/v1/admin/routes/${routeId}/unpublish`, + body, + schema: ModerationDecision, + auth: true, + }), + /** `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/index.ts b/packages/api-client/src/index.ts index 8e24f0bc..4373348b 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -5,3 +5,4 @@ export type { RouteCountParams, RouteSearchParams } from './search'; export type { GetRouteOptions, RouteSheetAnswer } from './route'; export type { PlannedListParams } from './planned'; export type { PublicProfileAnswer } from './profile'; +export type { ModerationQueueParams } from './moderation'; diff --git a/packages/api-client/src/moderation.test.ts b/packages/api-client/src/moderation.test.ts new file mode 100644 index 00000000..c89942af --- /dev/null +++ b/packages/api-client/src/moderation.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, it } from 'vitest'; +import { moderationQueryString } from './moderation'; + +describe('moderationQueryString', () => { + it('is empty for the default queue', () => { + expect(moderationQueryString({})).toBe(''); + }); + + it('writes the filters, the search, the sort and the page', () => { + expect( + moderationQueryString({ + status: 'pending', + late: 'true', + q: 'montmartre', + sort: 'title', + direction: 'desc', + cursor: 'NTA', + }), + ).toBe('status=pending&late=true&q=montmartre&sort=title&direction=desc&cursor=NTA'); + }); + + it('encodes what the person typed', () => { + expect(moderationQueryString({ q: 'café & thé' })).toBe('q=caf%C3%A9+%26+th%C3%A9'); + }); +}); diff --git a/packages/api-client/src/moderation.ts b/packages/api-client/src/moderation.ts new file mode 100644 index 00000000..38e8d8dc --- /dev/null +++ b/packages/api-client/src/moderation.ts @@ -0,0 +1,28 @@ +import type { ModerationQueueQuery } from '@widoo/shared'; + +/** + * What the admin asks of `GET /v1/admin/moderation`: filters, search, sort and page, all + * optional. The sort and its direction default on the server. + */ +export type ModerationQueueParams = Partial; + +/** Query string of the moderation queue, as the API reads it; what is not set is left out. */ +export function moderationQueryString(params: ModerationQueueParams): string { + const query = new URLSearchParams(); + for (const key of [ + 'status', + 'city', + 'late', + 'revision', + 'q', + 'sort', + 'direction', + 'cursor', + ] as const) { + const value = params[key]; + if (value) { + query.set(key, value); + } + } + return query.toString(); +} From e2b6519ce5c615d101ce61da3da4bcaf1aa508cd Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:08:28 +0700 Subject: [PATCH 06/23] feat(ui): add the search field and the filter with a menu Co-Authored-By: Claude Opus 5.5 --- packages/ui/src/components/FilterMenu.tsx | 61 ++++++++++++++++++++++ packages/ui/src/components/SearchInput.tsx | 17 ++++++ packages/ui/src/index.ts | 2 + 3 files changed, 80 insertions(+) create mode 100644 packages/ui/src/components/FilterMenu.tsx create mode 100644 packages/ui/src/components/SearchInput.tsx diff --git a/packages/ui/src/components/FilterMenu.tsx b/packages/ui/src/components/FilterMenu.tsx new file mode 100644 index 00000000..02dd4a07 --- /dev/null +++ b/packages/ui/src/components/FilterMenu.tsx @@ -0,0 +1,61 @@ +'use client'; + +import { CaretDownIcon, CheckIcon } from '@phosphor-icons/react/dist/ssr'; +import { cn } from '../lib/cn'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from './DropdownMenu'; + +export type FilterMenuOption = { value: string; label: string }; + +type FilterMenuProps = { + /** Name of the filter, shown while nothing is chosen: « Ville ». */ + label: string; + options: readonly FilterMenuOption[]; + /** The value chosen, null without a choice. */ + value: string | null; + /** Called with the option chosen, or null when the chosen one is picked again. */ + onChange: (value: string | null) => void; +}; + +/** + * A filter with several choices, as a pill that opens a menu: « Ville », then « Lyon » on dark + * ground once chosen. Picking the chosen option again removes the filter. + */ +export function FilterMenu({ label, options, value, onChange }: FilterMenuProps) { + const chosen = options.find((option) => option.value === value); + return ( + + + {chosen ? : null} + {chosen?.label ?? label} + + + + {options.map((option) => ( + onChange(option.value === value ? null : option.value)} + > + + {option.label} + + ))} + + + ); +} diff --git a/packages/ui/src/components/SearchInput.tsx b/packages/ui/src/components/SearchInput.tsx new file mode 100644 index 00000000..93f0aa5c --- /dev/null +++ b/packages/ui/src/components/SearchInput.tsx @@ -0,0 +1,17 @@ +import { MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr'; +import type { ComponentProps } from 'react'; +import { cn } from '../lib/cn'; +import { Input } from './Input'; + +/** Search field of a list: a text field with the magnifying glass on its left. */ +export function SearchInput({ className, ...props }: ComponentProps) { + return ( + + + + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 7c303382..398494d2 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -11,9 +11,11 @@ export { } from './components/DropdownMenu'; export { Field } from './components/Field'; export { FilterChip } from './components/FilterChip'; +export { FilterMenu, type FilterMenuOption } from './components/FilterMenu'; export { Input, Label } from './components/Input'; export { Notice } from './components/Notice'; export { Pagination } from './components/Pagination'; +export { SearchInput } from './components/SearchInput'; export { Select } from './components/Select'; export { SortableColumnHeader, type SortDirection } from './components/SortableColumnHeader'; export { StatusBadge } from './components/StatusBadge'; From 0f383414111309a181849dc911859a956c2fc87e Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:08:28 +0700 Subject: [PATCH 07/23] feat(admin): read remote data for a screen and map the queue address to the API Co-Authored-By: Claude Opus 5.5 --- apps/admin/src/config/moderation-list.test.ts | 46 ++++++++++++++++ apps/admin/src/config/moderation-list.ts | 55 +++++++++++++++++++ apps/admin/src/hooks/useDebouncedCallback.ts | 23 ++++++++ apps/admin/src/hooks/useRemoteData.ts | 43 +++++++++++++++ apps/admin/src/lib/dates.test.ts | 25 ++++++++- apps/admin/src/lib/dates.ts | 28 ++++++++++ 6 files changed, 219 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/config/moderation-list.test.ts create mode 100644 apps/admin/src/config/moderation-list.ts create mode 100644 apps/admin/src/hooks/useDebouncedCallback.ts create mode 100644 apps/admin/src/hooks/useRemoteData.ts diff --git a/apps/admin/src/config/moderation-list.test.ts b/apps/admin/src/config/moderation-list.test.ts new file mode 100644 index 00000000..fb458df9 --- /dev/null +++ b/apps/admin/src/config/moderation-list.test.ts @@ -0,0 +1,46 @@ +import { parseListQuery } from '@/lib/list-query'; +import { describe, expect, it } from 'vitest'; +import { moderationListOptions, toModerationQueueParams } from './moderation-list'; + +const paramsOf = (address: string) => + toModerationQueueParams(parseListQuery(new URLSearchParams(address), moderationListOptions)); + +describe('toModerationQueueParams', () => { + it('asks for the oldest first by default', () => { + expect(paramsOf('')).toEqual({ + status: undefined, + city: undefined, + late: undefined, + revision: undefined, + q: undefined, + sort: 'waiting', + direction: 'asc', + cursor: undefined, + }); + }); + + it('translates the filters, the search, the sort and the page of the address', () => { + expect( + paramsOf( + 'statut=modifications&ville=0199d0a2-7c1e-7000-8000-000000000001&retard=oui&revision=oui&q=marais&tri=-auteur&page=NTA', + ), + ).toEqual({ + status: 'changes_requested', + city: '0199d0a2-7c1e-7000-8000-000000000001', + late: 'true', + revision: 'true', + q: 'marais', + sort: 'author', + direction: 'desc', + cursor: 'NTA', + }); + }); + + it('drops a filter value it does not know', () => { + expect(paramsOf('statut=published&retard=non&ville=paris')).toMatchObject({ + status: undefined, + city: undefined, + late: undefined, + }); + }); +}); diff --git a/apps/admin/src/config/moderation-list.ts b/apps/admin/src/config/moderation-list.ts new file mode 100644 index 00000000..8b828799 --- /dev/null +++ b/apps/admin/src/config/moderation-list.ts @@ -0,0 +1,55 @@ +import type { ListQuery, ListQueryOptions } from '@/lib/list-query'; +import type { ModerationQueueParams } from '@widoo/api-client'; +import type { ModerationQueueSort, ModerationQueueStatus } from '@widoo/shared'; + +/** Filters and columns of the moderation queue as its address names them, in French. */ +export type ModerationFilter = 'statut' | 'retard' | 'revision' | 'ville'; +export type ModerationColumn = 'parcours' | 'auteur' | 'ville' | 'alertes' | 'file'; +export type ModerationListQuery = ListQuery; + +/** What the address of the queue accepts; the oldest in the queue comes first by default. */ +export const moderationListOptions: ListQueryOptions = { + filters: ['statut', 'retard', 'revision', 'ville'], + sortableColumns: ['parcours', 'auteur', 'ville', 'alertes', 'file'], + defaultSort: { column: 'file', direction: 'asc' }, +}; + +/** Value of the `retard` and `revision` filters when they are on. */ +export const onFilterValue = 'oui'; + +/** Values of the `statut` filter, and the status of the API each one asks for. */ +export const statusFilterValues = { + relecture: 'pending', + modifications: 'changes_requested', +} as const satisfies Record; + +export type StatusFilterValue = keyof typeof statusFilterValues; + +const sortOfColumn: Record = { + parcours: 'title', + auteur: 'author', + ville: 'city', + alertes: 'warnings', + file: 'waiting', +}; + +const isStatusFilterValue = (value: string | undefined): value is StatusFilterValue => + value !== undefined && value in statusFilterValues; + +// The identifier of a city, as the API gives it: anything else typed in the address is dropped. +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** What to ask the API for the queue shown at an address; an unknown filter value is dropped. */ +export function toModerationQueueParams(query: ModerationListQuery): ModerationQueueParams { + const status = query.filters.statut; + return { + status: isStatusFilterValue(status) ? statusFilterValues[status] : undefined, + city: uuidPattern.test(query.filters.ville ?? '') ? query.filters.ville : undefined, + late: query.filters.retard === onFilterValue ? 'true' : undefined, + revision: query.filters.revision === onFilterValue ? 'true' : undefined, + q: query.search || undefined, + sort: sortOfColumn[query.sort.column], + direction: query.sort.direction, + cursor: query.cursor ?? undefined, + }; +} diff --git a/apps/admin/src/hooks/useDebouncedCallback.ts b/apps/admin/src/hooks/useDebouncedCallback.ts new file mode 100644 index 00000000..25e2b0d9 --- /dev/null +++ b/apps/admin/src/hooks/useDebouncedCallback.ts @@ -0,0 +1,23 @@ +import { useEffect, useRef } from 'react'; + +/** + * `callback`, called once the calls have stopped for `delayMs`: a search waits for the person to + * stop typing. A call still waiting is dropped when the component leaves. + */ +export function useDebouncedCallback( + callback: (value: Value) => void, + delayMs: number, +): (value: Value) => void { + const timer = useRef(undefined); + const latest = useRef(callback); + + useEffect(() => { + latest.current = callback; + }); + useEffect(() => () => window.clearTimeout(timer.current), []); + + return (value) => { + window.clearTimeout(timer.current); + timer.current = window.setTimeout(() => latest.current(value), delayMs); + }; +} diff --git a/apps/admin/src/hooks/useRemoteData.ts b/apps/admin/src/hooks/useRemoteData.ts new file mode 100644 index 00000000..d38e531b --- /dev/null +++ b/apps/admin/src/hooks/useRemoteData.ts @@ -0,0 +1,43 @@ +import { useEffect, useEffectEvent, useState } from 'react'; + +type Loaded = { key: string; data: Data } | { key: string; data: null }; + +export type RemoteData = + | { status: 'loading' } + | { status: 'error'; reload: () => void } + | { status: 'ready'; data: Data; reload: () => void }; + +/** + * Data read from the API for a screen: loading, then ready or in error, and read again when + * `key` changes or `reload` is called. `key` says what is asked (the query of a list); `read` + * receives a signal, aborted when the answer is no longer wanted. + */ +export function useRemoteData( + key: string, + read: (signal: AbortSignal) => Promise, +): RemoteData { + const [loaded, setLoaded] = useState | null>(null); + const [attempt, setAttempt] = useState(0); + const requestKey = `${attempt}:${key}`; + const readLatest = useEffectEvent(read); + + useEffect(() => { + const controller = new AbortController(); + readLatest(controller.signal) + .then((data) => setLoaded({ key: requestKey, data })) + .catch(() => { + if (!controller.signal.aborted) { + setLoaded({ key: requestKey, data: null }); + } + }); + return () => controller.abort(); + }, [requestKey]); + + const reload = () => setAttempt((count) => count + 1); + if (loaded?.key !== requestKey) { + return { status: 'loading' }; + } + return loaded.data === null + ? { status: 'error', reload } + : { status: 'ready', data: loaded.data, reload }; +} diff --git a/apps/admin/src/lib/dates.test.ts b/apps/admin/src/lib/dates.test.ts index 8797491e..284d40a1 100644 --- a/apps/admin/src/lib/dates.test.ts +++ b/apps/admin/src/lib/dates.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { formatFullDate, formatRelativeDate } from './dates'; +import { formatDuration, formatFullDate, formatRelativeDate, formatWaitingTime } from './dates'; const now = new Date('2026-10-12T10:00:00Z'); const ago = (ms: number) => new Date(now.getTime() - ms); @@ -30,3 +30,26 @@ describe('formatFullDate', () => { expect(formatFullDate(new Date('2026-10-08T06:12:00Z'))).toBe('jeudi 8 octobre à 8:12'); }); }); + +describe('formatWaitingTime', () => { + it.each([ + [20 * minute, '20 min'], + [9 * hour, '9 h'], + [day + 22 * hour, '1 j 22 h'], + [3 * day + 4 * hour + 10 * minute, '3 j 4 h'], + [2 * day, '2 j'], + ])('says %i ms of waiting as « %s »', (elapsed, expected) => { + expect(formatWaitingTime(ago(elapsed), now)).toBe(expected); + }); +}); + +describe('formatDuration', () => { + it.each([ + [45, '45 min'], + [420, '7 h'], + [270, '4 h 30'], + [185, '3 h 05'], + ])('says %i minutes as « %s »', (minutes, expected) => { + expect(formatDuration(minutes)).toBe(expected); + }); +}); diff --git a/apps/admin/src/lib/dates.ts b/apps/admin/src/lib/dates.ts index 3985496f..eeb4beea 100644 --- a/apps/admin/src/lib/dates.ts +++ b/apps/admin/src/lib/dates.ts @@ -43,3 +43,31 @@ export function formatRelativeDate(date: Date, now = new Date()): string { export function formatFullDate(date: Date): string { return fullDate.format(date); } + +/** + * How long something has been waiting, as a queue shows it: « 20 min », « 9 h », « 1 j 22 h », + * « 3 j 4 h ». + */ +export function formatWaitingTime(since: Date, now = new Date()): string { + const elapsed = Math.max(0, now.getTime() - since.getTime()); + if (elapsed < hour) { + return `${Math.floor(elapsed / minute)} min`; + } + const hours = Math.floor((elapsed % day) / hour); + if (elapsed < day) { + return `${hours} h`; + } + const days = Math.floor(elapsed / day); + return hours > 0 ? `${days} j ${hours} h` : `${days} j`; +} + +/** A duration in minutes as a route shows it: « 45 min », « 7 h », « 4 h 30 ». */ +export function formatDuration(minutes: number): string { + if (minutes < 60) { + return `${minutes} min`; + } + const rest = minutes % 60; + return rest > 0 + ? `${Math.floor(minutes / 60)} h ${String(rest).padStart(2, '0')}` + : `${minutes / 60} h`; +} From 82fcd73e8b840dd60986bc428f1afe6b20590ffc Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:08:28 +0700 Subject: [PATCH 08/23] feat(admin): show the moderation queue with its filters, sort and search (A-02) Co-Authored-By: Claude Opus 5.5 --- .../admin/src/app/(shell)/moderation/page.tsx | 11 ++ .../components/moderation/CertifiedSeal.tsx | 12 ++ .../moderation/ModerationQueueFilters.tsx | 54 ++++++++ .../moderation/ModerationQueueTable.tsx | 124 +++++++++++++++++ .../src/components/moderation/WaitingTime.tsx | 22 +++ .../components/moderation/WarningCount.tsx | 15 ++ .../containers/ModerationQueueContainer.tsx | 130 ++++++++++++++++++ apps/admin/src/containers/ShellContainer.tsx | 6 +- apps/admin/src/messages/fr.ts | 46 +++++++ 9 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/app/(shell)/moderation/page.tsx create mode 100644 apps/admin/src/components/moderation/CertifiedSeal.tsx create mode 100644 apps/admin/src/components/moderation/ModerationQueueFilters.tsx create mode 100644 apps/admin/src/components/moderation/ModerationQueueTable.tsx create mode 100644 apps/admin/src/components/moderation/WaitingTime.tsx create mode 100644 apps/admin/src/components/moderation/WarningCount.tsx create mode 100644 apps/admin/src/containers/ModerationQueueContainer.tsx diff --git a/apps/admin/src/app/(shell)/moderation/page.tsx b/apps/admin/src/app/(shell)/moderation/page.tsx new file mode 100644 index 00000000..23f881d3 --- /dev/null +++ b/apps/admin/src/app/(shell)/moderation/page.tsx @@ -0,0 +1,11 @@ +import { ModerationQueueContainer } from '@/containers/ModerationQueueContainer'; +import { Suspense } from 'react'; + +/** A-02. The state of the queue is read from the address, in the browser only. */ +export default function ModerationPage() { + return ( + + + + ); +} diff --git a/apps/admin/src/components/moderation/CertifiedSeal.tsx b/apps/admin/src/components/moderation/CertifiedSeal.tsx new file mode 100644 index 00000000..d2a4be01 --- /dev/null +++ b/apps/admin/src/components/moderation/CertifiedSeal.tsx @@ -0,0 +1,12 @@ +import { messages } from '@/messages/fr'; +import { SealCheckIcon } from '@phosphor-icons/react/dist/ssr'; + +/** Blue seal next to the name of a certified creator (D-023), named for screen readers. */ +export function CertifiedSeal() { + return ( + <> + + , {messages.moderation.certified} + + ); +} diff --git a/apps/admin/src/components/moderation/ModerationQueueFilters.tsx b/apps/admin/src/components/moderation/ModerationQueueFilters.tsx new file mode 100644 index 00000000..f1f2108a --- /dev/null +++ b/apps/admin/src/components/moderation/ModerationQueueFilters.tsx @@ -0,0 +1,54 @@ +import { messages } from '@/messages/fr'; +import type { ModerationQueue } from '@widoo/shared'; +import { FilterChip, FilterMenu, type FilterMenuOption } from '@widoo/ui'; + +export type ModerationFilterChoice = 'pending' | 'changesRequested' | 'late' | 'revisions'; + +type ModerationQueueFiltersProps = { + /** Size of each filter over the whole queue; absent while the queue loads. */ + counts?: ModerationQueue['counts']; + /** The filters that apply now. */ + active: Record; + onToggle: (filter: ModerationFilterChoice) => void; + /** Cities that have a route in the queue. */ + cities: readonly FilterMenuOption[]; + /** Identifier of the city chosen, null without a choice. */ + cityId: string | null; + onCityChange: (cityId: string | null) => void; +}; + +const choices: ModerationFilterChoice[] = ['pending', 'changesRequested', 'late', 'revisions']; + +/** Filters of the moderation queue, as chips with the number of routes each one holds. */ +export function ModerationQueueFilters({ + counts, + active, + onToggle, + cities, + cityId, + onCityChange, +}: ModerationQueueFiltersProps) { + return ( +
+ {choices.map((choice) => ( + onToggle(choice)} + /> + ))} + +
+ ); +} diff --git a/apps/admin/src/components/moderation/ModerationQueueTable.tsx b/apps/admin/src/components/moderation/ModerationQueueTable.tsx new file mode 100644 index 00000000..644f7795 --- /dev/null +++ b/apps/admin/src/components/moderation/ModerationQueueTable.tsx @@ -0,0 +1,124 @@ +import type { ModerationColumn } from '@/config/moderation-list'; +import { formatDuration } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import { HourglassIcon } from '@phosphor-icons/react/dist/ssr'; +import type { ModerationQueueItem } from '@widoo/shared'; +import { + SortableColumnHeader, + StatusBadge, + Table, + TableCell, + TableHeaderCell, + TableRow, + TableSkeleton, + type SortDirection, +} from '@widoo/ui'; +import { CertifiedSeal } from './CertifiedSeal'; +import { WaitingTime } from './WaitingTime'; +import { WarningCount } from './WarningCount'; + +type ModerationQueueTableProps = { + /** Rows of the page; ignored while `isLoading`. */ + items: ModerationQueueItem[]; + isLoading: boolean; + sort: { column: ModerationColumn; direction: SortDirection }; + onSort: (column: ModerationColumn) => void; + /** True for a route that has been waiting for more than 72 hours. */ + isLate: (item: ModerationQueueItem) => boolean; +}; + +const texts = messages.moderation; +const columnCount = 7; + +/** The moderation queue as a table (A-02): one route per row, sortable by five columns. */ +export function ModerationQueueTable({ + items, + isLoading, + sort, + onSort, + isLate, +}: ModerationQueueTableProps) { + const renderSortableHeader = (column: ModerationColumn, label: string, className?: string) => { + const direction = sort.column === column ? sort.direction : null; + const ariaSort = direction === null ? 'none' : direction === 'asc' ? 'ascending' : 'descending'; + return ( + + onSort(column)}> + {label} + + + ); + }; + + return ( + + + + {renderSortableHeader('parcours', texts.columns.route)} + {renderSortableHeader('auteur', texts.columns.author)} + {renderSortableHeader('ville', texts.columns.city)} + {texts.columns.content} + {renderSortableHeader('alertes', texts.columns.warnings)} + {texts.columns.unverified} + {renderSortableHeader('file', texts.columns.waiting, 'text-right')} + + + + {isLoading ? ( + + ) : ( + items.map((item) => ( + + + + {item.title} + + {item.isRevision ? texts.revision : texts.firstSubmission} + {item.status === 'changes_requested' ? ( + } + > + {texts.toFix} + + ) : null} + + + + + {item.author ? ( + + {item.author.firstName} + {item.author.isCertified ? : null} + + ) : ( + {texts.noAuthor} + )} + + {item.cityName} + + {texts.content( + item.stepCount, + item.durationMin === null ? null : formatDuration(item.durationMin), + )} + + + + + + {item.unverifiedPlaceCount > 0 ? ( + texts.unverified(item.unverifiedPlaceCount) + ) : ( + {texts.noUnverified} + )} + + + + + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/components/moderation/WaitingTime.tsx b/apps/admin/src/components/moderation/WaitingTime.tsx new file mode 100644 index 00000000..7af66205 --- /dev/null +++ b/apps/admin/src/components/moderation/WaitingTime.tsx @@ -0,0 +1,22 @@ +import { formatWaitingTime } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import { WarningCircleIcon } from '@phosphor-icons/react/dist/ssr'; +import { cn } from '@widoo/ui'; + +type WaitingTimeProps = { + /** Since when the route waits in the queue. */ + since: Date; + /** Waiting for more than 72 hours: red, with an icon and « en retard » for screen readers. */ + isLate: boolean; +}; + +/** Time spent in the queue: « 9 h », « 3 j 4 h ». Lateness is never told by the red alone. */ +export function WaitingTime({ since, isLate }: WaitingTimeProps) { + return ( + + {isLate ? : null} + {formatWaitingTime(since)} + {isLate ? , {messages.moderation.late} : null} + + ); +} diff --git a/apps/admin/src/components/moderation/WarningCount.tsx b/apps/admin/src/components/moderation/WarningCount.tsx new file mode 100644 index 00000000..697ac559 --- /dev/null +++ b/apps/admin/src/components/moderation/WarningCount.tsx @@ -0,0 +1,15 @@ +import { messages } from '@/messages/fr'; +import { WarningIcon } from '@phosphor-icons/react/dist/ssr'; + +/** Coherence warnings still standing on a route: « 2 alertes » in amber, or « Aucune ». */ +export function WarningCount({ count }: { count: number }) { + if (count === 0) { + return {messages.moderation.noWarning}; + } + return ( + + + {messages.moderation.warnings(count)} + + ); +} diff --git a/apps/admin/src/containers/ModerationQueueContainer.tsx b/apps/admin/src/containers/ModerationQueueContainer.tsx new file mode 100644 index 00000000..8ff4d561 --- /dev/null +++ b/apps/admin/src/containers/ModerationQueueContainer.tsx @@ -0,0 +1,130 @@ +'use client'; + +import { + ModerationQueueFilters, + type ModerationFilterChoice, +} from '@/components/moderation/ModerationQueueFilters'; +import { ModerationQueueTable } from '@/components/moderation/ModerationQueueTable'; +import { PageMessage } from '@/components/states/PageMessage'; +import { + onFilterValue, + moderationListOptions, + toModerationQueueParams, + type ModerationFilter, + type StatusFilterValue, +} from '@/config/moderation-list'; +import { useDebouncedCallback } from '@/hooks/useDebouncedCallback'; +import { useListQuery } from '@/hooks/useListQuery'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; +import { serializeListQuery } from '@/lib/list-query'; +import { messages } from '@/messages/fr'; +import { adminLateAfterHours, type ModerationQueueItem } from '@widoo/shared'; +import { Button, Pagination, SearchInput } from '@widoo/ui'; + +const searchDelayMs = 300; +const lateAfterMs = adminLateAfterHours * 60 * 60 * 1000; + +/** The filter of the address and its value behind each chip. */ +const filterOfChoice: Record = { + pending: ['statut', 'relecture' satisfies StatusFilterValue], + changesRequested: ['statut', 'modifications' satisfies StatusFilterValue], + late: ['retard', onFilterValue], + revisions: ['revision', onFilterValue], +}; + +const isLate = (item: ModerationQueueItem) => + Date.now() - new Date(item.waitingSince).getTime() > lateAfterMs; + +/** + * A-02, the moderation queue: reads the page the address asks for, and writes the search, the + * filters, the sort and the page back to the address. + */ +export function ModerationQueueContainer() { + const { query, handleSearch, handleFilter, handleSort, handlePage } = + useListQuery(moderationListOptions); + const queue = useRemoteData(serializeListQuery(query, moderationListOptions), (signal) => + getSessionClients().api.getModerationQueue(toModerationQueueParams(query), signal), + ); + const handleSearchTyped = useDebouncedCallback(handleSearch, searchDelayMs); + + const texts = messages.moderation; + const data = queue.status === 'ready' ? queue.data : undefined; + const isFiltered = query.search !== '' || Object.keys(query.filters).length > 0; + const active = { + pending: query.filters.statut === 'relecture', + changesRequested: query.filters.statut === 'modifications', + late: query.filters.retard === onFilterValue, + revisions: query.filters.revision === onFilterValue, + }; + + const renderList = () => { + if (queue.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + if (data?.items.length === 0) { + return isFiltered ? ( + + ) : ( + + ); + } + return ( + + ); + }; + + return ( +
+
+ handleSearchTyped(event.target.value)} + className="w-full max-w-card-list-w" + /> + {data ? ( +

{texts.total(data.total)}

+ ) : null} +
+ handleFilter(...filterOfChoice[choice])} + cities={(data?.cities ?? []).map((city) => ({ value: city.id, label: city.name }))} + cityId={query.filters.ville ?? null} + onCityChange={(cityId) => handleFilter('ville', cityId ?? query.filters.ville ?? '')} + /> + {renderList()} + {data && data.items.length > 0 ? ( +
+

+ {messages.list.range(data.offset + 1, data.offset + data.items.length, data.total)} +

+ handlePage(data.previousCursor) + } + onNext={data.nextCursor === null ? undefined : () => handlePage(data.nextCursor)} + /> +
+ ) : null} +
+ ); +} diff --git a/apps/admin/src/containers/ShellContainer.tsx b/apps/admin/src/containers/ShellContainer.tsx index 9a65950f..7a58b883 100644 --- a/apps/admin/src/containers/ShellContainer.tsx +++ b/apps/admin/src/containers/ShellContainer.tsx @@ -87,7 +87,11 @@ export function ShellContainer({ children }: { children: ReactNode }) { return ( = { admin: 'Admin', }; +/** Title of a page when it differs from the entry of its section in the navigation. */ +const pageTitles: Partial> = { + moderation: 'File de modération', +}; + /** * Where « Reconnectez-vous pour revenir… » sends back, after « revenir » (A-01, session expirée). */ @@ -61,6 +66,7 @@ const counted: Record string> = { export const messages = { app: { name: 'Widoo', product: 'Admin', title: 'Admin Widoo' }, sections, + pageTitles, roles, shell: { navigation: 'Navigation principale', @@ -149,6 +155,7 @@ export const messages = { close: 'Fermer', }, list: { + range: (first: number, last: number, total: number) => `${first} à ${last} sur ${total}`, loading: 'Chargement de la liste', pages: 'Pages de la liste', previous: 'Précédent', @@ -156,6 +163,45 @@ export const messages = { error: "La liste n'a pas pu être chargée.", retry: 'Réessayer', }, + moderation: { + search: 'Rechercher un titre ou un auteur', + total: (count: number) => `${count} parcours`, + filters: { + label: 'Filtres de la file', + pending: 'En relecture', + changesRequested: 'Modifications demandées', + late: 'Plus de 72 h', + revisions: 'Révisions', + city: 'Ville', + }, + columns: { + route: 'Parcours', + author: 'Auteur', + city: 'Ville', + content: 'Contenu', + warnings: 'Alertes', + unverified: 'Non vérifiés', + waiting: 'En file', + }, + firstSubmission: 'Première soumission', + revision: 'Révision', + toFix: 'À corriger', + certified: 'créateur certifié', + noAuthor: 'Compte supprimé', + content: (steps: number, duration: string | null) => + `${steps} ${steps > 1 ? 'étapes' : 'étape'}${duration ? `, ${duration}` : ''}`, + warnings: (count: number) => (count > 1 ? `${count} alertes` : '1 alerte'), + noWarning: 'Aucune', + unverified: (count: number) => (count > 1 ? `${count} lieux` : '1 lieu'), + noUnverified: 'Aucun', + late: 'en retard', + empty: { + title: 'La file est vide', + text: 'Aucun parcours à relire pour le moment.', + filteredTitle: 'Aucun parcours ne correspond', + filteredText: 'Modifiez la recherche ou les filtres.', + }, + }, notFound: { title: 'Page introuvable', text: "Cette adresse n'existe pas dans l'admin.", From 009f20de53bf09212209f80f237a8b8497d7ee60 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:30:02 +0700 Subject: [PATCH 09/23] feat(shared): describe the moderation sheet of a route and the changes of a revision Co-Authored-By: Claude Opus 5.5 --- packages/shared/src/schemas/moderation.ts | 123 ++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/packages/shared/src/schemas/moderation.ts b/packages/shared/src/schemas/moderation.ts index c7818259..0b2fee8e 100644 --- a/packages/shared/src/schemas/moderation.ts +++ b/packages/shared/src/schemas/moderation.ts @@ -1,5 +1,6 @@ import { z } from 'zod'; import { taxonomies } from '../taxonomies'; +import { HttpsUrl, LatLng } from './common'; /** Statuses of the moderation queue (A-02): waiting for the team, or for the author's fix. */ export const moderationQueueStatuses = ['pending', 'changes_requested'] as const; @@ -129,3 +130,125 @@ export type ModerationDecision = z.infer; /** `details` of a 409 `already_decided`: the status another member of the team gave the route. */ export const AlreadyDecidedDetails = z.object({ status: z.enum(taxonomies.routeStatuses) }); export type AlreadyDecidedDetails = z.infer; + +/** Steps a route needs to be Premium by default (`premium.min_steps`, wiki Modele-de-Donnees). */ +export const defaultPremiumMinSteps = 4; + +/** A step of the route as the moderation sheet shows it. */ +export const ModerationStep = z.object({ + id: z.uuid(), + position: z.number().int().nonnegative(), + /** Title given by the author, or the name of the place. */ + title: z.string(), + durationMin: z.number().int().positive(), + costPerPersonEur: z.number().nonnegative().nullable(), + bookingUrl: HttpsUrl.nullable(), + /** Way from the previous step; null for the first one, or until it is computed. */ + transition: z + .object({ + mode: z.enum(taxonomies.transports), + durationMin: z.number().nonnegative(), + distanceM: z.number().nonnegative(), + }) + .nullable(), + place: z.object({ + id: z.uuid(), + name: z.string(), + category: z.enum(taxonomies.placeCategories), + location: LatLng, + verificationStatus: z.enum(taxonomies.verificationStatuses), + }), +}); +export type ModerationStep = z.infer; + +/** A line of the history of a route in moderation. */ +export const ModerationHistoryEntry = z.object({ + action: z.enum(moderationActions), + /** First name of the member of the team; null once the account is purged. */ + actorFirstName: z.string().nullable(), + reasonCode: z.enum(taxonomies.rejectionReasons).nullable(), + message: z.string().nullable(), + createdAt: z.iso.datetime(), +}); +export type ModerationHistoryEntry = z.infer; + +/** What a revision changed in a route, compared with its published version. */ +export const revisionChangeKinds = ['replaced', 'modified', 'duration_changed'] as const; + +/** + * One change of a revision (A-02, « Changements depuis la version publiée »): a step replaced by + * another place, a text rewritten, a duration changed. `before` and `after` are ready to show. + */ +export const RevisionChange = z.object({ + /** What changed: a step, by its number from 1, or a text of the route. */ + subject: z.discriminatedUnion('kind', [ + z.object({ kind: z.literal('step'), number: z.number().int().positive() }), + z.object({ kind: z.literal('title') }), + z.object({ kind: z.literal('description') }), + ]), + change: z.enum(revisionChangeKinds), + before: z.string(), + after: z.string(), +}); +export type RevisionChange = z.infer; + +/** Body of `GET /admin/routes/:id`: the moderation sheet of a route (A-02). */ +export const ModerationRouteDetail = z.object({ + id: z.uuid(), + title: z.string(), + description: z.string().nullable(), + status: z.enum(taxonomies.routeStatuses), + isRevision: z.boolean(), + /** True when the route has enough steps to be Premium once published. */ + isPremiumComputed: z.boolean(), + /** Steps a route needs to be Premium, said next to the badge. */ + premiumMinSteps: z.number().int().positive(), + moods: z.array(z.enum(taxonomies.moods)), + cityName: z.string(), + waitingSince: z.iso.datetime(), + /** Coherence warnings of the orchestration still standing, as sentences. */ + warnings: z.array(z.string()), + photoUrls: z.array(HttpsUrl), + durationMin: z.number().int().nonnegative().nullable(), + budgetPerPersonEur: z.number().nonnegative().nullable(), + distanceM: z.number().nonnegative().nullable(), + steps: z.array(ModerationStep), + /** Null once the account of the author is purged. */ + author: z + .object({ + id: z.uuid(), + firstName: z.string().nullable(), + isCertified: z.boolean(), + registeredAt: z.iso.datetime(), + publishedRouteCount: z.number().int().nonnegative(), + rejectedRouteCount: z.number().int().nonnegative(), + /** + * Reports received on the routes of the author. Always 0 until route reports exist + * (`route_reports`). + */ + reportsReceivedCount: z.number().int().nonnegative(), + }) + .nullable(), + /** + * For a revision, what changed since the published version, in the order of the sheet. Always + * empty until revisions exist (`route_revisions`, with the creation of a route). + */ + revisionChanges: z.array(RevisionChange), + /** When the version still online was published; null for a first submission. */ + publishedVersionAt: z.iso.datetime().nullable(), + /** Decisions already taken on this route, the most recent first. */ + history: z.array(ModerationHistoryEntry), + /** + * Place of the route among those waiting for the team, oldest first (« Parcours 1 sur 14 »), + * with its neighbours; null when the route no longer waits. + */ + queue: z + .object({ + position: z.number().int().positive(), + total: z.number().int().positive(), + previousId: z.uuid().nullable(), + nextId: z.uuid().nullable(), + }) + .nullable(), +}); +export type ModerationRouteDetail = z.infer; From ed7eecf8a9a8cab7252987239ddb9bd52b38ec9c Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:30:02 +0700 Subject: [PATCH 10/23] feat(api): serve the moderation sheet of a route, with its author, history and queue place Co-Authored-By: Claude Opus 5.5 --- apps/api/src/moderation/detail.ts | 210 +++++++++++++++++++ apps/api/src/routes/admin-moderation.test.ts | 56 +++++ apps/api/src/routes/admin-moderation.ts | 22 ++ 3 files changed, 288 insertions(+) create mode 100644 apps/api/src/moderation/detail.ts diff --git a/apps/api/src/moderation/detail.ts b/apps/api/src/moderation/detail.ts new file mode 100644 index 00000000..91f93b3e --- /dev/null +++ b/apps/api/src/moderation/detail.ts @@ -0,0 +1,210 @@ +import { + defaultPremiumMinSteps, + taxonomies, + type ModerationHistoryEntry, + type ModerationRouteDetail, + type ModerationStep, +} from '@widoo/shared'; +import { sql } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { readSetting } from '../search/settings'; + +const premiumMinStepsKey = 'premium.min_steps'; + +type RouteRow = { + id: string; + title: string; + description: string | null; + status: ModerationRouteDetail['status']; + moods: ModerationRouteDetail['moods']; + city_name: string; + waiting_since: Date; + computed: Record | null; + photos: string[] | null; + author_id: string | null; + author_first_name: string | null; + author_registered_at: Date | null; + author_published: number; + author_rejected: number; +}; + +type StepRow = { + id: string; + position: number; + title: string | null; + duration_min: number; + cost_per_person: number | null; + booking_url: string | null; + transition: { mode?: string; duration_min?: number; distance_m?: number } | null; + place_id: string; + place_name: string; + category: ModerationStep['place']['category']; + lat: number; + lng: number; + verification_status: ModerationStep['place']['verificationStatus']; +}; + +type HistoryRow = { + action: ModerationHistoryEntry['action']; + actor_first_name: string | null; + reason_code: ModerationHistoryEntry['reasonCode']; + message: string | null; + created_at: Date; +}; + +type QueueRow = { + position: number; + total: number; + previous_id: string | null; + next_id: string | null; +}; + +const httpsOnly = (urls: readonly string[]) => urls.filter((url) => url.startsWith('https://')); +const numberOrNull = (value: unknown) => (typeof value === 'number' ? value : null); +const stringsOf = (value: unknown) => + Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []; + +function toStep(row: StepRow): ModerationStep { + const { transition } = row; + const mode = taxonomies.transports.find((transport) => transport === transition?.mode); + return { + id: row.id, + position: row.position, + title: row.title ?? row.place_name, + durationMin: row.duration_min, + costPerPersonEur: row.cost_per_person === null ? null : Number(row.cost_per_person), + bookingUrl: row.booking_url?.startsWith('https://') ? row.booking_url : null, + transition: + transition && mode + ? { + mode, + durationMin: transition.duration_min ?? 0, + distanceM: transition.distance_m ?? 0, + } + : null, + place: { + id: row.place_id, + name: row.place_name, + category: row.category, + location: { lat: row.lat, lng: row.lng }, + verificationStatus: row.verification_status, + }, + }; +} + +/** Steps a route needs to be Premium: the setting of the team, or 4. */ +async function readPremiumMinSteps(db: Db): Promise { + const value = await readSetting(db, premiumMinStepsKey); + return typeof value === 'number' && Number.isInteger(value) && value > 0 + ? value + : defaultPremiumMinSteps; +} + +/** + * The moderation sheet of a route (A-02): the route as it will be published, its warnings, its + * author, its history and its place in the queue. Undefined when the route does not exist. + */ +export async function readModerationRoute( + db: Db, + routeId: string, +): Promise { + const [route] = await db.execute(sql` + select r.id, r.title, r.description, r.status, r.moods, r.computed, + c.name as city_name, r.updated_at as waiting_since, + (select array_agg(m.storage_path order by m.position) from media m + where m.owner_type = 'route' and m.owner_id = r.id) as photos, + u.id as author_id, u.first_name as author_first_name, u.created_at as author_registered_at, + (select count(*)::int from routes a where a.author_id = u.id and a.status = 'published') + as author_published, + (select count(*)::int from routes a where a.author_id = u.id and a.status = 'rejected') + as author_rejected + from routes r + join cities c on c.id = r.city_id + left join users u on u.id = r.author_id and u.deleted_at is null + where r.id = ${routeId}`); + if (!route) { + return undefined; + } + + const steps = await db.execute(sql` + select s.id, s.position, s.title, s.duration_min, s.cost_per_person, s.booking_url, + s.transition, p.id as place_id, p.name as place_name, p.category, + ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng, + p.verification_status + from steps s join places p on p.id = s.place_id + where s.route_id = ${routeId} + order by s.position`); + + const history = await db.execute(sql` + select a.action, u.first_name as actor_first_name, a.reason_code, a.message, a.created_at + from moderation_actions a left join users u on u.id = a.actor_id + where a.route_id = ${routeId} + order by a.created_at desc`); + + // The routes waiting for the team, oldest first, as the queue lists them by default. + const [queue] = await db.execute(sql` + select position, total, previous_id, next_id from ( + select id, + (row_number() over waiting)::int as position, + (count(*) over ())::int as total, + lag(id) over waiting as previous_id, + lead(id) over waiting as next_id + from routes + where status = 'pending' + window waiting as (order by updated_at, id) + ) queued + where id = ${routeId}`); + + const premiumMinSteps = await readPremiumMinSteps(db); + const computed = route.computed ?? {}; + return { + id: route.id, + title: route.title, + description: route.description, + status: route.status, + // No revision and no certification exist yet: see `queue.ts`. + isRevision: false, + isPremiumComputed: steps.length >= premiumMinSteps, + premiumMinSteps, + moods: route.moods, + cityName: route.city_name, + waitingSince: new Date(route.waiting_since).toISOString(), + warnings: stringsOf(computed.warnings), + photoUrls: httpsOnly(route.photos ?? []), + durationMin: numberOrNull(computed.duration_min), + budgetPerPersonEur: numberOrNull(computed.budget_per_person_eur), + distanceM: numberOrNull(computed.distance_m), + steps: steps.map(toStep), + author: + route.author_id && route.author_registered_at + ? { + id: route.author_id, + firstName: route.author_first_name, + isCertified: false, + registeredAt: new Date(route.author_registered_at).toISOString(), + publishedRouteCount: route.author_published, + rejectedRouteCount: route.author_rejected, + // No report exists yet (`route_reports`): none was received. + reportsReceivedCount: 0, + } + : null, + // No revision exists yet: nothing to compare with a published version. + revisionChanges: [], + publishedVersionAt: null, + history: history.map((entry) => ({ + action: entry.action, + actorFirstName: entry.actor_first_name, + reasonCode: entry.reason_code, + message: entry.message, + createdAt: new Date(entry.created_at).toISOString(), + })), + queue: queue + ? { + position: queue.position, + total: queue.total, + previousId: queue.previous_id, + nextId: queue.next_id, + } + : null, + }; +} diff --git a/apps/api/src/routes/admin-moderation.test.ts b/apps/api/src/routes/admin-moderation.test.ts index c67d364c..2b41c2f2 100644 --- a/apps/api/src/routes/admin-moderation.test.ts +++ b/apps/api/src/routes/admin-moderation.test.ts @@ -3,6 +3,7 @@ import { ApiError, ModerationDecision, ModerationQueue, + ModerationRouteDetail, type RouteStatus, type UserRole, } from '@widoo/shared'; @@ -290,3 +291,58 @@ describe('decisions', () => { expect(journal).toHaveLength(1); }); }); + +describe('GET /v1/admin/routes/:id', () => { + const sheet = (headers: Record, id: string) => + app.inject({ url: `/v1/admin/routes/${id}`, headers }); + + it('gives the route as it will be published, its author and its place in the queue', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending', 200, 'Fiche'); + + const response = await sheet(moderator.headers, id); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + const route = ModerationRouteDetail.parse(response.json()); + expect(route).toMatchObject({ + id, + status: 'pending', + isRevision: false, + author: { id: authorId, firstName: 'Camille', isCertified: false }, + }); + expect(route.title).toContain('Fiche'); + expect(route.premiumMinSteps).toBe(4); + expect(route.revisionChanges).toEqual([]); + expect(route.author?.reportsReceivedCount).toBe(0); + expect(route.isPremiumComputed).toBe(route.steps.length >= 4); + expect(route.queue?.position).toBeGreaterThanOrEqual(1); + expect(route.queue?.total).toBeGreaterThanOrEqual(route.queue?.position ?? 0); + }); + + it('keeps the history of the decisions, the most recent first', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending'); + await decide(moderator.headers, id, 'reject', refusal); + + const route = ModerationRouteDetail.parse((await sheet(moderator.headers, id)).json()); + expect(route.status).toBe('rejected'); + expect(route.queue).toBeNull(); + expect(route.history).toMatchObject([{ action: 'reject', ...refusal }]); + }); + + it('counts what the author already published and was refused', async () => { + const moderator = await signIn('moderator'); + const id = await addRoute('pending'); + const { author } = ModerationRouteDetail.parse((await sheet(moderator.headers, id)).json()); + expect(author?.publishedRouteCount).toBeGreaterThanOrEqual(1); + expect(author?.rejectedRouteCount).toBeGreaterThanOrEqual(1); + }); + + it('answers 404 for a route that does not exist, and 403 to an editor', async () => { + const moderator = await signIn('moderator'); + const editor = await signIn('editor'); + const id = await addRoute('pending'); + expect((await sheet(moderator.headers, uuidv7())).statusCode).toBe(404); + expect((await sheet(editor.headers, id)).statusCode).toBe(403); + }); +}); diff --git a/apps/api/src/routes/admin-moderation.ts b/apps/api/src/routes/admin-moderation.ts index a574a3b1..6b9c7d1b 100644 --- a/apps/api/src/routes/admin-moderation.ts +++ b/apps/api/src/routes/admin-moderation.ts @@ -2,6 +2,7 @@ import { ModerationDecision, ModerationQueue, ModerationQueueQuery, + ModerationRouteDetail, RejectionBody, RequestChangesBody, } from '@widoo/shared'; @@ -10,6 +11,7 @@ import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { z } from 'zod'; import { httpError } from '../errors'; import { decideRoute } from '../moderation/decisions'; +import { readModerationRoute } from '../moderation/detail'; import { readModerationQueue } from '../moderation/queue'; const security = [{ firebaseIdToken: [] }]; @@ -49,6 +51,26 @@ export const adminModerationRoutes: FastifyPluginAsyncZod = async (app) => { async (request) => readModerationQueue(app.db, request.query), ); + app.get( + '/admin/routes/:id', + { + schema: { + tags, + summary: 'Moderation sheet of a route', + security, + params: RouteParams, + response: { 200: ModerationRouteDetail }, + }, + }, + async (request) => { + const route = await readModerationRoute(app.db, request.params.id); + if (!route) { + throw httpError(404, 'Route not found'); + } + return route; + }, + ); + app.post( '/admin/routes/:id/publish', { From 2ef7a294161092ac77fb5fa59bd1ece318d01305 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:30:02 +0700 Subject: [PATCH 11/23] feat(api-client): read the moderation sheet of a route Co-Authored-By: Claude Opus 5.5 --- packages/api-client/src/client.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index adc3ebd1..c26f192c 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,4 +1,5 @@ import { + ModerationRouteDetail, ModerationQueue, ModerationDecision, AdminCounters, @@ -288,6 +289,16 @@ export function createApiClient(options: ApiClientOptions) { auth: true, }), + /** `GET /v1/admin/routes/:id`: the moderation sheet of a route (A-02). 404 when unknown. */ + getModerationRoute: (routeId: string, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/routes/${routeId}`, + schema: ModerationRouteDetail, + auth: true, + signal, + }), + /** `DELETE /v1/me`: anonymizes the account; its token is refused afterwards. */ deleteMe: () => requestEmpty({ method: 'DELETE', path: '/v1/me', auth: true }), }; From beeff34bd919fe9cbd1d290624676d9b9344114a Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:30:02 +0700 Subject: [PATCH 12/23] feat(ui): add the checkbox, the initial avatar and a non-destructive confirmation Co-Authored-By: Claude Opus 5.5 --- .../src/components/shell/AccountMenu.tsx | 8 ++---- packages/ui/src/components/Checkbox.tsx | 23 +++++++++++++++++ packages/ui/src/components/ConfirmDialog.tsx | 12 ++++++--- packages/ui/src/components/InitialAvatar.tsx | 25 +++++++++++++++++++ packages/ui/src/index.ts | 2 ++ packages/ui/tailwind-preset.cjs | 9 ++++++- 6 files changed, 69 insertions(+), 10 deletions(-) create mode 100644 packages/ui/src/components/Checkbox.tsx create mode 100644 packages/ui/src/components/InitialAvatar.tsx diff --git a/apps/admin/src/components/shell/AccountMenu.tsx b/apps/admin/src/components/shell/AccountMenu.tsx index 2045f861..3c24fea6 100644 --- a/apps/admin/src/components/shell/AccountMenu.tsx +++ b/apps/admin/src/components/shell/AccountMenu.tsx @@ -6,6 +6,7 @@ import { DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, + InitialAvatar, } from '@widoo/ui'; type AccountMenuProps = { @@ -25,12 +26,7 @@ export function AccountMenu({ name, email, roleLabel, onSignOut }: AccountMenuPr aria-label={`${messages.shell.account} : ${name}, ${roleLabel}`} className="flex min-h-touch-min shrink-0 items-center gap-10 rounded-pill bg-secondary py-4 pl-6 pr-12 text-left" > - - {name.charAt(0).toUpperCase()} - + {/* Below 640 px the initial alone: the name and the role stay in the label and the menu. */} {name} diff --git a/packages/ui/src/components/Checkbox.tsx b/packages/ui/src/components/Checkbox.tsx new file mode 100644 index 00000000..34c2c7fc --- /dev/null +++ b/packages/ui/src/components/Checkbox.tsx @@ -0,0 +1,23 @@ +import type { ComponentProps, ReactNode } from 'react'; + +type CheckboxProps = Omit, 'type' | 'children'> & { + /** What is checked, clickable with the box. */ + label: ReactNode; + /** Second line under the label, in gray. */ + description?: ReactNode; +}; + +/** A box to check with its label, on the checkbox of the browser tinted in the blue of Widoo. */ +export function Checkbox({ label, description, ...props }: CheckboxProps) { + return ( + + ); +} diff --git a/packages/ui/src/components/ConfirmDialog.tsx b/packages/ui/src/components/ConfirmDialog.tsx index 106465e8..3b13b465 100644 --- a/packages/ui/src/components/ConfirmDialog.tsx +++ b/packages/ui/src/components/ConfirmDialog.tsx @@ -23,11 +23,16 @@ type ConfirmDialogProps = { confirmLabel: string; /** The action is running: the buttons wait for its answer. */ isBusy?: boolean; + /** + * `destructive` (default): the red button of an action that removes or refuses. `primary`: an + * action that asks for a text without destroying anything (a message to an author). + */ + confirmVariant?: 'destructive' | 'primary'; }; /** - * Confirmation of a destructive action (wiki Ecrans › Admin web, « Confirmation »): a window on - * a veil, the only place where a button is red. Its fields are a form: required ones block the + * Confirmation of an action that needs a reason or a message (wiki Ecrans › Admin web, + * « Confirmation »): a window on a veil, the only place where a button is red. Its fields are a form: required ones block the * confirmation by themselves. */ export function ConfirmDialog({ @@ -41,6 +46,7 @@ export function ConfirmDialog({ closeLabel, confirmLabel, isBusy = false, + confirmVariant = 'destructive', }: ConfirmDialogProps) { const handleSubmit = (event: FormEvent) => { event.preventDefault(); @@ -70,7 +76,7 @@ export function ConfirmDialog({ - diff --git a/packages/ui/src/components/InitialAvatar.tsx b/packages/ui/src/components/InitialAvatar.tsx new file mode 100644 index 00000000..76d5fd73 --- /dev/null +++ b/packages/ui/src/components/InitialAvatar.tsx @@ -0,0 +1,25 @@ +import { cn } from '../lib/cn'; + +type InitialAvatarProps = { + /** Name whose first letter is shown. */ + name: string; + /** 32 px by default; 44 px with `large`. */ + large?: boolean; + className?: string; +}; + +/** Round avatar with the initial of a person, on light blue. Decorative: the name is next to it. */ +export function InitialAvatar({ name, large = false, className }: InitialAvatarProps) { + return ( + + {name.charAt(0).toUpperCase()} + + ); +} diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 398494d2..65567f70 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -1,4 +1,5 @@ export { Button, type ButtonProps } from './components/Button'; +export { Checkbox } from './components/Checkbox'; export { ConfirmDialog } from './components/ConfirmDialog'; export { CountBadge } from './components/CountBadge'; export { DetailPanel } from './components/DetailPanel'; @@ -12,6 +13,7 @@ export { export { Field } from './components/Field'; export { FilterChip } from './components/FilterChip'; export { FilterMenu, type FilterMenuOption } from './components/FilterMenu'; +export { InitialAvatar } from './components/InitialAvatar'; export { Input, Label } from './components/Input'; export { Notice } from './components/Notice'; export { Pagination } from './components/Pagination'; diff --git a/packages/ui/tailwind-preset.cjs b/packages/ui/tailwind-preset.cjs index 92461322..5b69bd35 100644 --- a/packages/ui/tailwind-preset.cjs +++ b/packages/ui/tailwind-preset.cjs @@ -87,8 +87,15 @@ module.exports = { // Zero, for what is pinned to an edge (`inset-0`): tokens.json starts at 4 px. spacing: { 0: '0px' }, minWidth: { 0: '0px' }, - width: { sidebar: '248px', 'sidebar-icons': '72px', 'detail-panel': '400px' }, + width: { + sidebar: '248px', + 'sidebar-icons': '72px', + 'detail-panel': '400px', + decision: '320px', + }, maxWidth: { 'sign-in': '440px', dialog: '520px' }, + // A change of a revision on the moderation sheet: its subject, then what changed. + gridTemplateColumns: { change: '150px 1fr' }, // Text of a button of 36 px and of a navigation entry: `body-s` in 700. fontSize: { control: ['14px', { lineHeight: '20px', fontWeight: '700' }], From d5cb19ac35f98b3b6bd17c5bc0dde3950c8f823c Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Mon, 12 Oct 2026 17:30:02 +0700 Subject: [PATCH 13/23] fix(admin): keep the window from scrolling under the page on long screens Co-Authored-By: Claude Opus 5.5 --- apps/admin/src/components/shell/AdminShell.tsx | 6 ++++-- apps/admin/src/components/shell/AdminSidebar.tsx | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/admin/src/components/shell/AdminShell.tsx b/apps/admin/src/components/shell/AdminShell.tsx index 5efeb830..c8b0f087 100644 --- a/apps/admin/src/components/shell/AdminShell.tsx +++ b/apps/admin/src/components/shell/AdminShell.tsx @@ -13,14 +13,16 @@ type AdminShellProps = { /** Frame of every page of the admin: navigation on the left, header, then the page. */ export function AdminShell({ sidebar, title, account, children }: AdminShellProps) { return ( -
+
{sidebar}

{title}

{account}
-
{children}
+ {/* `relative`: what is hidden for screen readers only (`sr-only`) is positioned inside the + scrolling area, not under the page, which would let the whole window scroll. */} +
{children}
); diff --git a/apps/admin/src/components/shell/AdminSidebar.tsx b/apps/admin/src/components/shell/AdminSidebar.tsx index aa5104fb..f17b773d 100644 --- a/apps/admin/src/components/shell/AdminSidebar.tsx +++ b/apps/admin/src/components/shell/AdminSidebar.tsx @@ -52,7 +52,7 @@ export function AdminSidebar({