diff --git a/apps/admin/src/app/(shell)/signalements/avis/page.tsx b/apps/admin/src/app/(shell)/signalements/avis/page.tsx
new file mode 100644
index 00000000..c0b1ca5c
--- /dev/null
+++ b/apps/admin/src/app/(shell)/signalements/avis/page.tsx
@@ -0,0 +1,15 @@
+import { ReportsTabs } from '@/components/reports/ReportsTabs';
+import { ReportRatingsContainer } from '@/containers/ReportRatingsContainer';
+import { Suspense } from 'react';
+
+/** A-03, the ratings with a comment. */
+export default function ReportRatingsPage() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(shell)/signalements/lieux/page.tsx b/apps/admin/src/app/(shell)/signalements/lieux/page.tsx
new file mode 100644
index 00000000..7e3a11a8
--- /dev/null
+++ b/apps/admin/src/app/(shell)/signalements/lieux/page.tsx
@@ -0,0 +1,15 @@
+import { ReportsTabs } from '@/components/reports/ReportsTabs';
+import { ReportPlacesContainer } from '@/containers/ReportPlacesContainer';
+import { Suspense } from 'react';
+
+/** A-03, the places the community flagged. */
+export default function ReportPlacesPage() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/app/(shell)/signalements/page.tsx b/apps/admin/src/app/(shell)/signalements/page.tsx
new file mode 100644
index 00000000..7af9f54e
--- /dev/null
+++ b/apps/admin/src/app/(shell)/signalements/page.tsx
@@ -0,0 +1,7 @@
+import { reportsPaths } from '@/components/reports/ReportsTabs';
+import { redirect } from 'next/navigation';
+
+/** A-03 opens on the reports of routes, where what waits for the team is counted. */
+export default function ReportsPage() {
+ redirect(reportsPaths.routes);
+}
diff --git a/apps/admin/src/app/(shell)/signalements/parcours/page.tsx b/apps/admin/src/app/(shell)/signalements/parcours/page.tsx
new file mode 100644
index 00000000..4b056cee
--- /dev/null
+++ b/apps/admin/src/app/(shell)/signalements/parcours/page.tsx
@@ -0,0 +1,15 @@
+import { ReportsTabs } from '@/components/reports/ReportsTabs';
+import { ReportRoutesContainer } from '@/containers/ReportRoutesContainer';
+import { Suspense } from 'react';
+
+/** A-03, the open reports of routes. The state of the list is read from the address. */
+export default function ReportRoutesPage() {
+ return (
+
+
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/catalog/CatalogTabs.tsx b/apps/admin/src/components/catalog/CatalogTabs.tsx
index 79491940..7138cd90 100644
--- a/apps/admin/src/components/catalog/CatalogTabs.tsx
+++ b/apps/admin/src/components/catalog/CatalogTabs.tsx
@@ -1,7 +1,6 @@
+import { SectionTabs } from '@/components/navigation/SectionTabs';
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;
@@ -14,22 +13,13 @@ export const catalogPaths = {
/** 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/navigation/SectionTabs.tsx b/apps/admin/src/components/navigation/SectionTabs.tsx
new file mode 100644
index 00000000..6935f57e
--- /dev/null
+++ b/apps/admin/src/components/navigation/SectionTabs.tsx
@@ -0,0 +1,36 @@
+import { cn } from '@widoo/ui';
+import Link from 'next/link';
+
+type SectionTabsProps = {
+ /** Name of the navigation for screen readers. */
+ label: string;
+ tabs: readonly { id: TabId; label: string; href: string }[];
+ current: TabId;
+};
+
+/** The lists of a section as tabs above them, each one its own address. */
+export function SectionTabs({
+ label,
+ tabs,
+ current,
+}: SectionTabsProps) {
+ return (
+
+ {tabs.map((tab) => (
+
+ {tab.label}
+
+ ))}
+
+ );
+}
diff --git a/apps/admin/src/components/reports/ClosePlaceDialog.tsx b/apps/admin/src/components/reports/ClosePlaceDialog.tsx
new file mode 100644
index 00000000..9d242b8e
--- /dev/null
+++ b/apps/admin/src/components/reports/ClosePlaceDialog.tsx
@@ -0,0 +1,51 @@
+import { messages } from '@/messages/fr';
+import { ConfirmDialog, Field, Textarea } from '@widoo/ui';
+
+const texts = messages.reports.places.closeDialog;
+
+type ClosePlaceDialogProps = {
+ isOpen: boolean;
+ /** Published routes that go through the place: they become « à corriger ». */
+ routeCount: number;
+ reason: string;
+ onReasonChange: (reason: string) => void;
+ onCancel: () => void;
+ onConfirm: () => void;
+ isBusy: boolean;
+};
+
+/** « Fermer le lieu »: the effect on its routes, and a required reason kept in the journal. */
+export function ClosePlaceDialog({
+ isOpen,
+ routeCount,
+ reason,
+ onReasonChange,
+ onCancel,
+ onConfirm,
+ isBusy,
+}: ClosePlaceDialogProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/reports/RatingsTable.tsx b/apps/admin/src/components/reports/RatingsTable.tsx
new file mode 100644
index 00000000..673f725f
--- /dev/null
+++ b/apps/admin/src/components/reports/RatingsTable.tsx
@@ -0,0 +1,66 @@
+import { StarIcon } from '@phosphor-icons/react/dist/ssr';
+import { formatRelativeDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import type { AdminRating } from '@widoo/shared';
+import { StatusBadge, Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';
+
+const texts = messages.reports.ratings;
+const columnCount = 5;
+
+type RatingsTableProps = {
+ /** Rows of the page; ignored while `isLoading`. */
+ items: AdminRating[];
+ isLoading: boolean;
+};
+
+/** The ratings with a comment as a table (A-03): the note, the comment, the route and its author. */
+export function RatingsTable({ items, isLoading }: RatingsTableProps) {
+ return (
+
+
+
+ {texts.columns.rating}
+ {texts.columns.comment}
+ {texts.columns.route}
+ {texts.columns.author}
+ {texts.columns.date}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ items.map((rating) => (
+
+
+
+
+ {rating.rating}
+
+
+
+
+ {rating.comment}
+ {rating.isHidden ? (
+ {texts.hidden}
+ ) : null}
+
+
+ {rating.route.title}
+
+ {rating.author ? (
+ (rating.author.firstName ?? '')
+ ) : (
+ {texts.unknownAuthor}
+ )}
+
+
+ {formatRelativeDate(new Date(rating.createdAt))}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/components/reports/ReportActions.tsx b/apps/admin/src/components/reports/ReportActions.tsx
new file mode 100644
index 00000000..d735f689
--- /dev/null
+++ b/apps/admin/src/components/reports/ReportActions.tsx
@@ -0,0 +1,52 @@
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+import Link from 'next/link';
+
+const texts = messages.reports.routes;
+
+export type ReportActionName = 'accept' | 'reject' | 'keep' | 'requestChanges' | 'unpublish';
+
+type ReportActionsProps = {
+ /** A note-based report offers « Garder », « Demander des modifications » and « Dépublier ». */
+ isLowRating: boolean;
+ /** Address of the sheet of the route, to read it before deciding. */
+ routePath: string;
+ onAct: (action: ReportActionName) => void;
+ isBusy: boolean;
+};
+
+/**
+ * The decisions on a report (A-03). A report of a reader is accepted or rejected, with a note; a
+ * low rating, which has no reader to thank, is kept, sent back for changes, or taken offline.
+ */
+export function ReportActions({ isLowRating, routePath, onAct, isBusy }: ReportActionsProps) {
+ return (
+ <>
+
+ {texts.panel.openRoute}
+
+ {isLowRating ? (
+ <>
+ onAct('keep')} disabled={isBusy}>
+ {texts.actions.keep}
+
+ onAct('requestChanges')} disabled={isBusy}>
+ {texts.actions.requestChanges}
+
+ onAct('unpublish')} disabled={isBusy}>
+ {texts.actions.unpublish}
+
+ >
+ ) : (
+ <>
+ onAct('accept')} disabled={isBusy}>
+ {texts.actions.accept}
+
+ onAct('reject')} disabled={isBusy}>
+ {texts.actions.reject}
+
+ >
+ )}
+ >
+ );
+}
diff --git a/apps/admin/src/components/reports/ReportNoteDialog.tsx b/apps/admin/src/components/reports/ReportNoteDialog.tsx
new file mode 100644
index 00000000..c63e78a8
--- /dev/null
+++ b/apps/admin/src/components/reports/ReportNoteDialog.tsx
@@ -0,0 +1,51 @@
+import { messages } from '@/messages/fr';
+import { ConfirmDialog, Field, Textarea } from '@widoo/ui';
+
+const texts = messages.reports.routes.dialogs;
+
+type ReportNoteDialogProps = {
+ isOpen: boolean;
+ /** What the window says: its question, the effect of the decision, and its button. */
+ dialog: { title: string; description: string; confirm: string };
+ note: string;
+ onNoteChange: (note: string) => void;
+ onCancel: () => void;
+ onConfirm: () => void;
+ isBusy: boolean;
+};
+
+/** A decision on a report with an optional note for the team, kept in the journal. */
+export function ReportNoteDialog({
+ isOpen,
+ dialog,
+ note,
+ onNoteChange,
+ onCancel,
+ onConfirm,
+ isBusy,
+}: ReportNoteDialogProps) {
+ return (
+
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/reports/ReportPanelContent.tsx b/apps/admin/src/components/reports/ReportPanelContent.tsx
new file mode 100644
index 00000000..9513e43c
--- /dev/null
+++ b/apps/admin/src/components/reports/ReportPanelContent.tsx
@@ -0,0 +1,56 @@
+import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge';
+import { messages } from '@/messages/fr';
+import type { AdminRouteReport } from '@widoo/shared';
+import type { ReactNode } from 'react';
+
+const texts = messages.reports.routes.panel;
+
+/** A line of the panel: what it is on the left, its value on the right. */
+function Line({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ );
+}
+
+/** What a report says, inside the detail panel: the route, who reported it, why, and where. */
+export function ReportPanelContent({ report }: { report: AdminRouteReport }) {
+ const isLowRating = report.reason === 'low_rating';
+ return (
+ <>
+ {isLowRating ? (
+
+ {texts.lowRatingHint}
+
+ ) : null}
+
+
+
+ {report.route.title}
+
+
+
+ {report.route.cityName}
+
+ {report.author?.firstName ??
+ (report.route.isOfficial ? messages.reports.routes.team : messages.moderation.noAuthor)}
+
+ {isLowRating ? null : (
+
+ {report.reporter?.firstName ?? messages.reports.routes.unknown}
+
+ )}
+ {report.step ? (
+
+ {messages.reports.routes.stepOf(report.step.position, report.step.placeName)}
+
+ ) : null}
+
+ {report.comment ?? {texts.noComment} }
+
+
+ >
+ );
+}
diff --git a/apps/admin/src/components/reports/ReportReasonBadge.tsx b/apps/admin/src/components/reports/ReportReasonBadge.tsx
new file mode 100644
index 00000000..8935aa8e
--- /dev/null
+++ b/apps/admin/src/components/reports/ReportReasonBadge.tsx
@@ -0,0 +1,20 @@
+import { messages } from '@/messages/fr';
+import type { RouteReportReason } from '@widoo/shared';
+import { StatusBadge } from '@widoo/ui';
+import type { ComponentProps } from 'react';
+
+const tones: Record['tone']> = {
+ place_changed: 'info',
+ inappropriate: 'neutral',
+ dangerous_place: 'error',
+ hidden_ad: 'neutral',
+ copied_content: 'neutral',
+ false_info: 'neutral',
+ other: 'neutral',
+ low_rating: 'alert',
+};
+
+/** Why a route was reported: « Lieu modifié », « Contenu copié », « Note basse »… */
+export function ReportReasonBadge({ reason }: { reason: RouteReportReason }) {
+ return {messages.reports.reasons[reason]} ;
+}
diff --git a/apps/admin/src/components/reports/ReportsTabs.tsx b/apps/admin/src/components/reports/ReportsTabs.tsx
new file mode 100644
index 00000000..2022f434
--- /dev/null
+++ b/apps/admin/src/components/reports/ReportsTabs.tsx
@@ -0,0 +1,27 @@
+import { SectionTabs } from '@/components/navigation/SectionTabs';
+import { sectionPaths } from '@/config/navigation';
+import { messages } from '@/messages/fr';
+
+const texts = messages.reports.tabs;
+
+/** Addresses of the three lists of the reports (A-03). */
+export const reportsPaths = {
+ places: `${sectionPaths.reports}/lieux`,
+ routes: `${sectionPaths.reports}/parcours`,
+ ratings: `${sectionPaths.reports}/avis`,
+} as const;
+
+/** The three lists of A-03 as tabs: places, routes, ratings. */
+export function ReportsTabs({ current }: { current: keyof typeof reportsPaths }) {
+ return (
+
+ );
+}
diff --git a/apps/admin/src/components/reports/RouteReportsTable.tsx b/apps/admin/src/components/reports/RouteReportsTable.tsx
new file mode 100644
index 00000000..b9d18fc9
--- /dev/null
+++ b/apps/admin/src/components/reports/RouteReportsTable.tsx
@@ -0,0 +1,86 @@
+import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge';
+import { formatRelativeDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import type { AdminRouteReport } from '@widoo/shared';
+import { Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';
+import { ReportReasonBadge } from './ReportReasonBadge';
+
+const texts = messages.reports.routes;
+const columnCount = 5;
+
+type RouteReportsTableProps = {
+ /** Rows of the page; ignored while `isLoading`. */
+ items: AdminRouteReport[];
+ isLoading: boolean;
+ /** The report whose decisions are open in the detail panel. */
+ openId: string | null;
+ onOpen: (report: AdminRouteReport) => void;
+};
+
+/** The open reports of routes as a table (A-03), oldest first; a row opens its decisions. */
+export function RouteReportsTable({ items, isLoading, openId, onOpen }: RouteReportsTableProps) {
+ return (
+
+
+
+ {texts.columns.route}
+ {texts.columns.reason}
+ {texts.columns.reporter}
+ {texts.columns.author}
+ {texts.columns.since}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ items.map((report) => (
+ onOpen(report)} isOpen={report.id === openId}>
+
+
+ {report.route.title}
+
+ {report.route.cityName}
+
+ {report.openOnRoute > 1 ? texts.otherOpen(report.openOnRoute) : null}
+
+
+
+
+
+
+ {report.step ? (
+
+ {texts.stepOf(report.step.position, report.step.placeName)}
+
+ ) : null}
+
+
+
+ {report.reason === 'low_rating' ? (
+ {texts.noReporter}
+ ) : (
+ (report.reporter?.firstName ?? (
+ {texts.unknown}
+ ))
+ )}
+
+
+ {report.author ? (
+ (report.author.firstName ?? '')
+ ) : (
+
+ {report.route.isOfficial ? texts.team : messages.moderation.noAuthor}
+
+ )}
+
+
+ {formatRelativeDate(new Date(report.createdAt))}
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/components/reports/SignaledPlacesTable.tsx b/apps/admin/src/components/reports/SignaledPlacesTable.tsx
new file mode 100644
index 00000000..6ac207e3
--- /dev/null
+++ b/apps/admin/src/components/reports/SignaledPlacesTable.tsx
@@ -0,0 +1,107 @@
+import { VerificationBadge } from '@/components/catalog/VerificationBadge';
+import { formatRelativeDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import type { AdminSignaledPlace } from '@widoo/shared';
+import { Button, Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';
+import Link from 'next/link';
+
+const texts = messages.reports.places;
+const columnCount = 6;
+
+type SignaledPlacesTableProps = {
+ /** Rows of the page; ignored while `isLoading`. */
+ items: AdminSignaledPlace[];
+ isLoading: boolean;
+ /** Address of the form of a place; absent for a role that does not edit the catalog. */
+ editPathOf?: (placeId: string) => string;
+ onVerify: (place: AdminSignaledPlace) => void;
+ onClose: (place: AdminSignaledPlace) => void;
+ isBusy: boolean;
+};
+
+/** The places the community flagged (A-03), each with the actions that settle it. */
+export function SignaledPlacesTable({
+ items,
+ isLoading,
+ editPathOf,
+ onVerify,
+ onClose,
+ isBusy,
+}: SignaledPlacesTableProps) {
+ return (
+
+
+
+ {texts.columns.place}
+ {texts.columns.status}
+ {texts.columns.signals}
+ {texts.columns.routes}
+ {texts.columns.verified}
+
+ {messages.reports.routes.panel.openRoute}
+
+
+
+
+ {isLoading ? (
+
+ ) : (
+ items.map((place) => (
+
+
+
+ {place.name}
+
+ {place.address}, {place.cityName}
+
+
+
+
+
+
+ {place.signalCount}
+
+ {place.routes.length === 0 ? (
+ {texts.noRoute}
+ ) : (
+
+ {place.routes.map((route) => (
+ {route.title}
+ ))}
+
+ )}
+
+
+ {place.verifiedAt ? (
+ formatRelativeDate(new Date(place.verifiedAt))
+ ) : (
+ {texts.neverVerified}
+ )}
+
+
+
+ onVerify(place)} disabled={isBusy}>
+ {texts.actions.verify}
+
+ {editPathOf ? (
+
+ {texts.actions.fix}
+
+ ) : null}
+ onClose(place)}
+ disabled={isBusy}
+ >
+ {texts.actions.close}
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/config/ratings-list.test.ts b/apps/admin/src/config/ratings-list.test.ts
new file mode 100644
index 00000000..6ce2e5e3
--- /dev/null
+++ b/apps/admin/src/config/ratings-list.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import { toRatingsParams } from './ratings-list';
+
+const base = { search: '', sort: { column: 'date', direction: 'desc' }, cursor: null } as const;
+
+describe('toRatingsParams', () => {
+ it('asks for every rating without a filter', () => {
+ expect(toRatingsParams({ ...base, filters: {} })).toEqual({
+ rating: undefined,
+ cursor: undefined,
+ });
+ });
+
+ it('reads the note and the page', () => {
+ expect(toRatingsParams({ ...base, filters: { note: '2' }, cursor: 'NTA' })).toEqual({
+ rating: 2,
+ cursor: 'NTA',
+ });
+ });
+
+ it('drops a note outside 1 to 5', () => {
+ expect(toRatingsParams({ ...base, filters: { note: '9' } }).rating).toBeUndefined();
+ expect(toRatingsParams({ ...base, filters: { note: 'x' } }).rating).toBeUndefined();
+ });
+});
diff --git a/apps/admin/src/config/ratings-list.ts b/apps/admin/src/config/ratings-list.ts
new file mode 100644
index 00000000..df59c6c7
--- /dev/null
+++ b/apps/admin/src/config/ratings-list.ts
@@ -0,0 +1,25 @@
+import type { ListQuery, ListQueryOptions } from '@/lib/list-query';
+import type { AdminRatingsQuery } from '@widoo/shared';
+
+/** The ratings are filtered by their note; the newest come first and cannot be sorted. */
+export type RatingFilter = 'note';
+export type RatingColumn = 'date';
+export type RatingsListQuery = ListQuery;
+
+export const ratingsListOptions: ListQueryOptions = {
+ filters: ['note'],
+ sortableColumns: [],
+ defaultSort: { column: 'date', direction: 'desc' },
+};
+
+/** Notes of the `note` filter, from 1 to 5. */
+export const ratingValues = [1, 2, 3, 4, 5] as const;
+
+/** What to ask the API for the list shown at an address; a note out of 1 to 5 is dropped. */
+export function toRatingsParams(query: RatingsListQuery): Partial {
+ const note = Number(query.filters.note);
+ return {
+ rating: ratingValues.find((value) => value === note),
+ cursor: query.cursor ?? undefined,
+ };
+}
diff --git a/apps/admin/src/config/reports-list.test.ts b/apps/admin/src/config/reports-list.test.ts
new file mode 100644
index 00000000..fce0ae60
--- /dev/null
+++ b/apps/admin/src/config/reports-list.test.ts
@@ -0,0 +1,37 @@
+import { describe, expect, it } from 'vitest';
+import { reasonFilterValues, toReportsParams } from './reports-list';
+
+const base = { search: '', sort: { column: 'signale', direction: 'asc' }, cursor: null } as const;
+
+describe('toReportsParams', () => {
+ it('asks for every open report without a filter', () => {
+ expect(toReportsParams({ ...base, filters: {} })).toEqual({
+ reason: undefined,
+ cursor: undefined,
+ });
+ });
+
+ it('turns a reason of the address into the reason of the API, and the page', () => {
+ expect(toReportsParams({ ...base, filters: { motif: 'note-basse' }, cursor: 'NTA' })).toEqual({
+ reason: 'low_rating',
+ cursor: 'NTA',
+ });
+ });
+
+ it('drops a reason it does not know', () => {
+ expect(toReportsParams({ ...base, filters: { motif: 'constructor' } }).reason).toBeUndefined();
+ });
+
+ it('covers every reason of a report', () => {
+ expect(Object.values(reasonFilterValues).sort()).toEqual([
+ 'copied_content',
+ 'dangerous_place',
+ 'false_info',
+ 'hidden_ad',
+ 'inappropriate',
+ 'low_rating',
+ 'other',
+ 'place_changed',
+ ]);
+ });
+});
diff --git a/apps/admin/src/config/reports-list.ts b/apps/admin/src/config/reports-list.ts
new file mode 100644
index 00000000..cf168bd5
--- /dev/null
+++ b/apps/admin/src/config/reports-list.ts
@@ -0,0 +1,37 @@
+import type { ListQuery, ListQueryOptions } from '@/lib/list-query';
+import type { AdminReportsQuery, RouteReportReason } from '@widoo/shared';
+
+/** The reports are read oldest first, with no column to sort by: only the reason filters them. */
+export type ReportFilter = 'motif';
+export type ReportColumn = 'signale';
+export type ReportsListQuery = ListQuery;
+
+export const reportsListOptions: ListQueryOptions = {
+ filters: ['motif'],
+ sortableColumns: [],
+ defaultSort: { column: 'signale', direction: 'asc' },
+};
+
+/** Values of the `motif` filter, and the reason of the API each one asks for. */
+export const reasonFilterValues = {
+ 'lieu-modifie': 'place_changed',
+ inapproprie: 'inappropriate',
+ dangereux: 'dangerous_place',
+ publicite: 'hidden_ad',
+ copie: 'copied_content',
+ faux: 'false_info',
+ autre: 'other',
+ 'note-basse': 'low_rating',
+} as const satisfies Record;
+
+/** What to ask the API for the list shown at an address; an unknown reason is dropped. */
+export function toReportsParams(query: ReportsListQuery): Partial {
+ const motif = query.filters.motif;
+ return {
+ reason:
+ motif !== undefined && Object.hasOwn(reasonFilterValues, motif)
+ ? reasonFilterValues[motif as keyof typeof reasonFilterValues]
+ : undefined,
+ cursor: query.cursor ?? undefined,
+ };
+}
diff --git a/apps/admin/src/containers/ReportDecisionsContainer.tsx b/apps/admin/src/containers/ReportDecisionsContainer.tsx
new file mode 100644
index 00000000..f30cbfbf
--- /dev/null
+++ b/apps/admin/src/containers/ReportDecisionsContainer.tsx
@@ -0,0 +1,88 @@
+'use client';
+
+import { RequestChangesDialog } from '@/components/moderation/sheet/RequestChangesDialog';
+import { ReportActions, type ReportActionName } from '@/components/reports/ReportActions';
+import { ReportNoteDialog } from '@/components/reports/ReportNoteDialog';
+import { RouteReasonDialog } from '@/components/routes/RouteReasonDialog';
+import { catalogRoutePathOf } from '@/containers/CatalogRoutesContainer';
+import { useRouteReportActions } from '@/hooks/useRouteReportActions';
+import { isRejectionReason } from '@/lib/rejection-reason';
+import { messages } from '@/messages/fr';
+import type { AdminRouteReport } from '@widoo/shared';
+import { useState } from 'react';
+
+const texts = messages.reports.routes.dialogs;
+
+type ReportDecisionsContainerProps = {
+ report: AdminRouteReport;
+ /** The report is decided: the list reads again and the panel closes. */
+ onSettled: () => void;
+};
+
+/**
+ * The decisions on one open report (A-03): the buttons that fit its reason, and the window each
+ * one opens (a note, a message to the author, or a reason to take the route offline).
+ */
+export function ReportDecisionsContainer({ report, onSettled }: ReportDecisionsContainerProps) {
+ const [openAction, setOpenAction] = useState(null);
+ const [note, setNote] = useState('');
+ const [message, setMessage] = useState('');
+ const [reasonCode, setReasonCode] = useState('');
+ const close = () => setOpenAction(null);
+ const actions = useRouteReportActions({ id: report.id, routeId: report.route.id }, () => {
+ close();
+ onSettled();
+ });
+
+ const handleAct = (action: ReportActionName) => {
+ setNote('');
+ setMessage('');
+ setReasonCode('');
+ setOpenAction(action);
+ };
+ const noteDialog = (action: 'accept' | 'reject' | 'keep') => ({
+ isOpen: openAction === action,
+ dialog: texts[action],
+ note,
+ onNoteChange: setNote,
+ onCancel: close,
+ onConfirm: () => void actions[action](note),
+ isBusy: actions.isBusy,
+ });
+
+ return (
+ <>
+
+
+
+
+ void actions.requestChanges(message)}
+ isBusy={actions.isBusy}
+ />
+
+ isRejectionReason(reasonCode) && void actions.unpublish(reasonCode, message)
+ }
+ isBusy={actions.isBusy}
+ />
+ >
+ );
+}
diff --git a/apps/admin/src/containers/ReportPlacesContainer.tsx b/apps/admin/src/containers/ReportPlacesContainer.tsx
new file mode 100644
index 00000000..e531b48e
--- /dev/null
+++ b/apps/admin/src/containers/ReportPlacesContainer.tsx
@@ -0,0 +1,103 @@
+'use client';
+
+import { catalogPaths } from '@/components/catalog/CatalogTabs';
+import { ClosePlaceDialog } from '@/components/reports/ClosePlaceDialog';
+import { SignaledPlacesTable } from '@/components/reports/SignaledPlacesTable';
+import { PageMessage } from '@/components/states/PageMessage';
+import { useCanWriteCatalog } from '@/hooks/useCanWriteCatalog';
+import { usePlaceSignalActions } from '@/hooks/usePlaceSignalActions';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { getSessionClients } from '@/lib/auth/clients';
+import { messages } from '@/messages/fr';
+import type { AdminSignaledPlace } from '@widoo/shared';
+import { Button, Pagination } from '@widoo/ui';
+import { useSearchParams, useRouter, usePathname } from 'next/navigation';
+import { useState } from 'react';
+
+const texts = messages.reports.places;
+const pageParam = 'page';
+
+/**
+ * A-03, the places the community flagged: the list, and for each place « Marquer vérifié »,
+ * « Corriger le lieu » (the form of A-04, for a role that edits the catalog) and « Fermer le lieu ».
+ */
+export function ReportPlacesContainer() {
+ const router = useRouter();
+ const pathname = usePathname();
+ const cursor = useSearchParams().get(pageParam);
+ const canEdit = useCanWriteCatalog();
+ const places = useRemoteData(cursor ?? '', (signal) =>
+ getSessionClients().api.getAdminSignals({ cursor: cursor ?? undefined }, signal),
+ );
+ const [closing, setClosing] = useState(null);
+ const [reason, setReason] = useState('');
+ const reload = places.status === 'loading' ? undefined : places.reload;
+ const actions = usePlaceSignalActions(() => {
+ setClosing(null);
+ reload?.();
+ });
+
+ const data = places.status === 'ready' ? places.data : undefined;
+ const handlePage = (next: string | null) =>
+ router.replace(next ? `${pathname}?${pageParam}=${next}` : pathname, { scroll: false });
+
+ const renderList = () => {
+ if (places.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+ if (data?.items.length === 0) {
+ return ;
+ }
+ return (
+ `${catalogPaths.places}/${placeId}` : undefined}
+ onVerify={(place) => void actions.verify(place.id)}
+ onClose={(place) => {
+ setReason('');
+ setClosing(place);
+ }}
+ isBusy={actions.isBusy}
+ />
+ );
+ };
+
+ return (
+
+ {data ?
{texts.total(data.total)}
: null}
+ {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}
+
setClosing(null)}
+ onConfirm={() => closing && void actions.close(closing.id, reason)}
+ isBusy={actions.isBusy}
+ />
+
+ );
+}
diff --git a/apps/admin/src/containers/ReportRatingsContainer.tsx b/apps/admin/src/containers/ReportRatingsContainer.tsx
new file mode 100644
index 00000000..7d7d0605
--- /dev/null
+++ b/apps/admin/src/containers/ReportRatingsContainer.tsx
@@ -0,0 +1,81 @@
+'use client';
+
+import { RatingsTable } from '@/components/reports/RatingsTable';
+import { PageMessage } from '@/components/states/PageMessage';
+import { ratingsListOptions, ratingValues, toRatingsParams } from '@/config/ratings-list';
+import { useListQuery } from '@/hooks/useListQuery';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { getSessionClients } from '@/lib/auth/clients';
+import { serializeListQuery } from '@/lib/list-query';
+import { messages } from '@/messages/fr';
+import { Button, FilterMenu, Pagination } from '@widoo/ui';
+
+const texts = messages.reports.ratings;
+
+const ratingOptions = ratingValues.map((value) => ({
+ value: String(value),
+ label: texts.ratingOption(value),
+}));
+
+/**
+ * A-03, the ratings with a comment, filtered by note. The ratings come with the rating of a route
+ * at the end of an outing; until then the list is empty.
+ */
+export function ReportRatingsContainer() {
+ const { query, handleFilter, handlePage } = useListQuery(ratingsListOptions);
+ const ratings = useRemoteData(serializeListQuery(query, ratingsListOptions), (signal) =>
+ getSessionClients().api.getAdminRatings(toRatingsParams(query), signal),
+ );
+ const data = ratings.status === 'ready' ? ratings.data : undefined;
+
+ const renderList = () => {
+ if (ratings.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+ if (data?.items.length === 0) {
+ return ;
+ }
+ return ;
+ };
+
+ return (
+
+
+
+ handleFilter('note', note ?? query.filters.note ?? '')}
+ />
+
+ {data ? (
+
{texts.total(data.total)}
+ ) : null}
+
+ {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/ReportRoutesContainer.tsx b/apps/admin/src/containers/ReportRoutesContainer.tsx
new file mode 100644
index 00000000..d47cb182
--- /dev/null
+++ b/apps/admin/src/containers/ReportRoutesContainer.tsx
@@ -0,0 +1,134 @@
+'use client';
+
+import { ReportPanelContent } from '@/components/reports/ReportPanelContent';
+import { ReportReasonBadge } from '@/components/reports/ReportReasonBadge';
+import { reportsPaths } from '@/components/reports/ReportsTabs';
+import { RouteReportsTable } from '@/components/reports/RouteReportsTable';
+import { PageMessage } from '@/components/states/PageMessage';
+import { reasonFilterValues, reportsListOptions, toReportsParams } from '@/config/reports-list';
+import { ReportDecisionsContainer } from '@/containers/ReportDecisionsContainer';
+import { useListQuery } from '@/hooks/useListQuery';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { getSessionClients } from '@/lib/auth/clients';
+import { serializeListQuery } from '@/lib/list-query';
+import { messages } from '@/messages/fr';
+import { Button, DetailPanel, FilterMenu, Pagination } from '@widoo/ui';
+import { useRouter, useSearchParams } from 'next/navigation';
+
+const texts = messages.reports.routes;
+/** Parameter of the address that holds the report whose decisions are open next to the list. */
+const openReportParam = 'signalement';
+
+const reasonOptions = Object.entries(reasonFilterValues).map(([value, reason]) => ({
+ value,
+ label: messages.reports.reasons[reason],
+}));
+
+/**
+ * A-03, the open reports of routes: the list the address asks for, and next to it the decisions
+ * on the report the address names. A report that is decided leaves the list.
+ */
+export function ReportRoutesContainer() {
+ const router = useRouter();
+ const openId = useSearchParams().get(openReportParam);
+ const { query, handleFilter, handlePage } = useListQuery(reportsListOptions);
+ const listAddress = serializeListQuery(query, reportsListOptions);
+ const reports = useRemoteData(listAddress, (signal) =>
+ getSessionClients().api.getAdminReports(toReportsParams(query), signal),
+ );
+ const data = reports.status === 'ready' ? reports.data : undefined;
+ const openReport = data?.items.find((report) => report.id === openId);
+ const reload = reports.status === 'loading' ? undefined : reports.reload;
+
+ /** The address of the list as it is, with the decisions of `reportId` open, or none. */
+ const showReport = (reportId: string | null) => {
+ const params = new URLSearchParams(listAddress);
+ if (reportId) {
+ params.set(openReportParam, reportId);
+ }
+ const address = params.toString();
+ router.replace(address ? `${reportsPaths.routes}?${address}` : reportsPaths.routes, {
+ scroll: false,
+ });
+ };
+
+ const renderList = () => {
+ if (reports.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+ if (data?.items.length === 0) {
+ return ;
+ }
+ return (
+ showReport(report.id)}
+ />
+ );
+ };
+
+ return (
+
+
+
+
+ handleFilter('motif', reason ?? query.filters.motif ?? '')}
+ />
+
+ {data ? (
+
{texts.total(data.total)}
+ ) : null}
+
+ {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}
+
+ {openReport ? (
+
}
+ closeLabel={messages.confirm.close}
+ onClose={() => showReport(null)}
+ actions={
+
{
+ showReport(null);
+ reload?.();
+ }}
+ />
+ }
+ >
+
+
+ ) : null}
+
+ );
+}
diff --git a/apps/admin/src/hooks/usePlaceSignalActions.ts b/apps/admin/src/hooks/usePlaceSignalActions.ts
new file mode 100644
index 00000000..cd09a03b
--- /dev/null
+++ b/apps/admin/src/hooks/usePlaceSignalActions.ts
@@ -0,0 +1,21 @@
+import { getSessionClients } from '@/lib/auth/clients';
+import { messages } from '@/messages/fr';
+import { useRouteAction } from './useRouteAction';
+
+const texts = messages.reports.places.toasts;
+
+/**
+ * The decisions on a place the community flagged (A-03): mark it verified, or close it, which
+ * puts the published routes that go through it « à corriger ». `onSettled` is called once the
+ * place has its new status, from this action or from another member of the team.
+ */
+export function usePlaceSignalActions(onSettled: () => void) {
+ const { isBusy, send } = useRouteAction(onSettled);
+ const { api } = getSessionClients();
+ return {
+ isBusy,
+ verify: (placeId: string) => send(() => api.verifyPlace(placeId), texts.verified),
+ close: (placeId: string, reason: string) =>
+ send(() => api.closePlace(placeId, { reason }), texts.closed),
+ };
+}
diff --git a/apps/admin/src/hooks/useRouteAction.ts b/apps/admin/src/hooks/useRouteAction.ts
index 714f1f2f..3909dcbe 100644
--- a/apps/admin/src/hooks/useRouteAction.ts
+++ b/apps/admin/src/hooks/useRouteAction.ts
@@ -1,7 +1,7 @@
import { notifyCountersStale } from '@/hooks/useAdminCounters';
import { messages } from '@/messages/fr';
+import { conflictStatusLabel } from '@/lib/conflict-status';
import { ApiRequestError } from '@widoo/api-client';
-import { AlreadyDecidedDetails, labels } from '@widoo/shared';
import { showToast } from '@widoo/ui';
import { useState } from 'react';
@@ -11,10 +11,10 @@ const texts = messages.moderation.decisions.toasts;
export type RouteActionOutcome = 'published' | 'other';
/**
- * 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.
- * The counters of the navigation are read again at once.
+ * Sends an action of the team on a route, a report or a place and tells its result in a toast.
+ * `onSettled` is called once it is settled, by this action or by the one another member of the
+ * team took first (409 `already_decided`, told with the status it was given): the screen then
+ * moves on. The counters of the navigation are read again at once.
*/
export function useRouteAction(onSettled: (outcome: RouteActionOutcome) => void) {
const [isBusy, setIsBusy] = useState(false);
@@ -31,14 +31,12 @@ export function useRouteAction(onSettled: (outcome: RouteActionOutcome) => void)
notifyCountersStale();
onSettled(outcome);
} catch (error) {
- const details =
+ const conflict =
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]),
- });
+ ? conflictStatusLabel(error.body?.details)
+ : null;
+ if (conflict) {
+ showToast('info', { title: texts.alreadyDecided(conflict) });
notifyCountersStale();
onSettled('other');
} else {
diff --git a/apps/admin/src/hooks/useRouteReportActions.ts b/apps/admin/src/hooks/useRouteReportActions.ts
new file mode 100644
index 00000000..f9615e9e
--- /dev/null
+++ b/apps/admin/src/hooks/useRouteReportActions.ts
@@ -0,0 +1,41 @@
+import { getSessionClients } from '@/lib/auth/clients';
+import { messages } from '@/messages/fr';
+import type { RejectionReason } from '@widoo/shared';
+import { useRouteAction } from './useRouteAction';
+
+const texts = messages.reports.routes.toasts;
+
+/** An empty note is no note: the API takes a text of at least one character, or none. */
+const noteOf = (note: string) => note.trim() || undefined;
+
+/**
+ * The decisions on a report of a route (A-03). Accepting or rejecting only decides the report. A
+ * low rating has no reader to thank: keeping the route rejects the report, and asking for changes
+ * or unpublishing act on the route first, then accept the report. `onSettled` is called once the
+ * report is decided, by this member of the team or by another one.
+ */
+export function useRouteReportActions(
+ report: { id: string; routeId: string },
+ onSettled: () => void,
+) {
+ const { isBusy, send } = useRouteAction(onSettled);
+ const { api } = getSessionClients();
+ const resolve = (outcome: 'accepted' | 'rejected', note?: string) =>
+ api.resolveReport(report.id, { outcome, note });
+ return {
+ isBusy,
+ accept: (note: string) => send(() => resolve('accepted', noteOf(note)), texts.accepted),
+ reject: (note: string) => send(() => resolve('rejected', noteOf(note)), texts.rejected),
+ keep: (note: string) => send(() => resolve('rejected', noteOf(note)), texts.kept),
+ requestChanges: (message: string) =>
+ send(async () => {
+ await api.requestRouteChanges(report.routeId, { message });
+ await resolve('accepted', message);
+ }, texts.changesRequested),
+ unpublish: (reasonCode: RejectionReason, message: string) =>
+ send(async () => {
+ await api.unpublishRoute(report.routeId, { reasonCode, message });
+ await resolve('accepted', message);
+ }, texts.unpublished),
+ };
+}
diff --git a/apps/admin/src/lib/conflict-status.test.ts b/apps/admin/src/lib/conflict-status.test.ts
new file mode 100644
index 00000000..624fa6f6
--- /dev/null
+++ b/apps/admin/src/lib/conflict-status.test.ts
@@ -0,0 +1,15 @@
+import { describe, expect, it } from 'vitest';
+import { conflictStatusLabel } from './conflict-status';
+
+describe('conflictStatusLabel', () => {
+ it('names the status of a route, of a report and of a place', () => {
+ expect(conflictStatusLabel({ status: 'published' })).toBe('Publié');
+ expect(conflictStatusLabel({ status: 'accepted' })).toBe('Accepté');
+ expect(conflictStatusLabel({ status: 'closed' })).toBe('Fermé');
+ });
+
+ it('gives nothing for other details', () => {
+ expect(conflictStatusLabel({ status: 'nope' })).toBeNull();
+ expect(conflictStatusLabel(undefined)).toBeNull();
+ });
+});
diff --git a/apps/admin/src/lib/conflict-status.ts b/apps/admin/src/lib/conflict-status.ts
new file mode 100644
index 00000000..2b71e012
--- /dev/null
+++ b/apps/admin/src/lib/conflict-status.ts
@@ -0,0 +1,24 @@
+import {
+ AlreadyDecidedDetails,
+ labels,
+ PlaceAlreadyDecidedDetails,
+ ReportAlreadyDecidedDetails,
+} from '@widoo/shared';
+import { messages } from '@/messages/fr';
+
+/**
+ * The status another member of the team gave first, as a label, from the `details` of a 409
+ * `already_decided`: of a route, of a report or of a place. Null when the details are none of them.
+ */
+export function conflictStatusLabel(details: unknown): string | null {
+ const route = AlreadyDecidedDetails.safeParse(details);
+ if (route.success) {
+ return labels.fr.routeStatuses[route.data.status];
+ }
+ const report = ReportAlreadyDecidedDetails.safeParse(details);
+ if (report.success) {
+ return messages.reports.routes.statuses[report.data.status];
+ }
+ const place = PlaceAlreadyDecidedDetails.safeParse(details);
+ return place.success ? labels.fr.verificationStatuses[place.data.status] : null;
+}
diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts
index b8133d6a..740dc68e 100644
--- a/apps/admin/src/messages/fr.ts
+++ b/apps/admin/src/messages/fr.ts
@@ -609,6 +609,161 @@ export const messages = {
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.',
},
+ reports: {
+ tabs: { label: 'Signalements et avis', places: 'Lieux', routes: 'Parcours', ratings: 'Avis' },
+ reasons: {
+ place_changed: 'Lieu modifié',
+ inappropriate: 'Contenu inapproprié',
+ dangerous_place: 'Lieu dangereux',
+ hidden_ad: 'Publicité cachée',
+ copied_content: 'Contenu copié',
+ false_info: 'Informations fausses',
+ other: 'Autre',
+ low_rating: 'Note basse',
+ },
+ routes: {
+ filters: { label: 'Filtres des signalements', reason: 'Motif' },
+ total: (count: number) =>
+ count > 1 ? `${count} signalements ouverts` : `${count} signalement ouvert`,
+ columns: {
+ route: 'Parcours',
+ reason: 'Motif',
+ reporter: 'Signalé par',
+ author: 'Auteur',
+ since: 'Depuis',
+ },
+ stepOf: (position: number, placeName: string) => `Étape ${position + 1}, ${placeName}`,
+ noReporter: 'Note moyenne basse',
+ team: 'Équipe Widoo',
+ unknown: 'Compte supprimé',
+ otherOpen: (count: number) =>
+ count > 1 ? `${count - 1} autres signalements ouverts` : '1 autre signalement ouvert',
+ empty: { title: 'Aucun signalement ouvert', text: 'Rien à traiter pour le moment.' },
+ panel: {
+ title: (routeTitle: string) => routeTitle,
+ reportedBy: 'Signalé par',
+ author: 'Auteur',
+ city: 'Ville',
+ comment: 'Commentaire',
+ step: 'Étape concernée',
+ noComment: 'Aucun commentaire',
+ openRoute: 'Voir le parcours',
+ lowRatingHint:
+ 'La note moyenne du parcours est passée sous 2,5 avec au moins 5 avis. Gardez-le, demandez des modifications ou dépubliez-le.',
+ },
+ actions: {
+ accept: 'Accepter',
+ reject: 'Rejeter',
+ keep: 'Garder',
+ requestChanges: 'Demander des modifications',
+ unpublish: 'Dépublier',
+ },
+ dialogs: {
+ accept: {
+ title: 'Accepter ce signalement ?',
+ description:
+ 'Le signaleur est remercié. Le parcours lui-même ne change pas : corrigez-le ou dépubliez-le depuis sa fiche si besoin.',
+ confirm: 'Accepter le signalement',
+ },
+ reject: {
+ title: 'Rejeter ce signalement ?',
+ description: "Le signalement est classé sans suite. Le signaleur n'est pas prévenu.",
+ confirm: 'Rejeter le signalement',
+ },
+ keep: {
+ title: 'Garder ce parcours ?',
+ description:
+ 'Le parcours reste en ligne. Un nouveau signalement ne reviendra qu’après cinq nouveaux avis.',
+ confirm: 'Garder le parcours',
+ },
+ requestChanges: {
+ title: 'Demander des modifications ?',
+ description:
+ "Le parcours reste en ligne et passe en « À corriger ». Votre message est gardé dans son historique ; l'auteur n'est pas encore averti automatiquement.",
+ message: "Message à l'auteur",
+ confirm: 'Demander les modifications',
+ },
+ unpublish: {
+ title: 'Dépublier ce parcours ?',
+ description:
+ "Le parcours n'est plus visible dans l'app. Le motif et votre message sont gardés dans son historique.",
+ message: "Message à l'auteur",
+ confirm: 'Dépublier le parcours',
+ },
+ note: 'Note',
+ noteHint: 'Facultative. Gardée dans le journal, visible par l’équipe.',
+ },
+ toasts: {
+ accepted: 'Signalement accepté',
+ rejected: 'Signalement rejeté',
+ kept: 'Parcours gardé',
+ changesRequested: 'Modifications demandées',
+ unpublished: 'Parcours dépublié',
+ failed: "La décision n'a pas pu être enregistrée.",
+ alreadyDecided: (status: string) =>
+ `Un autre membre de l'équipe a déjà tranché : ${status.toLowerCase()}.`,
+ },
+ statuses: { accepted: 'Accepté', rejected: 'Rejeté', open: 'Ouvert' },
+ error: 'Les signalements ne se sont pas chargés.',
+ },
+ places: {
+ total: (count: number) => (count > 1 ? `${count} lieux signalés` : `${count} lieu signalé`),
+ empty: {
+ title: 'Aucun lieu signalé',
+ text: 'Les lieux signalés par la communauté apparaissent ici. Le décompte des signalements arrive avec leur enregistrement.',
+ },
+ columns: {
+ place: 'Lieu',
+ status: 'Statut',
+ signals: 'Signalements',
+ confirmations: 'Confirmations',
+ routes: 'Parcours concernés',
+ verified: 'Dernière vérification',
+ },
+ neverVerified: 'Jamais',
+ noRoute: 'Aucun',
+ actions: { verify: 'Marquer vérifié', fix: 'Corriger le lieu', close: 'Fermer le lieu' },
+ closeDialog: {
+ title: 'Fermer ce lieu ?',
+ description: (count: number) =>
+ count === 0
+ ? "Le lieu est marqué fermé. Aucun parcours publié n'y passe."
+ : count === 1
+ ? 'Le lieu est marqué fermé. Le parcours publié qui y passe passe en « À corriger ».'
+ : `Le lieu est marqué fermé. Les ${count} parcours publiés qui y passent passent en « À corriger ».`,
+ reason: 'Motif',
+ reasonHint: 'Gardé dans le journal et dans l’historique des parcours concernés.',
+ confirm: 'Fermer le lieu',
+ },
+ toasts: {
+ verified: 'Lieu marqué vérifié',
+ closed: 'Lieu fermé',
+ failed: "L'action n'a pas pu être enregistrée.",
+ alreadyDecided: (status: string) =>
+ `Un autre membre de l'équipe a déjà tranché : ${status.toLowerCase()}.`,
+ },
+ error: 'Les lieux signalés ne se sont pas chargés.',
+ },
+ ratings: {
+ filters: { label: 'Filtres des avis', rating: 'Note' },
+ ratingOption: (value: number) => (value > 1 ? `${value} étoiles` : `${value} étoile`),
+ total: (count: number) => `${count} avis`,
+ columns: {
+ rating: 'Note',
+ comment: 'Commentaire',
+ route: 'Parcours',
+ author: 'Auteur',
+ date: 'Date',
+ },
+ empty: {
+ title: 'Aucun avis avec commentaire',
+ text: 'Les avis laissés par les utilisateurs après une sortie apparaissent ici.',
+ },
+ hidden: 'Masqué',
+ unknownAuthor: 'Compte supprimé',
+ error: 'Les avis ne se sont pas chargés.',
+ },
+ },
notFound: {
title: 'Page introuvable',
text: "Cette adresse n'existe pas dans l'admin.",
diff --git a/apps/api/src/admin/counters.ts b/apps/api/src/admin/counters.ts
index 95cc4f41..09231be8 100644
--- a/apps/api/src/admin/counters.ts
+++ b/apps/api/src/admin/counters.ts
@@ -7,7 +7,7 @@ import {
} from '@widoo/shared';
import { eq, sql } from 'drizzle-orm';
import type { Db } from '../db/client';
-import { routes } from '../db/schema';
+import { places, routeReports, routes } from '../db/schema';
/**
* Routes waiting for the team. Late after 72 hours without a change: until the history of a
@@ -27,8 +27,32 @@ async function countPendingRoutes(db: Db, now: Date): Promise {
}
/**
- * Counters of the navigation of the admin, for the sections `role` opens. Signals, support, AI
- * drafts and translations join as their tables arrive with their sections.
+ * What waits in A-03: the open reports of routes, late after 72 hours, and the places the
+ * community flagged. A flagged place has no date until its signals are stored (`place_signals`),
+ * so it is never late.
+ */
+async function countOpenReports(db: Db, now: Date): Promise {
+ const lateBefore = new Date(now.getTime() - adminLateAfterHours * 60 * 60 * 1000);
+ const [reports] = await db
+ .select({
+ count: sql`count(*)::int`,
+ lateCount: sql`(count(*) filter (where ${routeReports.createdAt} < ${lateBefore.toISOString()}::timestamptz))::int`,
+ })
+ .from(routeReports)
+ .where(eq(routeReports.status, 'open'));
+ const [flagged] = await db
+ .select({ count: sql`count(*)::int` })
+ .from(places)
+ .where(eq(places.verificationStatus, 'flagged'));
+ return {
+ count: (reports?.count ?? 0) + (flagged?.count ?? 0),
+ lateCount: reports?.lateCount ?? 0,
+ };
+}
+
+/**
+ * Counters of the navigation of the admin, for the sections `role` opens. Support, AI drafts and
+ * translations join as their tables arrive with their sections.
*/
export async function readAdminCounters(
db: Db,
@@ -39,5 +63,8 @@ export async function readAdminCounters(
if (canOpenAdminSection(role, 'moderation')) {
counters.moderation = await countPendingRoutes(db, now);
}
+ if (canOpenAdminSection(role, 'reports')) {
+ counters.reports = await countOpenReports(db, now);
+ }
return counters;
}
diff --git a/apps/api/src/admin/stats.ts b/apps/api/src/admin/stats.ts
index 08ceff7f..7bd78b66 100644
--- a/apps/api/src/admin/stats.ts
+++ b/apps/api/src/admin/stats.ts
@@ -14,6 +14,10 @@ type StatsRow = {
pending: number;
pending_late: number;
pending_oldest: Date | null;
+ reports: number;
+ reports_late: number;
+ reports_oldest: Date | null;
+ flagged_places: number;
published_routes: number;
premium_routes: number;
official_routes: number;
@@ -45,6 +49,11 @@ export async function readAdminStats(
(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 route_reports where status = 'open') as reports,
+ (select count(*)::int from route_reports
+ where status = 'open' and created_at < ${lateBefore}::timestamptz) as reports_late,
+ (select min(created_at) from route_reports where status = 'open') as reports_oldest,
+ (select count(*)::int from places where verification_status = 'flagged') as flagged_places,
(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,
@@ -66,8 +75,9 @@ export async function readAdminStats(
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.
+ // What waits in each section. Support messages, AI drafts and failed translations have no
+ // table yet: nothing can wait there, and each one gets its query with its section. The signals
+ // of places join the reports with `place_signals`.
const nothing: AdminToHandle = { count: 0, lateCount: 0, oldestSince: null };
const waiting: Record = {
moderation: {
@@ -75,7 +85,11 @@ export async function readAdminStats(
lateCount: row.pending_late,
oldestSince: row.pending_oldest ? new Date(row.pending_oldest).toISOString() : null,
},
- reports: nothing,
+ reports: {
+ count: row.reports + row.flagged_places,
+ lateCount: row.reports_late,
+ oldestSince: row.reports_oldest ? new Date(row.reports_oldest).toISOString() : null,
+ },
support: nothing,
aiDrafts: nothing,
translations: nothing,
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index 2aced356..2335c695 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -19,6 +19,7 @@ import { parseQueryString } from './query-string';
import { adminRoutes } from './routes/admin';
import { adminCatalogRoutes } from './routes/admin-catalog';
import { adminModerationRoutes } from './routes/admin-moderation';
+import { adminReportsRoutes } from './routes/admin-reports';
import { configRoutes } from './routes/config';
import { editorRoutes } from './routes/editor';
import { geocodeRoutes } from './routes/geocode';
@@ -125,6 +126,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) {
await app.register(adminRoutes, { prefix: '/v1' });
await app.register(adminModerationRoutes, { prefix: '/v1' });
await app.register(adminCatalogRoutes, { prefix: '/v1' });
+ await app.register(adminReportsRoutes, { prefix: '/v1' });
await app.register(searchRoutesPlugin, { prefix: '/v1' });
await app.register(usersRoutes, { prefix: '/v1' });
await app.register(editorRoutes, { prefix: '/v1' });
diff --git a/apps/api/src/moderation/decisions.ts b/apps/api/src/moderation/decisions.ts
index 23f05626..8befc1be 100644
--- a/apps/api/src/moderation/decisions.ts
+++ b/apps/api/src/moderation/decisions.ts
@@ -16,7 +16,9 @@ type DecidableAction = Exclude;
/** What each decision asks of the route, and the status it gives it (wiki, cycle de vie). */
const transitions: Record = {
publish: { from: ['pending'], to: 'published' },
- request_changes: { from: ['pending'], to: 'changes_requested' },
+ // A route waiting for its first look goes back to its author; one already online stays online
+ // and is marked « à corriger » (a low rating or a report, A-03): see `statusAfter`.
+ request_changes: { from: ['pending', 'published'], to: 'changes_requested' },
reject: { from: ['pending'], to: 'rejected' },
unpublish: { from: ['published', 'needs_fix'], to: 'unpublished' },
};
@@ -42,7 +44,7 @@ export async function decideRoute(
input: ModerationDecisionInput,
now = new Date(),
): Promise {
- const { from, to } = transitions[input.action];
+ const { from } = transitions[input.action];
return db.transaction(async (tx) => {
const [route] = await tx
@@ -57,6 +59,10 @@ export async function decideRoute(
const details: AlreadyDecidedDetails = { status: route.status };
throw httpError(409, 'Route already decided', 'already_decided', details);
}
+ const to: RouteStatus =
+ input.action === 'request_changes' && route.status === 'published'
+ ? 'needs_fix'
+ : transitions[input.action].to;
await tx
.update(routes)
diff --git a/apps/api/src/reports/admin-reports.ts b/apps/api/src/reports/admin-reports.ts
new file mode 100644
index 00000000..573e1888
--- /dev/null
+++ b/apps/api/src/reports/admin-reports.ts
@@ -0,0 +1,159 @@
+import {
+ adminReportsPageSize,
+ type AdminReportsQuery,
+ type AdminRouteReports,
+ type ReportAlreadyDecidedDetails,
+ type ResolvedReport,
+} from '@widoo/shared';
+import { eq, sql } from 'drizzle-orm';
+import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor';
+import { recordAdminAction } from '../admin/actions';
+import type { Db } from '../db/client';
+import { notifications, routeReports } from '../db/schema';
+import { httpError } from '../errors';
+
+type ReportRow = {
+ id: string;
+ reason: AdminRouteReports['items'][number]['reason'];
+ comment: string | null;
+ created_at: Date;
+ route_id: string;
+ route_title: string;
+ route_status: AdminRouteReports['items'][number]['route']['status'];
+ is_official: boolean;
+ city_name: string;
+ author_id: string | null;
+ author_first_name: string | null;
+ reporter_id: string | null;
+ reporter_first_name: string | null;
+ step_position: number | null;
+ place_name: string | null;
+ open_on_route: number;
+};
+
+const toReport = (row: ReportRow): AdminRouteReports['items'][number] => ({
+ id: row.id,
+ reason: row.reason,
+ comment: row.comment,
+ createdAt: new Date(row.created_at).toISOString(),
+ route: {
+ id: row.route_id,
+ title: row.route_title,
+ status: row.route_status,
+ isOfficial: row.is_official,
+ cityName: row.city_name,
+ },
+ author: row.author_id ? { id: row.author_id, firstName: row.author_first_name } : null,
+ reporter: row.reporter_id ? { id: row.reporter_id, firstName: row.reporter_first_name } : null,
+ step:
+ row.step_position === null || row.place_name === null
+ ? null
+ : { position: row.step_position, placeName: row.place_name },
+ openOnRoute: row.open_on_route,
+});
+
+/**
+ * A page of the open reports of routes (A-03), the oldest first. Deleted accounts are left out of
+ * the names: the report stays, its author or reporter reads as unknown.
+ */
+export async function readOpenRouteReports(
+ db: Db,
+ query: AdminReportsQuery,
+): Promise {
+ const offset = offsetOfCursor(query.cursor);
+ const where = query.reason
+ ? sql`rr.status = 'open' and rr.reason = ${query.reason}`
+ : sql`rr.status = 'open'`;
+ const from = sql`from route_reports rr
+ join routes r on r.id = rr.route_id
+ join cities c on c.id = r.city_id
+ left join users a on a.id = r.author_id and a.deleted_at is null
+ left join users rp on rp.id = rr.user_id and rp.deleted_at is null
+ left join steps s on s.id = rr.step_id
+ left join places p on p.id = s.place_id`;
+
+ const rows = await db.execute(sql`
+ select rr.id, rr.reason, rr.comment, rr.created_at,
+ r.id as route_id, r.title as route_title, r.status as route_status, r.is_official, c.name as city_name,
+ a.id as author_id, a.first_name as author_first_name,
+ rp.id as reporter_id, rp.first_name as reporter_first_name,
+ s.position as step_position, coalesce(s.title, p.name) as place_name,
+ (select count(*)::int from route_reports o
+ where o.route_id = rr.route_id and o.status = 'open') as open_on_route
+ ${from}
+ where ${where}
+ order by rr.created_at asc, rr.id
+ limit ${adminReportsPageSize} 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(toReport),
+ total,
+ offset,
+ ...neighbourCursors(offset, total, adminReportsPageSize),
+ };
+}
+
+type ResolveInput = {
+ reportId: string;
+ /** The member of the team who decides. */
+ actorId: string;
+ outcome: 'accepted' | 'rejected';
+ note?: string;
+};
+
+/**
+ * Decides an open report (A-03, D-056): `accepted` thanks the reporter with a notification
+ * (`report_resolved`), `rejected` sends nothing. The note stays in the journal of the admin. The
+ * report is locked while it is decided: a report that is no longer open answers 409
+ * `already_decided` with the status the first decision gave it. What happens to the route itself
+ * (unpublishing, a request for changes) is its own decision, made with the moderation actions.
+ */
+export async function resolveRouteReport(
+ db: Db,
+ input: ResolveInput,
+ now = new Date(),
+): Promise {
+ return db.transaction(async (tx) => {
+ const [report] = await tx
+ .select({
+ status: routeReports.status,
+ userId: routeReports.userId,
+ routeId: routeReports.routeId,
+ })
+ .from(routeReports)
+ .where(eq(routeReports.id, input.reportId))
+ .for('update');
+ if (!report) {
+ throw httpError(404, 'Report not found');
+ }
+ if (report.status !== 'open') {
+ const details: ReportAlreadyDecidedDetails = { status: report.status };
+ throw httpError(409, 'Report already decided', 'already_decided', details);
+ }
+
+ await tx
+ .update(routeReports)
+ .set({ status: input.outcome, reviewedBy: input.actorId, reviewedAt: now })
+ .where(eq(routeReports.id, input.reportId));
+ if (input.outcome === 'accepted' && report.userId) {
+ await tx.insert(notifications).values({
+ userId: report.userId,
+ kind: 'report_resolved',
+ payload: { reportId: input.reportId, routeId: report.routeId },
+ });
+ }
+ await recordAdminAction(tx, {
+ actorId: input.actorId,
+ action: 'report.resolve',
+ targetType: 'route_report',
+ targetId: input.reportId,
+ before: { status: 'open' },
+ after: { status: input.outcome },
+ reason: input.note,
+ });
+ return { id: input.reportId, status: input.outcome };
+ });
+}
diff --git a/apps/api/src/reports/place-signals.ts b/apps/api/src/reports/place-signals.ts
new file mode 100644
index 00000000..56caf513
--- /dev/null
+++ b/apps/api/src/reports/place-signals.ts
@@ -0,0 +1,181 @@
+import {
+ adminSignalsPageSize,
+ type AdminSignaledPlaces,
+ type AdminSignalsQuery,
+ type PlaceActionResult,
+ type PlaceAlreadyDecidedDetails,
+} from '@widoo/shared';
+import { eq, inArray, sql } from 'drizzle-orm';
+import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor';
+import { recordAdminAction } from '../admin/actions';
+import type { Db } from '../db/client';
+import { moderationActionsTable, places, routes } from '../db/schema';
+import { httpError } from '../errors';
+
+type PlaceRow = {
+ id: string;
+ name: string;
+ address: string;
+ city_name: string;
+ verification_status: AdminSignaledPlaces['items'][number]['verificationStatus'];
+ verified_at: Date | null;
+ confirmations: number;
+ routes: AdminSignaledPlaces['items'][number]['routes'];
+};
+
+// The signals of a place are not stored yet (`place_signals`, ticket #49): while no table holds
+// them, none is open. One expression to replace when the table exists.
+const openSignalCount = 0;
+
+/**
+ * The places the community flagged (A-03), the least recently verified first, with the published
+ * routes that go through each. Without the signals table, the list holds the places whose status
+ * is `flagged`, each with no signal counted.
+ */
+export async function readSignaledPlaces(
+ db: Db,
+ query: AdminSignalsQuery,
+): Promise {
+ const offset = offsetOfCursor(query.cursor);
+ const from = sql`from places p join cities c on c.id = p.city_id
+ where p.verification_status = 'flagged'`;
+ const rows = await db.execute(sql`
+ select p.id, p.name, p.address, c.name as city_name, p.verification_status, p.verified_at,
+ p.confirmations_since_verified::float as confirmations,
+ coalesce((
+ select jsonb_agg(jsonb_build_object('id', r.id, 'title', r.title, 'status', r.status)
+ order by r.title)
+ from routes r
+ where r.status = 'published'
+ and exists (select 1 from steps s where s.route_id = r.id and s.place_id = p.id)
+ ), '[]'::jsonb) as routes
+ ${from}
+ order by p.verified_at asc nulls first, p.id
+ limit ${adminSignalsPageSize} offset ${offset}`);
+ const [counted] = await db.execute<{ total: number }>(sql`select count(*)::int as total ${from}`);
+ const total = counted?.total ?? 0;
+ return {
+ items: rows.map((row) => ({
+ id: row.id,
+ name: row.name,
+ address: row.address,
+ cityName: row.city_name,
+ verificationStatus: row.verification_status,
+ verifiedAt: row.verified_at ? new Date(row.verified_at).toISOString() : null,
+ signalCount: openSignalCount,
+ confirmationCount: row.confirmations,
+ routes: row.routes,
+ })),
+ total,
+ offset,
+ ...neighbourCursors(offset, total, adminSignalsPageSize),
+ };
+}
+
+type PlaceActionInput = {
+ placeId: string;
+ /** The member of the team who acts. */
+ actorId: string;
+};
+
+function alreadyIs(status: PlaceActionResult['verificationStatus']) {
+ const details: PlaceAlreadyDecidedDetails = { status };
+ return httpError(409, `Place is already ${status}`, 'already_decided', details);
+}
+
+/**
+ * Marks a place verified by the team (A-03): the confirmations count from zero again. A place
+ * that is already verified answers 409 `already_decided`.
+ */
+export async function verifyPlace(
+ db: Db,
+ input: PlaceActionInput,
+ now = new Date(),
+): Promise {
+ return db.transaction(async (tx) => {
+ const [place] = await tx
+ .select({ status: places.verificationStatus })
+ .from(places)
+ .where(eq(places.id, input.placeId))
+ .for('update');
+ if (!place) {
+ throw httpError(404, 'Place not found');
+ }
+ if (place.status === 'verified') {
+ throw alreadyIs(place.status);
+ }
+ await tx
+ .update(places)
+ .set({ verificationStatus: 'verified', verifiedAt: now, confirmationsSinceVerified: 0 })
+ .where(eq(places.id, input.placeId));
+ await recordAdminAction(tx, {
+ actorId: input.actorId,
+ action: 'place.verify',
+ targetType: 'place',
+ targetId: input.placeId,
+ before: { status: place.status },
+ after: { status: 'verified' },
+ });
+ return { placeId: input.placeId, verificationStatus: 'verified', routesToFix: 0 };
+ });
+}
+
+/**
+ * Closes a place (A-03): its status becomes `closed`, and the published routes that go through it
+ * become `needs_fix`, each with a line in its history. The reason is required. A place that is
+ * already closed answers 409 `already_decided`.
+ */
+export async function closePlace(
+ db: Db,
+ input: PlaceActionInput & { reason: string },
+): Promise {
+ return db.transaction(async (tx) => {
+ const [place] = await tx
+ .select({ status: places.verificationStatus, name: places.name })
+ .from(places)
+ .where(eq(places.id, input.placeId))
+ .for('update');
+ if (!place) {
+ throw httpError(404, 'Place not found');
+ }
+ if (place.status === 'closed') {
+ throw alreadyIs(place.status);
+ }
+ await tx
+ .update(places)
+ .set({ verificationStatus: 'closed' })
+ .where(eq(places.id, input.placeId));
+
+ const concerned = await tx.execute<{ id: string }>(sql`
+ select r.id from routes r
+ where r.status = 'published'
+ and exists (select 1 from steps s where s.route_id = r.id and s.place_id = ${input.placeId})
+ for update`);
+ const routeIds = concerned.map((row) => row.id);
+ if (routeIds.length > 0) {
+ await tx.update(routes).set({ status: 'needs_fix' }).where(inArray(routes.id, routeIds));
+ await tx.insert(moderationActionsTable).values(
+ routeIds.map((routeId) => ({
+ routeId,
+ actorId: input.actorId,
+ action: 'request_changes' as const,
+ message: `Lieu fermé : ${place.name}. ${input.reason}`,
+ })),
+ );
+ }
+ await recordAdminAction(tx, {
+ actorId: input.actorId,
+ action: 'place.close',
+ targetType: 'place',
+ targetId: input.placeId,
+ before: { status: place.status },
+ after: { status: 'closed', routesToFix: routeIds },
+ reason: input.reason,
+ });
+ return {
+ placeId: input.placeId,
+ verificationStatus: 'closed',
+ routesToFix: routeIds.length,
+ };
+ });
+}
diff --git a/apps/api/src/reports/ratings.ts b/apps/api/src/reports/ratings.ts
new file mode 100644
index 00000000..5d60128c
--- /dev/null
+++ b/apps/api/src/reports/ratings.ts
@@ -0,0 +1,18 @@
+import { adminSignalsPageSize, type AdminRatings, type AdminRatingsQuery } from '@widoo/shared';
+import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor';
+
+/**
+ * The ratings with a comment (A-03). Ratings are not stored yet (`route_ratings`, ticket #64):
+ * until the table exists, none was ever given, and the list is empty. The query is read all the
+ * same, so that its filters are validated now.
+ */
+export async function readRatings(query: AdminRatingsQuery): Promise {
+ const offset = offsetOfCursor(query.cursor);
+ const total = 0;
+ return {
+ items: [],
+ total,
+ offset,
+ ...neighbourCursors(offset, total, adminSignalsPageSize),
+ };
+}
diff --git a/apps/api/src/routes/admin-reports.test.ts b/apps/api/src/routes/admin-reports.test.ts
new file mode 100644
index 00000000..649f6abd
--- /dev/null
+++ b/apps/api/src/routes/admin-reports.test.ts
@@ -0,0 +1,346 @@
+import {
+ AdminCounters,
+ AdminRouteReports,
+ AdminStats,
+ ApiError,
+ ReportAlreadyDecidedDetails,
+ ResolvedReport,
+ type RouteReportReason,
+ type RouteStatus,
+ type UserRole,
+} from '@widoo/shared';
+import { and, 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, notifications, routeReports, routes, steps, 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, and only those are listed or deleted.
+const verifier = new TestTokenVerifier();
+const createdUids: string[] = [];
+const createdRouteIds: string[] = [];
+const createdReportIds: string[] = [];
+const marker = `reporttest-${uuidv7()}`;
+const hour = 60 * 60 * 1000;
+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;
+let reporterId: string;
+let templateStepId: string;
+beforeAll(async () => {
+ app = await buildApp(testConfig(), { tokenVerifier: verifier });
+ await seed(app.db);
+ const create = async (firstName: string) => {
+ const uid = `test-${uuidv7()}`;
+ createdUids.push(uid);
+ const [user] = await app.db.insert(users).values({ firebaseUid: uid, firstName }).returning();
+ if (!user) throw new Error('User not created');
+ return user.id;
+ };
+ authorId = await create('Camille');
+ reporterId = await create('Lina');
+ const [step] = await app.db
+ .select({ id: steps.id })
+ .from(steps)
+ .where(eq(steps.routeId, templateId))
+ .limit(1);
+ if (!step) throw new Error('Demo step missing from the seed');
+ templateStepId = step.id;
+});
+afterAll(async () => {
+ await app.db.delete(adminActions).where(inArray(adminActions.targetId, createdReportIds));
+ await app.db.delete(routes).where(inArray(routes.id, createdRouteIds));
+ await app.db.delete(users).where(inArray(users.firebaseUid, createdUids));
+ await app.close();
+});
+
+async function signIn(role: UserRole) {
+ const { token, uid } = verifier.issue({ secondFactor: 'totp' });
+ createdUids.push(uid);
+ const [user] = await app.db.insert(users).values({ firebaseUid: uid, role }).returning();
+ if (!user) throw new Error('User not created');
+ return { id: user.id, headers: { authorization: `Bearer ${token}` } };
+}
+
+/** A route copied from the seed, far from every zone the search tests look at. */
+async function addRoute(status: RouteStatus, title: string) {
+ const [template] = await app.db.select().from(routes).where(eq(routes.id, templateId));
+ if (!template) throw new Error('Demo route missing from the seed');
+ const id = uuidv7();
+ createdRouteIds.push(id);
+ await app.db.insert(routes).values({
+ ...template,
+ id,
+ authorId,
+ shareToken: null,
+ startLocation: farAway,
+ bounds: farAwayBounds,
+ status,
+ title: `${title} ${marker}`,
+ });
+ return id;
+}
+
+async function addReport(
+ routeId: string,
+ reason: RouteReportReason,
+ hoursAgo = 1,
+ extra: { comment?: string; stepId?: string } = {},
+) {
+ const id = uuidv7();
+ createdReportIds.push(id);
+ await app.db.insert(routeReports).values({
+ id,
+ routeId,
+ userId: reason === 'low_rating' ? null : reporterId,
+ reason,
+ comment: extra.comment ?? null,
+ stepId: extra.stepId ?? null,
+ createdAt: new Date(Date.now() - hoursAgo * hour),
+ });
+ return id;
+}
+
+const list = (headers: Record, query = '') =>
+ app.inject({ url: `/v1/admin/reports${query}`, headers });
+const resolve = (headers: Record, id: string, payload: object) =>
+ app.inject({ method: 'POST', url: `/v1/admin/reports/${id}/resolve`, headers, payload });
+const titlesOf = (body: AdminRouteReports) =>
+ body.items.map((item) => item.route.title).filter((title) => title.includes(marker));
+
+describe('GET /v1/admin/reports', () => {
+ it('is for moderators and admins, not for an editor', async () => {
+ const editor = await signIn('editor');
+ const response = await list(editor.headers);
+ expect(response.statusCode).toBe(403);
+ expect(ApiError.parse(response.json()).code).toBe('forbidden');
+ });
+
+ it('lists the open reports, the oldest first, with route, author, reporter and step', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'Signalé');
+ const other = await addRoute('published', 'Autre');
+ const recent = await addReport(route, 'inappropriate', 2);
+ const old = await addReport(other, 'place_changed', 30, {
+ comment: 'Fermé depuis mars',
+ stepId: templateStepId,
+ });
+ await addReport(route, 'other', 5, { comment: 'Photo trompeuse' });
+
+ const response = await list(moderator.headers);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers['cache-control']).toBe('no-store');
+ const body = AdminRouteReports.parse(response.json());
+ const mine = body.items.filter((item) => item.route.title.includes(marker));
+ expect(mine.map((item) => item.id)[0]).toBe(old);
+ expect(mine.map((item) => item.id)).toContain(recent);
+ expect(mine[0]).toMatchObject({
+ reason: 'place_changed',
+ comment: 'Fermé depuis mars',
+ author: { id: authorId, firstName: 'Camille' },
+ reporter: { id: reporterId, firstName: 'Lina' },
+ openOnRoute: 1,
+ });
+ expect(mine[0]?.step?.placeName).toEqual(expect.any(String));
+ expect(mine[0]?.route.isOfficial).toEqual(expect.any(Boolean));
+ const twice = mine.find((item) => item.id === recent);
+ expect(twice?.openOnRoute).toBe(2);
+ });
+
+ it('shows a low rating without a reporter, and filters by reason', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'Note basse');
+ const low = await addReport(route, 'low_rating', 3);
+ await addReport(route, 'false_info', 2);
+
+ const body = AdminRouteReports.parse(
+ (await list(moderator.headers, '?reason=low_rating')).json(),
+ );
+
+ const item = body.items.find((report) => report.id === low);
+ expect(item).toMatchObject({ reason: 'low_rating', reporter: null, step: null });
+ expect(body.items.every((report) => report.reason === 'low_rating')).toBe(true);
+ });
+
+ it('leaves out the reports the team already decided', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'Décidé');
+ const report = await addReport(route, 'inappropriate');
+ await resolve(moderator.headers, report, { outcome: 'rejected' });
+
+ const body = AdminRouteReports.parse((await list(moderator.headers)).json());
+
+ expect(titlesOf(body)).not.toContain(`Décidé ${marker}`);
+ });
+
+ it('refuses a reason it does not know', async () => {
+ const moderator = await signIn('moderator');
+ expect((await list(moderator.headers, '?reason=spam')).statusCode).toBe(400);
+ });
+});
+
+describe('what waits in the navigation and on the dashboard', () => {
+ it('counts the open reports, and the ones waiting for more than 72 hours', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'Compteur');
+ await addReport(route, 'inappropriate', 80);
+ await addReport(route, 'false_info', 1);
+
+ const counters = AdminCounters.parse(
+ (await app.inject({ url: '/v1/admin/counters', headers: moderator.headers })).json(),
+ );
+ const stats = AdminStats.parse(
+ (await app.inject({ url: '/v1/admin/stats', headers: moderator.headers })).json(),
+ );
+
+ expect(counters.reports?.count).toBeGreaterThanOrEqual(2);
+ expect(counters.reports?.lateCount).toBeGreaterThanOrEqual(1);
+ expect(stats.toHandle.reports?.count).toBeGreaterThanOrEqual(2);
+ const oldest = new Date(stats.toHandle.reports?.oldestSince ?? '').getTime();
+ expect(Date.now() - oldest).toBeGreaterThanOrEqual(80 * hour - 60_000);
+ });
+});
+
+describe('POST /v1/admin/reports/:id/resolve', () => {
+ it('accepts a report, thanks the reporter and writes the note in the journal', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'Accepté');
+ const report = await addReport(route, 'inappropriate');
+
+ const response = await resolve(moderator.headers, report, {
+ outcome: 'accepted',
+ note: 'Photo retirée',
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(ResolvedReport.parse(response.json())).toEqual({ id: report, status: 'accepted' });
+ const [row] = await app.db.select().from(routeReports).where(eq(routeReports.id, report));
+ expect(row).toMatchObject({ status: 'accepted', reviewedBy: moderator.id });
+ expect(row?.reviewedAt).toBeInstanceOf(Date);
+ const sent = await app.db
+ .select()
+ .from(notifications)
+ .where(and(eq(notifications.userId, reporterId), eq(notifications.kind, 'report_resolved')));
+ expect(sent.some((item) => (item.payload as { reportId?: string }).reportId === report)).toBe(
+ true,
+ );
+ const [entry] = await app.db
+ .select()
+ .from(adminActions)
+ .where(eq(adminActions.targetId, report));
+ expect(entry).toMatchObject({
+ actorId: moderator.id,
+ action: 'report.resolve',
+ targetType: 'route_report',
+ reason: 'Photo retirée',
+ });
+ });
+
+ it('rejects a report without telling the reporter', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'Rejeté');
+ const report = await addReport(route, 'inappropriate');
+
+ const response = await resolve(moderator.headers, report, { outcome: 'rejected' });
+
+ expect(response.statusCode).toBe(200);
+ const sent = await app.db
+ .select()
+ .from(notifications)
+ .where(eq(notifications.userId, reporterId));
+ expect(sent.some((item) => (item.payload as { reportId?: string }).reportId === report)).toBe(
+ false,
+ );
+ });
+
+ it('closes a low rating without a reporter to thank', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'Gardé');
+ const report = await addReport(route, 'low_rating');
+
+ const response = await resolve(moderator.headers, report, { outcome: 'accepted' });
+
+ expect(response.statusCode).toBe(200);
+ });
+
+ it('answers 409 already_decided with the status the first decision gave', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'Deux fois');
+ const report = await addReport(route, 'inappropriate');
+ await resolve(moderator.headers, report, { outcome: 'rejected' });
+
+ const response = await resolve(moderator.headers, report, { outcome: 'accepted' });
+
+ expect(response.statusCode).toBe(409);
+ const error = ApiError.parse(response.json());
+ expect(error.code).toBe('already_decided');
+ expect(ReportAlreadyDecidedDetails.parse(error.details)).toEqual({ status: 'rejected' });
+ });
+
+ it('lets only one of two simultaneous decisions through', async () => {
+ const [first, second] = await Promise.all([signIn('moderator'), signIn('admin')]);
+ const route = await addRoute('published', 'Course');
+ const report = await addReport(route, 'inappropriate');
+
+ const answers = await Promise.all([
+ resolve(first.headers, report, { outcome: 'accepted' }),
+ resolve(second.headers, report, { outcome: 'rejected' }),
+ ]);
+
+ expect(answers.map((answer) => answer.statusCode).sort()).toEqual([200, 409]);
+ const journal = await app.db
+ .select()
+ .from(adminActions)
+ .where(eq(adminActions.targetId, report));
+ expect(journal).toHaveLength(1);
+ });
+
+ it('answers 404 to a report that does not exist, and 422 to an invalid body', async () => {
+ const moderator = await signIn('moderator');
+ expect((await resolve(moderator.headers, uuidv7(), { outcome: 'accepted' })).statusCode).toBe(
+ 404,
+ );
+ const route = await addRoute('published', 'Invalide');
+ const report = await addReport(route, 'inappropriate');
+ expect((await resolve(moderator.headers, report, { outcome: 'open' })).statusCode).toBe(422);
+ expect(
+ (await resolve(moderator.headers, report, { outcome: 'accepted', note: '' })).statusCode,
+ ).toBe(422);
+ });
+
+ it('is refused to an editor and leaves the report open', async () => {
+ const editor = await signIn('editor');
+ const route = await addRoute('published', 'Éditeur');
+ const report = await addReport(route, 'inappropriate');
+
+ expect((await resolve(editor.headers, report, { outcome: 'accepted' })).statusCode).toBe(403);
+ const [row] = await app.db.select().from(routeReports).where(eq(routeReports.id, report));
+ expect(row?.status).toBe('open');
+ });
+});
+
+describe('POST /v1/admin/routes/:id/request-changes on a published route', () => {
+ it('marks the route as to correct without taking it offline', async () => {
+ const moderator = await signIn('moderator');
+ const route = await addRoute('published', 'À corriger');
+
+ const response = await app.inject({
+ method: 'POST',
+ url: `/v1/admin/routes/${route}/request-changes`,
+ headers: moderator.headers,
+ payload: { message: 'La note moyenne est trop basse : reprenez les étapes 2 et 3.' },
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(response.json()).toMatchObject({ action: 'request_changes', status: 'needs_fix' });
+ const [row] = await app.db.select().from(routes).where(eq(routes.id, route));
+ expect(row?.status).toBe('needs_fix');
+ });
+});
diff --git a/apps/api/src/routes/admin-reports.ts b/apps/api/src/routes/admin-reports.ts
new file mode 100644
index 00000000..ed005aa8
--- /dev/null
+++ b/apps/api/src/routes/admin-reports.ts
@@ -0,0 +1,143 @@
+import {
+ AdminRatings,
+ AdminRatingsQuery,
+ AdminReportsQuery,
+ AdminRouteReports,
+ AdminSignaledPlaces,
+ AdminSignalsQuery,
+ ClosePlaceBody,
+ PlaceActionResult,
+ ResolvedReport,
+ ResolveReportBody,
+} from '@widoo/shared';
+import type { FastifyRequest } from 'fastify';
+import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod';
+import { z } from 'zod';
+import { httpError } from '../errors';
+import { readOpenRouteReports, resolveRouteReport } from '../reports/admin-reports';
+import { closePlace, readSignaledPlaces, verifyPlace } from '../reports/place-signals';
+import { readRatings } from '../reports/ratings';
+
+const security = [{ firebaseIdToken: [] }];
+const tags = ['admin'];
+const ReportParams = z.object({ id: z.uuid() });
+const PlaceParams = z.object({ id: z.uuid() });
+
+/** Set by the guard of the section, which every route of this plugin runs first. */
+function actorIdOf(request: FastifyRequest): string {
+ if (!request.user) {
+ throw httpError(401, 'Missing bearer token');
+ }
+ return request.user.id;
+}
+
+/**
+ * Signals, reports and ratings of the admin (A-03, wiki API › Admin): the places the community
+ * flagged and what the team does with them, the open reports of routes and their resolution, and
+ * the ratings. Moderators and admins only; each decision is written in the journal of the admin.
+ * Never cached.
+ */
+export const adminReportsRoutes: FastifyPluginAsyncZod = async (app) => {
+ app.addHook('onRequest', app.requireAdminSection('reports'));
+ app.addHook('onSend', async (_request, reply) => {
+ reply.header('cache-control', 'no-store');
+ });
+
+ app.get(
+ '/admin/reports',
+ {
+ schema: {
+ tags,
+ summary: 'Open reports of routes',
+ security,
+ querystring: AdminReportsQuery,
+ response: { 200: AdminRouteReports },
+ },
+ },
+ async (request) => readOpenRouteReports(app.db, request.query),
+ );
+
+ app.post(
+ '/admin/reports/:id/resolve',
+ {
+ schema: {
+ tags,
+ summary: 'Accept or reject a report of a route',
+ security,
+ params: ReportParams,
+ body: ResolveReportBody,
+ response: { 200: ResolvedReport },
+ },
+ },
+ async (request) =>
+ resolveRouteReport(app.db, {
+ reportId: request.params.id,
+ actorId: actorIdOf(request),
+ outcome: request.body.outcome,
+ note: request.body.note,
+ }),
+ );
+
+ app.get(
+ '/admin/signals',
+ {
+ schema: {
+ tags,
+ summary: 'Places flagged by the community',
+ security,
+ querystring: AdminSignalsQuery,
+ response: { 200: AdminSignaledPlaces },
+ },
+ },
+ async (request) => readSignaledPlaces(app.db, request.query),
+ );
+
+ app.post(
+ '/admin/places/:id/verify',
+ {
+ schema: {
+ tags,
+ summary: 'Mark a place verified',
+ security,
+ params: PlaceParams,
+ response: { 200: PlaceActionResult },
+ },
+ },
+ async (request) =>
+ verifyPlace(app.db, { placeId: request.params.id, actorId: actorIdOf(request) }),
+ );
+
+ app.post(
+ '/admin/places/:id/close',
+ {
+ schema: {
+ tags,
+ summary: 'Close a place, and mark the routes that go through it to correct',
+ security,
+ params: PlaceParams,
+ body: ClosePlaceBody,
+ response: { 200: PlaceActionResult },
+ },
+ },
+ async (request) =>
+ closePlace(app.db, {
+ placeId: request.params.id,
+ actorId: actorIdOf(request),
+ reason: request.body.reason,
+ }),
+ );
+
+ app.get(
+ '/admin/ratings',
+ {
+ schema: {
+ tags,
+ summary: 'Ratings with a comment',
+ security,
+ querystring: AdminRatingsQuery,
+ response: { 200: AdminRatings },
+ },
+ },
+ async (request) => readRatings(request.query),
+ );
+};
diff --git a/apps/api/src/routes/admin-signals.test.ts b/apps/api/src/routes/admin-signals.test.ts
new file mode 100644
index 00000000..a0fd48be
--- /dev/null
+++ b/apps/api/src/routes/admin-signals.test.ts
@@ -0,0 +1,305 @@
+import {
+ AdminRatings,
+ AdminSignaledPlaces,
+ ApiError,
+ PlaceActionResult,
+ PlaceAlreadyDecidedDetails,
+ type CatalogPlaceInput,
+ type RouteStatus,
+ 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 { createCatalogPlace } from '../catalog/place-writes';
+import { adminActions, moderationActionsTable, places, routes, steps, 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 places and routes found by a marker.
+const verifier = new TestTokenVerifier();
+const createdUids: string[] = [];
+const createdRouteIds: string[] = [];
+const marker = `signaltest-${uuidv7()}`;
+const templateId = seedId('route:marais-gourmand');
+const farAway = { lat: 33.5, lng: 33.5 };
+const farAwayBounds = { west: 33.5, south: 33.5, east: 33.501, north: 33.501 };
+
+let app: Awaited>;
+let adminId: string;
+beforeAll(async () => {
+ app = await buildApp(testConfig(), { tokenVerifier: verifier });
+ await seed(app.db);
+ adminId = (await signIn('admin')).id;
+});
+afterAll(async () => {
+ const created = await app.db
+ .select({ id: places.id })
+ .from(places)
+ .where(like(places.name, `%${marker}%`));
+ const placeIds = created.map((place) => place.id);
+ await app.db
+ .delete(adminActions)
+ .where(inArray(adminActions.targetId, [...placeIds, ...createdRouteIds]));
+ await app.db.delete(routes).where(inArray(routes.id, createdRouteIds));
+ if (placeIds.length > 0) {
+ await app.db.delete(places).where(inArray(places.id, placeIds));
+ }
+ 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): CatalogPlaceInput => ({
+ name: `${name} ${marker}`,
+ category: 'cafe',
+ address: '12 rue des Martyrs, 75009 Paris',
+ location: { lat: 48.8785, lng: 2.3397 },
+ isIndoor: true,
+ priceRange: null,
+ websiteUrl: null,
+ phone: null,
+ hours: [],
+});
+
+/** A place of the catalog in `status`, with a route in `routeStatus` that goes through it. */
+async function addPlace(
+ name: string,
+ status: 'flagged' | 'verified' | 'stale',
+ routeStatus: RouteStatus | null = null,
+) {
+ const placeId = await createCatalogPlace(app.db, sheet(name), adminId);
+ await app.db
+ .update(places)
+ .set({ verificationStatus: status, confirmationsSinceVerified: 3.5 })
+ .where(eq(places.id, placeId));
+ let routeId: string | null = null;
+ if (routeStatus) {
+ const [template] = await app.db.select().from(routes).where(eq(routes.id, templateId));
+ const [step] = await app.db.select().from(steps).where(eq(steps.routeId, templateId)).limit(1);
+ if (!template || !step) throw new Error('Demo route missing from the seed');
+ routeId = uuidv7();
+ createdRouteIds.push(routeId);
+ await app.db.insert(routes).values({
+ ...template,
+ id: routeId,
+ shareToken: null,
+ startLocation: farAway,
+ bounds: farAwayBounds,
+ status: routeStatus,
+ title: `${name} ${marker}`,
+ });
+ await app.db.insert(steps).values({ ...step, id: uuidv7(), routeId, placeId });
+ }
+ return { placeId, routeId };
+}
+
+const list = (headers: Record) => app.inject({ url: '/v1/admin/signals', headers });
+const act = (
+ headers: Record,
+ placeId: string,
+ action: 'verify' | 'close',
+ payload?: object,
+) => app.inject({ method: 'POST', url: `/v1/admin/places/${placeId}/${action}`, headers, payload });
+const routeStatusOf = async (id: string) =>
+ (await app.db.select({ status: routes.status }).from(routes).where(eq(routes.id, id)))[0]?.status;
+
+describe('GET /v1/admin/signals', () => {
+ it('is for moderators and admins, not for an editor', async () => {
+ const editor = await signIn('editor');
+ expect((await list(editor.headers)).statusCode).toBe(403);
+ });
+
+ it('lists the flagged places with the published routes that go through them', async () => {
+ const moderator = await signIn('moderator');
+ const flagged = await addPlace('Signalé', 'flagged', 'published');
+ await addPlace('Vérifié', 'verified');
+ const unpublished = await addPlace('Hors ligne', 'flagged', 'unpublished');
+
+ const response = await list(moderator.headers);
+
+ expect(response.statusCode).toBe(200);
+ expect(response.headers['cache-control']).toBe('no-store');
+ const body = AdminSignaledPlaces.parse(response.json());
+ const mine = body.items.filter((item) => item.name.includes(marker));
+ expect(mine.map((item) => item.name.split(' ')[0]).sort()).toEqual(['Hors', 'Signalé']);
+ const item = mine.find((place) => place.id === flagged.placeId);
+ expect(item).toMatchObject({
+ verificationStatus: 'flagged',
+ cityName: 'Paris',
+ signalCount: 0,
+ confirmationCount: 3.5,
+ });
+ expect(item?.routes.map((route) => route.id)).toEqual([flagged.routeId]);
+ const noRoute = mine.find((place) => place.id === unpublished.placeId);
+ expect(noRoute?.routes).toEqual([]);
+ });
+});
+
+describe('what waits in the navigation', () => {
+ it('counts a flagged place with the reports, until it is verified', async () => {
+ const moderator = await signIn('moderator');
+ const counted = async () =>
+ (
+ (await app.inject({ url: '/v1/admin/counters', headers: moderator.headers })).json() as {
+ reports?: { count: number };
+ }
+ ).reports?.count ?? 0;
+ const before = await counted();
+ const { placeId } = await addPlace('Compté', 'flagged');
+
+ expect(await counted()).toBe(before + 1);
+
+ await act(moderator.headers, placeId, 'verify');
+ expect(await counted()).toBe(before);
+ });
+});
+
+describe('POST /v1/admin/places/:id/verify', () => {
+ it('marks the place verified, from zero, and writes it in the journal', async () => {
+ const moderator = await signIn('moderator');
+ const { placeId } = await addPlace('À vérifier', 'flagged');
+
+ const response = await act(moderator.headers, placeId, 'verify');
+
+ expect(response.statusCode).toBe(200);
+ expect(PlaceActionResult.parse(response.json())).toEqual({
+ placeId,
+ verificationStatus: 'verified',
+ routesToFix: 0,
+ });
+ const [row] = await app.db.select().from(places).where(eq(places.id, placeId));
+ expect(row).toMatchObject({ verificationStatus: 'verified', confirmationsSinceVerified: 0 });
+ expect(row?.verifiedAt).toBeInstanceOf(Date);
+ const [entry] = await app.db
+ .select()
+ .from(adminActions)
+ .where(and(eq(adminActions.targetId, placeId), eq(adminActions.action, 'place.verify')));
+ expect(entry).toMatchObject({ actorId: moderator.id, action: 'place.verify' });
+ });
+
+ it('answers 409 already_decided to a place that is already verified', async () => {
+ const moderator = await signIn('moderator');
+ const { placeId } = await addPlace('Déjà', 'verified');
+
+ const response = await act(moderator.headers, placeId, 'verify');
+
+ expect(response.statusCode).toBe(409);
+ const error = ApiError.parse(response.json());
+ expect(error.code).toBe('already_decided');
+ expect(PlaceAlreadyDecidedDetails.parse(error.details)).toEqual({ status: 'verified' });
+ });
+
+ it('answers 404 to a place that does not exist', async () => {
+ const moderator = await signIn('moderator');
+ expect((await act(moderator.headers, uuidv7(), 'verify')).statusCode).toBe(404);
+ });
+});
+
+describe('POST /v1/admin/places/:id/close', () => {
+ it('closes the place and marks the published routes that go through it to correct', async () => {
+ const moderator = await signIn('moderator');
+ const online = await addPlace('Fermé', 'flagged', 'published');
+ const offline = await addPlace('Fermé aussi', 'flagged', 'unpublished');
+ // A second route through the first place, already out of the catalog: left as it is.
+ expect(offline.routeId).not.toBeNull();
+
+ const response = await act(moderator.headers, online.placeId, 'close', {
+ reason: 'Fermé définitivement depuis septembre.',
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(PlaceActionResult.parse(response.json())).toEqual({
+ placeId: online.placeId,
+ verificationStatus: 'closed',
+ routesToFix: 1,
+ });
+ expect(await routeStatusOf(online.routeId ?? '')).toBe('needs_fix');
+ expect(await routeStatusOf(offline.routeId ?? '')).toBe('unpublished');
+ const history = await app.db
+ .select()
+ .from(moderationActionsTable)
+ .where(eq(moderationActionsTable.routeId, online.routeId ?? ''));
+ expect(history).toHaveLength(1);
+ expect(history[0]).toMatchObject({ action: 'request_changes', actorId: moderator.id });
+ expect(history[0]?.message).toContain('Fermé définitivement');
+ const [entry] = await app.db
+ .select()
+ .from(adminActions)
+ .where(
+ and(eq(adminActions.targetId, online.placeId), eq(adminActions.action, 'place.close')),
+ );
+ expect(entry).toMatchObject({
+ action: 'place.close',
+ reason: 'Fermé définitivement depuis septembre.',
+ });
+ });
+
+ it('needs a reason, and leaves the place and its routes as they were', async () => {
+ const moderator = await signIn('moderator');
+ const { placeId, routeId } = await addPlace('Sans motif', 'flagged', 'published');
+
+ expect((await act(moderator.headers, placeId, 'close', {})).statusCode).toBe(422);
+ expect(
+ (await act(moderator.headers, placeId, 'close', { reason: 'x ' })).statusCode,
+ ).toBe(422);
+
+ const [row] = await app.db.select().from(places).where(eq(places.id, placeId));
+ expect(row?.verificationStatus).toBe('flagged');
+ expect(await routeStatusOf(routeId ?? '')).toBe('published');
+ });
+
+ it('answers 409 already_decided to a place that is already closed', async () => {
+ const moderator = await signIn('moderator');
+ const { placeId } = await addPlace('Deux fois', 'flagged');
+ await act(moderator.headers, placeId, 'close', { reason: 'Fermé.' });
+
+ const response = await act(moderator.headers, placeId, 'close', { reason: 'Encore.' });
+
+ expect(response.statusCode).toBe(409);
+ expect(PlaceAlreadyDecidedDetails.parse(ApiError.parse(response.json()).details)).toEqual({
+ status: 'closed',
+ });
+ });
+
+ it('is refused to an editor', async () => {
+ const editor = await signIn('editor');
+ const { placeId } = await addPlace('Éditeur', 'flagged');
+ expect((await act(editor.headers, placeId, 'close', { reason: 'Non.' })).statusCode).toBe(403);
+ });
+});
+
+describe('GET /v1/admin/ratings', () => {
+ it('is empty until ratings exist, and validates its filters', async () => {
+ const moderator = await signIn('moderator');
+
+ const response = await app.inject({
+ url: `/v1/admin/ratings?rating=2&routeId=${uuidv7()}`,
+ headers: moderator.headers,
+ });
+
+ expect(response.statusCode).toBe(200);
+ expect(AdminRatings.parse(response.json())).toEqual({
+ items: [],
+ total: 0,
+ offset: 0,
+ previousCursor: null,
+ nextCursor: null,
+ });
+ const tooHigh = await app.inject({
+ url: '/v1/admin/ratings?rating=6',
+ headers: moderator.headers,
+ });
+ expect(tooHigh.statusCode).toBe(400);
+ });
+});
diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts
index 437904c7..2b9d9dae 100644
--- a/packages/api-client/src/client.ts
+++ b/packages/api-client/src/client.ts
@@ -1,4 +1,9 @@
import {
+ AdminRatings,
+ AdminRouteReports,
+ AdminSignaledPlaces,
+ PlaceActionResult,
+ ResolvedReport,
PlaceImportResult,
CatalogPlaces,
CatalogPlaceDetail,
@@ -34,7 +39,12 @@ import {
type RequestChangesBody,
type CatalogRoutesQuery,
type RepublishBody,
+ type AdminRatingsQuery,
+ type AdminReportsQuery,
+ type AdminSignalsQuery,
type CatalogPlaceInput,
+ type ClosePlaceBody,
+ type ResolveReportBody,
type CatalogPlacesQuery,
type PlaceImportBody,
} from '@widoo/shared';
@@ -50,6 +60,7 @@ import { removedOf, routePath, type GetRouteOptions, type RouteSheetAnswer } fro
import { createTransport, type ApiClientOptions } from './transport';
import { moderationQueryString, type ModerationQueueParams } from './moderation';
import { catalogPlacesQueryString, catalogQueryString } from './catalog';
+import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports';
/**
* Typed client of the API, one method per route, answers validated by the `shared` schemas.
@@ -406,6 +417,65 @@ export function createApiClient(options: ApiClientOptions) {
auth: true,
}),
+ /** `GET /v1/admin/reports`: the open reports of routes, oldest first (A-03). */
+ getAdminReports: (params: Partial, signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: `/v1/admin/reports?${reportsQueryString(params)}`,
+ schema: AdminRouteReports,
+ auth: true,
+ signal,
+ }),
+
+ /** `POST /v1/admin/reports/:id/resolve`: accepts or rejects a report, with a note. */
+ resolveReport: (reportId: string, body: ResolveReportBody) =>
+ request({
+ method: 'POST',
+ path: `/v1/admin/reports/${reportId}/resolve`,
+ body,
+ schema: ResolvedReport,
+ auth: true,
+ }),
+
+ /** `GET /v1/admin/signals`: the places the community flagged, with their routes (A-03). */
+ getAdminSignals: (params: Partial, signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: `/v1/admin/signals?${signalsQueryString(params)}`,
+ schema: AdminSignaledPlaces,
+ auth: true,
+ signal,
+ }),
+
+ /** `POST /v1/admin/places/:id/verify`: marks a place verified by the team. */
+ verifyPlace: (placeId: string) =>
+ request({
+ method: 'POST',
+ path: `/v1/admin/places/${placeId}/verify`,
+ schema: PlaceActionResult,
+ auth: true,
+ }),
+
+ /** `POST /v1/admin/places/:id/close`: closes a place, its routes become `needs_fix`. */
+ closePlace: (placeId: string, body: ClosePlaceBody) =>
+ request({
+ method: 'POST',
+ path: `/v1/admin/places/${placeId}/close`,
+ body,
+ schema: PlaceActionResult,
+ auth: true,
+ }),
+
+ /** `GET /v1/admin/ratings`: the ratings with a comment, filtered by note and route (A-03). */
+ getAdminRatings: (params: Partial, signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: `/v1/admin/ratings?${ratingsQueryString(params)}`,
+ schema: AdminRatings,
+ auth: true,
+ signal,
+ }),
+
/** `DELETE /v1/me`: anonymizes the account; its token is refused afterwards. */
deleteMe: () => requestEmpty({ method: 'DELETE', path: '/v1/me', auth: true }),
};
diff --git a/packages/api-client/src/reports.test.ts b/packages/api-client/src/reports.test.ts
new file mode 100644
index 00000000..2ac21337
--- /dev/null
+++ b/packages/api-client/src/reports.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, it } from 'vitest';
+import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports';
+
+describe('reportsQueryString', () => {
+ it('is empty for the whole list, and writes the reason and the page', () => {
+ expect(reportsQueryString({})).toBe('');
+ expect(reportsQueryString({ reason: 'low_rating', cursor: 'NTA' })).toBe(
+ 'reason=low_rating&cursor=NTA',
+ );
+ });
+});
+
+describe('signalsQueryString', () => {
+ it('writes the page only', () => {
+ expect(signalsQueryString({ cursor: 'NTA' })).toBe('cursor=NTA');
+ });
+});
+
+describe('ratingsQueryString', () => {
+ it('writes the note as a number and the route', () => {
+ expect(ratingsQueryString({ rating: 2, routeId: '0198c1de-0000-7000-8000-000000000001' })).toBe(
+ 'rating=2&routeId=0198c1de-0000-7000-8000-000000000001',
+ );
+ });
+});
diff --git a/packages/api-client/src/reports.ts b/packages/api-client/src/reports.ts
new file mode 100644
index 00000000..c43ee040
--- /dev/null
+++ b/packages/api-client/src/reports.ts
@@ -0,0 +1,28 @@
+import type { AdminRatingsQuery, AdminReportsQuery, AdminSignalsQuery } from '@widoo/shared';
+
+/** A query string from the values that are set, in the order of `keys`. */
+function queryStringOf(
+ params: Record,
+ keys: readonly string[],
+): string {
+ const query = new URLSearchParams();
+ for (const key of keys) {
+ const value = params[key];
+ if (value !== undefined && value !== '') {
+ query.set(key, String(value));
+ }
+ }
+ return query.toString();
+}
+
+/** Query string of the open reports of routes, as the API reads it. */
+export const reportsQueryString = (params: Partial) =>
+ queryStringOf(params, ['reason', 'cursor']);
+
+/** Query string of the flagged places, as the API reads it. */
+export const signalsQueryString = (params: Partial) =>
+ queryStringOf(params, ['cursor']);
+
+/** Query string of the ratings with a comment, as the API reads it. */
+export const ratingsQueryString = (params: Partial) =>
+ queryStringOf(params, ['rating', 'routeId', 'cursor']);
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 016065e9..6fd94e48 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -20,6 +20,8 @@ export * from './thresholds';
export { hasRole } from './roles';
export { zoneMinSpanM, zoneSearchBox } from './zone';
export * from './schemas/admin';
+export * from './schemas/admin-reports';
+export * from './schemas/admin-signals';
export * from './schemas/catalog';
export * from './schemas/catalog-place';
export * from './schemas/common';
diff --git a/packages/shared/src/schemas/admin-reports.test.ts b/packages/shared/src/schemas/admin-reports.test.ts
new file mode 100644
index 00000000..0516e371
--- /dev/null
+++ b/packages/shared/src/schemas/admin-reports.test.ts
@@ -0,0 +1,27 @@
+import { describe, expect, it } from 'vitest';
+import { AdminReportsQuery, ResolveReportBody } from './admin-reports';
+
+describe('ResolveReportBody', () => {
+ it('takes an outcome, with an optional note', () => {
+ expect(ResolveReportBody.parse({ outcome: 'accepted' })).toEqual({ outcome: 'accepted' });
+ expect(ResolveReportBody.parse({ outcome: 'rejected', note: ' Déjà corrigé ' })).toEqual({
+ outcome: 'rejected',
+ note: 'Déjà corrigé',
+ });
+ });
+
+ it('refuses an open report as an outcome, markup and unknown fields', () => {
+ expect(ResolveReportBody.safeParse({ outcome: 'open' }).success).toBe(false);
+ expect(ResolveReportBody.safeParse({ outcome: 'accepted', note: 'x ' }).success).toBe(
+ false,
+ );
+ expect(ResolveReportBody.safeParse({ outcome: 'accepted', status: 'x' }).success).toBe(false);
+ });
+});
+
+describe('AdminReportsQuery', () => {
+ it('knows the reasons of a report, the system one included', () => {
+ expect(AdminReportsQuery.safeParse({ reason: 'low_rating' }).success).toBe(true);
+ expect(AdminReportsQuery.safeParse({ reason: 'spam' }).success).toBe(false);
+ });
+});
diff --git a/packages/shared/src/schemas/admin-reports.ts b/packages/shared/src/schemas/admin-reports.ts
new file mode 100644
index 00000000..6a34564e
--- /dev/null
+++ b/packages/shared/src/schemas/admin-reports.ts
@@ -0,0 +1,72 @@
+import { z } from 'zod';
+import { reportStatuses, routeReportReasons } from '../reports';
+import { taxonomies } from '../taxonomies';
+import { plainText } from './common';
+
+/** Rows of a page of the reports of the admin. */
+export const adminReportsPageSize = 50;
+
+/** Query of `GET /admin/reports`: the open reports of routes, oldest first (A-03). */
+export const AdminReportsQuery = z.strictObject({
+ reason: z.enum(routeReportReasons).optional(),
+ cursor: z.string().max(200).optional(),
+});
+export type AdminReportsQuery = z.infer;
+
+/**
+ * An open report of a route, as the team reads it (A-03, D-056). `low_rating` has no reporter. The
+ * step is the one of the place that changed, for `place_changed` only.
+ */
+export const AdminRouteReport = z.object({
+ id: z.uuid(),
+ reason: z.enum(routeReportReasons),
+ /** Written by the reporter, 80 characters at most; read by the team, never published. */
+ comment: z.string().nullable(),
+ createdAt: z.iso.datetime(),
+ route: z.object({
+ id: z.uuid(),
+ title: z.string(),
+ status: z.enum(taxonomies.routeStatuses),
+ /** « Par Widoo »: a route of the team, which has no author account. */
+ isOfficial: z.boolean(),
+ cityName: z.string(),
+ }),
+ /** 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(),
+ /** Null for `low_rating`, and once the account of the reporter is purged. */
+ reporter: z.object({ id: z.uuid(), firstName: z.string().nullable() }).nullable(),
+ step: z.object({ position: z.number().int().nonnegative(), placeName: z.string() }).nullable(),
+ /** Reports still open on the same route, this one included. */
+ openOnRoute: z.number().int().positive(),
+});
+export type AdminRouteReport = z.infer;
+
+/** Body of `GET /admin/reports`. */
+export const AdminRouteReports = z.object({
+ items: z.array(AdminRouteReport),
+ 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 AdminRouteReports = z.infer;
+
+/** Body of `POST /admin/reports/:id/resolve`: `accepted` leads to a correction, `rejected` to none. */
+export const ResolveReportBody = z.strictObject({
+ outcome: z.enum(['accepted', 'rejected']),
+ /** Kept in the journal of the admin, read by the team only. */
+ note: plainText(500).optional(),
+});
+export type ResolveReportBody = z.infer;
+
+/** Answer of `POST /admin/reports/:id/resolve`: the report as it stands afterwards. */
+export const ResolvedReport = z.object({
+ id: z.uuid(),
+ status: z.enum(['accepted', 'rejected']),
+});
+export type ResolvedReport = z.infer;
+
+/** `details` of a 409 `already_decided` on a report: the status another member gave it. */
+export const ReportAlreadyDecidedDetails = z.object({ status: z.enum(reportStatuses) });
+export type ReportAlreadyDecidedDetails = z.infer;
diff --git a/packages/shared/src/schemas/admin-signals.ts b/packages/shared/src/schemas/admin-signals.ts
new file mode 100644
index 00000000..84471f49
--- /dev/null
+++ b/packages/shared/src/schemas/admin-signals.ts
@@ -0,0 +1,93 @@
+import { z } from 'zod';
+import { taxonomies } from '../taxonomies';
+import { plainText } from './common';
+
+/** Rows of a page of the signals and ratings lists of the admin. */
+export const adminSignalsPageSize = 50;
+
+/** Query of `GET /admin/signals`: the places the community flagged, oldest check first (A-03). */
+export const AdminSignalsQuery = z.strictObject({ cursor: z.string().max(200).optional() });
+export type AdminSignalsQuery = z.infer;
+
+/**
+ * A place waiting for the team in A-03, with the routes that go through it. The signals
+ * themselves (their number, their reasons, the recent confirmations) come with `place_signals`
+ * (wiki Modele-de-Donnees): until then they read as none.
+ */
+export const AdminSignaledPlace = z.object({
+ id: z.uuid(),
+ name: z.string(),
+ address: z.string(),
+ cityName: z.string(),
+ verificationStatus: z.enum(taxonomies.verificationStatuses),
+ /** Null for a place never verified. */
+ verifiedAt: z.iso.datetime().nullable(),
+ /** Open signals on the place. */
+ signalCount: z.number().int().nonnegative(),
+ /** Confirmations since the last verification, weighted by trust. */
+ confirmationCount: z.number().nonnegative(),
+ /** Published routes that go through the place: they pass to `needs_fix` when it is closed. */
+ routes: z.array(
+ z.object({ id: z.uuid(), title: z.string(), status: z.enum(taxonomies.routeStatuses) }),
+ ),
+});
+export type AdminSignaledPlace = z.infer;
+
+/** Body of `GET /admin/signals`. */
+export const AdminSignaledPlaces = z.object({
+ items: z.array(AdminSignaledPlace),
+ total: z.number().int().nonnegative(),
+ offset: z.number().int().nonnegative(),
+ previousCursor: z.string().nullable(),
+ nextCursor: z.string().nullable(),
+});
+export type AdminSignaledPlaces = z.infer;
+
+/** Body of `POST /admin/places/:id/close`: why the place is closed, kept in the journal. */
+export const ClosePlaceBody = z.strictObject({ reason: plainText(500) });
+export type ClosePlaceBody = z.infer;
+
+/** Answer of `verify` and `close`: the place as it stands, and the routes marked to correct. */
+export const PlaceActionResult = z.object({
+ placeId: z.uuid(),
+ verificationStatus: z.enum(taxonomies.verificationStatuses),
+ /** Routes passed to `needs_fix` by a closing; 0 for a verification. */
+ routesToFix: z.number().int().nonnegative(),
+});
+export type PlaceActionResult = z.infer;
+
+/** `details` of a 409 `already_decided` on a place: the status it already has. */
+export const PlaceAlreadyDecidedDetails = z.object({
+ status: z.enum(taxonomies.verificationStatuses),
+});
+export type PlaceAlreadyDecidedDetails = z.infer;
+
+/** Query of `GET /admin/ratings`: ratings with a comment, filtered by note and by route (A-03). */
+export const AdminRatingsQuery = z.strictObject({
+ rating: z.coerce.number().int().min(1).max(5).optional(),
+ routeId: z.uuid().optional(),
+ cursor: z.string().max(200).optional(),
+});
+export type AdminRatingsQuery = z.infer;
+
+/** A rating with a comment, as the team reads it. Ratings come with `route_ratings` (E-15). */
+export const AdminRating = z.object({
+ id: z.uuid(),
+ rating: z.number().int().min(1).max(5),
+ comment: z.string(),
+ isHidden: z.boolean(),
+ createdAt: z.iso.datetime(),
+ route: z.object({ id: z.uuid(), title: z.string() }),
+ author: z.object({ id: z.uuid(), firstName: z.string().nullable() }).nullable(),
+});
+export type AdminRating = z.infer;
+
+/** Body of `GET /admin/ratings`. */
+export const AdminRatings = z.object({
+ items: z.array(AdminRating),
+ total: z.number().int().nonnegative(),
+ offset: z.number().int().nonnegative(),
+ previousCursor: z.string().nullable(),
+ nextCursor: z.string().nullable(),
+});
+export type AdminRatings = z.infer;