diff --git a/apps/admin/.env.example b/apps/admin/.env.example
index 52615e47..dee2a02a 100644
--- a/apps/admin/.env.example
+++ b/apps/admin/.env.example
@@ -17,3 +17,8 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=demo-widoo
# FIREBASE_AUTH_EMULATOR_HOST of the API. Accepted with a demo-* project only; empty on staging
# and production.
NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL=http://127.0.0.1:9099
+
+# Dashboards the admin links to from its home page (A-05), https: the exact page of the Widoo
+# project. Empty: the home page of PostHog Cloud EU and of RevenueCat.
+NEXT_PUBLIC_POSTHOG_URL=
+NEXT_PUBLIC_REVENUECAT_URL=
diff --git a/apps/admin/src/app/(shell)/catalogue/lieux/[id]/page.tsx b/apps/admin/src/app/(shell)/catalogue/lieux/[id]/page.tsx
new file mode 100644
index 00000000..ab60277d
--- /dev/null
+++ b/apps/admin/src/app/(shell)/catalogue/lieux/[id]/page.tsx
@@ -0,0 +1,12 @@
+import { PlaceEditContainer } from '@/containers/PlaceEditContainer';
+import { PlaceWriteGate } from '@/containers/PlaceWriteGate';
+
+/** A-04, the sheet of a place as a form. Editors and admins only. */
+export default async function EditPlacePage({ params }: { params: Promise<{ id: string }> }) {
+ const { id } = await params;
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(shell)/catalogue/lieux/import/page.tsx b/apps/admin/src/app/(shell)/catalogue/lieux/import/page.tsx
new file mode 100644
index 00000000..26e8edaa
--- /dev/null
+++ b/apps/admin/src/app/(shell)/catalogue/lieux/import/page.tsx
@@ -0,0 +1,13 @@
+import { catalogPaths } from '@/components/catalog/CatalogTabs';
+import { AdminOnlyGate } from '@/containers/AdminOnlyGate';
+import { PlaceImportContainer } from '@/containers/PlaceImportContainer';
+import { messages } from '@/messages/fr';
+
+/** A-04, the import of places from a CSV file. Admins only. */
+export default function PlaceImportPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(shell)/catalogue/lieux/nouveau/page.tsx b/apps/admin/src/app/(shell)/catalogue/lieux/nouveau/page.tsx
new file mode 100644
index 00000000..a3a289bd
--- /dev/null
+++ b/apps/admin/src/app/(shell)/catalogue/lieux/nouveau/page.tsx
@@ -0,0 +1,11 @@
+import { PlaceFormContainer } from '@/containers/PlaceFormContainer';
+import { PlaceWriteGate } from '@/containers/PlaceWriteGate';
+
+/** A-04, a new place created by hand. Editors and admins only. */
+export default function NewPlacePage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(shell)/catalogue/lieux/page.tsx b/apps/admin/src/app/(shell)/catalogue/lieux/page.tsx
new file mode 100644
index 00000000..45da305b
--- /dev/null
+++ b/apps/admin/src/app/(shell)/catalogue/lieux/page.tsx
@@ -0,0 +1,15 @@
+import { CatalogTabs } from '@/components/catalog/CatalogTabs';
+import { CatalogPlacesContainer } from '@/containers/CatalogPlacesContainer';
+import { Suspense } from 'react';
+
+/** A-04, the places of the catalog. The state of the list is read from the address. */
+export default function CatalogPlacesPage() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(shell)/catalogue/page.tsx b/apps/admin/src/app/(shell)/catalogue/page.tsx
new file mode 100644
index 00000000..8c2bee5d
--- /dev/null
+++ b/apps/admin/src/app/(shell)/catalogue/page.tsx
@@ -0,0 +1,7 @@
+import { catalogPaths } from '@/components/catalog/CatalogTabs';
+import { redirect } from 'next/navigation';
+
+/** A-04 opens on its places; its routes are the second tab. */
+export default function CatalogPage() {
+ redirect(catalogPaths.places);
+}
diff --git a/apps/admin/src/app/(shell)/catalogue/parcours/[id]/page.tsx b/apps/admin/src/app/(shell)/catalogue/parcours/[id]/page.tsx
new file mode 100644
index 00000000..94478a76
--- /dev/null
+++ b/apps/admin/src/app/(shell)/catalogue/parcours/[id]/page.tsx
@@ -0,0 +1,11 @@
+import { CatalogRouteSheetContainer } from '@/containers/CatalogRouteSheetContainer';
+
+/** A-04, the sheet of a route of the catalog, on its own page. */
+export default async function CatalogRouteSheetPage({
+ params,
+}: {
+ params: Promise<{ id: string }>;
+}) {
+ const { id } = await params;
+ return ;
+}
diff --git a/apps/admin/src/app/(shell)/catalogue/parcours/page.tsx b/apps/admin/src/app/(shell)/catalogue/parcours/page.tsx
new file mode 100644
index 00000000..779b144d
--- /dev/null
+++ b/apps/admin/src/app/(shell)/catalogue/parcours/page.tsx
@@ -0,0 +1,15 @@
+import { CatalogTabs } from '@/components/catalog/CatalogTabs';
+import { CatalogRoutesContainer } from '@/containers/CatalogRoutesContainer';
+import { Suspense } from 'react';
+
+/** A-04, the routes of the catalog. The state of the list is read from the address. */
+export default function CatalogRoutesPage() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(shell)/page.tsx b/apps/admin/src/app/(shell)/page.tsx
index 890f7b5a..2bf9f7e4 100644
--- a/apps/admin/src/app/(shell)/page.tsx
+++ b/apps/admin/src/app/(shell)/page.tsx
@@ -1,7 +1,6 @@
-/**
- * A-05, the dashboard: its tiles and figures come with the ticket of the dashboard (#55). The
- * shell around it already shows its title.
- */
+import { DashboardContainer } from '@/containers/DashboardContainer';
+
+/** A-05, the dashboard: the home page of the admin. */
export default function DashboardPage() {
- return null;
+ return ;
}
diff --git a/apps/admin/src/components/catalog/CatalogPlacesFilters.tsx b/apps/admin/src/components/catalog/CatalogPlacesFilters.tsx
new file mode 100644
index 00000000..55208c75
--- /dev/null
+++ b/apps/admin/src/components/catalog/CatalogPlacesFilters.tsx
@@ -0,0 +1,39 @@
+import { messages } from '@/messages/fr';
+import { FilterMenu, type FilterMenuOption } from '@widoo/ui';
+
+type CatalogPlacesFiltersProps = {
+ categories: readonly FilterMenuOption[];
+ category: string | null;
+ onCategoryChange: (category: string | null) => void;
+ statuses: readonly FilterMenuOption[];
+ status: string | null;
+ onStatusChange: (status: string | null) => void;
+};
+
+/** Filters of the places of the catalog: their category, and how far they are verified. */
+export function CatalogPlacesFilters({
+ categories,
+ category,
+ onCategoryChange,
+ statuses,
+ status,
+ onStatusChange,
+}: CatalogPlacesFiltersProps) {
+ const texts = messages.catalog.places.filters;
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/CatalogPlacesTable.tsx b/apps/admin/src/components/catalog/CatalogPlacesTable.tsx
new file mode 100644
index 00000000..890fec36
--- /dev/null
+++ b/apps/admin/src/components/catalog/CatalogPlacesTable.tsx
@@ -0,0 +1,89 @@
+import type { CatalogPlaceColumn } from '@/config/catalog-places-list';
+import { formatRelativeDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import { labels, type CatalogPlace } from '@widoo/shared';
+import {
+ SortableColumnHeader,
+ Table,
+ TableCell,
+ TableHeaderCell,
+ TableRow,
+ TableSkeleton,
+ type SortDirection,
+} from '@widoo/ui';
+import { VerificationBadge } from './VerificationBadge';
+
+type CatalogPlacesTableProps = {
+ /** Rows of the page; ignored while `isLoading`. */
+ items: CatalogPlace[];
+ isLoading: boolean;
+ sort: { column: CatalogPlaceColumn; direction: SortDirection };
+ onSort: (column: CatalogPlaceColumn) => void;
+ /** The place whose sheet is open in the detail panel. */
+ openId: string | null;
+ onOpen: (place: CatalogPlace) => void;
+};
+
+const texts = messages.catalog.places;
+const columnCount = 6;
+
+/** The places of the catalog as a table (A-04); a row opens its sheet next to the list. */
+export function CatalogPlacesTable({
+ items,
+ isLoading,
+ sort,
+ onSort,
+ openId,
+ onOpen,
+}: CatalogPlacesTableProps) {
+ const renderSortableHeader = (column: CatalogPlaceColumn, label: string, className?: string) => {
+ const direction = sort.column === column ? sort.direction : null;
+ const ariaSort = direction === null ? 'none' : direction === 'asc' ? 'ascending' : 'descending';
+ return (
+
+ onSort(column)}>
+ {label}
+
+
+ );
+ };
+
+ return (
+
+
+
+ {renderSortableHeader('lieu', texts.columns.place)}
+ {renderSortableHeader('categorie', texts.columns.category)}
+ {renderSortableHeader('ville', texts.columns.city)}
+ {texts.columns.status}
+ {renderSortableHeader('verifie', texts.columns.verified)}
+ {renderSortableHeader('parcours', texts.columns.routes, 'text-right')}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ items.map((place) => (
+ onOpen(place)} isOpen={place.id === openId}>
+ {place.name}
+ {labels.fr.placeCategories[place.category]}
+ {place.cityName}
+
+
+
+
+ {place.verifiedAt ? (
+ formatRelativeDate(new Date(place.verifiedAt))
+ ) : (
+ {texts.neverVerified}
+ )}
+
+ {place.routeCount}
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/CatalogRouteActions.tsx b/apps/admin/src/components/catalog/CatalogRouteActions.tsx
new file mode 100644
index 00000000..2f64e8fc
--- /dev/null
+++ b/apps/admin/src/components/catalog/CatalogRouteActions.tsx
@@ -0,0 +1,59 @@
+import { SideCard } from '@/components/moderation/sheet/SideCard';
+import { messages } from '@/messages/fr';
+import {
+ ArrowCounterClockwiseIcon,
+ ProhibitIcon,
+ UsersThreeIcon,
+} from '@phosphor-icons/react/dist/ssr';
+import { Button } from '@widoo/ui';
+
+const texts = messages.catalog.routes.sheet;
+
+type CatalogRouteActionsProps = {
+ /** Absent when the action does not apply to the route, or is not for the role. */
+ onUnpublish?: () => void;
+ onRepublish?: () => void;
+ onReassign?: () => void;
+ isBusy: boolean;
+};
+
+/**
+ * What the team may do to a route of the catalog (A-04): take it down, put it back online, hand
+ * it over to the Widoo team. Each one opens a window that names its effect.
+ */
+export function CatalogRouteActions({
+ onUnpublish,
+ onRepublish,
+ onReassign,
+ isBusy,
+}: CatalogRouteActionsProps) {
+ const hasAction = Boolean(onUnpublish ?? onRepublish ?? onReassign);
+ return (
+
+ {hasAction ? (
+
+ {onRepublish ? (
+
+
+ {texts.republish}
+
+ ) : null}
+ {onReassign ? (
+
+
+ {texts.reassign}
+
+ ) : null}
+ {onUnpublish ? (
+
+
+ {texts.unpublish}
+
+ ) : null}
+
+ ) : (
+ {texts.noAction}
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/CatalogRoutesFilters.tsx b/apps/admin/src/components/catalog/CatalogRoutesFilters.tsx
new file mode 100644
index 00000000..d49aa32a
--- /dev/null
+++ b/apps/admin/src/components/catalog/CatalogRoutesFilters.tsx
@@ -0,0 +1,39 @@
+import { messages } from '@/messages/fr';
+import { FilterMenu, type FilterMenuOption } from '@widoo/ui';
+
+type CatalogRoutesFiltersProps = {
+ statuses: readonly FilterMenuOption[];
+ status: string | null;
+ onStatusChange: (status: string | null) => void;
+ accesses: readonly FilterMenuOption[];
+ access: string | null;
+ onAccessChange: (access: string | null) => void;
+};
+
+/** Filters of the routes of the catalog: their status, and free or Premium. */
+export function CatalogRoutesFilters({
+ statuses,
+ status,
+ onStatusChange,
+ accesses,
+ access,
+ onAccessChange,
+}: CatalogRoutesFiltersProps) {
+ const texts = messages.catalog.routes.filters;
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/CatalogRoutesTable.tsx b/apps/admin/src/components/catalog/CatalogRoutesTable.tsx
new file mode 100644
index 00000000..6a0c9523
--- /dev/null
+++ b/apps/admin/src/components/catalog/CatalogRoutesTable.tsx
@@ -0,0 +1,94 @@
+import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge';
+import type { CatalogRouteColumn } from '@/config/catalog-routes-list';
+import { formatRelativeDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import type { CatalogRoute } from '@widoo/shared';
+import {
+ SortableColumnHeader,
+ Table,
+ TableCell,
+ TableHeaderCell,
+ TableRow,
+ TableSkeleton,
+ type SortDirection,
+} from '@widoo/ui';
+
+type CatalogRoutesTableProps = {
+ /** Rows of the page; ignored while `isLoading`. */
+ items: CatalogRoute[];
+ isLoading: boolean;
+ sort: { column: CatalogRouteColumn; direction: SortDirection };
+ onSort: (column: CatalogRouteColumn) => void;
+ /** Opens the sheet of a route; absent for a role that only reads the list. */
+ onOpen?: (route: CatalogRoute) => void;
+};
+
+const texts = messages.catalog.routes;
+const columnCount = 7;
+
+/** The routes of the catalog as a table (A-04): one route per row, whatever its status. */
+export function CatalogRoutesTable({
+ items,
+ isLoading,
+ sort,
+ onSort,
+ onOpen,
+}: CatalogRoutesTableProps) {
+ const renderSortableHeader = (column: CatalogRouteColumn, label: string, className?: string) => {
+ const direction = sort.column === column ? sort.direction : null;
+ const ariaSort = direction === null ? 'none' : direction === 'asc' ? 'ascending' : 'descending';
+ return (
+
+ onSort(column)}>
+ {label}
+
+
+ );
+ };
+
+ return (
+
+
+
+ {renderSortableHeader('parcours', texts.columns.route)}
+ {renderSortableHeader('auteur', texts.columns.author)}
+ {renderSortableHeader('ville', texts.columns.city)}
+ {texts.columns.status}
+ {texts.columns.access}
+ {renderSortableHeader('note', texts.columns.rating)}
+ {renderSortableHeader('modifie', texts.columns.updated, 'text-right')}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ items.map((route) => (
+ onOpen(route) : undefined}>
+ {route.title}
+
+ {route.author?.firstName ??
+ (route.isOfficial ? texts.team : messages.moderation.noAuthor)}
+
+ {route.cityName}
+
+
+
+ {texts.access[route.access]}
+
+ {route.rating.average === null ? (
+ {texts.noRating}
+ ) : (
+ texts.rating(route.rating.average, route.rating.count)
+ )}
+
+
+ {formatRelativeDate(new Date(route.updatedAt))}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/CatalogTabs.tsx b/apps/admin/src/components/catalog/CatalogTabs.tsx
new file mode 100644
index 00000000..79491940
--- /dev/null
+++ b/apps/admin/src/components/catalog/CatalogTabs.tsx
@@ -0,0 +1,35 @@
+import { sectionPaths } from '@/config/navigation';
+import { messages } from '@/messages/fr';
+import { cn } from '@widoo/ui';
+import Link from 'next/link';
+
+const texts = messages.catalog.tabs;
+
+/** Addresses of the two lists of the catalog. */
+export const catalogPaths = {
+ places: `${sectionPaths.catalog}/lieux`,
+ routes: `${sectionPaths.catalog}/parcours`,
+} as const;
+
+/** The two lists of the catalog (A-04), as tabs above them: places, then routes. */
+export function CatalogTabs({ current }: { current: keyof typeof catalogPaths }) {
+ return (
+
+ {(['places', 'routes'] as const).map((tab) => (
+
+ {texts[tab]}
+
+ ))}
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/PlaceDetailContent.tsx b/apps/admin/src/components/catalog/PlaceDetailContent.tsx
new file mode 100644
index 00000000..86c841dc
--- /dev/null
+++ b/apps/admin/src/components/catalog/PlaceDetailContent.tsx
@@ -0,0 +1,72 @@
+import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge';
+import { hoursToDayTexts } from '@/lib/place-hours';
+import { messages } from '@/messages/fr';
+import { labels, type CatalogPlaceDetail } from '@widoo/shared';
+import type { ReactNode } from 'react';
+
+const texts = messages.catalog.places.panel;
+
+/** A line of the sheet: what it is on the left, its value on the right. */
+function Line({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ );
+}
+
+/**
+ * The sheet of a place inside the detail panel: its address and practical details, its hours day
+ * by day, and the routes that go through it.
+ */
+export function PlaceDetailContent({ place }: { place: CatalogPlaceDetail }) {
+ const dayTexts = hoursToDayTexts(place.hours);
+ const hasHours = place.hours.length > 0;
+ return (
+ <>
+
+
+ {labels.fr.placeCategories[place.category]}
+
+ {place.address}
+
+ {place.location.lat.toFixed(5)}, {place.location.lng.toFixed(5)}
+
+ {place.isIndoor ? texts.yes : texts.no}
+
+ {place.priceRange ? labels.fr.priceRanges[place.priceRange] : texts.none}
+
+ {place.websiteUrl ?? texts.none}
+ {place.phone ?? texts.none}
+
+
+ {texts.hours}
+ {hasHours ? (
+
+ {messages.catalog.places.weekdays.map((day, index) => (
+
+ {dayTexts[index] || texts.closed}
+
+ ))}
+
+ ) : (
+ {texts.hoursUnknown}
+ )}
+
+
+
+ {place.routes.length > 0 ? texts.routes(place.routes.length) : texts.noRoute}
+
+
+ {place.routes.map((route) => (
+
+ {route.title}
+
+
+ ))}
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/components/catalog/PlaceForm.tsx b/apps/admin/src/components/catalog/PlaceForm.tsx
new file mode 100644
index 00000000..ad69a66d
--- /dev/null
+++ b/apps/admin/src/components/catalog/PlaceForm.tsx
@@ -0,0 +1,167 @@
+import type { PlaceFormValues } from '@/lib/place-form';
+import { messages } from '@/messages/fr';
+import { labels, taxonomies } from '@widoo/shared';
+import { Button, Checkbox, Field, Input, Select } from '@widoo/ui';
+import type { FormEvent, ReactNode } from 'react';
+
+const texts = messages.catalog.places.form;
+
+type PlaceFormProps = {
+ /** An error of the form or of the API, above the fields. */
+ notice: ReactNode;
+ values: PlaceFormValues;
+ /** Changes one field of the form. */
+ onChange: (key: Key, value: PlaceFormValues[Key]) => void;
+ onSubmit: () => void;
+ onCancel: () => void;
+ isBusy: boolean;
+};
+
+/**
+ * The sheet of a place as a form (A-04), for a new place and for an existing one: name, kind,
+ * address and position, practical details, then its opening hours day by day.
+ */
+export function PlaceForm({
+ notice,
+ values,
+ onChange,
+ onSubmit,
+ onCancel,
+ isBusy,
+}: PlaceFormProps) {
+ const handleSubmit = (event: FormEvent) => {
+ event.preventDefault();
+ onSubmit();
+ };
+ const handleDayChange = (day: number, text: string) =>
+ onChange(
+ 'dayHours',
+ values.dayHours.map((current, index) => (index === day ? text : current)),
+ );
+
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/PlaceImportFilePicker.tsx b/apps/admin/src/components/catalog/PlaceImportFilePicker.tsx
new file mode 100644
index 00000000..d366a2c1
--- /dev/null
+++ b/apps/admin/src/components/catalog/PlaceImportFilePicker.tsx
@@ -0,0 +1,54 @@
+import { messages } from '@/messages/fr';
+import { DownloadSimpleIcon, UploadSimpleIcon } from '@phosphor-icons/react/dist/ssr';
+import { Button } from '@widoo/ui';
+import { useRef } from 'react';
+
+const texts = messages.catalog.places.import;
+
+type PlaceImportFilePickerProps = {
+ onFile: (file: File) => void;
+ onDownloadTemplate: () => void;
+ isBusy: boolean;
+};
+
+/** First step of an import: what the file must hold, the template, and the file to choose. */
+export function PlaceImportFilePicker({
+ onFile,
+ onDownloadTemplate,
+ isBusy,
+}: PlaceImportFilePickerProps) {
+ const input = useRef(null);
+ return (
+
+
+ {texts.rules.map((rule) => (
+ {rule}
+ ))}
+
+
+ input.current?.click()} disabled={isBusy}>
+
+ {isBusy ? texts.reading : texts.choose}
+
+
+
+ {texts.template}
+
+
+
{
+ const file = event.target.files?.[0];
+ // Emptied so that choosing the same file again, once corrected, is read again.
+ event.target.value = '';
+ if (file) {
+ onFile(file);
+ }
+ }}
+ />
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/PlaceImportOutcomeBadge.tsx b/apps/admin/src/components/catalog/PlaceImportOutcomeBadge.tsx
new file mode 100644
index 00000000..6592982d
--- /dev/null
+++ b/apps/admin/src/components/catalog/PlaceImportOutcomeBadge.tsx
@@ -0,0 +1,21 @@
+import { messages } from '@/messages/fr';
+import type { PlaceImportOutcome } from '@widoo/shared';
+import { StatusBadge } from '@widoo/ui';
+import type { ComponentProps } from 'react';
+
+const tones: Record['tone']> = {
+ ready: 'info',
+ created: 'success',
+ invalid: 'error',
+ duplicate: 'alert',
+ outside: 'alert',
+};
+
+/** What became of a line of an import: « Prêt », « Créé », « Invalide », « Doublon »… */
+export function PlaceImportOutcomeBadge({ outcome }: { outcome: PlaceImportOutcome }) {
+ return (
+
+ {messages.catalog.places.import.outcomes[outcome]}
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/PlaceImportTable.tsx b/apps/admin/src/components/catalog/PlaceImportTable.tsx
new file mode 100644
index 00000000..a8216f35
--- /dev/null
+++ b/apps/admin/src/components/catalog/PlaceImportTable.tsx
@@ -0,0 +1,51 @@
+import type { PlaceImportLine } from '@/lib/place-import';
+import { messages } from '@/messages/fr';
+import { Table, TableCell, TableHeaderCell, TableRow } from '@widoo/ui';
+import { PlaceImportOutcomeBadge } from './PlaceImportOutcomeBadge';
+
+const texts = messages.catalog.places.import;
+const fieldLabels: Record = texts.fields;
+
+/** Why a line is set aside, or what happens to it, in a sentence. */
+function explanationOf(line: PlaceImportLine): string {
+ if (line.outcome === 'invalid') {
+ const fields = line.fields.map((field) => fieldLabels[field] ?? texts.otherField);
+ return texts.explanations.invalid([...new Set(fields)].join(', '));
+ }
+ if (line.outcome === 'duplicate') {
+ return line.duplicateOf?.line
+ ? texts.explanations.duplicateInFile(line.duplicateOf.line)
+ : texts.explanations.duplicateInCatalog(line.duplicateOf?.name ?? line.name);
+ }
+ return texts.explanations[line.outcome];
+}
+
+/** The lines of an imported file, one per row: its number, its place, its outcome and why. */
+export function PlaceImportTable({ lines }: { lines: PlaceImportLine[] }) {
+ return (
+
+
+
+ {texts.columns.line}
+ {texts.columns.place}
+ {texts.columns.outcome}
+ {texts.columns.explanation}
+
+
+
+ {lines.map((line) => (
+
+ {line.line}
+
+ {line.name || {texts.noName} }
+
+
+
+
+ {explanationOf(line)}
+
+ ))}
+
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/RepublishDialog.tsx b/apps/admin/src/components/catalog/RepublishDialog.tsx
new file mode 100644
index 00000000..29aaa328
--- /dev/null
+++ b/apps/admin/src/components/catalog/RepublishDialog.tsx
@@ -0,0 +1,49 @@
+import { messages } from '@/messages/fr';
+import { ConfirmDialog, Field, Textarea } from '@widoo/ui';
+
+const texts = messages.catalog.routes.republishDialog;
+
+type RepublishDialogProps = {
+ isOpen: boolean;
+ message: string;
+ onMessageChange: (message: string) => void;
+ onCancel: () => void;
+ onConfirm: () => void;
+ isBusy: boolean;
+};
+
+/** « Republier »: why the route goes back online, required, kept in its history. */
+export function RepublishDialog({
+ isOpen,
+ message,
+ onMessageChange,
+ onCancel,
+ onConfirm,
+ isBusy,
+}: RepublishDialogProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/VerificationBadge.tsx b/apps/admin/src/components/catalog/VerificationBadge.tsx
new file mode 100644
index 00000000..690fddc6
--- /dev/null
+++ b/apps/admin/src/components/catalog/VerificationBadge.tsx
@@ -0,0 +1,15 @@
+import { labels, type VerificationStatus } from '@widoo/shared';
+import { StatusBadge } from '@widoo/ui';
+import type { ComponentProps } from 'react';
+
+const tones: Record['tone']> = {
+ verified: 'success',
+ stale: 'neutral',
+ flagged: 'alert',
+ closed: 'error',
+};
+
+/** How far a place is verified: « Vérifié », « À vérifier », « Signalé », « Fermé ». */
+export function VerificationBadge({ status }: { status: VerificationStatus }) {
+ return {labels.fr.verificationStatuses[status]} ;
+}
diff --git a/apps/admin/src/components/dashboard/DashboardSection.tsx b/apps/admin/src/components/dashboard/DashboardSection.tsx
new file mode 100644
index 00000000..33901ccb
--- /dev/null
+++ b/apps/admin/src/components/dashboard/DashboardSection.tsx
@@ -0,0 +1,21 @@
+import type { ReactNode } from 'react';
+
+type DashboardSectionProps = {
+ title: string;
+ /** On the right of the title: when the figures were read, or a link to another dashboard. */
+ aside?: ReactNode;
+ children: ReactNode;
+};
+
+/** A block of the dashboard: its title, what goes on its right, then its figures. */
+export function DashboardSection({ title, aside, children }: DashboardSectionProps) {
+ return (
+
+
+
{title}
+ {aside}
+
+ {children}
+
+ );
+}
diff --git a/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx b/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx
new file mode 100644
index 00000000..3bc09ade
--- /dev/null
+++ b/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx
@@ -0,0 +1,16 @@
+import { ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr';
+
+/** Link to a dashboard kept outside the admin (PostHog, RevenueCat), in a new tab. */
+export function ExternalDashboardLink({ href, label }: { href: string; label: string }) {
+ return (
+
+ {label}
+
+
+ );
+}
diff --git a/apps/admin/src/components/dashboard/FigureBand.tsx b/apps/admin/src/components/dashboard/FigureBand.tsx
new file mode 100644
index 00000000..e5a0212a
--- /dev/null
+++ b/apps/admin/src/components/dashboard/FigureBand.tsx
@@ -0,0 +1,26 @@
+import { formatCount } from '@/lib/numbers';
+
+export type Figure = {
+ label: string;
+ value: number;
+ /** What the figure counts, under it: « sur les 30 derniers jours ». */
+ detail: string;
+};
+
+/** Figures side by side on warm gray, separated by a line: figures alone, no chart (A-05). */
+export function FigureBand({ figures }: { figures: Figure[] }) {
+ return (
+
+ {figures.map((figure) => (
+
+
{figure.label}
+ {formatCount(figure.value)}
+ {figure.detail}
+
+ ))}
+
+ );
+}
diff --git a/apps/admin/src/components/dashboard/RefreshStatus.tsx b/apps/admin/src/components/dashboard/RefreshStatus.tsx
new file mode 100644
index 00000000..5bc368e8
--- /dev/null
+++ b/apps/admin/src/components/dashboard/RefreshStatus.tsx
@@ -0,0 +1,28 @@
+import { useMinuteTick } from '@/hooks/useMinuteTick';
+import { formatRelativeDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import { ArrowClockwiseIcon } from '@phosphor-icons/react/dist/ssr';
+
+type RefreshStatusProps = {
+ /** When the figures shown were read. */
+ readAt: Date;
+ onRefresh: () => void;
+};
+
+/** « Mis à jour il y a 2 min » and the button that reads the figures again. */
+export function RefreshStatus({ readAt, onRefresh }: RefreshStatusProps) {
+ useMinuteTick();
+ return (
+
+ {messages.dashboard.updated(formatRelativeDate(readAt))}
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/dashboard/ToHandleTile.tsx b/apps/admin/src/components/dashboard/ToHandleTile.tsx
new file mode 100644
index 00000000..ce5c21cd
--- /dev/null
+++ b/apps/admin/src/components/dashboard/ToHandleTile.tsx
@@ -0,0 +1,49 @@
+import { formatCount } from '@/lib/numbers';
+import { ArrowRightIcon, WarningCircleIcon } from '@phosphor-icons/react/dist/ssr';
+import type { Icon } from '@phosphor-icons/react';
+import { cn } from '@widoo/ui';
+import Link from 'next/link';
+
+type ToHandleTileProps = {
+ /** The list the tile opens, already filtered on what waits. */
+ href: string;
+ icon: Icon;
+ label: string;
+ count: number;
+ /** Line under the figure: « Le plus ancien attend depuis 3 j 4 h ». */
+ detail: string;
+ /** Something has been waiting for more than 72 hours: the detail turns red, with an icon. */
+ isLate: boolean;
+};
+
+/** A tile « à traiter » of the dashboard, a link to its list as a whole. */
+export function ToHandleTile({
+ href,
+ icon: IconOf,
+ label,
+ count,
+ detail,
+ isLate,
+}: ToHandleTileProps) {
+ return (
+
+
+
+
+
+ {label}
+
+
+ {formatCount(count)}
+
+ {isLate ? : null}
+ {detail}
+
+
+ );
+}
diff --git a/apps/admin/src/components/dashboard/ToHandleTiles.tsx b/apps/admin/src/components/dashboard/ToHandleTiles.tsx
new file mode 100644
index 00000000..52adcb54
--- /dev/null
+++ b/apps/admin/src/components/dashboard/ToHandleTiles.tsx
@@ -0,0 +1,51 @@
+import { sectionIcons } from '@/components/shell/section-icons';
+import { formatWaitingTime } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import { countedAdminSections, type AdminStats, type CountedAdminSection } from '@widoo/shared';
+import { ToHandleTile } from './ToHandleTile';
+
+const texts = messages.dashboard.toHandle;
+
+type ToHandleTilesProps = {
+ /** What waits in each section the role opens; a section the role does not open is absent. */
+ toHandle: AdminStats['toHandle'];
+ /** The list each tile opens, already filtered on what waits. */
+ hrefs: Record;
+};
+
+/**
+ * The tiles « à traiter » of the dashboard, in the order of the navigation: one per section of
+ * the role, with its count, and since when its oldest item waits.
+ */
+export function ToHandleTiles({ toHandle, hrefs }: ToHandleTilesProps) {
+ const sections = countedAdminSections.filter((section) => toHandle[section] !== undefined);
+ if (sections.length === 0) {
+ return {texts.nothing}
;
+ }
+ return (
+
+ {sections.map((section) => {
+ const waiting = toHandle[section];
+ if (!waiting) {
+ return null;
+ }
+ return (
+
+ 0}
+ />
+
+ );
+ })}
+
+ );
+}
diff --git a/apps/admin/src/components/moderation/sheet/HistoryCard.tsx b/apps/admin/src/components/moderation/sheet/HistoryCard.tsx
index bbd65d66..9064c154 100644
--- a/apps/admin/src/components/moderation/sheet/HistoryCard.tsx
+++ b/apps/admin/src/components/moderation/sheet/HistoryCard.tsx
@@ -16,7 +16,7 @@ export function HistoryCard({ history }: { history: ModerationHistoryEntry[] })
{history.map((entry) => (
- {texts.by(texts.actions[entry.action], entry.actorFirstName)}
+ {texts.by(texts.actions[entry.action], entry.actorName)}
{formatRelativeDate(new Date(entry.createdAt))}
diff --git a/apps/admin/src/components/moderation/sheet/SheetHeader.tsx b/apps/admin/src/components/moderation/sheet/SheetHeader.tsx
index e4b45ec0..26b5251c 100644
--- a/apps/admin/src/components/moderation/sheet/SheetHeader.tsx
+++ b/apps/admin/src/components/moderation/sheet/SheetHeader.tsx
@@ -1,5 +1,6 @@
import { CertifiedSeal } from '@/components/moderation/CertifiedSeal';
import { WaitingTime } from '@/components/moderation/WaitingTime';
+import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge';
import { messages } from '@/messages/fr';
import { CrownSimpleIcon, MapPinIcon } from '@phosphor-icons/react/dist/ssr';
import type { ModerationRouteDetail } from '@widoo/shared';
@@ -19,9 +20,7 @@ export function SheetHeader({ route, isLate }: SheetHeaderProps) {
return (
-
- {texts.statuses[route.status]}
-
+
{route.isRevision ? (
{messages.moderation.revision}
) : null}
diff --git a/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx b/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx
index 74947af8..a66ef337 100644
--- a/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx
+++ b/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx
@@ -1,6 +1,7 @@
+import { BackLink } from '@/components/states/BackLink';
import { sectionPaths } from '@/config/navigation';
import { messages } from '@/messages/fr';
-import { ArrowLeftIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react/dist/ssr';
+import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react/dist/ssr';
import type { ModerationRouteDetail } from '@widoo/shared';
import { cn } from '@widoo/ui';
import Link from 'next/link';
@@ -38,13 +39,7 @@ function NeighbourLink({ routeId, label, direction }: NeighbourLinkProps) {
export function SheetNavigation({ queue }: { queue: ModerationRouteDetail['queue'] }) {
return (
-
-
- {texts.back}
-
+
{queue ? (
<>
diff --git a/apps/admin/src/components/moderation/sheet/RejectDialog.tsx b/apps/admin/src/components/routes/RouteReasonDialog.tsx
similarity index 66%
rename from apps/admin/src/components/moderation/sheet/RejectDialog.tsx
rename to apps/admin/src/components/routes/RouteReasonDialog.tsx
index 9869ffe6..471fcc05 100644
--- a/apps/admin/src/components/moderation/sheet/RejectDialog.tsx
+++ b/apps/admin/src/components/routes/RouteReasonDialog.tsx
@@ -3,15 +3,17 @@ import { messages } from '@/messages/fr';
import { labels, taxonomies } from '@widoo/shared';
import { ConfirmDialog } from '@widoo/ui';
-const texts = messages.moderation.decisions.rejectDialog;
-
-/** The seven codified reasons of a refusal, in the order of the wiki. */
+/** The seven codified reasons of a refusal or an unpublication, in the order of the wiki. */
const reasons = taxonomies.rejectionReasons.map((code) => ({
code,
label: labels.fr.rejectionReasons[code],
}));
-type RejectDialogProps = {
+type RouteReasonDialogProps = {
+ /** Prefix of the `id` of its fields, unique on the page. */
+ id: string;
+ /** What the window says: its question, the effect of the action, and its buttons. */
+ texts: { title: string; description: string; message: string; confirm: string };
isOpen: boolean;
reasonCode: string;
onReasonCodeChange: (code: string) => void;
@@ -22,8 +24,13 @@ type RejectDialogProps = {
isBusy: boolean;
};
-/** « Refuser »: a codified reason and a message to the author, both required. */
-export function RejectDialog({
+/**
+ * Confirmation of an action that takes a route away from its author's readers, refusing it or
+ * unpublishing it: a codified reason and a message to the author, both required.
+ */
+export function RouteReasonDialog({
+ id,
+ texts,
isOpen,
reasonCode,
onReasonCodeChange,
@@ -32,7 +39,7 @@ export function RejectDialog({
onCancel,
onConfirm,
isBusy,
-}: RejectDialogProps) {
+}: RouteReasonDialogProps) {
return (
['tone'];
+
+/** Color of each status of a route: blue while it waits, green online, red once refused. */
+const tones: Record = {
+ draft: 'neutral',
+ private: 'neutral',
+ pending: 'info',
+ changes_requested: 'neutral',
+ published: 'success',
+ needs_fix: 'alert',
+ rejected: 'error',
+ unpublished: 'neutral',
+};
+
+/** Status of a route as the team reads it: « En relecture », « Publié », « Retiré ». */
+export function RouteStatusBadge({ status }: { status: RouteStatus }) {
+ return {messages.routeStatuses[status]} ;
+}
diff --git a/apps/admin/src/components/states/AccessDenied.tsx b/apps/admin/src/components/states/AccessDenied.tsx
new file mode 100644
index 00000000..39add5d7
--- /dev/null
+++ b/apps/admin/src/components/states/AccessDenied.tsx
@@ -0,0 +1,26 @@
+import { PageMessage } from '@/components/states/PageMessage';
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+import Link from 'next/link';
+
+type AccessDeniedProps = {
+ /** Where the person may go instead: the list this page belongs to. */
+ backHref: string;
+ backLabel: string;
+};
+
+/** A page the role of the member does not open: said plainly, with the way back. */
+export function AccessDenied({ backHref, backLabel }: AccessDeniedProps) {
+ return (
+
+ {backLabel}
+
+ }
+ />
+ );
+}
diff --git a/apps/admin/src/components/states/BackLink.tsx b/apps/admin/src/components/states/BackLink.tsx
new file mode 100644
index 00000000..211bab33
--- /dev/null
+++ b/apps/admin/src/components/states/BackLink.tsx
@@ -0,0 +1,15 @@
+import { ArrowLeftIcon } from '@phosphor-icons/react/dist/ssr';
+import Link from 'next/link';
+
+/** Above a sheet: the way back to its list. */
+export function BackLink({ href, label }: { href: string; label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
diff --git a/apps/admin/src/config/catalog-places-list.test.ts b/apps/admin/src/config/catalog-places-list.test.ts
new file mode 100644
index 00000000..e20fcb00
--- /dev/null
+++ b/apps/admin/src/config/catalog-places-list.test.ts
@@ -0,0 +1,29 @@
+import { parseListQuery } from '@/lib/list-query';
+import { describe, expect, it } from 'vitest';
+import { catalogPlacesListOptions, toCatalogPlacesParams } from './catalog-places-list';
+
+const paramsOf = (address: string) =>
+ toCatalogPlacesParams(parseListQuery(new URLSearchParams(address), catalogPlacesListOptions));
+
+describe('toCatalogPlacesParams', () => {
+ it('asks for the places by name by default', () => {
+ expect(paramsOf('')).toMatchObject({ sort: 'name', direction: 'asc', category: undefined });
+ });
+
+ it('translates the filters, the search and the sort of the address', () => {
+ expect(paramsOf('q=martyrs&categorie=cafe&statut=a-verifier&tri=-parcours')).toMatchObject({
+ q: 'martyrs',
+ category: 'cafe',
+ status: 'stale',
+ sort: 'routes',
+ direction: 'desc',
+ });
+ });
+
+ it('drops a filter value it does not know', () => {
+ expect(paramsOf('categorie=casino&statut=toString')).toMatchObject({
+ category: undefined,
+ status: undefined,
+ });
+ });
+});
diff --git a/apps/admin/src/config/catalog-places-list.ts b/apps/admin/src/config/catalog-places-list.ts
new file mode 100644
index 00000000..7ec6d93e
--- /dev/null
+++ b/apps/admin/src/config/catalog-places-list.ts
@@ -0,0 +1,53 @@
+import type { ListQuery, ListQueryOptions } from '@/lib/list-query';
+import {
+ taxonomies,
+ type CatalogPlaceSort,
+ type CatalogPlacesQuery,
+ type PlaceCategory,
+ type VerificationStatus,
+} from '@widoo/shared';
+
+/** Filters and columns of the places of the catalog as their address names them, in French. */
+export type CatalogPlaceFilter = 'categorie' | 'statut';
+export type CatalogPlaceColumn = 'lieu' | 'categorie' | 'ville' | 'verifie' | 'parcours';
+export type CatalogPlacesListQuery = ListQuery;
+
+/** What the address of the list accepts; by name by default. */
+export const catalogPlacesListOptions: ListQueryOptions = {
+ filters: ['categorie', 'statut'],
+ sortableColumns: ['lieu', 'categorie', 'ville', 'verifie', 'parcours'],
+ defaultSort: { column: 'lieu', direction: 'asc' },
+};
+
+/** Values of the `statut` filter, and the verification status each one asks for. */
+export const placeStatusFilterValues = {
+ verifie: 'verified',
+ 'a-verifier': 'stale',
+ signale: 'flagged',
+ ferme: 'closed',
+} as const satisfies Record;
+
+const sortOfColumn: Record = {
+ lieu: 'name',
+ categorie: 'category',
+ ville: 'city',
+ verifie: 'verified',
+ parcours: 'routes',
+};
+
+/** What to ask the API for the list shown at an address; an unknown filter value is dropped. */
+export function toCatalogPlacesParams(query: CatalogPlacesListQuery): Partial {
+ const { categorie, statut } = query.filters;
+ return {
+ q: query.search || undefined,
+ // The category travels as its technical key, the same as in the taxonomy.
+ category: taxonomies.placeCategories.find((category: PlaceCategory) => category === categorie),
+ status:
+ statut !== undefined && Object.hasOwn(placeStatusFilterValues, statut)
+ ? placeStatusFilterValues[statut as keyof typeof placeStatusFilterValues]
+ : undefined,
+ sort: sortOfColumn[query.sort.column],
+ direction: query.sort.direction,
+ cursor: query.cursor ?? undefined,
+ };
+}
diff --git a/apps/admin/src/config/catalog-routes-list.test.ts b/apps/admin/src/config/catalog-routes-list.test.ts
new file mode 100644
index 00000000..83712303
--- /dev/null
+++ b/apps/admin/src/config/catalog-routes-list.test.ts
@@ -0,0 +1,37 @@
+import { parseListQuery } from '@/lib/list-query';
+import { describe, expect, it } from 'vitest';
+import { catalogRoutesListOptions, toCatalogRoutesParams } from './catalog-routes-list';
+
+const paramsOf = (address: string) =>
+ toCatalogRoutesParams(parseListQuery(new URLSearchParams(address), catalogRoutesListOptions));
+
+describe('toCatalogRoutesParams', () => {
+ it('asks for the last changed first by default', () => {
+ expect(paramsOf('')).toEqual({
+ q: undefined,
+ status: undefined,
+ access: undefined,
+ sort: 'updated',
+ direction: 'desc',
+ cursor: undefined,
+ });
+ });
+
+ it('translates the filters, the search, the sort and the page of the address', () => {
+ expect(paramsOf('q=marais&statut=retire&acces=premium&tri=note&page=NTA')).toEqual({
+ q: 'marais',
+ status: 'unpublished',
+ access: 'premium',
+ sort: 'rating',
+ direction: 'asc',
+ cursor: 'NTA',
+ });
+ });
+
+ it('drops a filter value it does not know, even the name of an object property', () => {
+ expect(paramsOf('statut=online&acces=toString')).toMatchObject({
+ status: undefined,
+ access: undefined,
+ });
+ });
+});
diff --git a/apps/admin/src/config/catalog-routes-list.ts b/apps/admin/src/config/catalog-routes-list.ts
new file mode 100644
index 00000000..46b5c044
--- /dev/null
+++ b/apps/admin/src/config/catalog-routes-list.ts
@@ -0,0 +1,56 @@
+import type { ListQuery, ListQueryOptions } from '@/lib/list-query';
+import type { CatalogRouteSort, CatalogRoutesQuery, Plan, RouteStatus } from '@widoo/shared';
+
+/** Filters and columns of the routes of the catalog as their address names them, in French. */
+export type CatalogRouteFilter = 'statut' | 'acces';
+export type CatalogRouteColumn = 'parcours' | 'auteur' | 'ville' | 'note' | 'modifie';
+export type CatalogRoutesListQuery = ListQuery;
+
+/** What the address of the list accepts; the last change comes first by default. */
+export const catalogRoutesListOptions: ListQueryOptions = {
+ filters: ['statut', 'acces'],
+ sortableColumns: ['parcours', 'auteur', 'ville', 'note', 'modifie'],
+ defaultSort: { column: 'modifie', direction: 'desc' },
+};
+
+/** Values of the `statut` filter, and the status of the API each one asks for. */
+export const statusFilterValues = {
+ brouillon: 'draft',
+ prive: 'private',
+ relecture: 'pending',
+ modifications: 'changes_requested',
+ publie: 'published',
+ 'a-corriger': 'needs_fix',
+ refuse: 'rejected',
+ retire: 'unpublished',
+} as const satisfies Record;
+
+/** Values of the `acces` filter. */
+export const accessFilterValues = {
+ gratuit: 'free',
+ premium: 'premium',
+} as const satisfies Record;
+
+const sortOfColumn: Record = {
+ parcours: 'title',
+ auteur: 'author',
+ ville: 'city',
+ note: 'rating',
+ modifie: 'updated',
+};
+
+/** The value of a table for a key typed in the address; undefined for an unknown key. */
+const valueOf = (table: Record, key: string | undefined) =>
+ key !== undefined && Object.hasOwn(table, key) ? table[key] : undefined;
+
+/** What to ask the API for the list shown at an address; an unknown filter value is dropped. */
+export function toCatalogRoutesParams(query: CatalogRoutesListQuery): Partial {
+ return {
+ q: query.search || undefined,
+ status: valueOf(statusFilterValues, query.filters.statut),
+ access: valueOf(accessFilterValues, query.filters.acces),
+ sort: sortOfColumn[query.sort.column],
+ direction: query.sort.direction,
+ cursor: query.cursor ?? undefined,
+ };
+}
diff --git a/apps/admin/src/config/env.test.ts b/apps/admin/src/config/env.test.ts
index b3441f60..c407d2e6 100644
--- a/apps/admin/src/config/env.test.ts
+++ b/apps/admin/src/config/env.test.ts
@@ -13,6 +13,8 @@ describe('parseEnv', () => {
expect(parseEnv(valid)).toEqual({
...valid,
NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: undefined,
+ NEXT_PUBLIC_POSTHOG_URL: undefined,
+ NEXT_PUBLIC_REVENUECAT_URL: undefined,
});
});
@@ -55,4 +57,12 @@ describe('parseEnv', () => {
}),
).toThrow(/demo-\* project/);
});
+
+ it('reads the dashboards the admin links to, https only', () => {
+ const env = parseEnv({ ...valid, NEXT_PUBLIC_POSTHOG_URL: 'https://eu.posthog.com/project/1' });
+ expect(env.NEXT_PUBLIC_POSTHOG_URL).toBe('https://eu.posthog.com/project/1');
+ expect(() =>
+ parseEnv({ ...valid, NEXT_PUBLIC_REVENUECAT_URL: 'http://plain.example' }),
+ ).toThrow(/NEXT_PUBLIC_REVENUECAT_URL/);
+ });
});
diff --git a/apps/admin/src/config/env.ts b/apps/admin/src/config/env.ts
index b0006793..82e62a81 100644
--- a/apps/admin/src/config/env.ts
+++ b/apps/admin/src/config/env.ts
@@ -10,6 +10,12 @@ const Origin = z
/** A host name, without scheme or path: `project.firebaseapp.com`. */
const Host = z.string().regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/);
+const OptionalHttpsUrl = z
+ .string()
+ .optional()
+ .transform((value) => value || undefined)
+ .pipe(z.url({ protocol: /^https$/ }).optional());
+
/**
* Environment variables of the admin, validated once. See `.env.example`. Every value is public:
* Next.js writes the `NEXT_PUBLIC_` variables in the pages at build time.
@@ -24,6 +30,9 @@ const Env = z.object({
NEXT_PUBLIC_FIREBASE_API_KEY: z.string().min(1),
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: Host,
NEXT_PUBLIC_FIREBASE_PROJECT_ID: z.string().regex(/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/),
+ /** Exact pages of the Widoo project in PostHog and RevenueCat, linked from A-05. */
+ NEXT_PUBLIC_POSTHOG_URL: OptionalHttpsUrl,
+ NEXT_PUBLIC_REVENUECAT_URL: OptionalHttpsUrl,
/** Local Auth emulator; empty on staging and production. */
NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: z
.string()
diff --git a/apps/admin/src/config/external-dashboards.ts b/apps/admin/src/config/external-dashboards.ts
new file mode 100644
index 00000000..7dee5cf3
--- /dev/null
+++ b/apps/admin/src/config/external-dashboards.ts
@@ -0,0 +1,11 @@
+import { publicEnv } from './public-env';
+
+/**
+ * Dashboards kept outside the admin, linked from A-05. By default their home page, which opens
+ * the project of the person once signed in: PostHog Cloud EU (wiki Securite-et-RGPD) and
+ * RevenueCat. The environment can name the exact page of the Widoo project instead.
+ */
+export const externalDashboards = {
+ posthog: publicEnv.NEXT_PUBLIC_POSTHOG_URL ?? 'https://eu.posthog.com',
+ revenueCat: publicEnv.NEXT_PUBLIC_REVENUECAT_URL ?? 'https://app.revenuecat.com',
+};
diff --git a/apps/admin/src/config/public-env.ts b/apps/admin/src/config/public-env.ts
index c1a2b3bc..0e6836b5 100644
--- a/apps/admin/src/config/public-env.ts
+++ b/apps/admin/src/config/public-env.ts
@@ -10,4 +10,6 @@ export const publicEnv = parseEnv({
NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN,
NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID,
NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL,
+ NEXT_PUBLIC_POSTHOG_URL: process.env.NEXT_PUBLIC_POSTHOG_URL,
+ NEXT_PUBLIC_REVENUECAT_URL: process.env.NEXT_PUBLIC_REVENUECAT_URL,
});
diff --git a/apps/admin/src/containers/AdminOnlyGate.tsx b/apps/admin/src/containers/AdminOnlyGate.tsx
new file mode 100644
index 00000000..e90f801d
--- /dev/null
+++ b/apps/admin/src/containers/AdminOnlyGate.tsx
@@ -0,0 +1,13 @@
+'use client';
+
+import { AccessDenied } from '@/components/states/AccessDenied';
+import { useIsAdmin } from '@/hooks/useIsAdmin';
+import type { ReactNode } from 'react';
+
+type AdminOnlyGateProps = { backHref: string; backLabel: string; children: ReactNode };
+
+/** Around a page of the admins: any other role gets a refusal, whatever the address typed. */
+export function AdminOnlyGate({ backHref, backLabel, children }: AdminOnlyGateProps) {
+ const isAdmin = useIsAdmin();
+ return isAdmin ? children : ;
+}
diff --git a/apps/admin/src/containers/CatalogPlacesContainer.tsx b/apps/admin/src/containers/CatalogPlacesContainer.tsx
new file mode 100644
index 00000000..cbf27ef4
--- /dev/null
+++ b/apps/admin/src/containers/CatalogPlacesContainer.tsx
@@ -0,0 +1,168 @@
+'use client';
+
+import { CatalogPlacesFilters } from '@/components/catalog/CatalogPlacesFilters';
+import { CatalogPlacesTable } from '@/components/catalog/CatalogPlacesTable';
+import { catalogPaths } from '@/components/catalog/CatalogTabs';
+import { PageMessage } from '@/components/states/PageMessage';
+import {
+ catalogPlacesListOptions,
+ placeStatusFilterValues,
+ toCatalogPlacesParams,
+} from '@/config/catalog-places-list';
+import { PlacePanelContainer } from '@/containers/PlacePanelContainer';
+import { useIsAdmin } from '@/hooks/useIsAdmin';
+import { useDebouncedCallback } from '@/hooks/useDebouncedCallback';
+import { useListQuery } from '@/hooks/useListQuery';
+import { useCanWriteCatalog } from '@/hooks/useCanWriteCatalog';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { getSessionClients } from '@/lib/auth/clients';
+import { serializeListQuery } from '@/lib/list-query';
+import { messages } from '@/messages/fr';
+import { PlusIcon, UploadSimpleIcon } from '@phosphor-icons/react/dist/ssr';
+import { labels, taxonomies } from '@widoo/shared';
+import { Button, Pagination, SearchInput } from '@widoo/ui';
+import Link from 'next/link';
+import { useRouter, useSearchParams } from 'next/navigation';
+
+const searchDelayMs = 300;
+const texts = messages.catalog.places;
+/** Parameter of the address that holds the place whose sheet is open next to the list. */
+const openPlaceParam = 'lieu';
+
+const categoryOptions = taxonomies.placeCategories.map((category) => ({
+ value: category,
+ label: labels.fr.placeCategories[category],
+}));
+const statusOptions = Object.entries(placeStatusFilterValues).map(([value, status]) => ({
+ value,
+ label: labels.fr.verificationStatuses[status],
+}));
+
+/**
+ * A-04, the places of the catalog: the list the address asks for, and next to it the sheet of
+ * the place the address names. Editors and admins create and edit; a moderator reads.
+ */
+export function CatalogPlacesContainer() {
+ const router = useRouter();
+ const openId = useSearchParams().get(openPlaceParam);
+ const canWrite = useCanWriteCatalog();
+ const isAdmin = useIsAdmin();
+ const { query, handleSearch, handleFilter, handleSort, handlePage } =
+ useListQuery(catalogPlacesListOptions);
+ const listAddress = serializeListQuery(query, catalogPlacesListOptions);
+ const places = useRemoteData(listAddress, (signal) =>
+ getSessionClients().api.getCatalogPlaces(toCatalogPlacesParams(query), signal),
+ );
+ const handleSearchTyped = useDebouncedCallback(handleSearch, searchDelayMs);
+
+ const data = places.status === 'ready' ? places.data : undefined;
+ /** The address of the list as it is, with the sheet of `placeId` open, or none. */
+ const showPlace = (placeId: string | null) => {
+ const params = new URLSearchParams(listAddress);
+ if (placeId) {
+ params.set(openPlaceParam, placeId);
+ }
+ const address = params.toString();
+ router.replace(address ? `${catalogPaths.places}?${address}` : catalogPaths.places, {
+ scroll: false,
+ });
+ };
+
+ const renderList = () => {
+ if (places.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+ if (data?.items.length === 0) {
+ return ;
+ }
+ return (
+ showPlace(place.id)}
+ />
+ );
+ };
+
+ return (
+
+
+
+
handleSearchTyped(event.target.value)}
+ className="w-full max-w-card-list-w"
+ />
+ {data ? (
+ {texts.total(data.total)}
+ ) : null}
+
+ {isAdmin ? (
+
+
+
+ {texts.import.open}
+
+
+ ) : null}
+ {canWrite ? (
+
+
+
+ {texts.create}
+
+
+ ) : null}
+
+
+
+ handleFilter('categorie', category ?? query.filters.categorie ?? '')
+ }
+ statuses={statusOptions}
+ status={query.filters.statut ?? null}
+ onStatusChange={(status) => handleFilter('statut', status ?? query.filters.statut ?? '')}
+ />
+ {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}
+
+ {openId ? (
+
place.id === openId)?.name ?? ''}
+ editPath={canWrite ? `${catalogPaths.places}/${openId}` : undefined}
+ onClose={() => showPlace(null)}
+ />
+ ) : null}
+
+ );
+}
diff --git a/apps/admin/src/containers/CatalogRouteSheetContainer.tsx b/apps/admin/src/containers/CatalogRouteSheetContainer.tsx
new file mode 100644
index 00000000..5c06cca0
--- /dev/null
+++ b/apps/admin/src/containers/CatalogRouteSheetContainer.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import { CatalogRouteActions } from '@/components/catalog/CatalogRouteActions';
+import { RepublishDialog } from '@/components/catalog/RepublishDialog';
+import { AuthorCard } from '@/components/moderation/sheet/AuthorCard';
+import { CoherenceWarnings } from '@/components/moderation/sheet/CoherenceWarnings';
+import { HistoryCard } from '@/components/moderation/sheet/HistoryCard';
+import { RoutePreview } from '@/components/moderation/sheet/RoutePreview';
+import { SheetHeader } from '@/components/moderation/sheet/SheetHeader';
+import { StepList } from '@/components/moderation/sheet/StepList';
+import { RouteReasonDialog } from '@/components/routes/RouteReasonDialog';
+import { AccessDenied } from '@/components/states/AccessDenied';
+import { BackLink } from '@/components/states/BackLink';
+import { PageMessage } from '@/components/states/PageMessage';
+import { sectionPaths } from '@/config/navigation';
+import { useCatalogRouteActions } from '@/hooks/useCatalogRouteActions';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { useIsAdmin } from '@/hooks/useIsAdmin';
+import { useSession } from '@/hooks/useSession';
+import { getSessionClients } from '@/lib/auth/clients';
+import { isRejectionReason } from '@/lib/rejection-reason';
+import { messages } from '@/messages/fr';
+import { Button, ConfirmDialog } from '@widoo/ui';
+import { useState } from 'react';
+
+const texts = messages.catalog.routes;
+const routesPath = `${sectionPaths.catalog}/parcours`;
+
+type OpenDialog = 'unpublish' | 'republish' | 'reassign' | null;
+
+/**
+ * A-04, the sheet of a route of the catalog: the route as it is published, read only, and the
+ * actions of the team. The texts of a route are edited in the editor of the app, never here.
+ */
+export function CatalogRouteSheetContainer({ routeId }: { routeId: string }) {
+ const { session } = useSession();
+ const isAdmin = useIsAdmin();
+ const sheet = useRemoteData(routeId, (signal) =>
+ getSessionClients().api.getModerationRoute(routeId, signal),
+ );
+ const [openDialog, setOpenDialog] = useState(null);
+ const [message, setMessage] = useState('');
+ const [reasonCode, setReasonCode] = useState('');
+ const reload = sheet.status === 'loading' ? undefined : sheet.reload;
+ const actions = useCatalogRouteActions(routeId, () => {
+ setOpenDialog(null);
+ reload?.();
+ });
+
+ // The sheet of a route is read by moderators and admins; the API refuses it to an editor.
+ if (session.status === 'signedIn' && session.member.role === 'editor') {
+ return ;
+ }
+ if (sheet.status === 'loading') {
+ return ;
+ }
+ if (sheet.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+
+ const route = sheet.data;
+ const isOnline = route.status === 'published' || route.status === 'needs_fix';
+ const handleOpen = (dialog: OpenDialog) => {
+ setMessage('');
+ setReasonCode('');
+ setOpenDialog(dialog);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+
+ handleOpen('unpublish') : undefined}
+ onRepublish={
+ isAdmin && route.status === 'unpublished' ? () => handleOpen('republish') : undefined
+ }
+ onReassign={isAdmin && route.author ? () => handleOpen('reassign') : undefined}
+ isBusy={actions.isBusy}
+ />
+ {route.author ? : null}
+
+
+
+
setOpenDialog(null)}
+ onConfirm={() =>
+ isRejectionReason(reasonCode) && void actions.unpublish(reasonCode, message)
+ }
+ isBusy={actions.isBusy}
+ />
+ setOpenDialog(null)}
+ onConfirm={() => void actions.republish(message)}
+ isBusy={actions.isBusy}
+ />
+ setOpenDialog(null)}
+ onConfirm={() => void actions.reassign()}
+ title={texts.reassignDialog.title}
+ description={texts.reassignDialog.description}
+ cancelLabel={messages.confirm.cancel}
+ closeLabel={messages.confirm.close}
+ confirmLabel={texts.reassignDialog.confirm}
+ confirmVariant="primary"
+ isBusy={actions.isBusy}
+ />
+
+ );
+}
diff --git a/apps/admin/src/containers/CatalogRoutesContainer.tsx b/apps/admin/src/containers/CatalogRoutesContainer.tsx
new file mode 100644
index 00000000..5d30436f
--- /dev/null
+++ b/apps/admin/src/containers/CatalogRoutesContainer.tsx
@@ -0,0 +1,122 @@
+'use client';
+
+import { CatalogRoutesFilters } from '@/components/catalog/CatalogRoutesFilters';
+import { CatalogRoutesTable } from '@/components/catalog/CatalogRoutesTable';
+import { PageMessage } from '@/components/states/PageMessage';
+import {
+ accessFilterValues,
+ catalogRoutesListOptions,
+ statusFilterValues,
+ toCatalogRoutesParams,
+} from '@/config/catalog-routes-list';
+import { sectionPaths } from '@/config/navigation';
+import { useDebouncedCallback } from '@/hooks/useDebouncedCallback';
+import { useListQuery } from '@/hooks/useListQuery';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { useSession } from '@/hooks/useSession';
+import { getSessionClients } from '@/lib/auth/clients';
+import { serializeListQuery } from '@/lib/list-query';
+import { messages } from '@/messages/fr';
+import { Button, Pagination, SearchInput } from '@widoo/ui';
+import { useRouter } from 'next/navigation';
+
+const searchDelayMs = 300;
+const texts = messages.catalog.routes;
+
+/** Address of the sheet of a route of the catalog. */
+export const catalogRoutePathOf = (routeId: string) =>
+ `${sectionPaths.catalog}/parcours/${routeId}`;
+
+const statusOptions = Object.entries(statusFilterValues).map(([value, status]) => ({
+ value,
+ label: messages.routeStatuses[status],
+}));
+const accessOptions = Object.entries(accessFilterValues).map(([value, access]) => ({
+ value,
+ label: texts.access[access],
+}));
+
+/**
+ * A-04, the routes of the catalog: reads the page the address asks for, and writes the search,
+ * the filters, the sort and the page back to the address.
+ */
+export function CatalogRoutesContainer() {
+ const router = useRouter();
+ const { session } = useSession();
+ const { query, handleSearch, handleFilter, handleSort, handlePage } =
+ useListQuery(catalogRoutesListOptions);
+ const routes = useRemoteData(serializeListQuery(query, catalogRoutesListOptions), (signal) =>
+ getSessionClients().api.getCatalogRoutes(toCatalogRoutesParams(query), signal),
+ );
+ const handleSearchTyped = useDebouncedCallback(handleSearch, searchDelayMs);
+
+ const data = routes.status === 'ready' ? routes.data : undefined;
+ // The sheet of a route is read by moderators and admins; an editor reads the list.
+ const canOpenSheet = session.status === 'signedIn' && session.member.role !== 'editor';
+
+ const renderList = () => {
+ if (routes.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+ if (data?.items.length === 0) {
+ return ;
+ }
+ return (
+ router.push(catalogRoutePathOf(route.id)) : undefined}
+ />
+ );
+ };
+
+ return (
+
+
+
handleSearchTyped(event.target.value)}
+ className="w-full max-w-card-list-w"
+ />
+ {data ? (
+ {texts.total(data.total)}
+ ) : null}
+
+
handleFilter('statut', status ?? query.filters.statut ?? '')}
+ accesses={accessOptions}
+ access={query.filters.acces ?? null}
+ onAccessChange={(access) => handleFilter('acces', access ?? query.filters.acces ?? '')}
+ />
+ {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/DashboardContainer.tsx b/apps/admin/src/containers/DashboardContainer.tsx
new file mode 100644
index 00000000..88c168f2
--- /dev/null
+++ b/apps/admin/src/containers/DashboardContainer.tsx
@@ -0,0 +1,121 @@
+'use client';
+
+import { DashboardSection } from '@/components/dashboard/DashboardSection';
+import { ExternalDashboardLink } from '@/components/dashboard/ExternalDashboardLink';
+import { FigureBand } from '@/components/dashboard/FigureBand';
+import { RefreshStatus } from '@/components/dashboard/RefreshStatus';
+import { ToHandleTiles } from '@/components/dashboard/ToHandleTiles';
+import { PageMessage } from '@/components/states/PageMessage';
+import { sectionPaths } from '@/config/navigation';
+import { externalDashboards } from '@/config/external-dashboards';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { getSessionClients } from '@/lib/auth/clients';
+import { messages } from '@/messages/fr';
+import type { CountedAdminSection } from '@widoo/shared';
+import { Button } from '@widoo/ui';
+
+const texts = messages.dashboard;
+
+/** The list each tile « à traiter » opens; the moderation queue filtered on what waits. */
+const toHandlePaths: Record = {
+ moderation: `${sectionPaths.moderation}?statut=relecture`,
+ reports: sectionPaths.reports,
+ support: sectionPaths.support,
+ aiDrafts: sectionPaths.aiDrafts,
+ translations: sectionPaths.translations,
+};
+
+/**
+ * A-05, the dashboard: what waits for the role of the member, then the figures of the catalog
+ * and of the users. Figures alone; the analyses stay in PostHog and RevenueCat.
+ */
+export function DashboardContainer() {
+ const stats = useRemoteData('stats', (signal) => getSessionClients().api.getAdminStats(signal));
+
+ if (stats.status === 'loading') {
+ return ;
+ }
+ if (stats.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+
+ const { toHandle, catalog, users, generatedAt } = stats.data;
+ return (
+
+ }
+ >
+
+
+
+ }
+ >
+
+
+
+ }
+ >
+
+
+
+ );
+}
diff --git a/apps/admin/src/containers/ModerationSheetContainer.tsx b/apps/admin/src/containers/ModerationSheetContainer.tsx
index fe8e101a..9c56ecb9 100644
--- a/apps/admin/src/containers/ModerationSheetContainer.tsx
+++ b/apps/admin/src/containers/ModerationSheetContainer.tsx
@@ -4,28 +4,25 @@ 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 { 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 { RouteReasonDialog } from '@/components/routes/RouteReasonDialog';
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 { isWaitingLate } from '@/lib/dates';
+import { isRejectionReason } from '@/lib/rejection-reason';
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;
/**
@@ -112,7 +109,9 @@ export function ModerationSheetContainer({ routeId }: { routeId: string }) {
onConfirm={() => void decision.requestChanges(message)}
isBusy={decision.isBusy}
/>
-
+ getSessionClients().api.getCatalogPlace(placeId, signal),
+ );
+ if (place.status === 'loading') {
+ return ;
+ }
+ if (place.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+ return ;
+}
diff --git a/apps/admin/src/containers/PlaceFormContainer.tsx b/apps/admin/src/containers/PlaceFormContainer.tsx
new file mode 100644
index 00000000..02a0e045
--- /dev/null
+++ b/apps/admin/src/containers/PlaceFormContainer.tsx
@@ -0,0 +1,60 @@
+'use client';
+
+import { catalogPaths } from '@/components/catalog/CatalogTabs';
+import { PlaceForm } from '@/components/catalog/PlaceForm';
+import { BackLink } from '@/components/states/BackLink';
+import { usePlaceForm } from '@/hooks/usePlaceForm';
+import { emptyPlaceForm, placeFormOf } from '@/lib/place-form';
+import { messages } from '@/messages/fr';
+import { WarningCircleIcon } from '@phosphor-icons/react/dist/ssr';
+import type { CatalogPlaceDetail } from '@widoo/shared';
+import { Notice, showToast } from '@widoo/ui';
+import { useRouter } from 'next/navigation';
+
+const texts = messages.catalog.places.form;
+
+/** Address of the list of places with the sheet of one of them open next to it. */
+export const placePanelPathOf = (placeId: string) =>
+ `${catalogPaths.places}?${new URLSearchParams({ lieu: placeId }).toString()}`;
+
+/**
+ * The form of a place on its own page (A-04): empty to create one, filled with `place` to edit
+ * it. Once saved, back to the list with the sheet of the place open.
+ */
+export function PlaceFormContainer({ place }: { place: CatalogPlaceDetail | null }) {
+ const router = useRouter();
+ const form = usePlaceForm({
+ initial: place ? placeFormOf(place) : emptyPlaceForm,
+ placeId: place?.id ?? null,
+ previousHours: place?.hours ?? [],
+ onSaved: (saved) => {
+ showToast('success', { title: place ? texts.saved : texts.created });
+ router.push(placePanelPathOf(saved.id));
+ },
+ });
+
+ return (
+
+
+
{place ? place.name : texts.createTitle}
+
}
+ >
+
{texts.errors[form.failure]}
+
+ ) : null
+ }
+ values={form.values}
+ onChange={form.handleChange}
+ onSubmit={form.handleSubmit}
+ onCancel={() => router.push(place ? placePanelPathOf(place.id) : catalogPaths.places)}
+ isBusy={form.isBusy}
+ />
+
+ );
+}
diff --git a/apps/admin/src/containers/PlaceImportContainer.tsx b/apps/admin/src/containers/PlaceImportContainer.tsx
new file mode 100644
index 00000000..fd31c4db
--- /dev/null
+++ b/apps/admin/src/containers/PlaceImportContainer.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { catalogPaths } from '@/components/catalog/CatalogTabs';
+import { PlaceImportFilePicker } from '@/components/catalog/PlaceImportFilePicker';
+import { PlaceImportTable } from '@/components/catalog/PlaceImportTable';
+import { BackLink } from '@/components/states/BackLink';
+import { usePlaceImport, type PlaceImportFailure } from '@/hooks/usePlaceImport';
+import { downloadTextFile } from '@/lib/download';
+import { countPlaceImportLines, placeImportTemplate } from '@/lib/place-import';
+import { messages } from '@/messages/fr';
+import { WarningCircleIcon } from '@phosphor-icons/react/dist/ssr';
+import { Button, Notice } from '@widoo/ui';
+import Link from 'next/link';
+
+const texts = messages.catalog.places.import;
+
+const failureText = (failure: PlaceImportFailure) =>
+ failure.reason === 'columns'
+ ? texts.errors.columns(failure.missing.join(', '))
+ : texts.errors[failure.reason];
+
+/**
+ * A-04, the import of places from a CSV file, for admins: the file is chosen, every line is
+ * shown with what it would become, and the lines that are ready are created on confirmation.
+ */
+export function PlaceImportContainer() {
+ const { state, failure, isBusy, handleFile, handleConfirm, handleReset } = usePlaceImport();
+
+ const renderStep = () => {
+ if (state.step === 'pick') {
+ return (
+
+ downloadTextFile(texts.templateFileName, placeImportTemplate, 'text/csv;charset=utf-8')
+ }
+ isBusy={isBusy}
+ />
+ );
+ }
+ const isDone = state.step === 'done';
+ const kept = countPlaceImportLines(state.lines, isDone ? 'created' : 'ready');
+ const setAside = state.lines.length - kept;
+ return (
+ <>
+
+
+
+ {isDone ? texts.summary.done(kept) : texts.summary.preview(kept)},{' '}
+ {texts.setAside(setAside)}
+
+
{state.fileName}
+
+
+ {texts.another}
+
+ {isDone ? (
+
+ {texts.backToList}
+
+ ) : (
+
+ {isBusy ? texts.creating : texts.confirm(kept)}
+
+ )}
+
+
+ >
+ );
+ };
+
+ return (
+
+
+
{texts.title}
+ {failure ? (
+
}
+ >
+
{failureText(failure)}
+
+ ) : null}
+ {renderStep()}
+
+ );
+}
diff --git a/apps/admin/src/containers/PlacePanelContainer.tsx b/apps/admin/src/containers/PlacePanelContainer.tsx
new file mode 100644
index 00000000..1ba7ad69
--- /dev/null
+++ b/apps/admin/src/containers/PlacePanelContainer.tsx
@@ -0,0 +1,62 @@
+'use client';
+
+import { PlaceDetailContent } from '@/components/catalog/PlaceDetailContent';
+import { VerificationBadge } from '@/components/catalog/VerificationBadge';
+import { PageMessage } from '@/components/states/PageMessage';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { getSessionClients } from '@/lib/auth/clients';
+import { messages } from '@/messages/fr';
+import { Button, DetailPanel } from '@widoo/ui';
+import Link from 'next/link';
+
+const texts = messages.catalog.places.panel;
+
+type PlacePanelContainerProps = {
+ placeId: string;
+ /** Name shown while the sheet loads, from the row that was opened. */
+ fallbackTitle: string;
+ /** Address of the form of this place; absent for a role that only reads the places. */
+ editPath?: string;
+ onClose: () => void;
+};
+
+/** The sheet of a place in the detail panel, next to the list (A-04). */
+export function PlacePanelContainer({
+ placeId,
+ fallbackTitle,
+ editPath,
+ onClose,
+}: PlacePanelContainerProps) {
+ const place = useRemoteData(placeId, (signal) =>
+ getSessionClients().api.getCatalogPlace(placeId, signal),
+ );
+ const data = place.status === 'ready' ? place.data : undefined;
+
+ return (
+ : undefined}
+ closeLabel={messages.confirm.close}
+ onClose={onClose}
+ actions={
+ data && editPath ? (
+
+ {texts.edit}
+
+ ) : undefined
+ }
+ >
+ {place.status === 'loading' ? (
+
+ ) : null}
+ {place.status === 'error' ? (
+ {messages.list.retry}}
+ />
+ ) : null}
+ {data ? : null}
+
+ );
+}
diff --git a/apps/admin/src/containers/PlaceWriteGate.tsx b/apps/admin/src/containers/PlaceWriteGate.tsx
new file mode 100644
index 00000000..22286b58
--- /dev/null
+++ b/apps/admin/src/containers/PlaceWriteGate.tsx
@@ -0,0 +1,21 @@
+'use client';
+
+import { catalogPaths } from '@/components/catalog/CatalogTabs';
+import { AccessDenied } from '@/components/states/AccessDenied';
+import { useCanWriteCatalog } from '@/hooks/useCanWriteCatalog';
+import { messages } from '@/messages/fr';
+import type { ReactNode } from 'react';
+
+/**
+ * Around the pages that write a place: a member whose role only reads the places gets a refusal
+ * instead of the form, whatever the address typed.
+ */
+export function PlaceWriteGate({ children }: { children: ReactNode }) {
+ const canWrite = useCanWriteCatalog();
+ if (!canWrite) {
+ return (
+
+ );
+ }
+ return children;
+}
diff --git a/apps/admin/src/containers/ShellContainer.tsx b/apps/admin/src/containers/ShellContainer.tsx
index f3532b91..a915219b 100644
--- a/apps/admin/src/containers/ShellContainer.tsx
+++ b/apps/admin/src/containers/ShellContainer.tsx
@@ -3,6 +3,7 @@
import { AccountMenu } from '@/components/shell/AccountMenu';
import { AdminShell } from '@/components/shell/AdminShell';
import { AdminSidebar, type SidebarEntry } from '@/components/shell/AdminSidebar';
+import { AccessDenied } from '@/components/states/AccessDenied';
import { PageMessage } from '@/components/states/PageMessage';
import { navigationFor, sectionOfPath, sectionPaths } from '@/config/navigation';
import { useAdminCounters } from '@/hooks/useAdminCounters';
@@ -12,6 +13,7 @@ import { counterLabel } from '@/lib/counter-label';
import { signInPathFor } from '@/lib/return-path';
import { messages } from '@/messages/fr';
import {
+ canOpenAdminSection,
countedAdminSections,
type AdminCounters,
type AdminSection,
@@ -117,7 +119,13 @@ export function ShellContainer({ children }: { children: ReactNode }) {
/>
}
>
- {children}
+ {/* A section the role does not open is refused here, whatever the address typed: the menu
+ hides it, and the API refuses its data anyway. */}
+ {activeSection && !canOpenAdminSection(member.role, activeSection) ? (
+
+ ) : (
+ children
+ )}
);
diff --git a/apps/admin/src/hooks/useCanWriteCatalog.ts b/apps/admin/src/hooks/useCanWriteCatalog.ts
new file mode 100644
index 00000000..a2a44c5b
--- /dev/null
+++ b/apps/admin/src/hooks/useCanWriteCatalog.ts
@@ -0,0 +1,11 @@
+import { hasRole } from '@widoo/shared';
+import { useSession } from './useSession';
+
+/**
+ * True for a member who creates and edits the places of the catalog: editors and admins; a
+ * moderator reads them (wiki Admin-Web › Rôles). The API decides again on every write.
+ */
+export function useCanWriteCatalog(): boolean {
+ const { session } = useSession();
+ return session.status === 'signedIn' && hasRole(session.member.role, 'editor');
+}
diff --git a/apps/admin/src/hooks/useCatalogRouteActions.ts b/apps/admin/src/hooks/useCatalogRouteActions.ts
new file mode 100644
index 00000000..dcdf6feb
--- /dev/null
+++ b/apps/admin/src/hooks/useCatalogRouteActions.ts
@@ -0,0 +1,23 @@
+import { getSessionClients } from '@/lib/auth/clients';
+import { messages } from '@/messages/fr';
+import type { RejectionReason } from '@widoo/shared';
+import { useRouteAction } from './useRouteAction';
+
+const texts = messages.catalog.routes.toasts;
+
+/**
+ * The actions of the team on a route of the catalog (A-04): take it down, put it back online,
+ * hand it over to the Widoo team. `onSettled` is called once the route changed.
+ */
+export function useCatalogRouteActions(routeId: string, onSettled: () => void) {
+ const { isBusy, send } = useRouteAction(onSettled);
+ const { api } = getSessionClients();
+ return {
+ isBusy,
+ unpublish: (reasonCode: RejectionReason, message: string) =>
+ send(() => api.unpublishRoute(routeId, { reasonCode, message }), texts.unpublished),
+ republish: (message: string) =>
+ send(() => api.republishRoute(routeId, { message }), texts.republished),
+ reassign: () => send(() => api.reassignRoute(routeId), texts.reassigned),
+ };
+}
diff --git a/apps/admin/src/hooks/useIsAdmin.ts b/apps/admin/src/hooks/useIsAdmin.ts
new file mode 100644
index 00000000..528f5008
--- /dev/null
+++ b/apps/admin/src/hooks/useIsAdmin.ts
@@ -0,0 +1,7 @@
+import { useSession } from './useSession';
+
+/** True for a signed-in admin: the only role that imports, republishes and reassigns. */
+export function useIsAdmin(): boolean {
+ const { session } = useSession();
+ return session.status === 'signedIn' && session.member.role === 'admin';
+}
diff --git a/apps/admin/src/hooks/useMinuteTick.ts b/apps/admin/src/hooks/useMinuteTick.ts
new file mode 100644
index 00000000..a697e4d3
--- /dev/null
+++ b/apps/admin/src/hooks/useMinuteTick.ts
@@ -0,0 +1,10 @@
+import { useEffect, useState } from 'react';
+
+/** Renders the component again every minute, for what is written relative to now. */
+export function useMinuteTick(): void {
+ const [, setTick] = useState(0);
+ useEffect(() => {
+ const timer = window.setInterval(() => setTick((tick) => tick + 1), 60_000);
+ return () => window.clearInterval(timer);
+ }, []);
+}
diff --git a/apps/admin/src/hooks/useModerationDecision.ts b/apps/admin/src/hooks/useModerationDecision.ts
index 73a99235..85c8f92a 100644
--- a/apps/admin/src/hooks/useModerationDecision.ts
+++ b/apps/admin/src/hooks/useModerationDecision.ts
@@ -1,49 +1,16 @@
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';
+import type { RejectionReason } from '@widoo/shared';
+import { useRouteAction } from './useRouteAction';
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.
+ * The decisions of a moderator on a pending route (A-02). `onSettled` is called once the route
+ * has a decision, the moderator's own or the one another member of the team took first.
*/
export function useModerationDecision(routeId: string, onSettled: () => void) {
- const [isBusy, setIsBusy] = useState(false);
-
- const send = async (decide: () => Promise, successTitle: string) => {
- setIsBusy(true);
- try {
- await decide();
- showToast('success', { title: successTitle });
- onSettled();
- } 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]),
- });
- onSettled();
- } else {
- showToast('error', { title: texts.failed });
- }
- } finally {
- setIsBusy(false);
- }
- };
-
+ const { isBusy, send } = useRouteAction(onSettled);
const { api } = getSessionClients();
return {
isBusy,
diff --git a/apps/admin/src/hooks/usePlaceForm.ts b/apps/admin/src/hooks/usePlaceForm.ts
new file mode 100644
index 00000000..1c5a97b5
--- /dev/null
+++ b/apps/admin/src/hooks/usePlaceForm.ts
@@ -0,0 +1,63 @@
+import { getSessionClients } from '@/lib/auth/clients';
+import { placeInputOf, type PlaceFormError, type PlaceFormValues } from '@/lib/place-form';
+import { ApiRequestError } from '@widoo/api-client';
+import type { CatalogPlaceDetail, PlaceHours } from '@widoo/shared';
+import { useState } from 'react';
+
+/** Why a place was not saved, as the form tells it. */
+export type PlaceSaveFailure = PlaceFormError | 'outside' | 'refused' | 'failed';
+
+// Message of the 400 the API sends for a position outside every city (place-writes.ts).
+const outsideEveryCity = 'Position outside every city';
+
+function failureOf(error: unknown): PlaceSaveFailure {
+ if (!(error instanceof ApiRequestError) || error.status !== 400) {
+ return 'failed';
+ }
+ return error.body?.message === outsideEveryCity ? 'outside' : 'refused';
+}
+
+/**
+ * The form of a place (A-04): its values, and saving them as a new place or over `placeId`.
+ * `previousHours` are the hours of the place being edited, whose seasonal slots are kept.
+ */
+export function usePlaceForm(options: {
+ initial: PlaceFormValues;
+ placeId: string | null;
+ previousHours: readonly PlaceHours[];
+ onSaved: (place: CatalogPlaceDetail) => void;
+}) {
+ const [values, setValues] = useState(options.initial);
+ const [failure, setFailure] = useState(null);
+ const [isBusy, setIsBusy] = useState(false);
+
+ const handleSubmit = async () => {
+ const result = placeInputOf(values, options.previousHours);
+ if ('error' in result) {
+ setFailure(result.error);
+ return;
+ }
+ setFailure(null);
+ setIsBusy(true);
+ try {
+ const { api } = getSessionClients();
+ const place = options.placeId
+ ? await api.updateCatalogPlace(options.placeId, result.input)
+ : await api.createCatalogPlace(result.input);
+ options.onSaved(place);
+ } catch (error) {
+ setFailure(failureOf(error));
+ } finally {
+ setIsBusy(false);
+ }
+ };
+
+ return {
+ values,
+ failure,
+ isBusy,
+ handleChange: (key: Key, value: PlaceFormValues[Key]) =>
+ setValues((current) => ({ ...current, [key]: value })),
+ handleSubmit: () => void handleSubmit(),
+ };
+}
diff --git a/apps/admin/src/hooks/usePlaceImport.ts b/apps/admin/src/hooks/usePlaceImport.ts
new file mode 100644
index 00000000..3f41ff71
--- /dev/null
+++ b/apps/admin/src/hooks/usePlaceImport.ts
@@ -0,0 +1,103 @@
+import { getSessionClients } from '@/lib/auth/clients';
+import { decodeCsv } from '@/lib/csv';
+import {
+ placeImportLinesOf,
+ readPlaceImport,
+ type PlaceImportLine,
+ type PlaceImportRow,
+} from '@/lib/place-import';
+import { useState } from 'react';
+
+/** Why a file gave nothing: it cannot be used as it is, or the API did not answer. */
+export type PlaceImportFailure =
+ { reason: 'empty' | 'tooMany' | 'failed' } | { reason: 'columns'; missing: string[] };
+
+type ImportState =
+ | { step: 'pick' }
+ | {
+ step: 'preview' | 'done';
+ fileName: string;
+ rows: PlaceImportRow[];
+ lines: PlaceImportLine[];
+ };
+
+/** Sends the lines that hold a place, and gives each line given with what became of it. */
+async function send(rows: PlaceImportRow[], dryRun: boolean): Promise {
+ const sent = rows.flatMap(({ line, place }) => (place ? [{ line, place }] : []));
+ if (sent.length === 0) {
+ return placeImportLinesOf(rows, {
+ rows: [],
+ counts: { ready: 0, created: 0, invalid: 0, duplicate: 0, outside: 0 },
+ });
+ }
+ return placeImportLinesOf(
+ rows,
+ await getSessionClients().api.importPlaces({ dryRun, rows: sent }),
+ );
+}
+
+/**
+ * The import of places from a CSV file (A-04), in two steps: the file is read and the API says
+ * what each line would become (`preview`), then the lines that are ready are created (`done`).
+ */
+export function usePlaceImport() {
+ const [state, setState] = useState({ step: 'pick' });
+ const [failure, setFailure] = useState(null);
+ const [isBusy, setIsBusy] = useState(false);
+
+ const run = async (task: () => Promise) => {
+ setFailure(null);
+ setIsBusy(true);
+ try {
+ await task();
+ } catch {
+ setFailure({ reason: 'failed' });
+ } finally {
+ setIsBusy(false);
+ }
+ };
+
+ const handleFile = (file: File) =>
+ run(async () => {
+ const read = readPlaceImport(decodeCsv(await file.arrayBuffer()));
+ if ('error' in read) {
+ setFailure(
+ read.error === 'columns'
+ ? { reason: 'columns', missing: read.missing }
+ : { reason: read.error },
+ );
+ return;
+ }
+ const lines = await send(read.rows, true);
+ setState({ step: 'preview', fileName: file.name, rows: read.rows, lines });
+ });
+
+ const handleConfirm = () =>
+ run(async () => {
+ if (state.step !== 'preview') {
+ return;
+ }
+ const isReady = (line: number) =>
+ state.lines.some((other) => other.line === line && other.outcome === 'ready');
+ const created = await send(
+ state.rows.filter((row) => isReady(row.line)),
+ false,
+ );
+ const lines = state.lines.map(
+ (line) => created.find((other) => other.line === line.line) ?? line,
+ );
+ setState({ ...state, step: 'done', lines });
+ });
+
+ return {
+ state,
+ failure,
+ isBusy,
+ handleFile: (file: File) => void handleFile(file),
+ handleConfirm: () => void handleConfirm(),
+ handleReset: () => {
+ setFailure(null);
+ setState({ step: 'pick' });
+ },
+ };
+}
diff --git a/apps/admin/src/hooks/useRouteAction.ts b/apps/admin/src/hooks/useRouteAction.ts
new file mode 100644
index 00000000..cee6bf83
--- /dev/null
+++ b/apps/admin/src/hooks/useRouteAction.ts
@@ -0,0 +1,42 @@
+import { messages } from '@/messages/fr';
+import { ApiRequestError } from '@widoo/api-client';
+import { AlreadyDecidedDetails, labels } from '@widoo/shared';
+import { showToast } from '@widoo/ui';
+import { useState } from 'react';
+
+const texts = messages.moderation.decisions.toasts;
+
+/**
+ * Sends an action of the team on a route and tells its result in a toast. `onSettled` is called
+ * once the route is settled, by this action or by the one another member of the team took first
+ * (409 `already_decided`, told with the status the route was given): the screen then moves on.
+ */
+export function useRouteAction(onSettled: () => void) {
+ const [isBusy, setIsBusy] = useState(false);
+
+ const send = async (action: () => Promise, successTitle: string) => {
+ setIsBusy(true);
+ try {
+ await action();
+ showToast('success', { title: successTitle });
+ onSettled();
+ } 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]),
+ });
+ onSettled();
+ } else {
+ showToast('error', { title: texts.failed });
+ }
+ } finally {
+ setIsBusy(false);
+ }
+ };
+
+ return { isBusy, send };
+}
diff --git a/apps/admin/src/lib/csv.test.ts b/apps/admin/src/lib/csv.test.ts
new file mode 100644
index 00000000..0a267598
--- /dev/null
+++ b/apps/admin/src/lib/csv.test.ts
@@ -0,0 +1,39 @@
+import { describe, expect, it } from 'vitest';
+import { decodeCsv, parseCsv } from './csv';
+
+describe('parseCsv', () => {
+ it('reads rows separated by semicolons, with the line of each', () => {
+ expect(parseCsv('nom;adresse\nCafé Lomi; 3 rue Marcadet \n\nLe Progrès;1 rue Yvonne')).toEqual([
+ { line: 1, cells: ['nom', 'adresse'] },
+ { line: 2, cells: ['Café Lomi', '3 rue Marcadet'] },
+ { line: 4, cells: ['Le Progrès', '1 rue Yvonne'] },
+ ]);
+ });
+
+ it('reads commas when the first line uses them, and Windows line ends', () => {
+ expect(parseCsv('nom,adresse\r\nLomi,Paris\r\n')).toEqual([
+ { line: 1, cells: ['nom', 'adresse'] },
+ { line: 2, cells: ['Lomi', 'Paris'] },
+ ]);
+ });
+
+ it('keeps separators, quotes and line breaks of a quoted cell', () => {
+ expect(parseCsv('nom;adresse\n"Chez ""Jo""";"3 rue; fond\nde cour"\nSuivant;Paris')).toEqual([
+ { line: 1, cells: ['nom', 'adresse'] },
+ { line: 2, cells: ['Chez "Jo"', '3 rue; fond\nde cour'] },
+ { line: 4, cells: ['Suivant', 'Paris'] },
+ ]);
+ });
+});
+
+describe('decodeCsv', () => {
+ it('reads UTF-8 without its byte order mark', () => {
+ const bytes = new TextEncoder().encode('\uFEFFCafé');
+ expect(decodeCsv(bytes.buffer as ArrayBuffer)).toBe('Café');
+ });
+
+ it('falls back to the encoding of a spreadsheet on Windows', () => {
+ const bytes = new Uint8Array([0x43, 0x61, 0x66, 0xe9]);
+ expect(decodeCsv(bytes.buffer)).toBe('Café');
+ });
+});
diff --git a/apps/admin/src/lib/csv.ts b/apps/admin/src/lib/csv.ts
new file mode 100644
index 00000000..e4e98171
--- /dev/null
+++ b/apps/admin/src/lib/csv.ts
@@ -0,0 +1,75 @@
+/** A row of a CSV file: its cells, and the line of the file it starts on (1 for the first). */
+export type CsvRow = { line: number; cells: string[] };
+
+/**
+ * The text of a CSV file, whatever saved it: UTF-8, or the Windows-1252 a spreadsheet writes
+ * when UTF-8 is not asked for. Without the byte order mark.
+ */
+export function decodeCsv(bytes: ArrayBuffer): string {
+ let text: string;
+ try {
+ text = new TextDecoder('utf-8', { fatal: true }).decode(bytes);
+ } catch {
+ text = new TextDecoder('windows-1252').decode(bytes);
+ }
+ return text.replace(/^\uFEFF/, '');
+}
+
+/** The separator of a file, read on its first line: a spreadsheet in French writes `;`. */
+function delimiterOf(text: string): ',' | ';' {
+ const firstLine = text.split(/\r?\n/, 1)[0] ?? '';
+ return firstLine.split(';').length > firstLine.split(',').length ? ';' : ',';
+}
+
+/**
+ * Rows of a CSV text. Cells are trimmed; a cell in double quotes keeps its separators and line
+ * breaks, and `""` is a quote. Empty lines are skipped, and keep their number.
+ */
+export function parseCsv(text: string): CsvRow[] {
+ const delimiter = delimiterOf(text);
+ const rows: CsvRow[] = [];
+ let cells: string[] = [];
+ let cell = '';
+ let isQuoted = false;
+ let line = 1;
+ let rowLine = 1;
+
+ const endCell = () => {
+ cells.push(cell.trim());
+ cell = '';
+ };
+ const endRow = () => {
+ endCell();
+ if (cells.some((value) => value !== '')) {
+ rows.push({ line: rowLine, cells });
+ }
+ cells = [];
+ rowLine = line + 1;
+ };
+
+ for (let index = 0; index < text.length; index += 1) {
+ const character = text[index];
+ if (isQuoted) {
+ if (character === '"' && text[index + 1] === '"') {
+ cell += '"';
+ index += 1;
+ } else if (character === '"') {
+ isQuoted = false;
+ } else {
+ cell += character;
+ line += character === '\n' ? 1 : 0;
+ }
+ } else if (character === '"') {
+ isQuoted = true;
+ } else if (character === delimiter) {
+ endCell();
+ } else if (character === '\n') {
+ endRow();
+ line += 1;
+ } else if (character !== '\r') {
+ cell += character;
+ }
+ }
+ endRow();
+ return rows;
+}
diff --git a/apps/admin/src/lib/download.ts b/apps/admin/src/lib/download.ts
new file mode 100644
index 00000000..bebf84c5
--- /dev/null
+++ b/apps/admin/src/lib/download.ts
@@ -0,0 +1,9 @@
+/** Hands a text to the browser as a file to save. */
+export function downloadTextFile(fileName: string, text: string, type: string): void {
+ const address = URL.createObjectURL(new Blob([text], { type }));
+ const link = document.createElement('a');
+ link.href = address;
+ link.download = fileName;
+ link.click();
+ URL.revokeObjectURL(address);
+}
diff --git a/apps/admin/src/lib/numbers.test.ts b/apps/admin/src/lib/numbers.test.ts
new file mode 100644
index 00000000..7c340749
--- /dev/null
+++ b/apps/admin/src/lib/numbers.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from 'vitest';
+import { formatCount } from './numbers';
+
+describe('formatCount', () => {
+ it('groups the thousands as French does', () => {
+ // Narrow no-break space (U+202F) between the groups.
+ expect(formatCount(2140)).toBe('2\u202f140');
+ expect(formatCount(86)).toBe('86');
+ });
+});
diff --git a/apps/admin/src/lib/numbers.ts b/apps/admin/src/lib/numbers.ts
new file mode 100644
index 00000000..0868914a
--- /dev/null
+++ b/apps/admin/src/lib/numbers.ts
@@ -0,0 +1,6 @@
+const frenchNumber = new Intl.NumberFormat('fr-FR');
+
+/** A count as the admin writes it, with the French thousands separator: « 2 140 ». */
+export function formatCount(value: number): string {
+ return frenchNumber.format(value);
+}
diff --git a/apps/admin/src/lib/place-form.test.ts b/apps/admin/src/lib/place-form.test.ts
new file mode 100644
index 00000000..cf87d2e3
--- /dev/null
+++ b/apps/admin/src/lib/place-form.test.ts
@@ -0,0 +1,77 @@
+import { describe, expect, it } from 'vitest';
+import { emptyPlaceForm, placeFormOf, placeInputOf, type PlaceFormValues } from './place-form';
+
+const filled: PlaceFormValues = {
+ ...emptyPlaceForm,
+ name: ' Café Lomi ',
+ category: 'cafe',
+ address: '3 ter rue Marcadet, 75018 Paris',
+ latitude: '48,8905',
+ longitude: '2.3558',
+ dayHours: ['8:00-18:00', '', '', '', '', '', ''],
+};
+
+describe('placeInputOf', () => {
+ it('turns the form into the sheet the API expects', () => {
+ expect(placeInputOf(filled, [])).toEqual({
+ input: {
+ name: 'Café Lomi',
+ category: 'cafe',
+ address: '3 ter rue Marcadet, 75018 Paris',
+ location: { lat: 48.8905, lng: 2.3558 },
+ isIndoor: false,
+ priceRange: null,
+ websiteUrl: null,
+ phone: null,
+ hours: [{ weekday: 0, opens: '08:00', closes: '18:00', validFrom: null, validTo: null }],
+ },
+ });
+ });
+
+ it.each([
+ ['an empty latitude', { latitude: '' }],
+ ['a latitude out of range', { latitude: '120' }],
+ ['a longitude that is not a number', { longitude: 'est' }],
+ ])('refuses %s', (_, changes) => {
+ expect(placeInputOf({ ...filled, ...changes }, [])).toEqual({ error: 'position' });
+ });
+
+ it('refuses hours it cannot read', () => {
+ const values = { ...filled, dayHours: ['le matin', '', '', '', '', '', ''] };
+ expect(placeInputOf(values, [])).toEqual({ error: 'hours' });
+ });
+});
+
+describe('placeFormOf', () => {
+ it('fills the form with an existing place, and gives it back unchanged', () => {
+ const place = {
+ id: '0199d0a2-7c1e-7000-8000-000000000001',
+ name: 'Café Lomi',
+ category: 'cafe' as const,
+ cityName: 'Paris',
+ verificationStatus: 'verified' as const,
+ verifiedAt: null,
+ routeCount: 1,
+ address: '3 ter rue Marcadet, 75018 Paris',
+ location: { lat: 48.8905, lng: 2.3558 },
+ isIndoor: true,
+ priceRange: 'low' as const,
+ websiteUrl: 'https://lomi.example',
+ phone: '+33 1 00 00 00 00',
+ hours: [{ weekday: 2, opens: '09:00', closes: '17:00', validFrom: null, validTo: null }],
+ routes: [],
+ };
+ const result = placeInputOf(placeFormOf(place), place.hours);
+ expect(result).toMatchObject({
+ input: {
+ name: place.name,
+ location: place.location,
+ isIndoor: true,
+ priceRange: 'low',
+ websiteUrl: place.websiteUrl,
+ phone: place.phone,
+ hours: place.hours,
+ },
+ });
+ });
+});
diff --git a/apps/admin/src/lib/place-form.ts b/apps/admin/src/lib/place-form.ts
new file mode 100644
index 00000000..06588ed7
--- /dev/null
+++ b/apps/admin/src/lib/place-form.ts
@@ -0,0 +1,87 @@
+import { dayTextsToHours, hoursToDayTexts } from '@/lib/place-hours';
+import type { CatalogPlaceDetail, CatalogPlaceInput, PlaceHours } from '@widoo/shared';
+
+/** The sheet of a place as the form holds it: everything is text until it is saved. */
+export type PlaceFormValues = {
+ name: string;
+ category: CatalogPlaceInput['category'];
+ address: string;
+ latitude: string;
+ longitude: string;
+ isIndoor: boolean;
+ /** Empty for a price level that is not given. */
+ priceRange: string;
+ websiteUrl: string;
+ phone: string;
+ /** One line per day, Monday first. */
+ dayHours: string[];
+};
+
+export type PlaceFormError = 'position' | 'hours';
+
+/** The form of a new place. */
+export const emptyPlaceForm: PlaceFormValues = {
+ name: '',
+ category: 'restaurant',
+ address: '',
+ latitude: '',
+ longitude: '',
+ isIndoor: false,
+ priceRange: '',
+ websiteUrl: '',
+ phone: '',
+ dayHours: ['', '', '', '', '', '', ''],
+};
+
+/** The form filled with the sheet of an existing place. */
+export function placeFormOf(place: CatalogPlaceDetail): PlaceFormValues {
+ return {
+ name: place.name,
+ category: place.category,
+ address: place.address,
+ latitude: String(place.location.lat),
+ longitude: String(place.location.lng),
+ isIndoor: place.isIndoor,
+ priceRange: place.priceRange ?? '',
+ websiteUrl: place.websiteUrl ?? '',
+ phone: place.phone ?? '',
+ dayHours: hoursToDayTexts(place.hours),
+ };
+}
+
+/** A coordinate typed with a comma or a point; NaN for anything else. */
+const coordinateOf = (text: string) =>
+ /^-?\d+([.,]\d+)?$/.test(text.trim()) ? Number(text.trim().replace(',', '.')) : Number.NaN;
+
+/**
+ * What to send to the API for a form, or which part of it cannot be read. The API checks the
+ * rest (texts, link, phone): only what the form itself turns into another shape is checked here.
+ * `previousHours` gives back the seasonal slots, which the form does not show.
+ */
+export function placeInputOf(
+ values: PlaceFormValues,
+ previousHours: readonly PlaceHours[],
+): { input: CatalogPlaceInput } | { error: PlaceFormError } {
+ const lat = coordinateOf(values.latitude);
+ const lng = coordinateOf(values.longitude);
+ if (!(Math.abs(lat) <= 90) || !(Math.abs(lng) <= 180)) {
+ return { error: 'position' };
+ }
+ const hours = dayTextsToHours(values.dayHours, previousHours);
+ if (!hours) {
+ return { error: 'hours' };
+ }
+ return {
+ input: {
+ name: values.name.trim(),
+ category: values.category,
+ address: values.address.trim(),
+ location: { lat, lng },
+ isIndoor: values.isIndoor,
+ priceRange: (values.priceRange || null) as CatalogPlaceInput['priceRange'],
+ websiteUrl: values.websiteUrl.trim() || null,
+ phone: values.phone.trim() || null,
+ hours,
+ },
+ };
+}
diff --git a/apps/admin/src/lib/place-hours.test.ts b/apps/admin/src/lib/place-hours.test.ts
new file mode 100644
index 00000000..e4f938c8
--- /dev/null
+++ b/apps/admin/src/lib/place-hours.test.ts
@@ -0,0 +1,65 @@
+import { describe, expect, it } from 'vitest';
+import { dayTextsToHours, hoursToDayTexts, parseDayHours } from './place-hours';
+
+const slot = (weekday: number, opens: string, closes: string) => ({
+ weekday,
+ opens,
+ closes,
+ validFrom: null,
+ validTo: null,
+});
+
+describe('parseDayHours', () => {
+ it.each([
+ ['9:00-18:00', [{ opens: '09:00', closes: '18:00' }]],
+ [
+ '09h00 – 12h30, 14:00-18:00',
+ [
+ { opens: '09:00', closes: '12:30' },
+ { opens: '14:00', closes: '18:00' },
+ ],
+ ],
+ ['19:00-01:00', [{ opens: '19:00', closes: '01:00' }]],
+ ['', []],
+ [' ', []],
+ ])('reads « %s »', (text, expected) => {
+ expect(parseDayHours(text)).toEqual(expected);
+ });
+
+ it.each(['9-18', 'matin', '25:00-26:00', '9:00-18:00;19:00-20:00'])('refuses « %s »', (text) => {
+ expect(parseDayHours(text)).toBeUndefined();
+ });
+});
+
+describe('hours of the form', () => {
+ const hours = [slot(0, '09:00', '12:30'), slot(0, '14:00', '18:00'), slot(5, '19:00', '01:00')];
+
+ it('writes one line per day, Monday first', () => {
+ expect(hoursToDayTexts(hours)).toEqual([
+ '09:00-12:30, 14:00-18:00',
+ '',
+ '',
+ '',
+ '',
+ '19:00-01:00',
+ '',
+ ]);
+ });
+
+ it('gives back the hours it was written from', () => {
+ expect(dayTextsToHours(hoursToDayTexts(hours), hours)).toEqual(hours);
+ });
+
+ it('keeps the seasonal slots, which the form does not show', () => {
+ const summer = { ...slot(6, '10:00', '20:00'), validFrom: '2026-07-01', validTo: '2026-08-31' };
+ expect(hoursToDayTexts([summer])).toEqual(['', '', '', '', '', '', '']);
+ expect(dayTextsToHours(['9:00-10:00', '', '', '', '', '', ''], [summer])).toEqual([
+ slot(0, '09:00', '10:00'),
+ summer,
+ ]);
+ });
+
+ it('gives nothing when a line cannot be read', () => {
+ expect(dayTextsToHours(['matin', '', '', '', '', '', ''], [])).toBeUndefined();
+ });
+});
diff --git a/apps/admin/src/lib/place-hours.ts b/apps/admin/src/lib/place-hours.ts
new file mode 100644
index 00000000..e5615283
--- /dev/null
+++ b/apps/admin/src/lib/place-hours.ts
@@ -0,0 +1,62 @@
+import type { PlaceHours } from '@widoo/shared';
+
+/** The seven days of a week, Monday first, as `place_hours.weekday` numbers them. */
+export const weekdays = [0, 1, 2, 3, 4, 5, 6] as const;
+
+const slotPattern = /^([01]?\d|2[0-3])[:h]([0-5]\d)\s*[-–]\s*([01]?\d|2[0-3])[:h]([0-5]\d)$/;
+const twoDigits = (value: string) => value.padStart(2, '0');
+
+/**
+ * The opening hours of a day as the team types them: « 9:00-12:30, 14:00-18:00 », nothing for a
+ * closed day. Undefined when a slot cannot be read.
+ */
+export function parseDayHours(text: string): { opens: string; closes: string }[] | undefined {
+ const slots: { opens: string; closes: string }[] = [];
+ for (const part of text.split(',').map((slot) => slot.trim())) {
+ if (part === '') {
+ continue;
+ }
+ const match = slotPattern.exec(part);
+ if (!match) {
+ return undefined;
+ }
+ const [, openHour = '', openMinute = '', closeHour = '', closeMinute = ''] = match;
+ slots.push({
+ opens: `${twoDigits(openHour)}:${openMinute}`,
+ closes: `${twoDigits(closeHour)}:${closeMinute}`,
+ });
+ }
+ return slots;
+}
+
+/** The year-round hours of a place, one line of text per day, Monday first. */
+export function hoursToDayTexts(hours: readonly PlaceHours[]): string[] {
+ return weekdays.map((weekday) =>
+ hours
+ .filter(
+ (slot) => slot.weekday === weekday && slot.validFrom === null && slot.validTo === null,
+ )
+ .map((slot) => `${slot.opens}-${slot.closes}`)
+ .join(', '),
+ );
+}
+
+/**
+ * The hours to save from the seven lines of the form. Seasonal slots are not shown in the form:
+ * they are kept as they were. Undefined when a line cannot be read.
+ */
+export function dayTextsToHours(
+ dayTexts: readonly string[],
+ previous: readonly PlaceHours[],
+): PlaceHours[] | undefined {
+ const seasonal = previous.filter((slot) => slot.validFrom !== null || slot.validTo !== null);
+ const yearRound: PlaceHours[] = [];
+ for (const weekday of weekdays) {
+ const slots = parseDayHours(dayTexts[weekday] ?? '');
+ if (!slots) {
+ return undefined;
+ }
+ yearRound.push(...slots.map((slot) => ({ weekday, ...slot, validFrom: null, validTo: null })));
+ }
+ return [...yearRound, ...seasonal];
+}
diff --git a/apps/admin/src/lib/place-import.test.ts b/apps/admin/src/lib/place-import.test.ts
new file mode 100644
index 00000000..7a3fda9d
--- /dev/null
+++ b/apps/admin/src/lib/place-import.test.ts
@@ -0,0 +1,81 @@
+import { describe, expect, it } from 'vitest';
+import {
+ placeImportLinesOf,
+ placeImportTemplate,
+ readPlaceImport,
+ type PlaceImportRow,
+} from './place-import';
+
+const header = 'nom;categorie;adresse;latitude;longitude;interieur;prix;lundi';
+
+describe('readPlaceImport', () => {
+ it('reads the template as one place ready to send', () => {
+ const result = readPlaceImport(placeImportTemplate.replace('\uFEFF', ''));
+
+ expect(result).toMatchObject({
+ rows: [
+ {
+ line: 2,
+ name: 'Café Exemple',
+ unreadFields: [],
+ place: {
+ category: 'cafe',
+ location: { lat: 48.8785, lng: 2.3397 },
+ isIndoor: true,
+ priceRange: 'low',
+ websiteUrl: 'https://cafe.example',
+ },
+ },
+ ],
+ });
+ expect('rows' in result && result.rows[0]?.place?.hours).toHaveLength(7);
+ });
+
+ it('names what cannot be read on a line, and leaves the rest to the API', () => {
+ const result = readPlaceImport(
+ [
+ header,
+ 'Sans position;Café;1 rue A;;2,3;;;',
+ 'Horaire faux;Café;1 rue A;48,8;2,3;peut-être;;matin',
+ 'Catégorie inconnue;Astroport;1 rue A;48,8;2,3;non;;',
+ ].join('\n'),
+ );
+
+ const rows = 'rows' in result ? result.rows : [];
+ expect(rows.map((row) => row.unreadFields)).toEqual([['location'], ['isIndoor', 'hours'], []]);
+ expect(rows.map((row) => row.place === null)).toEqual([true, true, false]);
+ expect(rows[2]?.place?.category).toBe('Astroport');
+ });
+
+ it('refuses a file without line or without a required column', () => {
+ expect(readPlaceImport(header)).toEqual({ error: 'empty' });
+ expect(readPlaceImport('nom;adresse\nLomi;Paris')).toEqual({
+ error: 'columns',
+ missing: ['categorie', 'latitude', 'longitude'],
+ });
+ });
+
+ it('refuses more lines than an import takes', () => {
+ const lines = Array.from({ length: 501 }, (_, index) => `Lieu ${index};Café;A;48,8;2,3;;;`);
+ expect(readPlaceImport([header, ...lines].join('\n'))).toEqual({ error: 'tooMany' });
+ });
+});
+
+describe('placeImportLinesOf', () => {
+ it('joins the answer of the API to the lines that were not sent', () => {
+ const rows: PlaceImportRow[] = [
+ { line: 2, name: 'Lomi', place: null, unreadFields: ['hours'] },
+ { line: 3, name: 'Progrès', place: null, unreadFields: [] },
+ ];
+
+ const lines = placeImportLinesOf(rows, {
+ rows: [{ line: 3, outcome: 'ready', fields: [], duplicateOf: null }],
+ counts: { ready: 1, created: 0, invalid: 0, duplicate: 0, outside: 0 },
+ });
+
+ expect(lines).toEqual([
+ { line: 2, name: 'Lomi', outcome: 'invalid', fields: ['hours'], duplicateOf: null },
+ { line: 3, name: 'Progrès', outcome: 'ready', fields: [], duplicateOf: null },
+ ]);
+ });
+});
diff --git a/apps/admin/src/lib/place-import.ts b/apps/admin/src/lib/place-import.ts
new file mode 100644
index 00000000..15ee85d9
--- /dev/null
+++ b/apps/admin/src/lib/place-import.ts
@@ -0,0 +1,151 @@
+import { parseCsv } from '@/lib/csv';
+import { emptyPlaceForm, placeInputOf, type PlaceFormValues } from '@/lib/place-form';
+import {
+ labels,
+ placeImportMaxRows,
+ taxonomies,
+ type CatalogPlaceInput,
+ type PlaceImportOutcome,
+ type PlaceImportResult,
+ type PlaceImportRowResult,
+} from '@widoo/shared';
+
+const dayColumns = ['lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche'];
+const requiredColumns = ['nom', 'categorie', 'adresse', 'latitude', 'longitude'];
+/** Columns of the file of places, in the order of the template. */
+export const placeImportColumns = [
+ ...requiredColumns,
+ 'interieur',
+ 'prix',
+ 'site_web',
+ 'telephone',
+ ...dayColumns,
+];
+
+const exampleRow = [
+ 'Café Exemple',
+ labels.fr.placeCategories.cafe,
+ '12 rue des Martyrs, 75009 Paris',
+ '48,8785',
+ '2,3397',
+ 'oui',
+ labels.fr.priceRanges.low,
+ 'https://cafe.example',
+ '+33 1 42 00 00 00',
+ '8:00-18:00',
+ '8:00-18:00',
+ '8:00-18:00',
+ '8:00-18:00',
+ '8:00-18:00',
+ '9:00-12:30, 14:00-19:00',
+ '',
+];
+/** The template to download: the columns, and a line to replace. Opens in a spreadsheet. */
+export const placeImportTemplate = `\uFEFF${placeImportColumns.join(';')}\n${exampleRow.join(';')}\n`;
+
+/** A line of the file: the place to send, or the fields that cannot be read here. */
+export type PlaceImportRow = {
+ line: number;
+ name: string;
+ place: CatalogPlaceInput | null;
+ /** Fields refused before the API is asked, named as `CatalogPlaceInput` names them. */
+ unreadFields: string[];
+};
+
+export type PlaceImportFileError =
+ { error: 'empty' | 'tooMany' } | { error: 'columns'; missing: string[] };
+
+const normalized = (text: string) => text.trim().toLocaleLowerCase('fr');
+
+/** The key a cell names by its French label or by the key itself; the cell as typed otherwise. */
+function keyOf(cell: string, keys: readonly Key[], names: Record) {
+ return keys.find((key) => normalized(names[key]) === normalized(cell) || key === cell) ?? cell;
+}
+
+const yes = ['oui', 'o', 'yes', 'true', '1'];
+const no = ['', 'non', 'n', 'no', 'false', '0'];
+
+function rowOf(line: number, cellOf: (column: string) => string): PlaceImportRow {
+ const indoor = normalized(cellOf('interieur'));
+ const values: PlaceFormValues = {
+ ...emptyPlaceForm,
+ name: cellOf('nom'),
+ category: keyOf(
+ cellOf('categorie'),
+ taxonomies.placeCategories,
+ labels.fr.placeCategories,
+ ) as PlaceFormValues['category'],
+ address: cellOf('adresse'),
+ latitude: cellOf('latitude'),
+ longitude: cellOf('longitude'),
+ isIndoor: yes.includes(indoor),
+ priceRange: keyOf(cellOf('prix'), taxonomies.priceRanges, labels.fr.priceRanges),
+ websiteUrl: cellOf('site_web'),
+ phone: cellOf('telephone'),
+ dayHours: dayColumns.map(cellOf),
+ };
+ const unreadFields = yes.includes(indoor) || no.includes(indoor) ? [] : ['isIndoor'];
+ const result = placeInputOf(values, []);
+ if ('error' in result) {
+ unreadFields.push(result.error === 'position' ? 'location' : 'hours');
+ }
+ const place = 'input' in result && unreadFields.length === 0 ? result.input : null;
+ return { line, name: values.name, place, unreadFields };
+}
+
+/**
+ * The places of a CSV text, one per line under the header, or why the file cannot be used: no
+ * line, a required column missing, or more lines than an import takes.
+ */
+export function readPlaceImport(text: string): { rows: PlaceImportRow[] } | PlaceImportFileError {
+ const [header, ...lines] = parseCsv(text);
+ if (!header || lines.length === 0) {
+ return { error: 'empty' };
+ }
+ const columns = header.cells.map(normalized);
+ const missing = requiredColumns.filter((column) => !columns.includes(column));
+ if (missing.length > 0) {
+ return { error: 'columns', missing };
+ }
+ if (lines.length > placeImportMaxRows) {
+ return { error: 'tooMany' };
+ }
+ return {
+ rows: lines.map(({ line, cells }) =>
+ rowOf(line, (column) => cells[columns.indexOf(column)] ?? ''),
+ ),
+ };
+}
+
+/** A line of the file with what became of it, as the table shows it. */
+export type PlaceImportLine = PlaceImportRowResult & { name: string };
+
+/**
+ * Every line of the file with its outcome: the answer of the API for the lines sent, and
+ * `invalid` for those that could not be read and were not sent.
+ */
+export function placeImportLinesOf(
+ rows: readonly PlaceImportRow[],
+ result: PlaceImportResult,
+): PlaceImportLine[] {
+ return rows.map((row) => {
+ const answered = result.rows.find((answer) => answer.line === row.line);
+ return (
+ answered
+ ? { ...answered, name: row.name }
+ : {
+ line: row.line,
+ name: row.name,
+ outcome: 'invalid',
+ fields: row.unreadFields,
+ duplicateOf: null,
+ }
+ ) satisfies PlaceImportLine;
+ });
+}
+
+/** How many lines ended with `outcome`. */
+export const countPlaceImportLines = (
+ lines: readonly PlaceImportLine[],
+ outcome: PlaceImportOutcome,
+) => lines.filter((line) => line.outcome === outcome).length;
diff --git a/apps/admin/src/lib/rejection-reason.ts b/apps/admin/src/lib/rejection-reason.ts
new file mode 100644
index 00000000..ad489385
--- /dev/null
+++ b/apps/admin/src/lib/rejection-reason.ts
@@ -0,0 +1,5 @@
+import { taxonomies, type RejectionReason } from '@widoo/shared';
+
+/** True for one of the codified reasons: a select can also hold its empty first choice. */
+export const isRejectionReason = (code: string): code is RejectionReason =>
+ taxonomies.rejectionReasons.some((reason) => reason === code);
diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts
index 97dd903f..ea727ca6 100644
--- a/apps/admin/src/messages/fr.ts
+++ b/apps/admin/src/messages/fr.ts
@@ -1,4 +1,4 @@
-import type { AdminSection, CountedAdminSection, TeamRole } from '@widoo/shared';
+import type { AdminSection, CountedAdminSection, RouteStatus, TeamRole } from '@widoo/shared';
import type { NavigationGroupId } from '@/config/navigation';
const sections: Record = {
@@ -62,12 +62,25 @@ const counted: Record string> = {
translations: (count) => (count > 1 ? `${count} traductions en échec` : '1 traduction en échec'),
};
+/** Status of a route as the team reads it, shorter than the labels of the app. */
+const routeStatuses: Record = {
+ draft: 'Brouillon',
+ private: 'Privé',
+ pending: 'En relecture',
+ changes_requested: 'À corriger',
+ published: 'Publié',
+ needs_fix: 'À corriger',
+ rejected: 'Refusé',
+ unpublished: 'Retiré',
+};
+
/** Texts of the admin. French only (wiki Admin-Web › Principes). */
export const messages = {
app: { name: 'Widoo', product: 'Admin', title: 'Admin Widoo' },
sections,
pageTitles,
roles,
+ routeStatuses,
shell: {
navigation: 'Navigation principale',
groups: navigationGroups,
@@ -157,6 +170,241 @@ export const messages = {
error: "La liste n'a pas pu être chargée.",
retry: 'Réessayer',
},
+ dashboard: {
+ error: "Le tableau de bord n'a pas pu être chargé.",
+ updated: (when: string) => `Mis à jour ${when}`,
+ refresh: 'Actualiser',
+ toHandle: {
+ title: 'À traiter',
+ nothing: 'Rien à traiter pour le moment.',
+ oldest: (time: string) => `Le plus ancien attend depuis ${time}`,
+ tiles: {
+ moderation: { label: 'Parcours à relire', none: 'Aucun parcours en attente' },
+ reports: { label: 'Signalements ouverts', none: 'Aucun signalement ouvert' },
+ support: { label: 'Messages de support', none: 'Aucun message à traiter' },
+ aiDrafts: { label: 'Brouillons IA à relire', none: 'Aucun brouillon à relire' },
+ translations: { label: 'Traductions en échec', none: 'Aucune traduction en échec' },
+ },
+ },
+ catalog: {
+ title: 'Catalogue',
+ link: 'Analyses dans PostHog',
+ publishedRoutes: 'Parcours publiés',
+ publishedDetail: (premium: number, official: number) =>
+ `dont ${premium} Premium et ${official} de Widoo`,
+ places: 'Lieux',
+ placesDetail: 'utilisés par les parcours',
+ cities: 'Villes',
+ citiesDetail: 'où des parcours existent',
+ creators: 'Créateurs actifs',
+ last30Days: 'sur les 30 derniers jours',
+ },
+ users: {
+ title: 'Utilisateurs',
+ link: 'Abonnements dans RevenueCat',
+ registered: 'Inscrits',
+ registeredDetail: 'depuis le lancement',
+ newUsers: 'Nouveaux',
+ newUsersDetail: 'sur les 7 derniers jours',
+ premium: 'Premium actifs',
+ premiumDetail: 'abonnements et pass',
+ unlocks: 'Déblocages',
+ },
+ },
+ catalog: {
+ tabs: { label: 'Catalogue', places: 'Lieux', routes: 'Parcours' },
+ places: {
+ search: 'Rechercher un nom ou une adresse',
+ total: (count: number) => (count > 1 ? `${count} lieux` : `${count} lieu`),
+ create: 'Créer un lieu',
+ filters: { label: 'Filtres des lieux', category: 'Catégorie', status: 'Statut' },
+ columns: {
+ place: 'Lieu',
+ category: 'Catégorie',
+ city: 'Ville',
+ status: 'Statut',
+ verified: 'Dernière vérification',
+ routes: 'Parcours',
+ },
+ neverVerified: 'Jamais',
+ empty: { title: 'Aucun lieu', text: 'Aucun lieu ne correspond à cette recherche.' },
+ panel: {
+ error: "La fiche du lieu n'a pas pu être chargée.",
+ edit: 'Modifier',
+ address: 'Adresse',
+ position: 'Position',
+ indoor: 'En intérieur',
+ yes: 'Oui',
+ no: 'Non',
+ price: 'Niveau de prix',
+ website: 'Site web',
+ phone: 'Téléphone',
+ none: 'Non renseigné',
+ hours: 'Horaires',
+ hoursUnknown: 'Horaires inconnus',
+ closed: 'Fermé',
+ routes: (count: number) =>
+ count > 1
+ ? `${count} parcours passent par ce lieu`
+ : `${count} parcours passe par ce lieu`,
+ noRoute: 'Aucun parcours ne passe par ce lieu.',
+ },
+ weekdays: ['Lundi', 'Mardi', 'Mercredi', 'Jeudi', 'Vendredi', 'Samedi', 'Dimanche'],
+ form: {
+ back: 'Lieux du catalogue',
+ createTitle: 'Créer un lieu',
+ name: 'Nom',
+ category: 'Catégorie',
+ address: 'Adresse',
+ addressHint: 'Adresse complète, avec le code postal et la ville.',
+ latitude: 'Latitude',
+ longitude: 'Longitude',
+ positionHint:
+ 'En degrés décimaux, par exemple 48,8785 et 2,3397. La ville du lieu est déduite de sa position.',
+ indoor: 'Lieu en intérieur',
+ indoorHint: "Un lieu à l'abri compte pour les parcours « par temps de pluie ».",
+ price: 'Niveau de prix',
+ noPrice: 'Non renseigné',
+ website: 'Site web',
+ websiteHint: 'Une adresse en https.',
+ phone: 'Téléphone',
+ hours: 'Horaires',
+ hoursHint:
+ 'Par jour : « 9:00-12:30, 14:00-18:00 ». Laissez vide un jour de fermeture. Tout vide : horaires inconnus.',
+ save: 'Enregistrer',
+ cancel: 'Annuler',
+ errors: {
+ position: 'Saisissez une latitude et une longitude valides.',
+ hours: 'Un horaire ne peut pas être lu. Écrivez par exemple « 9:00-18:00 ».',
+ outside: "Cette position n'est dans aucune ville ouverte.",
+ refused: 'Un champ est refusé. Vérifiez le nom, le site web (https) et le téléphone.',
+ failed: "Le lieu n'a pas pu être enregistré.",
+ },
+ created: 'Lieu créé',
+ saved: 'Lieu enregistré',
+ },
+ import: {
+ open: 'Importer un CSV',
+ title: 'Importer des lieux',
+ rules: [
+ 'Un lieu par ligne, sous une première ligne qui nomme les colonnes.',
+ 'Colonnes obligatoires : nom, categorie, adresse, latitude, longitude.',
+ 'Colonnes facultatives : interieur (oui ou non), prix, site_web, telephone, et les horaires de lundi à dimanche.',
+ "Rien n'est créé avant votre validation : chaque ligne est d'abord vérifiée et affichée.",
+ '500 lignes au plus par fichier.',
+ ],
+ choose: 'Choisir un fichier CSV',
+ reading: 'Lecture du fichier…',
+ template: 'Télécharger le modèle',
+ templateFileName: 'modele-lieux.csv',
+ another: 'Choisir un autre fichier',
+ backToList: 'Voir les lieux',
+ creating: 'Création…',
+ confirm: (count: number) => (count > 1 ? `Créer ${count} lieux` : `Créer ${count} lieu`),
+ setAside: (count: number) =>
+ count > 1 ? `${count} lignes écartées` : `${count} ligne écartée`,
+ summary: {
+ preview: (count: number) =>
+ count > 1 ? `${count} lieux prêts à être créés` : `${count} lieu prêt à être créé`,
+ done: (count: number) => (count > 1 ? `${count} lieux créés` : `${count} lieu créé`),
+ },
+ columns: { line: 'Ligne', place: 'Lieu', outcome: 'Résultat', explanation: 'Détail' },
+ noName: 'Sans nom',
+ outcomes: {
+ ready: 'Prêt',
+ created: 'Créé',
+ invalid: 'Invalide',
+ duplicate: 'Doublon',
+ outside: 'Hors zone',
+ },
+ fields: {
+ name: 'Nom',
+ category: 'Catégorie',
+ address: 'Adresse',
+ location: 'Latitude et longitude',
+ isIndoor: 'Intérieur (oui ou non)',
+ priceRange: 'Prix',
+ websiteUrl: 'Site web (https)',
+ phone: 'Téléphone',
+ hours: 'Horaires',
+ },
+ otherField: 'Ligne illisible',
+ explanations: {
+ ready: 'Sera créé, avec le statut « À vérifier ».',
+ created: 'Créé, avec le statut « À vérifier ».',
+ invalid: (fields: string) => `À corriger : ${fields}.`,
+ duplicateInCatalog: (name: string) =>
+ `« ${name} » existe déjà dans le catalogue, à moins de 50 m.`,
+ duplicateInFile: (line: number) => `Même lieu que la ligne ${line}, à moins de 50 m.`,
+ outside: "Cette position n'est dans aucune ville ouverte.",
+ },
+ errors: {
+ empty: 'Ce fichier ne contient aucune ligne de lieu.',
+ tooMany: 'Ce fichier dépasse 500 lignes. Découpez-le en plusieurs fichiers.',
+ columns: (missing: string) => `Colonnes manquantes dans le fichier : ${missing}.`,
+ failed: "L'import n'a pas abouti. Réessayez.",
+ },
+ },
+ },
+ routes: {
+ search: 'Rechercher un titre ou un auteur',
+ total: (count: number) => `${count} parcours`,
+ filters: {
+ label: 'Filtres des parcours',
+ status: 'Statut',
+ access: 'Accès',
+ },
+ columns: {
+ route: 'Parcours',
+ author: 'Auteur',
+ city: 'Ville',
+ status: 'Statut',
+ access: 'Accès',
+ rating: 'Note',
+ updated: 'Modifié',
+ },
+ team: 'Équipe Widoo',
+ access: { free: 'Gratuit', premium: 'Premium' },
+ rating: (average: number, count: number) =>
+ `${average.toLocaleString('fr-FR', { maximumFractionDigits: 1 })} (${count} avis)`,
+ noRating: 'Pas encore noté',
+ empty: { title: 'Aucun parcours', text: 'Aucun parcours ne correspond à cette recherche.' },
+ sheet: {
+ back: 'Parcours du catalogue',
+ actions: 'Actions',
+ unpublish: 'Dépublier',
+ republish: 'Republier',
+ reassign: "Réassigner à l'équipe Widoo",
+ alreadyTeam: "Ce parcours est déjà un parcours de l'équipe Widoo.",
+ noAction: "Aucune action n'est possible sur ce parcours dans son statut actuel.",
+ },
+ unpublishDialog: {
+ title: 'Dépublier ce parcours ?',
+ description:
+ "Le parcours n'est plus visible dans l'app. L'auteur garde son parcours et reçoit le motif et votre message.",
+ message: "Message à l'auteur",
+ confirm: 'Dépublier le parcours',
+ },
+ republishDialog: {
+ title: 'Republier ce parcours ?',
+ description: "Le parcours redevient visible par tous dans l'app.",
+ message: 'Pourquoi le remettre en ligne',
+ messageHint: "Gardé dans l'historique du parcours et dans le journal.",
+ confirm: 'Republier le parcours',
+ },
+ reassignDialog: {
+ title: "Réassigner ce parcours à l'équipe Widoo ?",
+ description:
+ "Le parcours devient « Par Widoo » et n'a plus d'auteur : l'équipe le maintient depuis l'éditeur de l'app. Cette action ne se défait pas depuis l'admin.",
+ confirm: "Réassigner à l'équipe",
+ },
+ toasts: {
+ unpublished: 'Parcours dépublié',
+ republished: 'Parcours republié',
+ reassigned: "Parcours réassigné à l'équipe Widoo",
+ },
+ },
+ },
moderation: {
search: 'Rechercher un titre ou un auteur',
total: (count: number) => `${count} parcours`,
@@ -196,16 +444,6 @@ export const messages = {
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',
@@ -326,6 +564,10 @@ export const messages = {
filteredText: 'Modifiez la recherche ou les filtres.',
},
},
+ accessDenied: {
+ title: "Vous n'avez pas accès à cette page",
+ text: 'Votre rôle ne permet pas cette action. Adressez-vous à un admin si vous en avez besoin.',
+ },
notFound: {
title: 'Page introuvable',
text: "Cette adresse n'existe pas dans l'admin.",
diff --git a/apps/api/src/admin/page-cursor.test.ts b/apps/api/src/admin/page-cursor.test.ts
new file mode 100644
index 00000000..3cd6bc85
--- /dev/null
+++ b/apps/api/src/admin/page-cursor.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { neighbourCursors, offsetOfCursor } from './page-cursor';
+
+describe('page cursors', () => {
+ it('has no neighbour for a list that fits in one page', () => {
+ expect(neighbourCursors(0, 4, 50)).toEqual({ previousCursor: null, nextCursor: null });
+ });
+
+ it('names the next page, which names the previous one back', () => {
+ const first = neighbourCursors(0, 120, 50);
+ expect(offsetOfCursor(first.nextCursor ?? undefined)).toBe(50);
+ const second = neighbourCursors(50, 120, 50);
+ expect(offsetOfCursor(second.previousCursor ?? undefined)).toBe(0);
+ expect(offsetOfCursor(second.nextCursor ?? undefined)).toBe(100);
+ expect(neighbourCursors(100, 120, 50).nextCursor).toBeNull();
+ });
+
+ it.each([undefined, '', 'not-a-cursor', Buffer.from('-5').toString('base64url')])(
+ 'reads %s as the first page',
+ (cursor) => {
+ expect(offsetOfCursor(cursor)).toBe(0);
+ },
+ );
+});
diff --git a/apps/api/src/admin/page-cursor.ts b/apps/api/src/admin/page-cursor.ts
new file mode 100644
index 00000000..f6898c14
--- /dev/null
+++ b/apps/api/src/admin/page-cursor.ts
@@ -0,0 +1,27 @@
+/**
+ * Pages of an admin list. The cursor of a page is the number of rows before it, kept opaque for
+ * the client: these lists are short and sorted by several columns, so an offset is enough, and a
+ * row that leaves the list meanwhile only shifts the next page by one.
+ */
+const encode = (offset: number) => Buffer.from(String(offset)).toString('base64url');
+
+/** Rows before the page a cursor names; 0 without a cursor, or for one that reads as nothing. */
+export function offsetOfCursor(cursor: string | undefined): number {
+ if (!cursor) {
+ return 0;
+ }
+ const offset = Number(Buffer.from(cursor, 'base64url').toString());
+ return Number.isSafeInteger(offset) && offset > 0 ? offset : 0;
+}
+
+/** Cursors of the pages before and after the one that starts at `offset`; null at either end. */
+export function neighbourCursors(
+ offset: number,
+ total: number,
+ pageSize: number,
+): { previousCursor: string | null; nextCursor: string | null } {
+ return {
+ previousCursor: offset > 0 ? encode(Math.max(0, offset - pageSize)) : null,
+ nextCursor: offset + pageSize < total ? encode(offset + pageSize) : null,
+ };
+}
diff --git a/apps/api/src/admin/stats.ts b/apps/api/src/admin/stats.ts
new file mode 100644
index 00000000..08ceff7f
--- /dev/null
+++ b/apps/api/src/admin/stats.ts
@@ -0,0 +1,108 @@
+import {
+ adminLateAfterHours,
+ canOpenAdminSection,
+ countedAdminSections,
+ type AdminStats,
+ type AdminToHandle,
+ type CountedAdminSection,
+ type UserRole,
+} from '@widoo/shared';
+import { sql } from 'drizzle-orm';
+import type { Db } from '../db/client';
+
+type StatsRow = {
+ pending: number;
+ pending_late: number;
+ pending_oldest: Date | null;
+ published_routes: number;
+ premium_routes: number;
+ official_routes: number;
+ places_in_routes: number;
+ cities: number;
+ active_creators: number;
+ registered: number;
+ new_users: number;
+ premium_active: number;
+};
+
+const day = 24 * 60 * 60 * 1000;
+const ago = (now: Date, ms: number) => new Date(now.getTime() - ms).toISOString();
+
+/**
+ * The figures of the dashboard (A-05), read in one query. « À traiter » follows the role, as the
+ * counters of the navigation do. Deleted accounts
+ * are left out of every figure about users.
+ */
+export async function readAdminStats(
+ db: Db,
+ role: UserRole,
+ now = new Date(),
+): Promise {
+ const lateBefore = ago(now, adminLateAfterHours * 60 * 60 * 1000);
+ const [row] = await db.execute(sql`
+ select
+ (select count(*)::int from routes where status = 'pending') as pending,
+ (select count(*)::int from routes
+ where status = 'pending' and updated_at < ${lateBefore}::timestamptz) as pending_late,
+ (select min(updated_at) from routes where status = 'pending') as pending_oldest,
+ (select count(*)::int from routes where status = 'published') as published_routes,
+ (select count(*)::int from routes
+ where status = 'published' and access = 'premium') as premium_routes,
+ (select count(*)::int from routes where status = 'published' and is_official)
+ as official_routes,
+ (select count(distinct s.place_id)::int from steps s) as places_in_routes,
+ (select count(distinct city_id)::int from routes where status = 'published') as cities,
+ (select count(distinct author_id)::int from routes
+ where author_id is not null and updated_at >= ${ago(now, 30 * day)}::timestamptz)
+ as active_creators,
+ (select count(*)::int from users where deleted_at is null) as registered,
+ (select count(*)::int from users
+ where deleted_at is null and created_at >= ${ago(now, 7 * day)}::timestamptz) as new_users,
+ (select count(*)::int from users
+ where deleted_at is null and plan = 'premium'
+ and (plan_expires_at is null or plan_expires_at > ${now.toISOString()}::timestamptz))
+ as premium_active`);
+ if (!row) {
+ throw new Error('Dashboard figures not read');
+ }
+
+ // What waits in each section. Reports, support messages, AI drafts and failed translations
+ // have no table yet: nothing can wait there, and each one gets its query with its section.
+ const nothing: AdminToHandle = { count: 0, lateCount: 0, oldestSince: null };
+ const waiting: Record = {
+ moderation: {
+ count: row.pending,
+ lateCount: row.pending_late,
+ oldestSince: row.pending_oldest ? new Date(row.pending_oldest).toISOString() : null,
+ },
+ reports: nothing,
+ support: nothing,
+ aiDrafts: nothing,
+ translations: nothing,
+ };
+ const toHandle: AdminStats['toHandle'] = {};
+ for (const section of countedAdminSections) {
+ if (canOpenAdminSection(role, section)) {
+ toHandle[section] = waiting[section];
+ }
+ }
+ return {
+ toHandle,
+ catalog: {
+ publishedRoutes: row.published_routes,
+ premiumRoutes: row.premium_routes,
+ officialRoutes: row.official_routes,
+ placesInRoutes: row.places_in_routes,
+ cities: row.cities,
+ activeCreators: row.active_creators,
+ },
+ users: {
+ registered: row.registered,
+ newLast7Days: row.new_users,
+ premiumActive: row.premium_active,
+ // No unit unlock exists yet (`route_unlocks`): none happened.
+ unlocksLast30Days: 0,
+ },
+ generatedAt: now.toISOString(),
+ };
+}
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index b4851cb5..f65667b7 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 { adminCatalogRoutes } from './routes/admin-catalog';
import { adminModerationRoutes } from './routes/admin-moderation';
import { configRoutes } from './routes/config';
import { geocodeRoutes } from './routes/geocode';
@@ -113,6 +114,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) {
await app.register(meRoutes, { prefix: '/v1' });
await app.register(adminRoutes, { prefix: '/v1' });
await app.register(adminModerationRoutes, { prefix: '/v1' });
+ await app.register(adminCatalogRoutes, { prefix: '/v1' });
await app.register(searchRoutesPlugin, { prefix: '/v1' });
await app.register(geocodeRoutes, {
prefix: '/v1',
diff --git a/apps/api/src/catalog/place-import.ts b/apps/api/src/catalog/place-import.ts
new file mode 100644
index 00000000..1c6e8765
--- /dev/null
+++ b/apps/api/src/catalog/place-import.ts
@@ -0,0 +1,109 @@
+import {
+ CatalogPlaceInput,
+ placeImportOutcomes,
+ type PlaceImportBody,
+ type PlaceImportOutcome,
+ type PlaceImportResult,
+ type PlaceImportRowResult,
+} from '@widoo/shared';
+import { sql } from 'drizzle-orm';
+import type { Db } from '../db/client';
+import { createCatalogPlace, findCityId } from './place-writes';
+
+/** Two places of the same name closer than this are the same place (wiki Admin-Web › A-04). */
+const duplicateWithinM = 50;
+
+type Accepted = { line: number; place: CatalogPlaceInput };
+
+const sameName = (first: string, second: string) =>
+ first.trim().toLocaleLowerCase('fr') === second.trim().toLocaleLowerCase('fr');
+
+/** Distance in meters between two positions, on a sphere: enough to compare with 50 m. */
+function distanceM(first: CatalogPlaceInput['location'], second: CatalogPlaceInput['location']) {
+ const radians = (degrees: number) => (degrees * Math.PI) / 180;
+ const dLat = radians(second.lat - first.lat);
+ const dLng = radians(second.lng - first.lng);
+ const a =
+ Math.sin(dLat / 2) ** 2 +
+ Math.cos(radians(first.lat)) * Math.cos(radians(second.lat)) * Math.sin(dLng / 2) ** 2;
+ return 2 * 6_371_000 * Math.asin(Math.sqrt(a));
+}
+
+/** A place of the catalog with the same name within 50 m, if there is one. */
+async function findDuplicateInCatalog(db: Db, place: CatalogPlaceInput) {
+ const [found] = await db.execute<{ id: string; name: string }>(sql`
+ select id, name from places
+ where lower(name) = lower(${place.name})
+ and ST_DWithin(location,
+ ST_SetSRID(ST_MakePoint(${place.location.lng}, ${place.location.lat}), 4326)::geography,
+ ${duplicateWithinM})
+ limit 1`);
+ return found;
+}
+
+/** The fields of a row the schema refuses, each named once, in the order of the sheet. */
+function refusedFields(issues: readonly { path: PropertyKey[] }[]): string[] {
+ return [...new Set(issues.map((issue) => String(issue.path[0] ?? 'place')))];
+}
+
+/**
+ * Imports places from the rows of a file (A-04), or previews the import with `dryRun`. Every
+ * row is judged on its own, in the order of the file: an invalid row, a duplicate or a place
+ * outside every city is reported and skipped, the others are created (or `ready` in a preview).
+ * Each created place has its own line in the journal of the admin.
+ */
+export async function importPlaces(
+ db: Db,
+ body: PlaceImportBody,
+ actorId: string,
+): Promise {
+ const accepted: Accepted[] = [];
+ const rows: PlaceImportRowResult[] = [];
+ const report = (
+ line: number,
+ outcome: PlaceImportOutcome,
+ details: Partial> = {},
+ ) => rows.push({ line, outcome, fields: [], duplicateOf: null, ...details });
+
+ for (const row of body.rows) {
+ const parsed = CatalogPlaceInput.safeParse(row.place);
+ if (!parsed.success) {
+ report(row.line, 'invalid', { fields: refusedFields(parsed.error.issues) });
+ continue;
+ }
+ const place = parsed.data;
+ const earlier = accepted.find(
+ (other) =>
+ sameName(other.place.name, place.name) &&
+ distanceM(other.place.location, place.location) < duplicateWithinM,
+ );
+ if (earlier) {
+ const duplicateOf = { name: earlier.place.name, placeId: null, line: earlier.line };
+ report(row.line, 'duplicate', { duplicateOf });
+ continue;
+ }
+ const existing = await findDuplicateInCatalog(db, place);
+ if (existing) {
+ const duplicateOf = { name: existing.name, placeId: existing.id, line: null };
+ report(row.line, 'duplicate', { duplicateOf });
+ continue;
+ }
+ if (!(await findCityId(db, place.location))) {
+ report(row.line, 'outside');
+ continue;
+ }
+ accepted.push({ line: row.line, place });
+ if (!body.dryRun) {
+ await createCatalogPlace(db, place, actorId, 'place.import');
+ }
+ report(row.line, body.dryRun ? 'ready' : 'created');
+ }
+
+ const counts = Object.fromEntries(
+ placeImportOutcomes.map((outcome) => [
+ outcome,
+ rows.filter((row) => row.outcome === outcome).length,
+ ]),
+ ) as PlaceImportResult['counts'];
+ return { rows, counts };
+}
diff --git a/apps/api/src/catalog/place-writes.ts b/apps/api/src/catalog/place-writes.ts
new file mode 100644
index 00000000..374e1a51
--- /dev/null
+++ b/apps/api/src/catalog/place-writes.ts
@@ -0,0 +1,132 @@
+import type { CatalogPlaceInput } from '@widoo/shared';
+import { eq, sql } from 'drizzle-orm';
+import { recordAdminAction } from '../admin/actions';
+import type { Db } from '../db/client';
+import { placeHours, places } from '../db/schema';
+import { httpError } from '../errors';
+
+type Transaction = Parameters[0]>[0];
+
+/** The city a position belongs to: the one whose bounds hold it; undefined outside every city. */
+export async function findCityId(
+ db: Pick,
+ location: CatalogPlaceInput['location'],
+): Promise {
+ const [city] = await db.execute<{ id: string }>(sql`
+ select id from cities
+ where ST_Covers(bounds, ST_SetSRID(ST_MakePoint(${location.lng}, ${location.lat}), 4326)::geography)
+ limit 1`);
+ return city?.id;
+}
+
+/**
+ * The city of a place being written. A place outside every city is refused until cities are
+ * created from the position itself (wiki Admin-Web › Villes).
+ */
+async function cityOf(tx: Transaction, location: CatalogPlaceInput['location']): Promise {
+ const cityId = await findCityId(tx, location);
+ if (!cityId) {
+ throw httpError(400, 'Position outside every city');
+ }
+ return cityId;
+}
+
+const fieldsOf = (input: CatalogPlaceInput) => ({
+ name: input.name,
+ category: input.category,
+ address: input.address,
+ location: input.location,
+ isIndoor: input.isIndoor,
+ priceRange: input.priceRange,
+ websiteUrl: input.websiteUrl,
+ phone: input.phone,
+});
+
+async function replaceHours(tx: Transaction, placeId: string, hours: CatalogPlaceInput['hours']) {
+ await tx.delete(placeHours).where(eq(placeHours.placeId, placeId));
+ if (hours.length > 0) {
+ await tx.insert(placeHours).values(hours.map((slot) => ({ ...slot, placeId })));
+ }
+}
+
+/**
+ * Creates a place by hand (A-04). The team says nothing of its verification: a place nobody
+ * checked on the spot starts `stale`, to be verified.
+ */
+export async function createCatalogPlace(
+ db: Db,
+ input: CatalogPlaceInput,
+ actorId: string,
+ /** How the place came in, as the journal names it: by hand, or from an imported file. */
+ action: 'place.create' | 'place.import' = 'place.create',
+): Promise {
+ return db.transaction(async (tx) => {
+ const cityId = await cityOf(tx, input.location);
+ const [created] = await tx
+ .insert(places)
+ .values({ ...fieldsOf(input), cityId, verificationStatus: 'stale', createdBy: actorId })
+ .returning({ id: places.id });
+ if (!created) {
+ throw new Error('Place not created');
+ }
+ await replaceHours(tx, created.id, input.hours);
+ await recordAdminAction(tx, {
+ actorId,
+ action,
+ targetType: 'place',
+ targetId: created.id,
+ after: input,
+ });
+ return created.id;
+ });
+}
+
+/**
+ * Replaces the sheet of a place and its opening hours (A-04), and keeps the sheet before and
+ * after in the journal. False when the place does not exist.
+ */
+export async function updateCatalogPlace(
+ db: Db,
+ placeId: string,
+ input: CatalogPlaceInput,
+ actorId: string,
+): Promise {
+ return db.transaction(async (tx) => {
+ const [before] = await tx.select().from(places).where(eq(places.id, placeId)).for('update');
+ if (!before) {
+ return false;
+ }
+ const hoursBefore = await tx.select().from(placeHours).where(eq(placeHours.placeId, placeId));
+ const cityId = await cityOf(tx, input.location);
+ await tx
+ .update(places)
+ .set({ ...fieldsOf(input), cityId })
+ .where(eq(places.id, placeId));
+ await replaceHours(tx, placeId, input.hours);
+ await recordAdminAction(tx, {
+ actorId,
+ action: 'place.update',
+ targetType: 'place',
+ targetId: placeId,
+ before: {
+ name: before.name,
+ category: before.category,
+ address: before.address,
+ location: before.location,
+ isIndoor: before.isIndoor,
+ priceRange: before.priceRange,
+ websiteUrl: before.websiteUrl,
+ phone: before.phone,
+ hours: hoursBefore.map(({ weekday, opens, closes, validFrom, validTo }) => ({
+ weekday,
+ opens,
+ closes,
+ validFrom,
+ validTo,
+ })),
+ },
+ after: input,
+ });
+ return true;
+ });
+}
diff --git a/apps/api/src/catalog/places.ts b/apps/api/src/catalog/places.ts
new file mode 100644
index 00000000..412df66a
--- /dev/null
+++ b/apps/api/src/catalog/places.ts
@@ -0,0 +1,149 @@
+import {
+ catalogPageSize,
+ type CatalogPlace,
+ type CatalogPlaceDetail,
+ type CatalogPlaces,
+ type CatalogPlaceSort,
+ type CatalogPlacesQuery,
+} from '@widoo/shared';
+import { sql, type SQL } from 'drizzle-orm';
+import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor';
+import type { Db } from '../db/client';
+
+type PlaceRow = {
+ id: string;
+ name: string;
+ category: CatalogPlace['category'];
+ city_name: string;
+ verification_status: CatalogPlace['verificationStatus'];
+ verified_at: Date | null;
+ route_count: number;
+};
+
+type DetailRow = PlaceRow & {
+ address: string;
+ lat: number;
+ lng: number;
+ is_indoor: boolean;
+ price_range: CatalogPlaceDetail['priceRange'];
+ website_url: string | null;
+ phone: string | null;
+};
+
+type HoursRow = {
+ weekday: number;
+ opens: string;
+ closes: string;
+ valid_from: string | null;
+ valid_to: string | null;
+};
+
+const routeCount = sql`(select count(distinct s.route_id)::int from steps s where s.place_id = p.id)`;
+
+const sortExpressions: Record = {
+ name: sql`lower(p.name)`,
+ category: sql`p.category`,
+ city: sql`lower(c.name)`,
+ verified: sql`p.verified_at`,
+ routes: routeCount,
+};
+
+const columns = sql`p.id, p.name, p.category, c.name as city_name, p.verification_status,
+ p.verified_at, ${routeCount} as route_count`;
+const from = sql`from places p join cities c on c.id = p.city_id`;
+
+/** `%` and `_` typed in the search are searched as they are, not read as wildcards. */
+const likePattern = (text: string) => `%${text.replaceAll(/[\\%_]/g, '\\$&')}%`;
+
+function filtersOf(query: CatalogPlacesQuery): SQL {
+ const filters: SQL[] = [sql`true`];
+ if (query.category) {
+ filters.push(sql`p.category = ${query.category}`);
+ }
+ if (query.status) {
+ filters.push(sql`p.verification_status = ${query.status}`);
+ }
+ if (query.q) {
+ const pattern = likePattern(query.q);
+ filters.push(sql`(p.name ilike ${pattern} or p.address ilike ${pattern})`);
+ }
+ return sql.join(filters, sql` and `);
+}
+
+const toPlace = (row: PlaceRow): CatalogPlace => ({
+ id: row.id,
+ name: row.name,
+ category: row.category,
+ cityName: row.city_name,
+ verificationStatus: row.verification_status,
+ verifiedAt: row.verified_at ? new Date(row.verified_at).toISOString() : null,
+ routeCount: row.route_count,
+});
+
+/** `HH:MM` of a time the database returns with its seconds. */
+const hourAndMinute = (time: string) => time.slice(0, 5);
+
+/** A page of the places of the catalog (A-04). */
+export async function readCatalogPlaces(db: Db, query: CatalogPlacesQuery): Promise {
+ const offset = offsetOfCursor(query.cursor);
+ const where = filtersOf(query);
+ const direction = query.direction === 'desc' ? sql`desc` : sql`asc`;
+
+ const rows = await db.execute(sql`
+ select ${columns} ${from}
+ where ${where}
+ order by ${sortExpressions[query.sort]} ${direction} nulls last, p.id
+ limit ${catalogPageSize} offset ${offset}`);
+ const [counted] = await db.execute<{ total: number }>(
+ sql`select count(*)::int as total ${from} where ${where}`,
+ );
+ const total = counted?.total ?? 0;
+ return {
+ items: rows.map(toPlace),
+ total,
+ offset,
+ ...neighbourCursors(offset, total, catalogPageSize),
+ };
+}
+
+/** The sheet of a place, its opening hours and the routes that go through it. */
+export async function readCatalogPlace(
+ db: Db,
+ placeId: string,
+): Promise {
+ const [place] = await db.execute(sql`
+ select ${columns}, p.address, p.is_indoor, p.price_range, p.website_url, p.phone,
+ ST_Y(p.location::geometry) as lat, ST_X(p.location::geometry) as lng
+ ${from}
+ where p.id = ${placeId}`);
+ if (!place) {
+ return undefined;
+ }
+ const hours = await db.execute(sql`
+ select weekday, opens::text, closes::text, valid_from::text, valid_to::text
+ from place_hours where place_id = ${placeId}
+ order by weekday, opens`);
+ const routes = await db.execute(sql`
+ select distinct r.id, r.title, r.status
+ from steps s join routes r on r.id = s.route_id
+ where s.place_id = ${placeId}
+ order by r.title`);
+
+ return {
+ ...toPlace(place),
+ address: place.address,
+ location: { lat: place.lat, lng: place.lng },
+ isIndoor: place.is_indoor,
+ priceRange: place.price_range,
+ websiteUrl: place.website_url,
+ phone: place.phone,
+ hours: hours.map((slot) => ({
+ weekday: slot.weekday,
+ opens: hourAndMinute(slot.opens),
+ closes: hourAndMinute(slot.closes),
+ validFrom: slot.valid_from,
+ validTo: slot.valid_to,
+ })),
+ routes: routes.map(({ id, title, status }) => ({ id, title, status })),
+ };
+}
diff --git a/apps/api/src/catalog/route-actions.ts b/apps/api/src/catalog/route-actions.ts
new file mode 100644
index 00000000..708f6342
--- /dev/null
+++ b/apps/api/src/catalog/route-actions.ts
@@ -0,0 +1,95 @@
+import type { AlreadyDecidedDetails, CatalogRouteAction } 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 RouteActionInput = {
+ routeId: string;
+ /** The member of the team who acts. */
+ actorId: string;
+};
+
+/**
+ * Puts an unpublished route back online (A-04), with the reason kept in its history and in the
+ * journal of the admin. A route that is not unpublished answers 409 `already_decided` with its
+ * status: someone else acted first, or the route was never taken down.
+ */
+export async function republishRoute(
+ db: Db,
+ input: RouteActionInput & { message: string },
+ now = new Date(),
+): Promise {
+ return db.transaction(async (tx) => {
+ const [route] = await tx
+ .select({ status: routes.status, isOfficial: routes.isOfficial })
+ .from(routes)
+ .where(eq(routes.id, input.routeId))
+ .for('update');
+ if (!route) {
+ throw httpError(404, 'Route not found');
+ }
+ if (route.status !== 'unpublished') {
+ const details: AlreadyDecidedDetails = { status: route.status };
+ throw httpError(409, 'Route is not unpublished', 'already_decided', details);
+ }
+
+ await tx
+ .update(routes)
+ .set({ status: 'published', publishedAt: now })
+ .where(eq(routes.id, input.routeId));
+ await tx.insert(moderationActionsTable).values({
+ routeId: input.routeId,
+ actorId: input.actorId,
+ action: 'publish',
+ message: input.message,
+ });
+ await recordAdminAction(tx, {
+ actorId: input.actorId,
+ action: 'route.republish',
+ targetType: 'route',
+ targetId: input.routeId,
+ before: { status: route.status },
+ after: { status: 'published' },
+ reason: input.message,
+ });
+ return { routeId: input.routeId, status: 'published', isOfficial: route.isOfficial };
+ });
+}
+
+/**
+ * Hands a route over to the Widoo team (A-04): it becomes « Par Widoo » and loses its author,
+ * so that the team can maintain it in the editor of the app. Its status does not change.
+ * Reassigning a route of the team again changes nothing and writes nothing.
+ */
+export async function reassignRouteToTeam(
+ db: Db,
+ input: RouteActionInput,
+): Promise {
+ return db.transaction(async (tx) => {
+ const [route] = await tx
+ .select({ status: routes.status, isOfficial: routes.isOfficial, authorId: routes.authorId })
+ .from(routes)
+ .where(eq(routes.id, input.routeId))
+ .for('update');
+ if (!route) {
+ throw httpError(404, 'Route not found');
+ }
+ if (!route.isOfficial || route.authorId !== null) {
+ await tx
+ .update(routes)
+ .set({ isOfficial: true, authorId: null })
+ .where(eq(routes.id, input.routeId));
+ await recordAdminAction(tx, {
+ actorId: input.actorId,
+ action: 'route.reassign',
+ targetType: 'route',
+ targetId: input.routeId,
+ before: { isOfficial: route.isOfficial, authorId: route.authorId },
+ after: { isOfficial: true, authorId: null },
+ });
+ }
+ return { routeId: input.routeId, status: route.status, isOfficial: true };
+ });
+}
diff --git a/apps/api/src/catalog/routes.ts b/apps/api/src/catalog/routes.ts
new file mode 100644
index 00000000..d1325c8e
--- /dev/null
+++ b/apps/api/src/catalog/routes.ts
@@ -0,0 +1,104 @@
+import {
+ catalogPageSize,
+ type CatalogRoute,
+ type CatalogRoutes,
+ type CatalogRoutesQuery,
+ type CatalogRouteSort,
+} from '@widoo/shared';
+import { sql, type SQL } from 'drizzle-orm';
+import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor';
+import type { Db } from '../db/client';
+import { ratingOf } from '../db/route-stats';
+
+type RouteRow = {
+ id: string;
+ title: string;
+ status: CatalogRoute['status'];
+ access: CatalogRoute['access'];
+ is_official: boolean;
+ author_id: string | null;
+ author_first_name: string | null;
+ city_name: string;
+ stats: unknown;
+ updated_at: Date;
+};
+
+const sortExpressions: Record = {
+ updated: sql`r.updated_at`,
+ title: sql`lower(r.title)`,
+ author: sql`lower(coalesce(u.first_name, ''))`,
+ city: sql`lower(c.name)`,
+ // Routes without a rating last, whatever the direction.
+ rating: sql`(r.stats ->> 'rating_avg')::numeric`,
+};
+
+/** `%` and `_` typed in the search are searched as they are, not read as wildcards. */
+const likePattern = (text: string) => `%${text.replaceAll(/[\\%_]/g, '\\$&')}%`;
+
+export type CatalogScope = {
+ /** An editor only sees the routes of the team (wiki Admin-Web › Rôles). */
+ isTeamOnly: boolean;
+};
+
+function filtersOf(query: CatalogRoutesQuery, scope: CatalogScope): SQL {
+ const filters: SQL[] = [sql`true`];
+ if (scope.isTeamOnly) {
+ filters.push(sql`r.is_official`);
+ }
+ if (query.status) {
+ filters.push(sql`r.status = ${query.status}`);
+ }
+ if (query.access) {
+ filters.push(sql`r.access = ${query.access}`);
+ }
+ 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 toRoute = (row: RouteRow): CatalogRoute => ({
+ id: row.id,
+ title: row.title,
+ status: row.status,
+ access: row.access,
+ isOfficial: row.is_official,
+ author: row.author_id ? { id: row.author_id, firstName: row.author_first_name } : null,
+ cityName: row.city_name,
+ rating: ratingOf(row.stats),
+ updatedAt: new Date(row.updated_at).toISOString(),
+});
+
+/** A page of the routes of the catalog (A-04), whatever their status. */
+export async function readCatalogRoutes(
+ db: Db,
+ query: CatalogRoutesQuery,
+ scope: CatalogScope,
+): Promise {
+ const offset = offsetOfCursor(query.cursor);
+ const where = filtersOf(query, scope);
+ const direction = query.direction === 'desc' ? sql`desc` : sql`asc`;
+ const from = 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`;
+
+ const rows = await db.execute(sql`
+ select r.id, r.title, r.status, r.access, r.is_official, r.stats, r.updated_at,
+ u.id as author_id, u.first_name as author_first_name, c.name as city_name
+ ${from}
+ where ${where}
+ order by ${sortExpressions[query.sort]} ${direction} nulls last, r.id
+ limit ${catalogPageSize} offset ${offset}`);
+ const [counted] = await db.execute<{ total: number }>(
+ sql`select count(*)::int as total ${from} where ${where}`,
+ );
+ const total = counted?.total ?? 0;
+
+ return {
+ items: rows.map(toRoute),
+ total,
+ offset,
+ ...neighbourCursors(offset, total, catalogPageSize),
+ };
+}
diff --git a/apps/api/src/moderation/detail.ts b/apps/api/src/moderation/detail.ts
index 91f93b3e..674341b3 100644
--- a/apps/api/src/moderation/detail.ts
+++ b/apps/api/src/moderation/detail.ts
@@ -46,7 +46,7 @@ type StepRow = {
type HistoryRow = {
action: ModerationHistoryEntry['action'];
- actor_first_name: string | null;
+ actor_name: string | null;
reason_code: ModerationHistoryEntry['reasonCode'];
message: string | null;
created_at: Date;
@@ -136,7 +136,7 @@ export async function readModerationRoute(
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
+ select a.action, coalesce(u.first_name, split_part(u.email, '@', 1)) as actor_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`);
@@ -193,7 +193,7 @@ export async function readModerationRoute(
publishedVersionAt: null,
history: history.map((entry) => ({
action: entry.action,
- actorFirstName: entry.actor_first_name,
+ actorName: entry.actor_name,
reasonCode: entry.reason_code,
message: entry.message,
createdAt: new Date(entry.created_at).toISOString(),
diff --git a/apps/api/src/moderation/queue.ts b/apps/api/src/moderation/queue.ts
index 45d5ae9f..387b6f93 100644
--- a/apps/api/src/moderation/queue.ts
+++ b/apps/api/src/moderation/queue.ts
@@ -9,6 +9,7 @@ import {
type ModerationQueueStatus,
} from '@widoo/shared';
import { sql, type SQL } from 'drizzle-orm';
+import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor';
import type { Db } from '../db/client';
type QueueRow = {
@@ -84,21 +85,6 @@ 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,
@@ -125,7 +111,7 @@ export async function readModerationQueue(
query: ModerationQueueQuery,
now = new Date(),
): Promise {
- const offset = decodeCursor(query.cursor);
+ const offset = offsetOfCursor(query.cursor);
const where = filtersOf(query, now);
const direction = query.direction === 'desc' ? sql`desc` : sql`asc`;
@@ -177,10 +163,6 @@ export async function readModerationQueue(
},
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,
+ ...neighbourCursors(offset, total, moderationQueuePageSize),
};
}
diff --git a/apps/api/src/routes/admin-catalog-import.test.ts b/apps/api/src/routes/admin-catalog-import.test.ts
new file mode 100644
index 00000000..711f7d32
--- /dev/null
+++ b/apps/api/src/routes/admin-catalog-import.test.ts
@@ -0,0 +1,153 @@
+import { PlaceImportResult, type CatalogPlaceInput, type UserRole } from '@widoo/shared';
+import { and, eq, inArray, like } from 'drizzle-orm';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { buildApp } from '../app';
+import { TestTokenVerifier } from '../auth/test-verifier';
+import { adminActions, places, users } from '../db/schema';
+import { seed } from '../db/seed';
+import { uuidv7 } from '../db/uuid';
+import { testConfig } from '../test-config';
+
+// Shared database, parallel files: unique users, and places found by a marker in their name.
+const verifier = new TestTokenVerifier();
+const createdUids: string[] = [];
+const marker = `importtest-${uuidv7()}`;
+
+let app: Awaited>;
+beforeAll(async () => {
+ app = await buildApp(testConfig(), { tokenVerifier: verifier });
+ await seed(app.db);
+});
+
+const createdPlaces = () =>
+ app.db
+ .select({ id: places.id, name: places.name })
+ .from(places)
+ .where(like(places.name, `%${marker}%`));
+
+afterAll(async () => {
+ const ids = (await createdPlaces()).map((place) => place.id);
+ if (ids.length > 0) {
+ await app.db.delete(adminActions).where(inArray(adminActions.targetId, ids));
+ await app.db.delete(places).where(inArray(places.id, ids));
+ }
+ 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}` } };
+}
+
+// Inside the bounds of Paris of the seed.
+const paris = { lat: 48.8785, lng: 2.3397 };
+const place = (name: string, changes: Partial = {}): CatalogPlaceInput => ({
+ name: `${name} ${marker}`,
+ category: 'cafe',
+ address: '12 rue des Martyrs, 75009 Paris',
+ location: paris,
+ isIndoor: true,
+ priceRange: null,
+ websiteUrl: null,
+ phone: null,
+ hours: [],
+ ...changes,
+});
+
+async function importRows(headers: Record, rowPlaces: unknown[], dryRun = false) {
+ const rows = rowPlaces.map((rowPlace, index) => ({ line: index + 2, place: rowPlace }));
+ return app.inject({
+ method: 'POST',
+ url: '/v1/admin/places/import',
+ headers,
+ payload: { dryRun, rows },
+ });
+}
+
+describe('POST /v1/admin/places/import', () => {
+ it('creates 48 places out of 50 rows and explains the 2 invalid ones', async () => {
+ const admin = await signIn('admin');
+ const rows: unknown[] = Array.from({ length: 50 }, (_, index) => place(`Lot ${index}`));
+ rows[9] = { ...place('Lot 9'), category: 'spaceport' };
+ rows[30] = { ...place('Lot 30'), name: '', location: { lat: 120, lng: 2 } };
+
+ const response = await importRows(admin.headers, rows);
+
+ expect(response.statusCode).toBe(200);
+ const result = PlaceImportResult.parse(response.json());
+ expect(result.counts).toMatchObject({ created: 48, invalid: 2, duplicate: 0, outside: 0 });
+ expect(result.rows.filter((row) => row.outcome === 'invalid')).toEqual([
+ { line: 11, outcome: 'invalid', fields: ['category'], duplicateOf: null },
+ { line: 32, outcome: 'invalid', fields: ['name', 'location'], duplicateOf: null },
+ ]);
+
+ const created = (await createdPlaces()).filter((row) => row.name.startsWith('Lot '));
+ expect(created).toHaveLength(48);
+ const journal = await app.db
+ .select({ id: adminActions.id })
+ .from(adminActions)
+ .where(and(eq(adminActions.actorId, admin.id), eq(adminActions.action, 'place.import')));
+ expect(journal).toHaveLength(48);
+ });
+
+ it('previews without creating anything', async () => {
+ const admin = await signIn('admin');
+
+ const response = await importRows(admin.headers, [place('Aperçu')], true);
+
+ const result = PlaceImportResult.parse(response.json());
+ expect(result.rows).toEqual([{ line: 2, outcome: 'ready', fields: [], duplicateOf: null }]);
+ expect((await createdPlaces()).some((row) => row.name.startsWith('Aperçu'))).toBe(false);
+ });
+
+ it('flags a place of the same name within 50 m, in the catalog or earlier in the file', async () => {
+ const admin = await signIn('admin');
+ await importRows(admin.headers, [place('Doublon')]);
+ const near = { lat: paris.lat + 0.0002, lng: paris.lng };
+ const far = { lat: paris.lat + 0.002, lng: paris.lng };
+
+ const response = await importRows(admin.headers, [
+ place('doublon', { location: near }),
+ place('Doublon', { location: far }),
+ place('Jumeau'),
+ place('Jumeau', { location: near }),
+ ]);
+
+ const result = PlaceImportResult.parse(response.json());
+ expect(result.rows.map((row) => row.outcome)).toEqual([
+ 'duplicate',
+ 'created',
+ 'created',
+ 'duplicate',
+ ]);
+ expect(result.rows[0]?.duplicateOf).toMatchObject({ name: `Doublon ${marker}`, line: null });
+ expect(result.rows[0]?.duplicateOf?.placeId).toEqual(expect.any(String));
+ expect(result.rows[3]?.duplicateOf).toEqual({
+ name: `Jumeau ${marker}`,
+ placeId: null,
+ line: 4,
+ });
+ });
+
+ it('skips a place outside every city', async () => {
+ const admin = await signIn('admin');
+
+ const response = await importRows(admin.headers, [
+ place('Ailleurs', { location: { lat: 32.5, lng: 32.5 } }),
+ ]);
+
+ expect(PlaceImportResult.parse(response.json()).counts).toMatchObject({ outside: 1 });
+ });
+
+ it('is refused to an editor', async () => {
+ const editor = await signIn('editor');
+
+ const response = await importRows(editor.headers, [place('Refusé')], true);
+
+ expect(response.statusCode).toBe(403);
+ });
+});
diff --git a/apps/api/src/routes/admin-catalog-places.test.ts b/apps/api/src/routes/admin-catalog-places.test.ts
new file mode 100644
index 00000000..01830bfd
--- /dev/null
+++ b/apps/api/src/routes/admin-catalog-places.test.ts
@@ -0,0 +1,181 @@
+import {
+ CatalogPlaceDetail,
+ CatalogPlaces,
+ type CatalogPlaceInput,
+ type UserRole,
+} from '@widoo/shared';
+import { eq, inArray, like } from 'drizzle-orm';
+import { afterAll, beforeAll, describe, expect, it } from 'vitest';
+import { buildApp } from '../app';
+import { TestTokenVerifier } from '../auth/test-verifier';
+import { adminActions, placeHours, places, users } from '../db/schema';
+import { seed } from '../db/seed';
+import { uuidv7 } from '../db/uuid';
+import { testConfig } from '../test-config';
+
+// Shared database, parallel files: unique users, and places found by a marker in their name.
+const verifier = new TestTokenVerifier();
+const createdUids: string[] = [];
+const marker = `placetest-${uuidv7()}`;
+
+let app: Awaited>;
+beforeAll(async () => {
+ app = await buildApp(testConfig(), { tokenVerifier: verifier });
+ await seed(app.db);
+});
+afterAll(async () => {
+ const created = await app.db
+ .select({ id: places.id })
+ .from(places)
+ .where(like(places.name, `%${marker}%`));
+ const ids = created.map((place) => place.id);
+ if (ids.length > 0) {
+ await app.db.delete(adminActions).where(inArray(adminActions.targetId, ids));
+ await app.db.delete(places).where(inArray(places.id, ids));
+ }
+ 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}` } };
+}
+
+// Inside the bounds of Paris of the seed.
+const sheet = (name: string, changes: Partial = {}): CatalogPlaceInput => ({
+ name: `${name} ${marker}`,
+ category: 'cafe',
+ address: '12 rue des Martyrs, 75009 Paris',
+ location: { lat: 48.8785, lng: 2.3397 },
+ isIndoor: true,
+ priceRange: 'low',
+ websiteUrl: 'https://cafe.example',
+ phone: '+33 1 42 00 00 00',
+ hours: [
+ { weekday: 0, opens: '08:00', closes: '18:00', validFrom: null, validTo: null },
+ { weekday: 1, opens: '08:00', closes: '18:00', validFrom: null, validTo: null },
+ ],
+ ...changes,
+});
+
+const create = (headers: Record, payload: object) =>
+ app.inject({ method: 'POST', url: '/v1/admin/places', headers, payload });
+const update = (headers: Record, id: string, payload: object) =>
+ app.inject({ method: 'PATCH', url: `/v1/admin/places/${id}`, headers, payload });
+const list = (headers: Record, query = '') =>
+ app.inject({ url: `/v1/admin/places?q=${marker}${query}`, headers });
+
+describe('POST /v1/admin/places', () => {
+ it('creates a place to verify, in the city of its position, with its hours', async () => {
+ const editor = await signIn('editor');
+ const response = await create(editor.headers, sheet('Création'));
+
+ expect(response.statusCode).toBe(201);
+ const place = CatalogPlaceDetail.parse(response.json());
+ expect(place).toMatchObject({
+ category: 'cafe',
+ cityName: 'Paris',
+ verificationStatus: 'stale',
+ verifiedAt: null,
+ routeCount: 0,
+ isIndoor: true,
+ phone: '+33 1 42 00 00 00',
+ });
+ expect(place.location.lat).toBeCloseTo(48.8785, 4);
+ expect(place.hours).toEqual(sheet('').hours);
+
+ const [journal] = await app.db
+ .select()
+ .from(adminActions)
+ .where(eq(adminActions.targetId, place.id));
+ expect(journal).toMatchObject({ action: 'place.create', actorId: editor.id });
+ });
+
+ it.each([
+ ['a position outside every city', { location: { lat: 43.6, lng: 1.44 } }],
+ ['markup in the name', { name: 'Café ' }],
+ ['a link that is not https', { websiteUrl: 'http://cafe.example' }],
+ ['an unknown category', { category: 'casino' }],
+ ['a field it does not know', { verificationStatus: 'verified' }],
+ ])('refuses %s', async (_, changes) => {
+ const admin = await signIn('admin');
+ const response = await create(admin.headers, { ...sheet('Refus'), ...changes });
+ expect(response.statusCode).toBe(400);
+ });
+
+ it('is not for a moderator, who only reads the places', async () => {
+ const moderator = await signIn('moderator');
+ expect((await create(moderator.headers, sheet('Lecture'))).statusCode).toBe(403);
+ expect((await list(moderator.headers)).statusCode).toBe(200);
+ });
+});
+
+describe('PATCH /v1/admin/places/:id', () => {
+ it('replaces the sheet and its hours, and keeps both states in the journal', async () => {
+ const admin = await signIn('admin');
+ const created = CatalogPlaceDetail.parse((await create(admin.headers, sheet('Avant'))).json());
+ const changed = sheet('Après', {
+ category: 'restaurant',
+ phone: null,
+ hours: [{ weekday: 5, opens: '19:00', closes: '01:00', validFrom: null, validTo: null }],
+ });
+
+ const response = await update(admin.headers, created.id, changed);
+ expect(response.statusCode).toBe(200);
+ const place = CatalogPlaceDetail.parse(response.json());
+ expect(place).toMatchObject({ name: changed.name, category: 'restaurant', phone: null });
+ expect(place.hours).toEqual(changed.hours);
+ const slots = await app.db.select().from(placeHours).where(eq(placeHours.placeId, created.id));
+ expect(slots).toHaveLength(1);
+
+ const journal = await app.db
+ .select()
+ .from(adminActions)
+ .where(eq(adminActions.targetId, created.id));
+ const updateEntry = journal.find((entry) => entry.action === 'place.update');
+ expect(updateEntry?.before).toMatchObject({ category: 'cafe', phone: '+33 1 42 00 00 00' });
+ expect(updateEntry?.after).toMatchObject({ category: 'restaurant', phone: null });
+ });
+
+ it('answers 404 for a place that does not exist', async () => {
+ const admin = await signIn('admin');
+ expect((await update(admin.headers, uuidv7(), sheet('Absent'))).statusCode).toBe(404);
+ });
+});
+
+describe('GET /v1/admin/places', () => {
+ it('searches the name and the address, filters and sorts', async () => {
+ const admin = await signIn('admin');
+ await create(admin.headers, sheet('Zèbre', { category: 'park' }));
+
+ const all = CatalogPlaces.parse((await list(admin.headers)).json());
+ expect(all.total).toBeGreaterThanOrEqual(2);
+ const names = all.items.map((place) => place.name.split(' ')[0] ?? '');
+ expect(names).toEqual([...names].sort((a, b) => a.localeCompare(b, 'fr')));
+
+ const parks = CatalogPlaces.parse((await list(admin.headers, '&category=park')).json());
+ expect(parks.items.map((place) => place.name.split(' ')[0])).toEqual(['Zèbre']);
+ const verified = CatalogPlaces.parse((await list(admin.headers, '&status=verified')).json());
+ expect(verified.items).toEqual([]);
+ });
+
+ it('counts the routes that go through a place of the seed', async () => {
+ const admin = await signIn('admin');
+ const response = await app.inject({
+ url: '/v1/admin/places?sort=routes&direction=desc',
+ headers: admin.headers,
+ });
+ const [most] = CatalogPlaces.parse(response.json()).items;
+ expect(most?.routeCount).toBeGreaterThanOrEqual(1);
+
+ const detail = await app.inject({
+ url: `/v1/admin/places/${most?.id}`,
+ headers: admin.headers,
+ });
+ expect(CatalogPlaceDetail.parse(detail.json()).routes.length).toBe(most?.routeCount);
+ });
+});
diff --git a/apps/api/src/routes/admin-catalog.test.ts b/apps/api/src/routes/admin-catalog.test.ts
new file mode 100644
index 00000000..96a29cef
--- /dev/null
+++ b/apps/api/src/routes/admin-catalog.test.ts
@@ -0,0 +1,220 @@
+import {
+ ApiError,
+ CatalogRouteAction,
+ CatalogRoutes,
+ 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, found by a marker in their title, far
+// from every zone the search tests look at.
+const verifier = new TestTokenVerifier();
+const createdUids: string[] = [];
+const createdRouteIds: string[] = [];
+const marker = `cattest-${uuidv7()}`;
+const templateId = seedId('route:marais-gourmand');
+const farAway = { lat: 33, lng: 33 };
+const farAwayBounds = { west: 33, south: 33, east: 33.001, north: 33.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: 'Nadia' })
+ .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}` } };
+}
+
+type RouteOptions = { status?: RouteStatus; isOfficial?: boolean; rating?: number };
+
+/** A route copied from the seed: of an author unless `isOfficial`, published unless told. */
+async function addRoute(title: string, options: RouteOptions = {}) {
+ 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);
+ const isOfficial = options.isOfficial ?? false;
+ await app.db.insert(routes).values({
+ ...template,
+ id,
+ shareToken: null,
+ startLocation: farAway,
+ bounds: farAwayBounds,
+ title: `${title} ${marker}`,
+ status: options.status ?? 'published',
+ isOfficial,
+ authorId: isOfficial ? null : authorId,
+ stats: options.rating ? { rating_avg: options.rating, rating_count: 4 } : {},
+ });
+ return id;
+}
+
+const routeOf = async (id: string) =>
+ (await app.db.select().from(routes).where(eq(routes.id, id)))[0];
+const list = (headers: Record, query = '') =>
+ app.inject({ url: `/v1/admin/routes?q=${marker}${query}`, headers });
+const titles = async (headers: Record, query = '') =>
+ CatalogRoutes.parse((await list(headers, query)).json()).items.map(
+ (route) => route.title.split(' ')[0],
+ );
+const act = (headers: Record, id: string, action: string, payload?: object) =>
+ app.inject({ method: 'POST', url: `/v1/admin/routes/${id}/${action}`, headers, payload });
+
+describe('GET /v1/admin/routes', () => {
+ it('lists the routes whatever their status, with author, city and rating', async () => {
+ const moderator = await signIn('moderator');
+ await addRoute('Alpha', { rating: 4.5 });
+ await addRoute('Bravo', { status: 'unpublished' });
+ await addRoute('Charlie', { isOfficial: true, status: 'draft' });
+
+ const response = await list(moderator.headers, '&sort=title&direction=asc');
+ expect(response.statusCode).toBe(200);
+ expect(response.headers['cache-control']).toBe('no-store');
+ const body = CatalogRoutes.parse(response.json());
+ expect(body.items.map((route) => route.title.split(' ')[0])).toEqual([
+ 'Alpha',
+ 'Bravo',
+ 'Charlie',
+ ]);
+ expect(body.total).toBe(3);
+ expect(body.items[0]).toMatchObject({
+ status: 'published',
+ isOfficial: false,
+ author: { id: authorId, firstName: 'Nadia' },
+ rating: { average: 4.5, count: 4 },
+ });
+ expect(body.items[2]).toMatchObject({ isOfficial: true, author: null });
+ });
+
+ it('filters by status and sorts by rating, unrated routes last', async () => {
+ const admin = await signIn('admin');
+ expect(await titles(admin.headers, '&status=unpublished')).toEqual(['Bravo']);
+ expect((await titles(admin.headers, '&sort=rating&direction=desc'))[0]).toBe('Alpha');
+ expect((await titles(admin.headers, '&sort=rating&direction=asc'))[0]).toBe('Alpha');
+ });
+
+ it('shows an editor the routes of the team only', async () => {
+ const editor = await signIn('editor');
+ expect(await titles(editor.headers)).toEqual(['Charlie']);
+ });
+
+ it('refuses an account outside the team, and a filter it does not know', async () => {
+ const admin = await signIn('admin');
+ expect((await list((await signIn('user')).headers)).statusCode).toBe(403);
+ expect((await list(admin.headers, '&status=online')).statusCode).toBe(400);
+ });
+});
+
+describe('POST /v1/admin/routes/:id/republish', () => {
+ const body = { message: 'Lieu rouvert, vérifié sur place.' };
+
+ it('puts an unpublished route back online, and keeps why in both journals', async () => {
+ const admin = await signIn('admin');
+ const id = await addRoute('Retour', { status: 'unpublished' });
+
+ const response = await act(admin.headers, id, 'republish', body);
+ expect(response.statusCode).toBe(200);
+ expect(CatalogRouteAction.parse(response.json())).toMatchObject({ status: 'published' });
+ expect((await routeOf(id))?.status).toBe('published');
+
+ const history = await app.db
+ .select()
+ .from(moderationActionsTable)
+ .where(eq(moderationActionsTable.routeId, id));
+ expect(history).toMatchObject([{ action: 'publish', actorId: admin.id, ...body }]);
+ const journal = await app.db.select().from(adminActions).where(eq(adminActions.targetId, id));
+ expect(journal).toMatchObject([
+ {
+ action: 'route.republish',
+ before: { status: 'unpublished' },
+ after: { status: 'published' },
+ reason: body.message,
+ },
+ ]);
+ });
+
+ it('answers 409 for a route that is not unpublished, and writes nothing', async () => {
+ const admin = await signIn('admin');
+ const id = await addRoute('Encore', { status: 'published' });
+
+ const response = await act(admin.headers, id, 'republish', body);
+ expect(response.statusCode).toBe(409);
+ expect(ApiError.parse(response.json()).code).toBe('already_decided');
+ const journal = await app.db.select().from(adminActions).where(eq(adminActions.targetId, id));
+ expect(journal).toEqual([]);
+ });
+
+ it('is for admins only, and needs a reason', async () => {
+ const moderator = await signIn('moderator');
+ const admin = await signIn('admin');
+ const id = await addRoute('Garde', { status: 'unpublished' });
+
+ expect((await act(moderator.headers, id, 'republish', body)).statusCode).toBe(403);
+ expect((await act(admin.headers, id, 'republish', {})).statusCode).toBe(400);
+ expect((await routeOf(id))?.status).toBe('unpublished');
+ });
+});
+
+describe('POST /v1/admin/routes/:id/reassign', () => {
+ it('hands a route over to the team, once', async () => {
+ const admin = await signIn('admin');
+ const id = await addRoute('Transfert');
+
+ const first = await act(admin.headers, id, 'reassign');
+ expect(first.statusCode).toBe(200);
+ expect(CatalogRouteAction.parse(first.json())).toMatchObject({
+ status: 'published',
+ isOfficial: true,
+ });
+ expect(await routeOf(id)).toMatchObject({ isOfficial: true, authorId: null });
+
+ expect((await act(admin.headers, id, 'reassign')).statusCode).toBe(200);
+ const journal = await app.db.select().from(adminActions).where(eq(adminActions.targetId, id));
+ expect(journal).toMatchObject([
+ {
+ action: 'route.reassign',
+ actorId: admin.id,
+ before: { isOfficial: false, authorId },
+ after: { isOfficial: true, authorId: null },
+ },
+ ]);
+ });
+
+ it('is for admins only, and answers 404 for an unknown route', async () => {
+ const moderator = await signIn('moderator');
+ const admin = await signIn('admin');
+ const id = await addRoute('Intact');
+
+ expect((await act(moderator.headers, id, 'reassign')).statusCode).toBe(403);
+ expect((await routeOf(id))?.isOfficial).toBe(false);
+ expect((await act(admin.headers, uuidv7(), 'reassign')).statusCode).toBe(404);
+ });
+});
diff --git a/apps/api/src/routes/admin-catalog.ts b/apps/api/src/routes/admin-catalog.ts
new file mode 100644
index 00000000..10193bb3
--- /dev/null
+++ b/apps/api/src/routes/admin-catalog.ts
@@ -0,0 +1,199 @@
+import {
+ CatalogPlaceDetail,
+ CatalogPlaceInput,
+ CatalogPlaces,
+ CatalogPlacesQuery,
+ CatalogRouteAction,
+ CatalogRoutes,
+ CatalogRoutesQuery,
+ PlaceImportBody,
+ PlaceImportResult,
+ RepublishBody,
+} from '@widoo/shared';
+import type { FastifyRequest } from 'fastify';
+import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
+import { z } from 'zod';
+import { importPlaces } from '../catalog/place-import';
+import { createCatalogPlace, updateCatalogPlace } from '../catalog/place-writes';
+import { readCatalogPlace, readCatalogPlaces } from '../catalog/places';
+import { reassignRouteToTeam, republishRoute } from '../catalog/route-actions';
+import { readCatalogRoutes } from '../catalog/routes';
+import { httpError } from '../errors';
+import type { User } from '../users/repository';
+
+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 memberOf(request: FastifyRequest): User {
+ if (!request.user) {
+ throw httpError(401, 'Missing bearer token');
+ }
+ return request.user;
+}
+
+/**
+ * Routes and places of the catalog (A-04, wiki API › Admin). Everyone who opens the catalog reads
+ * the lists, an editor the routes of the team only; putting a route back online or handing it to the team
+ * is for admins. Unpublishing stays with the moderation decisions. Never cached.
+ */
+export const adminCatalogRoutes: FastifyPluginAsyncZod = async (app) => {
+ app.addHook('onRequest', app.requireAdminSection('catalog'));
+ app.addHook('onSend', async (_request, reply) => {
+ reply.header('cache-control', 'no-store');
+ });
+
+ app.get(
+ '/admin/routes',
+ {
+ schema: {
+ tags,
+ summary: 'Routes of the catalog',
+ security,
+ querystring: CatalogRoutesQuery,
+ response: { 200: CatalogRoutes },
+ },
+ },
+ async (request) =>
+ readCatalogRoutes(app.db, request.query, {
+ isTeamOnly: memberOf(request).role === 'editor',
+ }),
+ );
+
+ app.post(
+ '/admin/routes/:id/republish',
+ {
+ onRequest: app.requireRole('admin'),
+ schema: {
+ tags,
+ summary: 'Put an unpublished route back online',
+ security,
+ params: RouteParams,
+ body: RepublishBody,
+ response: { 200: CatalogRouteAction },
+ },
+ },
+ async (request) =>
+ republishRoute(app.db, {
+ routeId: request.params.id,
+ actorId: memberOf(request).id,
+ message: request.body.message,
+ }),
+ );
+
+ app.post(
+ '/admin/routes/:id/reassign',
+ {
+ onRequest: app.requireRole('admin'),
+ schema: {
+ tags,
+ summary: 'Hand a route over to the Widoo team',
+ security,
+ params: RouteParams,
+ response: { 200: CatalogRouteAction },
+ },
+ },
+ async (request) =>
+ reassignRouteToTeam(app.db, {
+ routeId: request.params.id,
+ actorId: memberOf(request).id,
+ }),
+ );
+
+ app.get(
+ '/admin/places',
+ {
+ schema: {
+ tags,
+ summary: 'Places of the catalog',
+ security,
+ querystring: CatalogPlacesQuery,
+ response: { 200: CatalogPlaces },
+ },
+ },
+ async (request) => readCatalogPlaces(app.db, request.query),
+ );
+
+ app.get(
+ '/admin/places/:id',
+ {
+ schema: {
+ tags,
+ summary: 'Sheet of a place',
+ security,
+ params: RouteParams,
+ response: { 200: CatalogPlaceDetail },
+ },
+ },
+ async (request) => {
+ const place = await readCatalogPlace(app.db, request.params.id);
+ if (!place) {
+ throw httpError(404, 'Place not found');
+ }
+ return place;
+ },
+ );
+
+ // Editors and admins write the places; a moderator reads them (wiki Admin-Web › Rôles).
+ app.post(
+ '/admin/places',
+ {
+ onRequest: app.requireRole('editor'),
+ schema: {
+ tags,
+ summary: 'Create a place by hand',
+ security,
+ body: CatalogPlaceInput,
+ response: { 201: CatalogPlaceDetail },
+ },
+ },
+ async (request, reply) => {
+ const id = await createCatalogPlace(app.db, request.body, memberOf(request).id);
+ const place = await readCatalogPlace(app.db, id);
+ if (!place) {
+ throw new Error('Created place not found');
+ }
+ return reply.status(201).send(place);
+ },
+ );
+
+ app.patch(
+ '/admin/places/:id',
+ {
+ onRequest: app.requireRole('editor'),
+ schema: {
+ tags,
+ summary: 'Replace the sheet of a place',
+ security,
+ params: RouteParams,
+ body: CatalogPlaceInput,
+ response: { 200: CatalogPlaceDetail },
+ },
+ },
+ async (request) => {
+ const { id } = request.params;
+ const wasUpdated = await updateCatalogPlace(app.db, id, request.body, memberOf(request).id);
+ const place = wasUpdated ? await readCatalogPlace(app.db, id) : undefined;
+ if (!place) {
+ throw httpError(404, 'Place not found');
+ }
+ return place;
+ },
+ );
+
+ app.post(
+ '/admin/places/import',
+ {
+ onRequest: app.requireRole('admin'),
+ schema: {
+ tags,
+ summary: 'Import places from the rows of a file, or preview the import',
+ security,
+ body: PlaceImportBody,
+ response: { 200: PlaceImportResult },
+ },
+ },
+ async (request) => importPlaces(app.db, request.body, memberOf(request).id),
+ );
+};
diff --git a/apps/api/src/routes/admin-moderation.test.ts b/apps/api/src/routes/admin-moderation.test.ts
index f293a962..d2e0dc14 100644
--- a/apps/api/src/routes/admin-moderation.test.ts
+++ b/apps/api/src/routes/admin-moderation.test.ts
@@ -50,7 +50,10 @@ afterAll(async () => {
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();
+ const [user] = await app.db
+ .insert(users)
+ .values({ firebaseUid: uid, role, email: 'julie.test@example.com' })
+ .returning();
if (!user) throw new Error('User not created');
return { id: user.id, headers: { authorization: `Bearer ${token}` } };
}
@@ -333,7 +336,10 @@ describe('GET /v1/admin/routes/:id', () => {
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 }]);
+ // The moderator of this test has no first name: the start of the address stands for it.
+ expect(route.history).toMatchObject([
+ { action: 'reject', actorName: 'julie.test', ...refusal },
+ ]);
});
it('counts what the author already published and was refused', async () => {
diff --git a/apps/api/src/routes/admin.test.ts b/apps/api/src/routes/admin.test.ts
index 2fd8d39b..8807e3ac 100644
--- a/apps/api/src/routes/admin.test.ts
+++ b/apps/api/src/routes/admin.test.ts
@@ -1,4 +1,4 @@
-import { AdminCounters, ApiError, type UserRole } from '@widoo/shared';
+import { AdminCounters, AdminStats, ApiError, type UserRole } from '@widoo/shared';
import { eq, inArray } from 'drizzle-orm';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { buildApp } from '../app';
@@ -129,3 +129,60 @@ describe('GET /v1/admin/counters', () => {
expect(AdminCounters.parse(response.json())).toEqual({});
});
});
+
+describe('GET /v1/admin/stats', () => {
+ const getStats = (headers: Record) =>
+ app.inject({ url: '/v1/admin/stats', headers });
+
+ it('refuses an account outside the team', async () => {
+ expect((await getStats(await signIn('user'))).statusCode).toBe(403);
+ });
+
+ it('gives a moderator what waits, with the oldest route of the queue', async () => {
+ const headers = await signIn('moderator');
+ await addPendingRoute(90);
+
+ const stats = AdminStats.parse((await getStats(headers)).json());
+ expect(Object.keys(stats.toHandle).sort()).toEqual([
+ 'moderation',
+ 'reports',
+ 'support',
+ 'translations',
+ ]);
+ const moderation = stats.toHandle.moderation;
+ expect(moderation?.count).toBeGreaterThanOrEqual(1);
+ expect(moderation?.lateCount).toBeGreaterThanOrEqual(1);
+ const oldest = new Date(moderation?.oldestSince ?? '').getTime();
+ expect(Date.now() - oldest).toBeGreaterThanOrEqual(90 * hour - 60_000);
+ });
+
+ it('gives an editor the catalog, the users and the tiles of its sections only', async () => {
+ const response = await getStats(await signIn('editor'));
+ expect(response.headers['cache-control']).toBe('no-store');
+ const stats = AdminStats.parse(response.json());
+ const nothing = { count: 0, lateCount: 0, oldestSince: null };
+ expect(stats.toHandle).toEqual({ aiDrafts: nothing, translations: nothing });
+ expect(stats.catalog.publishedRoutes).toBeGreaterThanOrEqual(1);
+ expect(stats.catalog.premiumRoutes).toBeLessThanOrEqual(stats.catalog.publishedRoutes);
+ expect(stats.catalog.placesInRoutes).toBeGreaterThanOrEqual(1);
+ expect(stats.catalog.cities).toBeGreaterThanOrEqual(1);
+ expect(stats.users.registered).toBeGreaterThanOrEqual(1);
+ expect(stats.users.newLast7Days).toBeGreaterThanOrEqual(1);
+ expect(stats.users.unlocksLast30Days).toBe(0);
+ });
+
+ it('counts a new account once, and a Premium plan only while it runs', async () => {
+ const headers = await signIn('admin');
+ const before = AdminStats.parse((await getStats(headers)).json()).users;
+ const day = 24 * hour;
+ for (const planExpiresAt of [new Date(Date.now() + day), new Date(Date.now() - day)]) {
+ const { uid } = verifier.issue();
+ createdUids.push(uid);
+ await app.db.insert(users).values({ firebaseUid: uid, plan: 'premium', planExpiresAt });
+ }
+
+ const after = AdminStats.parse((await getStats(headers)).json()).users;
+ expect(after.registered - before.registered).toBeGreaterThanOrEqual(2);
+ expect(after.premiumActive - before.premiumActive).toBeGreaterThanOrEqual(1);
+ });
+});
diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts
index d42a4ef1..d5c65b4c 100644
--- a/apps/api/src/routes/admin.ts
+++ b/apps/api/src/routes/admin.ts
@@ -1,6 +1,7 @@
-import { AdminCounters } from '@widoo/shared';
+import { AdminCounters, AdminStats } from '@widoo/shared';
import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
import { readAdminCounters } from '../admin/counters';
+import { readAdminStats } from '../admin/stats';
import { httpError } from '../errors';
const security = [{ firebaseIdToken: [] }];
@@ -33,4 +34,23 @@ export const adminRoutes: FastifyPluginAsyncZod = async (app) => {
return readAdminCounters(app.db, request.user.role);
},
);
+
+ app.get(
+ '/admin/stats',
+ {
+ onRequest: app.requireAdminSection('dashboard'),
+ schema: {
+ tags: ['admin'],
+ summary: 'Figures of the dashboard',
+ security,
+ response: { 200: AdminStats },
+ },
+ },
+ async (request) => {
+ if (!request.user) {
+ throw httpError(401, 'Missing bearer token');
+ }
+ return readAdminStats(app.db, request.user.role);
+ },
+ );
};
diff --git a/packages/api-client/src/catalog.test.ts b/packages/api-client/src/catalog.test.ts
new file mode 100644
index 00000000..91bde4f4
--- /dev/null
+++ b/packages/api-client/src/catalog.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it } from 'vitest';
+import { catalogPlacesQueryString, catalogQueryString } from './catalog';
+
+describe('catalogQueryString', () => {
+ it('is empty for the default list', () => {
+ expect(catalogQueryString({})).toBe('');
+ });
+
+ it('writes the search, the filters, the sort and the page', () => {
+ expect(
+ catalogQueryString({
+ q: 'marais',
+ status: 'unpublished',
+ access: 'premium',
+ sort: 'rating',
+ direction: 'asc',
+ cursor: 'NTA',
+ }),
+ ).toBe('q=marais&status=unpublished&access=premium&sort=rating&direction=asc&cursor=NTA');
+ });
+});
+
+describe('catalogPlacesQueryString', () => {
+ it('writes the search, the filters and the sort of the places', () => {
+ expect(catalogPlacesQueryString({ q: 'café', category: 'cafe', status: 'stale' })).toBe(
+ 'q=caf%C3%A9&category=cafe&status=stale',
+ );
+ });
+});
diff --git a/packages/api-client/src/catalog.ts b/packages/api-client/src/catalog.ts
new file mode 100644
index 00000000..a89ac3f1
--- /dev/null
+++ b/packages/api-client/src/catalog.ts
@@ -0,0 +1,24 @@
+import type { CatalogPlacesQuery, CatalogRoutesQuery } from '@widoo/shared';
+
+/** A query string from the values that are set, in the order of `keys`. */
+function queryStringOf>(
+ params: Params,
+ keys: readonly (keyof Params & string)[],
+): string {
+ const query = new URLSearchParams();
+ for (const key of keys) {
+ const value = params[key];
+ if (value) {
+ query.set(key, value);
+ }
+ }
+ return query.toString();
+}
+
+/** Query string of the routes of the catalog, as the API reads it; what is not set is left out. */
+export const catalogQueryString = (params: Partial) =>
+ queryStringOf(params, ['q', 'status', 'access', 'sort', 'direction', 'cursor']);
+
+/** Query string of the places of the catalog, as the API reads it. */
+export const catalogPlacesQueryString = (params: Partial) =>
+ queryStringOf(params, ['q', 'category', 'status', 'sort', 'direction', 'cursor']);
diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts
index f130b473..30e91687 100644
--- a/packages/api-client/src/client.ts
+++ b/packages/api-client/src/client.ts
@@ -1,17 +1,29 @@
import {
AdminCounters,
+ AdminStats,
AppConfig,
+ CatalogPlaceDetail,
+ CatalogPlaces,
+ CatalogRouteAction,
+ CatalogRoutes,
GeocodeResult,
Me,
ModerationDecision,
ModerationQueue,
ModerationRouteDetail,
+ PlaceImportResult,
RouteCount,
RouteSearchResult,
+ type CatalogPlaceInput,
+ type CatalogPlacesQuery,
+ type CatalogRoutesQuery,
+ type PlaceImportBody,
type RejectionBody,
+ type RepublishBody,
type RequestChangesBody,
type UpdateMe,
} from '@widoo/shared';
+import { catalogPlacesQueryString, catalogQueryString } from './catalog';
import { moderationQueryString, type ModerationQueueParams } from './moderation';
import {
countQueryString,
@@ -92,6 +104,16 @@ export function createApiClient(options: ApiClientOptions) {
signal,
}),
+ /** `GET /v1/admin/stats`: the figures of the dashboard (A-05), for the role of the caller. */
+ getAdminStats: (signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: '/v1/admin/stats',
+ schema: AdminStats,
+ auth: true,
+ signal,
+ }),
+
/** `GET /v1/admin/moderation`: a page of the moderation queue (A-02). Moderators and admins. */
getModerationQueue: (params: ModerationQueueParams, signal?: AbortSignal) =>
request({
@@ -148,6 +170,91 @@ export function createApiClient(options: ApiClientOptions) {
auth: true,
}),
+ /**
+ * `GET /v1/admin/routes`: a page of the routes of the catalog (A-04), whatever their status.
+ * An editor receives the routes of the team only.
+ */
+ getCatalogRoutes: (params: Partial, signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: `/v1/admin/routes?${catalogQueryString(params)}`,
+ schema: CatalogRoutes,
+ auth: true,
+ signal,
+ }),
+
+ /** `POST /v1/admin/routes/:id/republish`: puts an unpublished route back online. Admins. */
+ republishRoute: (routeId: string, body: RepublishBody) =>
+ request({
+ method: 'POST',
+ path: `/v1/admin/routes/${routeId}/republish`,
+ body,
+ schema: CatalogRouteAction,
+ auth: true,
+ }),
+
+ /** `POST /v1/admin/routes/:id/reassign`: hands a route over to the Widoo team. Admins. */
+ reassignRoute: (routeId: string) =>
+ request({
+ method: 'POST',
+ path: `/v1/admin/routes/${routeId}/reassign`,
+ schema: CatalogRouteAction,
+ auth: true,
+ }),
+
+ /** `GET /v1/admin/places`: a page of the places of the catalog (A-04). */
+ getCatalogPlaces: (params: Partial, signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: `/v1/admin/places?${catalogPlacesQueryString(params)}`,
+ schema: CatalogPlaces,
+ auth: true,
+ signal,
+ }),
+
+ /** `GET /v1/admin/places/:id`: the sheet of a place, its hours and the routes that use it. */
+ getCatalogPlace: (placeId: string, signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: `/v1/admin/places/${placeId}`,
+ schema: CatalogPlaceDetail,
+ auth: true,
+ signal,
+ }),
+
+ /** `POST /v1/admin/places`: creates a place by hand. Editors and admins. */
+ createCatalogPlace: (place: CatalogPlaceInput) =>
+ request({
+ method: 'POST',
+ path: '/v1/admin/places',
+ body: place,
+ schema: CatalogPlaceDetail,
+ auth: true,
+ }),
+
+ /** `PATCH /v1/admin/places/:id`: replaces the sheet of a place. Editors and admins. */
+ updateCatalogPlace: (placeId: string, place: CatalogPlaceInput) =>
+ request({
+ method: 'PATCH',
+ path: `/v1/admin/places/${placeId}`,
+ body: place,
+ schema: CatalogPlaceDetail,
+ auth: true,
+ }),
+
+ /**
+ * `POST /v1/admin/places/import`: creates the places of a file, row by row, or says what
+ * each row would become with `dryRun`. Admins.
+ */
+ importPlaces: (body: PlaceImportBody) =>
+ request({
+ method: 'POST',
+ path: '/v1/admin/places/import',
+ body,
+ schema: PlaceImportResult,
+ auth: true,
+ }),
+
/** `DELETE /v1/me`: anonymizes the account; its token is refused afterwards. */
deleteMe: () => requestEmpty({ method: 'DELETE', path: '/v1/me', auth: true }),
};
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index efb9088d..4e49e127 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -12,6 +12,8 @@ export * from './thresholds';
export { hasRole } from './roles';
export { zoneMinSpanM, zoneSearchBox } from './zone';
export * from './schemas/admin';
+export * from './schemas/catalog';
+export * from './schemas/catalog-place';
export * from './schemas/common';
export * from './schemas/config';
export * from './schemas/error';
@@ -19,6 +21,7 @@ export * from './schemas/geocode';
export * from './schemas/me';
export * from './schemas/moderation';
export * from './schemas/place';
+export * from './schemas/place-import';
export * from './schemas/recommendation';
export * from './schemas/route';
export * from './schemas/search';
diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts
index 7c6f5b3d..a9cdc4c7 100644
--- a/packages/shared/src/schemas/admin.ts
+++ b/packages/shared/src/schemas/admin.ts
@@ -18,3 +18,42 @@ export type AdminCounter = z.infer;
*/
export const AdminCounters = z.partialRecord(z.enum(countedAdminSections), AdminCounter);
export type AdminCounters = z.infer;
+
+const count = z.number().int().nonnegative();
+
+/** A tile « à traiter » of the dashboard: a counter, and since when its oldest item waits. */
+export const AdminToHandle = AdminCounter.extend({
+ /** Null when nothing waits. */
+ oldestSince: z.iso.datetime().nullable(),
+});
+export type AdminToHandle = z.infer;
+
+/**
+ * Body of `GET /admin/stats`, the figures of the dashboard (A-05). `toHandle` holds the sections
+ * the role of the caller opens and the API counts; the other figures are the same for the team.
+ */
+export const AdminStats = z.object({
+ toHandle: z.partialRecord(z.enum(countedAdminSections), AdminToHandle),
+ catalog: z.object({
+ publishedRoutes: count,
+ /** Among the published routes. */
+ premiumRoutes: count,
+ officialRoutes: count,
+ /** Places that at least one route goes through. */
+ placesInRoutes: count,
+ /** Cities with at least one published route. */
+ cities: count,
+ /** Authors who created or changed a route in the last 30 days. */
+ activeCreators: count,
+ }),
+ users: z.object({
+ registered: count,
+ newLast7Days: count,
+ /** Accounts whose Premium plan runs now, subscription or pass. */
+ premiumActive: count,
+ /** Always 0 until unit unlocks exist (`route_unlocks`). */
+ unlocksLast30Days: count,
+ }),
+ generatedAt: z.iso.datetime(),
+});
+export type AdminStats = z.infer;
diff --git a/packages/shared/src/schemas/catalog-place.ts b/packages/shared/src/schemas/catalog-place.ts
new file mode 100644
index 00000000..84df1304
--- /dev/null
+++ b/packages/shared/src/schemas/catalog-place.ts
@@ -0,0 +1,80 @@
+import { z } from 'zod';
+import { taxonomies } from '../taxonomies';
+import { HttpsUrl, LatLng, plainText } from './common';
+import { PlaceHours } from './place';
+
+/** Columns the list of places of the catalog sorts by; by name by default. */
+export const catalogPlaceSorts = ['name', 'category', 'city', 'verified', 'routes'] as const;
+export type CatalogPlaceSort = (typeof catalogPlaceSorts)[number];
+
+/** Query of `GET /admin/places`: search, filters, sort and page (A-04). */
+export const CatalogPlacesQuery = z.strictObject({
+ /** Text searched in the name and in the address. */
+ q: z.string().trim().min(1).max(100).optional(),
+ category: z.enum(taxonomies.placeCategories).optional(),
+ status: z.enum(taxonomies.verificationStatuses).optional(),
+ sort: z.enum(catalogPlaceSorts).default('name'),
+ direction: z.enum(['asc', 'desc']).default('asc'),
+ cursor: z.string().max(200).optional(),
+});
+export type CatalogPlacesQuery = z.infer;
+
+/** A place of the catalog, as its row shows it (A-04). */
+export const CatalogPlace = z.object({
+ id: z.uuid(),
+ name: z.string(),
+ category: z.enum(taxonomies.placeCategories),
+ cityName: z.string(),
+ verificationStatus: z.enum(taxonomies.verificationStatuses),
+ verifiedAt: z.iso.datetime().nullable(),
+ /** Routes that go through the place, whatever their status. */
+ routeCount: z.number().int().nonnegative(),
+});
+export type CatalogPlace = z.infer;
+
+/** Body of `GET /admin/places`. */
+export const CatalogPlaces = z.object({
+ items: z.array(CatalogPlace),
+ total: z.number().int().nonnegative(),
+ offset: z.number().int().nonnegative(),
+ previousCursor: z.string().nullable(),
+ nextCursor: z.string().nullable(),
+});
+export type CatalogPlaces = z.infer;
+
+/**
+ * What the team writes of a place (A-04), on creation and on edition alike: the whole sheet is
+ * sent, and its opening hours replace the previous ones. The city is never sent: it follows
+ * from the position. The verification status has its own actions (A-03).
+ */
+export const CatalogPlaceInput = z.strictObject({
+ name: plainText(120),
+ category: z.enum(taxonomies.placeCategories),
+ address: plainText(240),
+ location: LatLng,
+ isIndoor: z.boolean(),
+ priceRange: z.enum(taxonomies.priceRanges).nullable(),
+ websiteUrl: HttpsUrl.nullable(),
+ phone: z
+ .string()
+ .trim()
+ .regex(/^\+?[0-9 .()-]{6,24}$/, 'Expected a phone number')
+ .nullable(),
+ hours: z.array(PlaceHours).max(60),
+});
+export type CatalogPlaceInput = z.infer;
+
+/** Body of `GET /admin/places/:id`: the sheet of a place, and the routes that use it. */
+export const CatalogPlaceDetail = CatalogPlace.extend({
+ address: z.string(),
+ location: LatLng,
+ isIndoor: z.boolean(),
+ priceRange: z.enum(taxonomies.priceRanges).nullable(),
+ websiteUrl: z.string().nullable(),
+ phone: z.string().nullable(),
+ hours: z.array(PlaceHours),
+ routes: z.array(
+ z.object({ id: z.uuid(), title: z.string(), status: z.enum(taxonomies.routeStatuses) }),
+ ),
+});
+export type CatalogPlaceDetail = z.infer;
diff --git a/packages/shared/src/schemas/catalog.ts b/packages/shared/src/schemas/catalog.ts
new file mode 100644
index 00000000..b3b7f1e3
--- /dev/null
+++ b/packages/shared/src/schemas/catalog.ts
@@ -0,0 +1,65 @@
+import { z } from 'zod';
+import { taxonomies } from '../taxonomies';
+import { MessageToAuthor } from './moderation';
+
+/** Columns the list of routes of the catalog sorts by; the last change first by default. */
+export const catalogRouteSorts = ['updated', 'title', 'author', 'city', 'rating'] as const;
+export type CatalogRouteSort = (typeof catalogRouteSorts)[number];
+
+/** Rows of a page of the catalog. */
+export const catalogPageSize = 50;
+
+/** Query of `GET /admin/routes`: search, filters, sort and page (A-04). */
+export const CatalogRoutesQuery = z.strictObject({
+ /** Text searched in the title and in the first name of the author. */
+ q: z.string().trim().min(1).max(100).optional(),
+ status: z.enum(taxonomies.routeStatuses).optional(),
+ access: z.enum(taxonomies.plans).optional(),
+ sort: z.enum(catalogRouteSorts).default('updated'),
+ direction: z.enum(['asc', 'desc']).default('desc'),
+ cursor: z.string().max(200).optional(),
+});
+export type CatalogRoutesQuery = z.infer;
+
+/** A route of the catalog, as its row shows it (A-04). */
+export const CatalogRoute = z.object({
+ id: z.uuid(),
+ title: z.string(),
+ status: z.enum(taxonomies.routeStatuses),
+ access: z.enum(taxonomies.plans),
+ /** « Par Widoo »: a route of the team. */
+ isOfficial: z.boolean(),
+ /** Null for a route of the team, or once the account of the author is purged. */
+ author: z.object({ id: z.uuid(), firstName: z.string().nullable() }).nullable(),
+ cityName: z.string(),
+ rating: z.object({
+ /** Null until the first rating. */
+ average: z.number().min(1).max(5).nullable(),
+ count: z.number().int().nonnegative(),
+ }),
+ updatedAt: z.iso.datetime(),
+});
+export type CatalogRoute = z.infer;
+
+/** Body of `GET /admin/routes`. */
+export const CatalogRoutes = z.object({
+ items: z.array(CatalogRoute),
+ total: z.number().int().nonnegative(),
+ /** 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 CatalogRoutes = z.infer;
+
+/** Body of `POST /admin/routes/:id/republish`: why the route goes back online. */
+export const RepublishBody = z.strictObject({ message: MessageToAuthor });
+export type RepublishBody = z.infer;
+
+/** Answer of a catalog action on a route: the route as it stands afterwards. */
+export const CatalogRouteAction = z.object({
+ routeId: z.uuid(),
+ status: z.enum(taxonomies.routeStatuses),
+ isOfficial: z.boolean(),
+});
+export type CatalogRouteAction = z.infer;
diff --git a/packages/shared/src/schemas/moderation.ts b/packages/shared/src/schemas/moderation.ts
index 0b2fee8e..06169b03 100644
--- a/packages/shared/src/schemas/moderation.ts
+++ b/packages/shared/src/schemas/moderation.ts
@@ -164,8 +164,11 @@ 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(),
+ /**
+ * Who decided: the first name of the member of the team, or the start of their address when
+ * the account has none. Null once the account is purged.
+ */
+ actorName: z.string().nullable(),
reasonCode: z.enum(taxonomies.rejectionReasons).nullable(),
message: z.string().nullable(),
createdAt: z.iso.datetime(),
diff --git a/packages/shared/src/schemas/place-import.ts b/packages/shared/src/schemas/place-import.ts
new file mode 100644
index 00000000..a2242e55
--- /dev/null
+++ b/packages/shared/src/schemas/place-import.ts
@@ -0,0 +1,45 @@
+import { z } from 'zod';
+
+/** Rows an import may hold at once. */
+export const placeImportMaxRows = 500;
+
+/**
+ * Body of `POST /admin/places/import` (A-04). Each row is a place as `CatalogPlaceInput`
+ * describes it, sent unchecked with the line of the file it comes from: the API checks every row
+ * and answers row by row. With `dryRun`, nothing is created: this is the preview.
+ */
+export const PlaceImportBody = z.strictObject({
+ dryRun: z.boolean(),
+ rows: z
+ .array(z.strictObject({ line: z.number().int().positive(), place: z.unknown() }))
+ .min(1)
+ .max(placeImportMaxRows),
+});
+export type PlaceImportBody = z.infer;
+
+/**
+ * What became of a row: `ready` to be created (preview), `created`, `invalid` (fields refused),
+ * `duplicate` (a place of the same name within 50 m, in the catalog or earlier in the file), or
+ * `outside` every city.
+ */
+export const placeImportOutcomes = ['ready', 'created', 'invalid', 'duplicate', 'outside'] as const;
+export type PlaceImportOutcome = (typeof placeImportOutcomes)[number];
+
+export const PlaceImportRowResult = z.object({
+ line: z.number().int().positive(),
+ outcome: z.enum(placeImportOutcomes),
+ /** For `invalid`: the fields of the place that are missing or refused. */
+ fields: z.array(z.string()),
+ /** For `duplicate`: the place it doubles, with the line of the file when it is another row. */
+ duplicateOf: z
+ .object({ name: z.string(), placeId: z.uuid().nullable(), line: z.number().int().nullable() })
+ .nullable(),
+});
+export type PlaceImportRowResult = z.infer;
+
+/** Answer of an import, or of its preview: every row, in the order of the file. */
+export const PlaceImportResult = z.object({
+ rows: z.array(PlaceImportRowResult),
+ counts: z.record(z.enum(placeImportOutcomes), z.number().int().nonnegative()),
+});
+export type PlaceImportResult = z.infer;
diff --git a/packages/ui/tailwind-preset.cjs b/packages/ui/tailwind-preset.cjs
index 5b69bd35..d2227295 100644
--- a/packages/ui/tailwind-preset.cjs
+++ b/packages/ui/tailwind-preset.cjs
@@ -95,10 +95,13 @@ module.exports = {
},
maxWidth: { 'sign-in': '440px', dialog: '520px' },
// A change of a revision on the moderation sheet: its subject, then what changed.
- gridTemplateColumns: { change: '150px 1fr' },
+ // And a day of the week next to its opening hours, in the form of a place.
+ gridTemplateColumns: { change: '150px 1fr', hours: '110px 1fr' },
// Text of a button of 36 px and of a navigation entry: `body-s` in 700.
fontSize: {
control: ['14px', { lineHeight: '20px', fontWeight: '700' }],
+ // Figure of the dashboard: 30/36 in 800.
+ figure: ['30px', { lineHeight: '36px', fontWeight: '800', letterSpacing: '-0.6px' }],
// Header of a table column and text of a badge: 12/16 in 600.
'column-head': ['12px', { lineHeight: '16px', fontWeight: '600' }],
},