;
+ onToggleRule: (rule: number) => void;
+ onPublish: () => void;
+ onRequestChanges: () => void;
+ onReject: () => void;
+ /** A decision is being sent: the buttons wait for its answer. */
+ isBusy: boolean;
+};
+
+/**
+ * The decision of the moderator: the charter to check, then « Publier » (once every rule is
+ * checked, without confirmation), « Demander des modifications » and « Refuser », which both
+ * open a window.
+ */
+export function DecisionPanel({
+ checkedRules,
+ onToggleRule,
+ onPublish,
+ onRequestChanges,
+ onReject,
+ isBusy,
+}: DecisionPanelProps) {
+ const isCharterMet = checkedRules.size === charterRuleCount;
+ return (
+
+ {texts.charter.progress(checkedRules.size, charterRuleCount)}
+
+ }
+ >
+
+
+
+
+ {texts.decisions.publish}
+
+ {isCharterMet ? null : (
+
+ {texts.charter.hint(charterRuleCount)}
+
+ )}
+
+
+ {texts.decisions.requestChanges}
+
+
+
+ {texts.decisions.reject}
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/HistoryCard.tsx b/apps/admin/src/components/moderation/sheet/HistoryCard.tsx
new file mode 100644
index 00000000..bbd65d66
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/HistoryCard.tsx
@@ -0,0 +1,32 @@
+import { formatRelativeDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import { labels, type ModerationHistoryEntry } from '@widoo/shared';
+import { SideCard } from './SideCard';
+
+const texts = messages.moderation.sheet.history;
+
+/** Decisions already taken on the route, the most recent first, with reason and message. */
+export function HistoryCard({ history }: { history: ModerationHistoryEntry[] }) {
+ return (
+
+ {history.length === 0 ? (
+ {texts.empty}
+ ) : (
+
+ {history.map((entry) => (
+
+
+ {texts.by(texts.actions[entry.action], entry.actorFirstName)}
+
+
+ {formatRelativeDate(new Date(entry.createdAt))}
+ {entry.reasonCode ? ` · ${labels.fr.rejectionReasons[entry.reasonCode]}` : null}
+
+ {entry.message ? {entry.message} : null}
+
+ ))}
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/PublishedPanel.tsx b/apps/admin/src/components/moderation/sheet/PublishedPanel.tsx
new file mode 100644
index 00000000..ab22de7e
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/PublishedPanel.tsx
@@ -0,0 +1,42 @@
+import { messages } from '@/messages/fr';
+import { ArrowRightIcon, CheckCircleIcon } from '@phosphor-icons/react/dist/ssr';
+import { Button } from '@widoo/ui';
+import { SideCard } from './SideCard';
+
+const texts = messages.moderation.decisions.published;
+
+type PublishedPanelProps = {
+ /** First name of the member who published; null when it is not known. */
+ publishedBy: string | null;
+ onNext: () => void;
+ onBackToCatalog: () => void;
+};
+
+/**
+ * What the decision column shows once the route is published (A-02, « Publié à l'instant »):
+ * the result, then the way on to the next route of the queue or to the catalog.
+ */
+export function PublishedPanel({ publishedBy, onNext, onBackToCatalog }: PublishedPanelProps) {
+ return (
+ }
+ >
+
+ {publishedBy ? (
+
{texts.by(publishedBy)}
+ ) : null}
+
{texts.text}
+
+
+
+ {texts.next}
+
+
+
+ {texts.toCatalog}
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/RejectDialog.tsx b/apps/admin/src/components/moderation/sheet/RejectDialog.tsx
new file mode 100644
index 00000000..59395ab7
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/RejectDialog.tsx
@@ -0,0 +1,86 @@
+import { ReasonFields } from '@/components/confirm/ReasonFields';
+import { messages } from '@/messages/fr';
+import { labels, taxonomies } from '@widoo/shared';
+import { ConfirmDialog } from '@widoo/ui';
+import { useState } from 'react';
+
+const texts = messages.moderation.decisions.rejectDialog;
+
+/** The seven codified reasons of a refusal, in the order of the wiki, each explained. */
+const reasons = taxonomies.rejectionReasons.map((code) => ({
+ code,
+ label: labels.fr.rejectionReasons[code],
+ description: texts.reasonExplanations[code],
+}));
+
+type RejectDialogProps = {
+ isOpen: boolean;
+ /** First name of the author, for « Message à Camille »; null when the account is purged. */
+ authorFirstName: string | null;
+ /** When the version still online was published, for a revision; null for a first submission. */
+ publishedVersionOn: string | null;
+ reasonCode: string;
+ onReasonCodeChange: (code: string) => void;
+ message: string;
+ onMessageChange: (message: string) => void;
+ onCancel: () => void;
+ onConfirm: () => void;
+ isBusy: boolean;
+};
+
+/** « Refuser »: a codified reason and a message to the author, both required. */
+export function RejectDialog({
+ isOpen,
+ authorFirstName,
+ publishedVersionOn,
+ reasonCode,
+ onReasonCodeChange,
+ message,
+ onMessageChange,
+ onCancel,
+ onConfirm,
+ isBusy,
+}: RejectDialogProps) {
+ const [isMessageMissing, setIsMessageMissing] = useState(false);
+ const isRevision = publishedVersionOn !== null;
+
+ const handleConfirm = () => {
+ if (message.trim() === '') {
+ setIsMessageMissing(true);
+ return;
+ }
+ onConfirm();
+ };
+ const handleMessageChange = (next: string) => {
+ setIsMessageMissing(false);
+ onMessageChange(next);
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/RequestChangesDialog.tsx b/apps/admin/src/components/moderation/sheet/RequestChangesDialog.tsx
new file mode 100644
index 00000000..5ef32528
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/RequestChangesDialog.tsx
@@ -0,0 +1,54 @@
+import { messages } from '@/messages/fr';
+import { ConfirmDialog, Field, Textarea } from '@widoo/ui';
+
+const texts = messages.moderation.decisions.requestChangesDialog;
+
+type RequestChangesDialogProps = {
+ isOpen: boolean;
+ message: string;
+ onMessageChange: (message: string) => void;
+ onCancel: () => void;
+ onConfirm: () => void;
+ isBusy: boolean;
+};
+
+/** « Demander des modifications »: a required message to the author, then the request. */
+export function RequestChangesDialog({
+ isOpen,
+ message,
+ onMessageChange,
+ onCancel,
+ onConfirm,
+ isBusy,
+}: RequestChangesDialogProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/RevisionChanges.tsx b/apps/admin/src/components/moderation/sheet/RevisionChanges.tsx
new file mode 100644
index 00000000..9d187508
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/RevisionChanges.tsx
@@ -0,0 +1,81 @@
+import { messages } from '@/messages/fr';
+import type { RevisionChange } from '@widoo/shared';
+import { StatusBadge } from '@widoo/ui';
+
+const texts = messages.moderation.sheet.changes;
+const dayAndMonth = new Intl.DateTimeFormat('fr-FR', {
+ day: 'numeric',
+ month: 'long',
+ timeZone: 'Europe/Paris',
+});
+
+const subjectLabel = (subject: RevisionChange['subject']) =>
+ subject.kind === 'step' ? texts.subjects.step(subject.number) : texts.subjects[subject.kind];
+
+/** What one change says: a rewritten text side by side, anything else in a sentence. */
+function ChangeDetail({ change }: { change: RevisionChange }) {
+ if (change.change === 'modified') {
+ return (
+
+
+ {texts.before}
+ {change.before}
+
+
+ {texts.after}
+ {change.after}
+
+
+ );
+ }
+ return (
+
+ {change.change === 'duration_changed' ? `${texts.duration} ` : null}
+ {change.change === 'replaced' ? (
+ {change.before}
+ ) : (
+ change.before
+ )}{' '}
+ {texts.becomes} {change.after}
+
+ );
+}
+
+type RevisionChangesProps = {
+ changes: RevisionChange[];
+ /** When the version still online was published. */
+ publishedVersionAt: Date | null;
+};
+
+/**
+ * « Changements depuis la version publiée »: what a revision changed, one row per change.
+ * Nothing for a first submission.
+ */
+export function RevisionChanges({ changes, publishedVersionAt }: RevisionChangesProps) {
+ if (changes.length === 0) {
+ return null;
+ }
+ return (
+
+
+
{texts.title}
+ {publishedVersionAt ? (
+
+ {texts.version(dayAndMonth.format(publishedVersionAt))}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/RoutePreview.tsx b/apps/admin/src/components/moderation/sheet/RoutePreview.tsx
new file mode 100644
index 00000000..70e7f6cc
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/RoutePreview.tsx
@@ -0,0 +1,51 @@
+import { formatDuration } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import type { ModerationRouteDetail } from '@widoo/shared';
+import Image from 'next/image';
+import { StatTile } from './StatTile';
+
+const texts = messages.moderation.sheet;
+const missing = '—';
+
+/** The route as it will be published: photos, description, then duration, budget and distance. */
+export function RoutePreview({ route }: { route: ModerationRouteDetail }) {
+ return (
+
+ {texts.previewTitle}
+ {route.photoUrls.length > 0 ? (
+
+ {route.photoUrls.slice(0, 3).map((url) => (
+
+
+
+ ))}
+
+ ) : null}
+ {route.description ?? texts.noDescription}
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/SheetHeader.tsx b/apps/admin/src/components/moderation/sheet/SheetHeader.tsx
new file mode 100644
index 00000000..e4b45ec0
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/SheetHeader.tsx
@@ -0,0 +1,67 @@
+import { CertifiedSeal } from '@/components/moderation/CertifiedSeal';
+import { WaitingTime } from '@/components/moderation/WaitingTime';
+import { messages } from '@/messages/fr';
+import { CrownSimpleIcon, MapPinIcon } from '@phosphor-icons/react/dist/ssr';
+import type { ModerationRouteDetail } from '@widoo/shared';
+import { StatusBadge } from '@widoo/ui';
+
+const texts = messages.moderation.sheet;
+
+type SheetHeaderProps = {
+ route: ModerationRouteDetail;
+ /** True when the route has been waiting for more than 72 hours. */
+ isLate: boolean;
+};
+
+/** Top of the sheet: status, revision and Premium badges, title, author, city and waiting time. */
+export function SheetHeader({ route, isLate }: SheetHeaderProps) {
+ const isWaiting = route.status === 'pending' || route.status === 'changes_requested';
+ return (
+
+
+
+ {texts.statuses[route.status]}
+
+ {route.isRevision ? (
+ {messages.moderation.revision}
+ ) : null}
+ {route.isPremiumComputed ? (
+ <>
+ }
+ >
+ {texts.premium}
+
+
+ {texts.premiumComputed(route.premiumMinSteps)}
+
+ >
+ ) : null}
+
+ {route.title}
+
+
+ {route.author ? (
+ <>
+ {route.author.firstName}
+ {route.author.isCertified ? : null}
+ >
+ ) : (
+ messages.moderation.noAuthor
+ )}
+
+
+
+ {route.cityName}
+
+ {isWaiting ? (
+
+ {texts.waitingSince}
+
+
+ ) : null}
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx b/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx
new file mode 100644
index 00000000..74947af8
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx
@@ -0,0 +1,61 @@
+import { sectionPaths } from '@/config/navigation';
+import { messages } from '@/messages/fr';
+import { ArrowLeftIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react/dist/ssr';
+import type { ModerationRouteDetail } from '@widoo/shared';
+import { cn } from '@widoo/ui';
+import Link from 'next/link';
+
+const texts = messages.moderation.sheet;
+
+/** Address of the moderation sheet of a route. */
+export const sheetPathOf = (routeId: string) => `${sectionPaths.moderation}/${routeId}`;
+
+type NeighbourLinkProps = { routeId: string | null; label: string; direction: 'previous' | 'next' };
+
+/** Arrow to the previous or next route of the queue; grayed out at either end. */
+function NeighbourLink({ routeId, label, direction }: NeighbourLinkProps) {
+ const Icon = direction === 'previous' ? CaretLeftIcon : CaretRightIcon;
+ const className = 'flex size-32 items-center justify-center rounded-pill';
+ if (!routeId) {
+ return (
+
+
+
+ );
+ }
+ return (
+
+
+
+ );
+}
+
+/** Above the sheet: back to the queue, « Parcours 1 sur 14 », and the arrows to its neighbours. */
+export function SheetNavigation({ queue }: { queue: ModerationRouteDetail['queue'] }) {
+ return (
+
+
+
+ {texts.back}
+
+ {queue ? (
+ <>
+
+ {texts.position(queue.position, queue.total)}
+
+
+
+
+
+ >
+ ) : null}
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/SideCard.tsx b/apps/admin/src/components/moderation/sheet/SideCard.tsx
new file mode 100644
index 00000000..40d0dee3
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/SideCard.tsx
@@ -0,0 +1,22 @@
+import type { ReactNode } from 'react';
+
+/** A block of the right column of the sheet, on warm gray: decision, author, history. */
+export function SideCard({
+ title,
+ aside,
+ children,
+}: {
+ title: string;
+ aside?: ReactNode;
+ children: ReactNode;
+}) {
+ return (
+
+
+
{title}
+ {aside}
+
+ {children}
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/StatTile.tsx b/apps/admin/src/components/moderation/sheet/StatTile.tsx
new file mode 100644
index 00000000..a9fa7a30
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/StatTile.tsx
@@ -0,0 +1,9 @@
+/** A figure of the route on warm gray, with its name above: « Durée », « 7 h ». */
+export function StatTile({ label, value }: { label: string; value: string }) {
+ return (
+
+
{label}
+ {value}
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/StepItem.tsx b/apps/admin/src/components/moderation/sheet/StepItem.tsx
new file mode 100644
index 00000000..2c14945e
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/StepItem.tsx
@@ -0,0 +1,87 @@
+import { formatDuration } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import {
+ ArrowSquareOutIcon,
+ ClockIcon,
+ FootprintsIcon,
+ WalletIcon,
+ WarningIcon,
+} from '@phosphor-icons/react/dist/ssr';
+import { labels, type ModerationStep } from '@widoo/shared';
+import { StatusBadge } from '@widoo/ui';
+
+const texts = messages.moderation.sheet;
+
+/** Host of a booking link, as the sheet shows it next to « Réserver »: « thefork.fr ». */
+const hostOf = (url: string) => new URL(url).hostname.replace(/^www\./, '');
+
+/**
+ * A step of the route on the sheet: the way from the previous step, then its number, its place,
+ * the time spent there, its cost and its booking link.
+ */
+export function StepItem({ step, number }: { step: ModerationStep; number: number }) {
+ const { transition, place } = step;
+ return (
+
+ {transition ? (
+
+
+ {texts.transition(
+ `${formatDuration(Math.round(transition.durationMin))} ${texts.transports[transition.mode]}`,
+ transition.distanceM,
+ )}
+
+ ) : null}
+
+
+ {number}
+
+
+
+ {number}.
+ {step.title}
+ {place.verificationStatus === 'verified' ? null : (
+ }
+ >
+ {texts.neverVerified}
+
+ )}
+
+
+ {labels.fr.placeCategories[place.category]}
+ {step.title === place.name ? null : ` · ${place.name}`}
+
+
+
+
+ {texts.onSite(formatDuration(step.durationMin))}
+
+ {step.costPerPersonEur === null ? null : (
+
+
+ {step.costPerPersonEur === 0 ? texts.free : texts.perPerson(step.costPerPersonEur)}
+
+ )}
+
+ {step.bookingUrl ? (
+
+ {texts.book}
+
+ {hostOf(step.bookingUrl)}
+
+ ) : null}
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/StepList.tsx b/apps/admin/src/components/moderation/sheet/StepList.tsx
new file mode 100644
index 00000000..7c8454ed
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/StepList.tsx
@@ -0,0 +1,19 @@
+import { messages } from '@/messages/fr';
+import type { ModerationStep } from '@widoo/shared';
+import { StepItem } from './StepItem';
+import { StepPlan } from './StepPlan';
+
+/** « Carte et étapes »: the plan of the route, then its steps in order. */
+export function StepList({ steps }: { steps: ModerationStep[] }) {
+ return (
+
+ {messages.moderation.sheet.mapTitle}
+
+
+ {steps.map((step, index) => (
+
+ ))}
+
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/StepPlan.tsx b/apps/admin/src/components/moderation/sheet/StepPlan.tsx
new file mode 100644
index 00000000..26490169
--- /dev/null
+++ b/apps/admin/src/components/moderation/sheet/StepPlan.tsx
@@ -0,0 +1,41 @@
+import { layOutSteps, planSize } from '@/lib/step-plan';
+import { messages } from '@/messages/fr';
+import type { ModerationStep } from '@widoo/shared';
+
+/**
+ * Plan of the steps of a route: its line and its numbered steps on warm gray, to read its shape
+ * and its order at a glance. The list under it says the same for screen readers.
+ */
+export function StepPlan({ steps }: { steps: ModerationStep[] }) {
+ const points = layOutSteps(steps.map((step) => step.place.location));
+ return (
+
+ `${x},${y}`).join(' ')}
+ className="fill-none stroke-blue"
+ strokeWidth={4}
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ />
+ {points.map(({ x, y }, index) => (
+
+
+
+ {index + 1}
+
+
+ ))}
+
+ );
+}
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/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}
- {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({
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/containers/ModerationQueueContainer.tsx b/apps/admin/src/containers/ModerationQueueContainer.tsx
new file mode 100644
index 00000000..f97995a6
--- /dev/null
+++ b/apps/admin/src/containers/ModerationQueueContainer.tsx
@@ -0,0 +1,135 @@
+'use client';
+
+import {
+ ModerationQueueFilters,
+ type ModerationFilterChoice,
+} from '@/components/moderation/ModerationQueueFilters';
+import { ModerationQueueTable } from '@/components/moderation/ModerationQueueTable';
+import { sheetPathOf } from '@/components/moderation/sheet/SheetNavigation';
+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 { isWaitingLate } from '@/lib/dates';
+import { serializeListQuery } from '@/lib/list-query';
+import { messages } from '@/messages/fr';
+import type { ModerationQueueItem } from '@widoo/shared';
+import { Button, Pagination, SearchInput } from '@widoo/ui';
+import { useRouter } from 'next/navigation';
+
+const searchDelayMs = 300;
+
+/** 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],
+};
+
+// Late counts for a route waiting for its first look; one sent back to its author is not late.
+const isLate = (item: ModerationQueueItem) =>
+ item.status === 'pending' && isWaitingLate(new Date(item.waitingSince));
+
+/**
+ * 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 router = useRouter();
+ 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 (
+ router.push(sheetPathOf(item.id))}
+ />
+ );
+ };
+
+ 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/ModerationSheetContainer.tsx b/apps/admin/src/containers/ModerationSheetContainer.tsx
new file mode 100644
index 00000000..f558cd56
--- /dev/null
+++ b/apps/admin/src/containers/ModerationSheetContainer.tsx
@@ -0,0 +1,153 @@
+'use client';
+
+import { AuthorCard } from '@/components/moderation/sheet/AuthorCard';
+import { CoherenceWarnings } from '@/components/moderation/sheet/CoherenceWarnings';
+import { DecisionPanel } from '@/components/moderation/sheet/DecisionPanel';
+import { HistoryCard } from '@/components/moderation/sheet/HistoryCard';
+import { PublishedPanel } from '@/components/moderation/sheet/PublishedPanel';
+import { RejectDialog } from '@/components/moderation/sheet/RejectDialog';
+import { RequestChangesDialog } from '@/components/moderation/sheet/RequestChangesDialog';
+import { RevisionChanges } from '@/components/moderation/sheet/RevisionChanges';
+import { RoutePreview } from '@/components/moderation/sheet/RoutePreview';
+import { SheetHeader } from '@/components/moderation/sheet/SheetHeader';
+import { SheetNavigation, sheetPathOf } from '@/components/moderation/sheet/SheetNavigation';
+import { StepList } from '@/components/moderation/sheet/StepList';
+import { PageMessage } from '@/components/states/PageMessage';
+import { sectionPaths } from '@/config/navigation';
+import { useModerationDecision } from '@/hooks/useModerationDecision';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { getSessionClients } from '@/lib/auth/clients';
+import { formatDayMonth, isWaitingLate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import { taxonomies, type RejectionReason } from '@widoo/shared';
+import { Button } from '@widoo/ui';
+import { useRouter } from 'next/navigation';
+import { useState } from 'react';
+
+const isRejectionReason = (code: string): code is RejectionReason =>
+ taxonomies.rejectionReasons.some((reason) => reason === code);
+
+type OpenDialog = 'requestChanges' | 'reject' | null;
+
+/**
+ * A-02, the moderation sheet of a route: reads it, shows it as it will be published, and sends
+ * the decision of the moderator. Once decided, the next route of the queue opens, or the queue.
+ */
+export function ModerationSheetContainer({ routeId }: { routeId: string }) {
+ const router = useRouter();
+ const sheet = useRemoteData(routeId, (signal) =>
+ getSessionClients().api.getModerationRoute(routeId, signal),
+ );
+ const [checkedRules, setCheckedRules] = useState>(new Set());
+ const [openDialog, setOpenDialog] = useState(null);
+ const [message, setMessage] = useState('');
+ const [reasonCode, setReasonCode] = useState('');
+
+ // Set once the route is published: the sheet stays, with the way on, instead of moving away.
+ const [publishedTo, setPublishedTo] = useState<{ nextPath: string } | null>(null);
+
+ const nextId = sheet.status === 'ready' ? sheet.data.queue?.nextId : null;
+ const nextPath = nextId ? sheetPathOf(nextId) : sectionPaths.moderation;
+ const reload = sheet.status === 'loading' ? undefined : sheet.reload;
+ const decision = useModerationDecision(routeId, (outcome) => {
+ setOpenDialog(null);
+ if (outcome === 'published') {
+ setPublishedTo({ nextPath });
+ reload?.();
+ return;
+ }
+ router.push(nextPath);
+ });
+
+ if (sheet.status === 'loading') {
+ return ;
+ }
+ if (sheet.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+
+ const route = sheet.data;
+ const handleToggleRule = (rule: number) =>
+ setCheckedRules((current) => {
+ const next = new Set(current);
+ return next.delete(rule) ? next : next.add(rule);
+ });
+ const handleOpen = (dialog: OpenDialog) => {
+ setMessage('');
+ setReasonCode('');
+ setOpenDialog(dialog);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ {publishedTo ? (
+ router.push(publishedTo.nextPath)}
+ onBackToCatalog={() => router.push(sectionPaths.catalog)}
+ />
+ ) : route.status === 'pending' ? (
+ void decision.publish()}
+ onRequestChanges={() => handleOpen('requestChanges')}
+ onReject={() => handleOpen('reject')}
+ isBusy={decision.isBusy}
+ />
+ ) : null}
+ {route.author ? : null}
+
+
+
+
setOpenDialog(null)}
+ onConfirm={() => void decision.requestChanges(message)}
+ isBusy={decision.isBusy}
+ />
+ setOpenDialog(null)}
+ onConfirm={() => isRejectionReason(reasonCode) && void decision.reject(reasonCode, message)}
+ isBusy={decision.isBusy}
+ />
+
+ );
+}
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 (
undefined);
void read();
const timer = window.setInterval(read, refreshMs);
+ window.addEventListener(staleEvent, read);
return () => {
controller.abort();
window.clearInterval(timer);
+ window.removeEventListener(staleEvent, read);
};
}, [isSignedIn]);
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/useModerationDecision.ts b/apps/admin/src/hooks/useModerationDecision.ts
new file mode 100644
index 00000000..1f11566d
--- /dev/null
+++ b/apps/admin/src/hooks/useModerationDecision.ts
@@ -0,0 +1,68 @@
+import { notifyCountersStale } from '@/hooks/useAdminCounters';
+import { getSessionClients } from '@/lib/auth/clients';
+import { messages } from '@/messages/fr';
+import { ApiRequestError } from '@widoo/api-client';
+import {
+ AlreadyDecidedDetails,
+ labels,
+ type ModerationDecision,
+ type RejectionReason,
+} from '@widoo/shared';
+import { showToast } from '@widoo/ui';
+import { useState } from 'react';
+
+const texts = messages.moderation.decisions.toasts;
+
+/**
+ * Sends the decisions of a moderator on a route and tells the result in a toast. `onSettled` is
+ * called once the route has a decision, the moderator's own or the one another member of the
+ * team took first (409 `already_decided`): the screen then moves on.
+ */
+export function useModerationDecision(
+ routeId: string,
+ onSettled: (outcome: 'published' | 'other') => void,
+) {
+ const [isBusy, setIsBusy] = useState(false);
+
+ const send = async (
+ decide: () => Promise,
+ successTitle: string,
+ outcome: 'published' | 'other',
+ ) => {
+ setIsBusy(true);
+ try {
+ await decide();
+ showToast('success', { title: successTitle });
+ notifyCountersStale();
+ onSettled(outcome);
+ } catch (error) {
+ const details =
+ error instanceof ApiRequestError && error.code === 'already_decided'
+ ? AlreadyDecidedDetails.safeParse(error.body?.details)
+ : undefined;
+ if (details?.success) {
+ showToast('info', {
+ title: texts.alreadyDecided(labels.fr.routeStatuses[details.data.status]),
+ });
+ notifyCountersStale();
+ onSettled('other');
+ } else {
+ const isInvalidMessage =
+ error instanceof ApiRequestError && error.code === 'validation_error';
+ showToast('error', { title: isInvalidMessage ? texts.invalidMessage : texts.failed });
+ }
+ } finally {
+ setIsBusy(false);
+ }
+ };
+
+ const { api } = getSessionClients();
+ return {
+ isBusy,
+ publish: () => send(() => api.publishRoute(routeId), texts.published, 'published'),
+ requestChanges: (message: string) =>
+ send(() => api.requestRouteChanges(routeId, { message }), texts.changesRequested, 'other'),
+ reject: (reasonCode: RejectionReason, message: string) =>
+ send(() => api.rejectRoute(routeId, { reasonCode, message }), texts.rejected, 'other'),
+ };
+}
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..2a367c58 100644
--- a/apps/admin/src/lib/dates.test.ts
+++ b/apps/admin/src/lib/dates.test.ts
@@ -1,5 +1,11 @@
import { describe, expect, it } from 'vitest';
-import { formatFullDate, formatRelativeDate } from './dates';
+import {
+ formatDuration,
+ formatFullDate,
+ formatRelativeDate,
+ formatWaitingTime,
+ isWaitingLate,
+} from './dates';
const now = new Date('2026-10-12T10:00:00Z');
const ago = (ms: number) => new Date(now.getTime() - ms);
@@ -30,3 +36,33 @@ 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);
+ });
+});
+
+describe('isWaitingLate', () => {
+ it('is late after 72 hours, not before', () => {
+ expect(isWaitingLate(ago(72 * hour + minute), now)).toBe(true);
+ expect(isWaitingLate(ago(71 * hour), now)).toBe(false);
+ });
+});
diff --git a/apps/admin/src/lib/dates.ts b/apps/admin/src/lib/dates.ts
index 3985496f..76b6546e 100644
--- a/apps/admin/src/lib/dates.ts
+++ b/apps/admin/src/lib/dates.ts
@@ -1,3 +1,5 @@
+import { adminLateAfterHours } from '@widoo/shared';
+
// Wiki Admin-Web › Modèles communs: relative up to 7 days, then absolute; Paris time.
const timeZone = 'Europe/Paris';
const minute = 60_000;
@@ -6,6 +8,7 @@ const day = 24 * hour;
const relativeForDays = 7;
const absoluteDate = new Intl.DateTimeFormat('fr-FR', { dateStyle: 'medium', timeZone });
+const dayMonth = new Intl.DateTimeFormat('fr-FR', { day: 'numeric', month: 'long', timeZone });
const fullDate = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long',
day: 'numeric',
@@ -39,7 +42,45 @@ export function formatRelativeDate(date: Date, now = new Date()): string {
return absoluteDate.format(date);
}
+/** A day without its year, inside a sentence: « 2 octobre ». */
+export function formatDayMonth(date: Date): string {
+ return dayMonth.format(date);
+}
+
/** A date in full, as a record shows it: « jeudi 9 octobre à 8:12 », Paris time. */
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`;
+}
+
+/** True when something has been waiting since `since` for more than 72 hours (wiki Admin-Web). */
+export function isWaitingLate(since: Date, now = new Date()): boolean {
+ return now.getTime() - since.getTime() > adminLateAfterHours * hour;
+}
diff --git a/apps/admin/src/lib/response-headers.ts b/apps/admin/src/lib/response-headers.ts
index 7b22ffe3..e341d330 100644
--- a/apps/admin/src/lib/response-headers.ts
+++ b/apps/admin/src/lib/response-headers.ts
@@ -37,7 +37,9 @@ export function contentSecurityPolicy(options: HeaderOptions): string {
...(dev ? ["'unsafe-eval'"] : []),
],
'style-src': ["'self'", "'unsafe-inline'"],
- 'img-src': ["'self'", 'data:', 'blob:'],
+ // Photos of the routes and places are served from their own address until they move to the
+ // storage bucket of Widoo: any https image, nothing else.
+ 'img-src': ["'self'", 'data:', 'blob:', 'https:'],
'font-src': ["'self'"],
'connect-src': ["'self'", apiOrigin, ...authOrigins, ...(dev ? ['ws:'] : [])],
'frame-src': [`https://${authDomain}`, ...(authEmulatorOrigin ? [authEmulatorOrigin] : [])],
diff --git a/apps/admin/src/lib/step-plan.test.ts b/apps/admin/src/lib/step-plan.test.ts
new file mode 100644
index 00000000..1e94cc31
--- /dev/null
+++ b/apps/admin/src/lib/step-plan.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from 'vitest';
+import { layOutSteps, planSize } from './step-plan';
+
+const paris = [
+ { lat: 48.8846, lng: 2.3385 },
+ { lat: 48.8867, lng: 2.3431 },
+ { lat: 48.8822, lng: 2.3402 },
+];
+
+describe('layOutSteps', () => {
+ it('keeps every step inside the plan, with a margin', () => {
+ for (const { x, y } of layOutSteps(paris)) {
+ expect(x).toBeGreaterThanOrEqual(28);
+ expect(x).toBeLessThanOrEqual(planSize.width - 28);
+ expect(y).toBeGreaterThanOrEqual(28);
+ expect(y).toBeLessThanOrEqual(planSize.height - 28);
+ }
+ });
+
+ it('puts the north at the top and the east on the right', () => {
+ const [first, second, third] = layOutSteps(paris);
+ expect(second?.y).toBeLessThan(third?.y ?? 0);
+ expect(second?.x).toBeGreaterThan(first?.x ?? Infinity);
+ });
+
+ it('centers a route with a single place', () => {
+ expect(layOutSteps([paris[0] ?? { lat: 0, lng: 0 }])).toEqual([
+ { x: planSize.width / 2, y: planSize.height / 2 },
+ ]);
+ });
+
+ it('gives nothing for a route without step', () => {
+ expect(layOutSteps([])).toEqual([]);
+ });
+});
diff --git a/apps/admin/src/lib/step-plan.ts b/apps/admin/src/lib/step-plan.ts
new file mode 100644
index 00000000..a7b1508f
--- /dev/null
+++ b/apps/admin/src/lib/step-plan.ts
@@ -0,0 +1,41 @@
+export type PlanPoint = { lat: number; lng: number };
+
+/** Size of the plan, in the units of its SVG: 2.5 times as wide as high, as on the sheet. */
+export const planSize = { width: 600, height: 240 };
+const margin = 28;
+
+/**
+ * Places the steps of a route on a small plan: longitudes from left to right, latitudes from
+ * bottom to top, the whole route centered with a margin. A plan to read the shape and the order
+ * of a route, not a map: no background, no scale. Longitudes are narrowed by the latitude so
+ * that the shape is not stretched.
+ */
+export function layOutSteps(points: readonly PlanPoint[]): { x: number; y: number }[] {
+ if (points.length === 0) {
+ return [];
+ }
+ const meanLat = points.reduce((sum, point) => sum + point.lat, 0) / points.length;
+ const narrowing = Math.cos((meanLat * Math.PI) / 180);
+ const xs = points.map((point) => point.lng * narrowing);
+ const ys = points.map((point) => -point.lat);
+ const [minX, maxX, minY, maxY] = [
+ Math.min(...xs),
+ Math.max(...xs),
+ Math.min(...ys),
+ Math.max(...ys),
+ ];
+ const usable = { width: planSize.width - 2 * margin, height: planSize.height - 2 * margin };
+ // One scale for both axes; a route with a single place, or in a straight line, is centered.
+ const scale = Math.min(
+ usable.width / Math.max(maxX - minX, 1e-9),
+ usable.height / Math.max(maxY - minY, 1e-9),
+ );
+ const offset = {
+ x: (planSize.width - (maxX - minX) * scale) / 2,
+ y: (planSize.height - (maxY - minY) * scale) / 2,
+ };
+ return xs.map((x, index) => ({
+ x: Math.round((x - minX) * scale + offset.x),
+ y: Math.round(((ys[index] ?? minY) - minY) * scale + offset.y),
+ }));
+}
diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts
index e4fa6b5f..79a33103 100644
--- a/apps/admin/src/messages/fr.ts
+++ b/apps/admin/src/messages/fr.ts
@@ -30,9 +30,25 @@ const roles: Record = {
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).
*/
+/** What each refusal reason means, under its label (A-02, « Refuser »). */
+const rejectionExplanations = {
+ not_a_route: 'Une liste de lieux sans enchaînement',
+ place_invalid: 'Fermé, privé, introuvable ou adresse fausse',
+ description_insufficient: 'Trop courte ou copiée',
+ photos_invalid: 'Droits, crédit, visages ou mineurs',
+ promotional: 'Publicité ou coordonnées personnelles',
+ policy_violation: 'Illicite, dangereux ou discriminatoire',
+ duplicate: 'Même parcours déjà publié',
+} as const;
+
const returnTargets: Record = {
dashboard: 'au tableau de bord',
moderation: 'à la file de modération',
@@ -61,6 +77,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 +166,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 +174,193 @@ 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',
+ sheet: {
+ back: 'File de modération',
+ position: (position: number, total: number) => `Parcours ${position} sur ${total}`,
+ previous: 'Parcours précédent',
+ next: 'Parcours suivant',
+ notFound: "Ce parcours n'existe pas.",
+ error: "La fiche n'a pas pu être chargée.",
+ statuses: {
+ pending: 'En relecture',
+ changes_requested: 'À corriger',
+ published: 'Publié',
+ rejected: 'Refusé',
+ unpublished: 'Retiré',
+ needs_fix: 'À corriger',
+ draft: 'Brouillon',
+ private: 'Privé',
+ },
+ premium: 'Premium',
+ premiumComputed: (minSteps: number) => `Premium calculé : ${minSteps} étapes ou plus`,
+ waitingSince: 'En file depuis',
+ warningsTitle: (count: number) =>
+ count > 1 ? `${count} avertissements de cohérence` : '1 avertissement de cohérence',
+ changes: {
+ title: 'Changements depuis la version publiée',
+ version: (date: string) => `Version du ${date}`,
+ subjects: {
+ step: (number: number) => `Étape ${number}`,
+ title: 'Titre',
+ description: 'Description',
+ },
+ kinds: { replaced: 'Remplacée', modified: 'Modifiée', duration_changed: 'Durée changée' },
+ becomes: 'devient',
+ before: 'Avant',
+ after: 'Après',
+ duration: 'Durée conseillée :',
+ },
+ previewTitle: "Le parcours tel qu'il sera publié",
+ noDescription: 'Aucune description.',
+ tiles: { duration: 'Durée', budget: 'Budget', steps: 'Étapes', distance: 'Distance' },
+ budget: (euros: number) => (euros === 0 ? 'Gratuit' : `≈ ${Math.round(euros)} €`),
+ distance: (meters: number) =>
+ `${(meters / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })} km`,
+ mapTitle: 'Carte et étapes',
+ mapLabel: (count: number) => `Plan des ${count} étapes, dans l'ordre du parcours`,
+ onSite: (duration: string) => `${duration} sur place`,
+ free: 'Gratuit',
+ perPerson: (euros: number) => `≈ ${Math.round(euros)} € par pers.`,
+ book: 'Réserver',
+ neverVerified: 'Lieu jamais vérifié',
+ transition: (duration: string, meters: number) =>
+ `${duration} (${(meters / 1000).toLocaleString('fr-FR', { maximumFractionDigits: 1 })} km)`,
+ transports: { walk: 'à pied', bike: 'à vélo', metro: 'en métro', car: 'en voiture' },
+ author: {
+ title: 'Auteur',
+ certified: 'Créateur certifié',
+ published: 'Parcours publiés',
+ rejected: 'Parcours refusés',
+ reports: 'Signalements reçus',
+ registered: 'Inscription',
+ seeAccount: 'Voir son compte',
+ },
+ history: {
+ title: 'Historique',
+ empty: 'Aucune décision pour le moment.',
+ actions: {
+ publish: 'Publié',
+ request_changes: 'Modifications demandées',
+ reject: 'Refusé',
+ unpublish: 'Dépublié',
+ edit: 'Modifié',
+ },
+ by: (action: string, name: string | null) => (name ? `${action} par ${name}` : action),
+ },
+ },
+ charter: {
+ title: 'Charte de publication',
+ progress: (checked: number, total: number) => `${checked} sur ${total}`,
+ hint: (total: number) => `Cochez les ${total} règles pour publier.`,
+ rules: [
+ {
+ label: 'Un vrai parcours, pas une liste',
+ description: "Les étapes s'enchaînent et forment un moment cohérent.",
+ },
+ {
+ label: 'Des lieux réels et ouverts au public',
+ description: 'Adresse exacte, aucun lieu privé.',
+ },
+ {
+ label: 'Une description originale',
+ description: "Le pourquoi et l'ambiance, rien de copié.",
+ },
+ {
+ label: 'Des photos conformes',
+ description: 'Personnelles ou libres avec crédit, sans visage ni mineur.',
+ },
+ {
+ label: 'Aucune promotion déguisée',
+ description: 'Pas de coordonnées ; liens affiliés déclarés.',
+ },
+ { label: 'Aucun contenu illicite ou dangereux', description: null },
+ ],
+ },
+ decisions: {
+ publish: 'Publier',
+ requestChanges: 'Demander des modifications',
+ reject: 'Refuser',
+ requestChangesDialog: {
+ title: 'Demander des modifications ?',
+ description:
+ "Le parcours passe en « À corriger ». Votre message est gardé dans son historique ; l'auteur n'est pas encore averti automatiquement.",
+ message: "Message à l'auteur",
+ messageHint: "Texte brut, sans < ni >. Gardé dans l'historique du parcours.",
+ confirm: 'Demander les modifications',
+ },
+ rejectDialog: {
+ reasonExplanations: rejectionExplanations,
+ revisionTitle: 'Refuser cette révision ?',
+ revisionDescription: (publishedOn: string) =>
+ `Cette révision ne sera pas publiée : la version du ${publishedOn} reste en ligne. Le motif et votre message sont gardés dans l'historique ; l'auteur n'est pas encore averti automatiquement.`,
+ revisionConfirm: 'Refuser la révision',
+ messageTo: (firstName: string) => `Message à ${firstName}`,
+ messagePlaceholder: 'Expliquez ce qui a conduit au refus.',
+ messageRequired: "Écrivez un message à l'auteur.",
+ title: 'Refuser ce parcours ?',
+ description:
+ "Le parcours est refusé. Le motif et votre message sont gardés dans son historique ; l'auteur n'est pas encore averti automatiquement.",
+ message: "Message à l'auteur",
+ messageHint: "Texte brut, sans < ni >. Gardé dans l'historique du parcours.",
+ confirm: 'Refuser le parcours',
+ },
+ published: {
+ title: "Publié à l'instant",
+ by: (firstName: string) => `par ${firstName}, les 6 règles vérifiées`,
+ text: 'Le parcours est en ligne.',
+ next: 'Parcours suivant',
+ toCatalog: 'Voir dans Lieux et parcours',
+ },
+ toasts: {
+ published: 'Parcours publié',
+ changesRequested: 'Modifications demandées',
+ rejected: 'Parcours refusé',
+ failed: "La décision n'a pas pu être enregistrée.",
+ invalidMessage:
+ 'Le message est refusé : texte brut uniquement, sans < ni >, ni tabulation, 1 000 caractères au plus.',
+ alreadyDecided: (status: string) =>
+ `Un autre membre de l'équipe a déjà tranché : ${status.toLowerCase()}.`,
+ },
+ },
+ 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.",
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/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/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/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/moderation/queue.ts b/apps/api/src/moderation/queue.ts
new file mode 100644
index 00000000..0f38cfed
--- /dev/null
+++ b/apps/api/src/moderation/queue.ts
@@ -0,0 +1,190 @@
+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) {
+ // Only a route waiting for its first look is late: one sent back to its author is not.
+ filters.push(sql`r.status = 'pending'`);
+ 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 r.status = 'pending' and ${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.test.ts b/apps/api/src/routes/admin-moderation.test.ts
new file mode 100644
index 00000000..94258a84
--- /dev/null
+++ b/apps/api/src/routes/admin-moderation.test.ts
@@ -0,0 +1,370 @@
+import {
+ AlreadyDecidedDetails,
+ ApiError,
+ ModerationDecision,
+ ModerationQueue,
+ ModerationRouteDetail,
+ 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, seedId } 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;
+const templateId = seedId('route:marais-gourmand');
+const farAway = { lat: 32, lng: 32 };
+const farAwayBounds = { west: 32, south: 32, east: 32.001, north: 32.001 };
+
+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') {
+ // Always the same demo route of the seed, never a row another test file is using, and far
+ // from every zone the search tests look at: some of these copies are published.
+ const [template] = await app.db.select().from(routes).where(eq(routes.id, templateId));
+ if (!template) throw new Error('Demo route missing from the seed');
+ const id = uuidv7();
+ createdRouteIds.push(id);
+ await app.db.insert(routes).values({
+ ...template,
+ id,
+ authorId,
+ shareToken: null,
+ startLocation: farAway,
+ bounds: farAwayBounds,
+ status,
+ 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('counts as late only a route waiting for its first look', async () => {
+ const moderator = await signIn('moderator');
+ await addRoute('changes_requested', 100, 'Renvoyé');
+ const waiting = await addRoute('pending', 100, 'Attente');
+
+ const late = ModerationQueue.parse((await queue(moderator.headers, '&late=true')).json());
+
+ expect(late.items.map((item) => item.id)).toContain(waiting);
+ expect(late.items.map((item) => item.title.split(' ')[0])).not.toContain('Renvoyé');
+ const all = ModerationQueue.parse((await queue(moderator.headers)).json());
+ const lateIds = new Set(late.items.map((item) => item.id));
+ // The counter covers the whole queue, which other test files fill too: at least ours.
+ expect(all.counts.late).toBeGreaterThanOrEqual(lateIds.size);
+ });
+
+ 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);
+ });
+});
+
+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
new file mode 100644
index 00000000..6b9c7d1b
--- /dev/null
+++ b/apps/api/src/routes/admin-moderation.ts
@@ -0,0 +1,140 @@
+import {
+ ModerationDecision,
+ ModerationQueue,
+ ModerationQueueQuery,
+ ModerationRouteDetail,
+ 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 { readModerationRoute } from '../moderation/detail';
+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.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',
+ {
+ 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,
+ }),
+ );
+ }
+};
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.
diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts
index c846e22d..c26f192c 100644
--- a/packages/api-client/src/client.ts
+++ b/packages/api-client/src/client.ts
@@ -1,4 +1,7 @@
import {
+ ModerationRouteDetail,
+ ModerationQueue,
+ ModerationDecision,
AdminCounters,
AppConfig,
GeocodeResult,
@@ -21,6 +24,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 +37,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 +243,62 @@ 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,
+ }),
+
+ /** `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 }),
};
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();
+}
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..0b2fee8e
--- /dev/null
+++ b/packages/shared/src/schemas/moderation.ts
@@ -0,0 +1,254 @@
+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;
+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;
+
+/** 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;
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'>;
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 (
+
+
+
+ {label}
+ {description ? (
+ {description}
+ ) : null}
+
+
+ );
+}
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({
{cancelLabel}
-
+
{confirmLabel}
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/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/components/RadioGroup.tsx b/packages/ui/src/components/RadioGroup.tsx
new file mode 100644
index 00000000..6cd102ab
--- /dev/null
+++ b/packages/ui/src/components/RadioGroup.tsx
@@ -0,0 +1,69 @@
+import type { ComponentProps } from 'react';
+
+export type RadioOption = {
+ value: string;
+ label: string;
+ /** Second line under the label, in gray. */
+ description?: string;
+};
+
+type RadioGroupProps = Omit, 'onChange'> & {
+ /** Name shared by the radios of the group. */
+ name: string;
+ /** Question of the group, read before each option. */
+ legend: string;
+ /** Marked with a red star; the radios carry `required`. */
+ isRequired?: boolean;
+ options: readonly RadioOption[];
+ /** The chosen value; empty while none is chosen. */
+ value: string;
+ onChange: (value: string) => void;
+};
+
+/**
+ * One choice among a few, each with its explanation, on the radio buttons of the browser tinted
+ * in the blue of Widoo. Two columns once the window is wide enough.
+ */
+export function RadioGroup({
+ name,
+ legend,
+ isRequired = false,
+ options,
+ value,
+ onChange,
+ ...props
+}: RadioGroupProps) {
+ return (
+
+
+ {legend}
+ {isRequired ? (
+
+ {' *'}
+
+ ) : null}
+
+
+ {options.map((option) => (
+
+ onChange(option.value)}
+ className="mt-4 size-18 shrink-0 accent-primary"
+ />
+
+ {option.label}
+ {option.description ? (
+ {option.description}
+ ) : null}
+
+
+ ))}
+
+
+ );
+}
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..0858a302 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';
@@ -11,9 +12,13 @@ export {
} from './components/DropdownMenu';
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';
+export { RadioGroup, type RadioOption } from './components/RadioGroup';
+export { SearchInput } from './components/SearchInput';
export { Select } from './components/Select';
export { SortableColumnHeader, type SortDirection } from './components/SortableColumnHeader';
export { StatusBadge } from './components/StatusBadge';
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' }],