From a0411d4daa76ed6322e4984776aed6ad0dc0fc3d Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:01:05 +0700 Subject: [PATCH 01/23] feat(api): serve the figures of the dashboard, by role Co-Authored-By: Claude Opus 5.5 --- apps/api/src/admin/stats.ts | 108 +++++++++++++++++++++++++++ apps/api/src/routes/admin.test.ts | 59 ++++++++++++++- apps/api/src/routes/admin.ts | 22 +++++- packages/api-client/src/client.ts | 11 +++ packages/shared/src/schemas/admin.ts | 39 ++++++++++ 5 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/admin/stats.ts diff --git a/apps/api/src/admin/stats.ts b/apps/api/src/admin/stats.ts new file mode 100644 index 00000000..08ceff7f --- /dev/null +++ b/apps/api/src/admin/stats.ts @@ -0,0 +1,108 @@ +import { + adminLateAfterHours, + canOpenAdminSection, + countedAdminSections, + type AdminStats, + type AdminToHandle, + type CountedAdminSection, + type UserRole, +} from '@widoo/shared'; +import { sql } from 'drizzle-orm'; +import type { Db } from '../db/client'; + +type StatsRow = { + pending: number; + pending_late: number; + pending_oldest: Date | null; + published_routes: number; + premium_routes: number; + official_routes: number; + places_in_routes: number; + cities: number; + active_creators: number; + registered: number; + new_users: number; + premium_active: number; +}; + +const day = 24 * 60 * 60 * 1000; +const ago = (now: Date, ms: number) => new Date(now.getTime() - ms).toISOString(); + +/** + * The figures of the dashboard (A-05), read in one query. « À traiter » follows the role, as the + * counters of the navigation do. Deleted accounts + * are left out of every figure about users. + */ +export async function readAdminStats( + db: Db, + role: UserRole, + now = new Date(), +): Promise { + const lateBefore = ago(now, adminLateAfterHours * 60 * 60 * 1000); + const [row] = await db.execute(sql` + select + (select count(*)::int from routes where status = 'pending') as pending, + (select count(*)::int from routes + where status = 'pending' and updated_at < ${lateBefore}::timestamptz) as pending_late, + (select min(updated_at) from routes where status = 'pending') as pending_oldest, + (select count(*)::int from routes where status = 'published') as published_routes, + (select count(*)::int from routes + where status = 'published' and access = 'premium') as premium_routes, + (select count(*)::int from routes where status = 'published' and is_official) + as official_routes, + (select count(distinct s.place_id)::int from steps s) as places_in_routes, + (select count(distinct city_id)::int from routes where status = 'published') as cities, + (select count(distinct author_id)::int from routes + where author_id is not null and updated_at >= ${ago(now, 30 * day)}::timestamptz) + as active_creators, + (select count(*)::int from users where deleted_at is null) as registered, + (select count(*)::int from users + where deleted_at is null and created_at >= ${ago(now, 7 * day)}::timestamptz) as new_users, + (select count(*)::int from users + where deleted_at is null and plan = 'premium' + and (plan_expires_at is null or plan_expires_at > ${now.toISOString()}::timestamptz)) + as premium_active`); + if (!row) { + throw new Error('Dashboard figures not read'); + } + + // What waits in each section. Reports, support messages, AI drafts and failed translations + // have no table yet: nothing can wait there, and each one gets its query with its section. + const nothing: AdminToHandle = { count: 0, lateCount: 0, oldestSince: null }; + const waiting: Record = { + moderation: { + count: row.pending, + lateCount: row.pending_late, + oldestSince: row.pending_oldest ? new Date(row.pending_oldest).toISOString() : null, + }, + reports: nothing, + support: nothing, + aiDrafts: nothing, + translations: nothing, + }; + const toHandle: AdminStats['toHandle'] = {}; + for (const section of countedAdminSections) { + if (canOpenAdminSection(role, section)) { + toHandle[section] = waiting[section]; + } + } + return { + toHandle, + catalog: { + publishedRoutes: row.published_routes, + premiumRoutes: row.premium_routes, + officialRoutes: row.official_routes, + placesInRoutes: row.places_in_routes, + cities: row.cities, + activeCreators: row.active_creators, + }, + users: { + registered: row.registered, + newLast7Days: row.new_users, + premiumActive: row.premium_active, + // No unit unlock exists yet (`route_unlocks`): none happened. + unlocksLast30Days: 0, + }, + generatedAt: now.toISOString(), + }; +} diff --git a/apps/api/src/routes/admin.test.ts b/apps/api/src/routes/admin.test.ts index 2fd8d39b..8807e3ac 100644 --- a/apps/api/src/routes/admin.test.ts +++ b/apps/api/src/routes/admin.test.ts @@ -1,4 +1,4 @@ -import { AdminCounters, ApiError, type UserRole } from '@widoo/shared'; +import { AdminCounters, AdminStats, ApiError, type UserRole } from '@widoo/shared'; import { eq, inArray } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; @@ -129,3 +129,60 @@ describe('GET /v1/admin/counters', () => { expect(AdminCounters.parse(response.json())).toEqual({}); }); }); + +describe('GET /v1/admin/stats', () => { + const getStats = (headers: Record) => + app.inject({ url: '/v1/admin/stats', headers }); + + it('refuses an account outside the team', async () => { + expect((await getStats(await signIn('user'))).statusCode).toBe(403); + }); + + it('gives a moderator what waits, with the oldest route of the queue', async () => { + const headers = await signIn('moderator'); + await addPendingRoute(90); + + const stats = AdminStats.parse((await getStats(headers)).json()); + expect(Object.keys(stats.toHandle).sort()).toEqual([ + 'moderation', + 'reports', + 'support', + 'translations', + ]); + const moderation = stats.toHandle.moderation; + expect(moderation?.count).toBeGreaterThanOrEqual(1); + expect(moderation?.lateCount).toBeGreaterThanOrEqual(1); + const oldest = new Date(moderation?.oldestSince ?? '').getTime(); + expect(Date.now() - oldest).toBeGreaterThanOrEqual(90 * hour - 60_000); + }); + + it('gives an editor the catalog, the users and the tiles of its sections only', async () => { + const response = await getStats(await signIn('editor')); + expect(response.headers['cache-control']).toBe('no-store'); + const stats = AdminStats.parse(response.json()); + const nothing = { count: 0, lateCount: 0, oldestSince: null }; + expect(stats.toHandle).toEqual({ aiDrafts: nothing, translations: nothing }); + expect(stats.catalog.publishedRoutes).toBeGreaterThanOrEqual(1); + expect(stats.catalog.premiumRoutes).toBeLessThanOrEqual(stats.catalog.publishedRoutes); + expect(stats.catalog.placesInRoutes).toBeGreaterThanOrEqual(1); + expect(stats.catalog.cities).toBeGreaterThanOrEqual(1); + expect(stats.users.registered).toBeGreaterThanOrEqual(1); + expect(stats.users.newLast7Days).toBeGreaterThanOrEqual(1); + expect(stats.users.unlocksLast30Days).toBe(0); + }); + + it('counts a new account once, and a Premium plan only while it runs', async () => { + const headers = await signIn('admin'); + const before = AdminStats.parse((await getStats(headers)).json()).users; + const day = 24 * hour; + for (const planExpiresAt of [new Date(Date.now() + day), new Date(Date.now() - day)]) { + const { uid } = verifier.issue(); + createdUids.push(uid); + await app.db.insert(users).values({ firebaseUid: uid, plan: 'premium', planExpiresAt }); + } + + const after = AdminStats.parse((await getStats(headers)).json()).users; + expect(after.registered - before.registered).toBeGreaterThanOrEqual(2); + expect(after.premiumActive - before.premiumActive).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index d42a4ef1..d5c65b4c 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -1,6 +1,7 @@ -import { AdminCounters } from '@widoo/shared'; +import { AdminCounters, AdminStats } from '@widoo/shared'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; import { readAdminCounters } from '../admin/counters'; +import { readAdminStats } from '../admin/stats'; import { httpError } from '../errors'; const security = [{ firebaseIdToken: [] }]; @@ -33,4 +34,23 @@ export const adminRoutes: FastifyPluginAsyncZod = async (app) => { return readAdminCounters(app.db, request.user.role); }, ); + + app.get( + '/admin/stats', + { + onRequest: app.requireAdminSection('dashboard'), + schema: { + tags: ['admin'], + summary: 'Figures of the dashboard', + security, + response: { 200: AdminStats }, + }, + }, + async (request) => { + if (!request.user) { + throw httpError(401, 'Missing bearer token'); + } + return readAdminStats(app.db, request.user.role); + }, + ); }; diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index f130b473..03b0da79 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,5 +1,6 @@ import { AdminCounters, + AdminStats, AppConfig, GeocodeResult, Me, @@ -92,6 +93,16 @@ export function createApiClient(options: ApiClientOptions) { signal, }), + /** `GET /v1/admin/stats`: the figures of the dashboard (A-05), for the role of the caller. */ + getAdminStats: (signal?: AbortSignal) => + request({ + method: 'GET', + path: '/v1/admin/stats', + schema: AdminStats, + auth: true, + signal, + }), + /** `GET /v1/admin/moderation`: a page of the moderation queue (A-02). Moderators and admins. */ getModerationQueue: (params: ModerationQueueParams, signal?: AbortSignal) => request({ diff --git a/packages/shared/src/schemas/admin.ts b/packages/shared/src/schemas/admin.ts index 7c6f5b3d..a9cdc4c7 100644 --- a/packages/shared/src/schemas/admin.ts +++ b/packages/shared/src/schemas/admin.ts @@ -18,3 +18,42 @@ export type AdminCounter = z.infer; */ export const AdminCounters = z.partialRecord(z.enum(countedAdminSections), AdminCounter); export type AdminCounters = z.infer; + +const count = z.number().int().nonnegative(); + +/** A tile « à traiter » of the dashboard: a counter, and since when its oldest item waits. */ +export const AdminToHandle = AdminCounter.extend({ + /** Null when nothing waits. */ + oldestSince: z.iso.datetime().nullable(), +}); +export type AdminToHandle = z.infer; + +/** + * Body of `GET /admin/stats`, the figures of the dashboard (A-05). `toHandle` holds the sections + * the role of the caller opens and the API counts; the other figures are the same for the team. + */ +export const AdminStats = z.object({ + toHandle: z.partialRecord(z.enum(countedAdminSections), AdminToHandle), + catalog: z.object({ + publishedRoutes: count, + /** Among the published routes. */ + premiumRoutes: count, + officialRoutes: count, + /** Places that at least one route goes through. */ + placesInRoutes: count, + /** Cities with at least one published route. */ + cities: count, + /** Authors who created or changed a route in the last 30 days. */ + activeCreators: count, + }), + users: z.object({ + registered: count, + newLast7Days: count, + /** Accounts whose Premium plan runs now, subscription or pass. */ + premiumActive: count, + /** Always 0 until unit unlocks exist (`route_unlocks`). */ + unlocksLast30Days: count, + }), + generatedAt: z.iso.datetime(), +}); +export type AdminStats = z.infer; From 0c416e3052f8c4a1d937b765814c84e521b10319 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:01:05 +0700 Subject: [PATCH 02/23] feat(admin): show the dashboard with what waits, the catalog and the users (A-05) Co-Authored-By: Claude Opus 5.5 --- apps/admin/.env.example | 5 + apps/admin/src/app/(shell)/page.tsx | 9 +- .../components/dashboard/DashboardSection.tsx | 21 +++ .../dashboard/ExternalDashboardLink.tsx | 16 +++ .../src/components/dashboard/FigureBand.tsx | 26 ++++ .../components/dashboard/RefreshStatus.tsx | 28 ++++ .../src/components/dashboard/ToHandleTile.tsx | 49 +++++++ .../components/dashboard/ToHandleTiles.tsx | 51 ++++++++ apps/admin/src/config/env.test.ts | 10 ++ apps/admin/src/config/env.ts | 9 ++ apps/admin/src/config/external-dashboards.ts | 11 ++ apps/admin/src/config/public-env.ts | 2 + .../src/containers/DashboardContainer.tsx | 121 ++++++++++++++++++ apps/admin/src/hooks/useMinuteTick.ts | 10 ++ apps/admin/src/lib/numbers.test.ts | 10 ++ apps/admin/src/lib/numbers.ts | 6 + apps/admin/src/messages/fr.ts | 41 ++++++ packages/ui/tailwind-preset.cjs | 2 + 18 files changed, 422 insertions(+), 5 deletions(-) create mode 100644 apps/admin/src/components/dashboard/DashboardSection.tsx create mode 100644 apps/admin/src/components/dashboard/ExternalDashboardLink.tsx create mode 100644 apps/admin/src/components/dashboard/FigureBand.tsx create mode 100644 apps/admin/src/components/dashboard/RefreshStatus.tsx create mode 100644 apps/admin/src/components/dashboard/ToHandleTile.tsx create mode 100644 apps/admin/src/components/dashboard/ToHandleTiles.tsx create mode 100644 apps/admin/src/config/external-dashboards.ts create mode 100644 apps/admin/src/containers/DashboardContainer.tsx create mode 100644 apps/admin/src/hooks/useMinuteTick.ts create mode 100644 apps/admin/src/lib/numbers.test.ts create mode 100644 apps/admin/src/lib/numbers.ts diff --git a/apps/admin/.env.example b/apps/admin/.env.example index 52615e47..dee2a02a 100644 --- a/apps/admin/.env.example +++ b/apps/admin/.env.example @@ -17,3 +17,8 @@ NEXT_PUBLIC_FIREBASE_PROJECT_ID=demo-widoo # FIREBASE_AUTH_EMULATOR_HOST of the API. Accepted with a demo-* project only; empty on staging # and production. NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL=http://127.0.0.1:9099 + +# Dashboards the admin links to from its home page (A-05), https: the exact page of the Widoo +# project. Empty: the home page of PostHog Cloud EU and of RevenueCat. +NEXT_PUBLIC_POSTHOG_URL= +NEXT_PUBLIC_REVENUECAT_URL= diff --git a/apps/admin/src/app/(shell)/page.tsx b/apps/admin/src/app/(shell)/page.tsx index 890f7b5a..2bf9f7e4 100644 --- a/apps/admin/src/app/(shell)/page.tsx +++ b/apps/admin/src/app/(shell)/page.tsx @@ -1,7 +1,6 @@ -/** - * A-05, the dashboard: its tiles and figures come with the ticket of the dashboard (#55). The - * shell around it already shows its title. - */ +import { DashboardContainer } from '@/containers/DashboardContainer'; + +/** A-05, the dashboard: the home page of the admin. */ export default function DashboardPage() { - return null; + return ; } diff --git a/apps/admin/src/components/dashboard/DashboardSection.tsx b/apps/admin/src/components/dashboard/DashboardSection.tsx new file mode 100644 index 00000000..33901ccb --- /dev/null +++ b/apps/admin/src/components/dashboard/DashboardSection.tsx @@ -0,0 +1,21 @@ +import type { ReactNode } from 'react'; + +type DashboardSectionProps = { + title: string; + /** On the right of the title: when the figures were read, or a link to another dashboard. */ + aside?: ReactNode; + children: ReactNode; +}; + +/** A block of the dashboard: its title, what goes on its right, then its figures. */ +export function DashboardSection({ title, aside, children }: DashboardSectionProps) { + return ( +
+
+

{title}

+ {aside} +
+ {children} +
+ ); +} diff --git a/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx b/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx new file mode 100644 index 00000000..3bc09ade --- /dev/null +++ b/apps/admin/src/components/dashboard/ExternalDashboardLink.tsx @@ -0,0 +1,16 @@ +import { ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr'; + +/** Link to a dashboard kept outside the admin (PostHog, RevenueCat), in a new tab. */ +export function ExternalDashboardLink({ href, label }: { href: string; label: string }) { + return ( + + {label} + + + ); +} diff --git a/apps/admin/src/components/dashboard/FigureBand.tsx b/apps/admin/src/components/dashboard/FigureBand.tsx new file mode 100644 index 00000000..e5a0212a --- /dev/null +++ b/apps/admin/src/components/dashboard/FigureBand.tsx @@ -0,0 +1,26 @@ +import { formatCount } from '@/lib/numbers'; + +export type Figure = { + label: string; + value: number; + /** What the figure counts, under it: « sur les 30 derniers jours ». */ + detail: string; +}; + +/** Figures side by side on warm gray, separated by a line: figures alone, no chart (A-05). */ +export function FigureBand({ figures }: { figures: Figure[] }) { + return ( +
+ {figures.map((figure) => ( +
+
{figure.label}
+
{formatCount(figure.value)}
+
{figure.detail}
+
+ ))} +
+ ); +} diff --git a/apps/admin/src/components/dashboard/RefreshStatus.tsx b/apps/admin/src/components/dashboard/RefreshStatus.tsx new file mode 100644 index 00000000..5bc368e8 --- /dev/null +++ b/apps/admin/src/components/dashboard/RefreshStatus.tsx @@ -0,0 +1,28 @@ +import { useMinuteTick } from '@/hooks/useMinuteTick'; +import { formatRelativeDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import { ArrowClockwiseIcon } from '@phosphor-icons/react/dist/ssr'; + +type RefreshStatusProps = { + /** When the figures shown were read. */ + readAt: Date; + onRefresh: () => void; +}; + +/** « Mis à jour il y a 2 min » and the button that reads the figures again. */ +export function RefreshStatus({ readAt, onRefresh }: RefreshStatusProps) { + useMinuteTick(); + return ( + + {messages.dashboard.updated(formatRelativeDate(readAt))} + + + ); +} diff --git a/apps/admin/src/components/dashboard/ToHandleTile.tsx b/apps/admin/src/components/dashboard/ToHandleTile.tsx new file mode 100644 index 00000000..ce5c21cd --- /dev/null +++ b/apps/admin/src/components/dashboard/ToHandleTile.tsx @@ -0,0 +1,49 @@ +import { formatCount } from '@/lib/numbers'; +import { ArrowRightIcon, WarningCircleIcon } from '@phosphor-icons/react/dist/ssr'; +import type { Icon } from '@phosphor-icons/react'; +import { cn } from '@widoo/ui'; +import Link from 'next/link'; + +type ToHandleTileProps = { + /** The list the tile opens, already filtered on what waits. */ + href: string; + icon: Icon; + label: string; + count: number; + /** Line under the figure: « Le plus ancien attend depuis 3 j 4 h ». */ + detail: string; + /** Something has been waiting for more than 72 hours: the detail turns red, with an icon. */ + isLate: boolean; +}; + +/** A tile « à traiter » of the dashboard, a link to its list as a whole. */ +export function ToHandleTile({ + href, + icon: IconOf, + label, + count, + detail, + isLate, +}: ToHandleTileProps) { + return ( + + + + + + {label} + + + {formatCount(count)} + + {isLate ? : null} + {detail} + + + ); +} diff --git a/apps/admin/src/components/dashboard/ToHandleTiles.tsx b/apps/admin/src/components/dashboard/ToHandleTiles.tsx new file mode 100644 index 00000000..52adcb54 --- /dev/null +++ b/apps/admin/src/components/dashboard/ToHandleTiles.tsx @@ -0,0 +1,51 @@ +import { sectionIcons } from '@/components/shell/section-icons'; +import { formatWaitingTime } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import { countedAdminSections, type AdminStats, type CountedAdminSection } from '@widoo/shared'; +import { ToHandleTile } from './ToHandleTile'; + +const texts = messages.dashboard.toHandle; + +type ToHandleTilesProps = { + /** What waits in each section the role opens; a section the role does not open is absent. */ + toHandle: AdminStats['toHandle']; + /** The list each tile opens, already filtered on what waits. */ + hrefs: Record; +}; + +/** + * The tiles « à traiter » of the dashboard, in the order of the navigation: one per section of + * the role, with its count, and since when its oldest item waits. + */ +export function ToHandleTiles({ toHandle, hrefs }: ToHandleTilesProps) { + const sections = countedAdminSections.filter((section) => toHandle[section] !== undefined); + if (sections.length === 0) { + return

{texts.nothing}

; + } + return ( +
    + {sections.map((section) => { + const waiting = toHandle[section]; + if (!waiting) { + return null; + } + return ( +
  • + 0} + /> +
  • + ); + })} +
+ ); +} diff --git a/apps/admin/src/config/env.test.ts b/apps/admin/src/config/env.test.ts index b3441f60..c407d2e6 100644 --- a/apps/admin/src/config/env.test.ts +++ b/apps/admin/src/config/env.test.ts @@ -13,6 +13,8 @@ describe('parseEnv', () => { expect(parseEnv(valid)).toEqual({ ...valid, NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: undefined, + NEXT_PUBLIC_POSTHOG_URL: undefined, + NEXT_PUBLIC_REVENUECAT_URL: undefined, }); }); @@ -55,4 +57,12 @@ describe('parseEnv', () => { }), ).toThrow(/demo-\* project/); }); + + it('reads the dashboards the admin links to, https only', () => { + const env = parseEnv({ ...valid, NEXT_PUBLIC_POSTHOG_URL: 'https://eu.posthog.com/project/1' }); + expect(env.NEXT_PUBLIC_POSTHOG_URL).toBe('https://eu.posthog.com/project/1'); + expect(() => + parseEnv({ ...valid, NEXT_PUBLIC_REVENUECAT_URL: 'http://plain.example' }), + ).toThrow(/NEXT_PUBLIC_REVENUECAT_URL/); + }); }); diff --git a/apps/admin/src/config/env.ts b/apps/admin/src/config/env.ts index b0006793..82e62a81 100644 --- a/apps/admin/src/config/env.ts +++ b/apps/admin/src/config/env.ts @@ -10,6 +10,12 @@ const Origin = z /** A host name, without scheme or path: `project.firebaseapp.com`. */ const Host = z.string().regex(/^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/); +const OptionalHttpsUrl = z + .string() + .optional() + .transform((value) => value || undefined) + .pipe(z.url({ protocol: /^https$/ }).optional()); + /** * Environment variables of the admin, validated once. See `.env.example`. Every value is public: * Next.js writes the `NEXT_PUBLIC_` variables in the pages at build time. @@ -24,6 +30,9 @@ const Env = z.object({ NEXT_PUBLIC_FIREBASE_API_KEY: z.string().min(1), NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: Host, NEXT_PUBLIC_FIREBASE_PROJECT_ID: z.string().regex(/^[a-z][a-z0-9-]{4,28}[a-z0-9]$/), + /** Exact pages of the Widoo project in PostHog and RevenueCat, linked from A-05. */ + NEXT_PUBLIC_POSTHOG_URL: OptionalHttpsUrl, + NEXT_PUBLIC_REVENUECAT_URL: OptionalHttpsUrl, /** Local Auth emulator; empty on staging and production. */ NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: z .string() diff --git a/apps/admin/src/config/external-dashboards.ts b/apps/admin/src/config/external-dashboards.ts new file mode 100644 index 00000000..7dee5cf3 --- /dev/null +++ b/apps/admin/src/config/external-dashboards.ts @@ -0,0 +1,11 @@ +import { publicEnv } from './public-env'; + +/** + * Dashboards kept outside the admin, linked from A-05. By default their home page, which opens + * the project of the person once signed in: PostHog Cloud EU (wiki Securite-et-RGPD) and + * RevenueCat. The environment can name the exact page of the Widoo project instead. + */ +export const externalDashboards = { + posthog: publicEnv.NEXT_PUBLIC_POSTHOG_URL ?? 'https://eu.posthog.com', + revenueCat: publicEnv.NEXT_PUBLIC_REVENUECAT_URL ?? 'https://app.revenuecat.com', +}; diff --git a/apps/admin/src/config/public-env.ts b/apps/admin/src/config/public-env.ts index c1a2b3bc..0e6836b5 100644 --- a/apps/admin/src/config/public-env.ts +++ b/apps/admin/src/config/public-env.ts @@ -10,4 +10,6 @@ export const publicEnv = parseEnv({ NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN: process.env.NEXT_PUBLIC_FIREBASE_AUTH_DOMAIN, NEXT_PUBLIC_FIREBASE_PROJECT_ID: process.env.NEXT_PUBLIC_FIREBASE_PROJECT_ID, NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL: process.env.NEXT_PUBLIC_FIREBASE_AUTH_EMULATOR_URL, + NEXT_PUBLIC_POSTHOG_URL: process.env.NEXT_PUBLIC_POSTHOG_URL, + NEXT_PUBLIC_REVENUECAT_URL: process.env.NEXT_PUBLIC_REVENUECAT_URL, }); diff --git a/apps/admin/src/containers/DashboardContainer.tsx b/apps/admin/src/containers/DashboardContainer.tsx new file mode 100644 index 00000000..88c168f2 --- /dev/null +++ b/apps/admin/src/containers/DashboardContainer.tsx @@ -0,0 +1,121 @@ +'use client'; + +import { DashboardSection } from '@/components/dashboard/DashboardSection'; +import { ExternalDashboardLink } from '@/components/dashboard/ExternalDashboardLink'; +import { FigureBand } from '@/components/dashboard/FigureBand'; +import { RefreshStatus } from '@/components/dashboard/RefreshStatus'; +import { ToHandleTiles } from '@/components/dashboard/ToHandleTiles'; +import { PageMessage } from '@/components/states/PageMessage'; +import { sectionPaths } from '@/config/navigation'; +import { externalDashboards } from '@/config/external-dashboards'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; +import { messages } from '@/messages/fr'; +import type { CountedAdminSection } from '@widoo/shared'; +import { Button } from '@widoo/ui'; + +const texts = messages.dashboard; + +/** The list each tile « à traiter » opens; the moderation queue filtered on what waits. */ +const toHandlePaths: Record = { + moderation: `${sectionPaths.moderation}?statut=relecture`, + reports: sectionPaths.reports, + support: sectionPaths.support, + aiDrafts: sectionPaths.aiDrafts, + translations: sectionPaths.translations, +}; + +/** + * A-05, the dashboard: what waits for the role of the member, then the figures of the catalog + * and of the users. Figures alone; the analyses stay in PostHog and RevenueCat. + */ +export function DashboardContainer() { + const stats = useRemoteData('stats', (signal) => getSessionClients().api.getAdminStats(signal)); + + if (stats.status === 'loading') { + return ; + } + if (stats.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + + const { toHandle, catalog, users, generatedAt } = stats.data; + return ( +
+ } + > + + + + } + > + + + + } + > + + +
+ ); +} diff --git a/apps/admin/src/hooks/useMinuteTick.ts b/apps/admin/src/hooks/useMinuteTick.ts new file mode 100644 index 00000000..a697e4d3 --- /dev/null +++ b/apps/admin/src/hooks/useMinuteTick.ts @@ -0,0 +1,10 @@ +import { useEffect, useState } from 'react'; + +/** Renders the component again every minute, for what is written relative to now. */ +export function useMinuteTick(): void { + const [, setTick] = useState(0); + useEffect(() => { + const timer = window.setInterval(() => setTick((tick) => tick + 1), 60_000); + return () => window.clearInterval(timer); + }, []); +} diff --git a/apps/admin/src/lib/numbers.test.ts b/apps/admin/src/lib/numbers.test.ts new file mode 100644 index 00000000..7c340749 --- /dev/null +++ b/apps/admin/src/lib/numbers.test.ts @@ -0,0 +1,10 @@ +import { describe, expect, it } from 'vitest'; +import { formatCount } from './numbers'; + +describe('formatCount', () => { + it('groups the thousands as French does', () => { + // Narrow no-break space (U+202F) between the groups. + expect(formatCount(2140)).toBe('2\u202f140'); + expect(formatCount(86)).toBe('86'); + }); +}); diff --git a/apps/admin/src/lib/numbers.ts b/apps/admin/src/lib/numbers.ts new file mode 100644 index 00000000..0868914a --- /dev/null +++ b/apps/admin/src/lib/numbers.ts @@ -0,0 +1,6 @@ +const frenchNumber = new Intl.NumberFormat('fr-FR'); + +/** A count as the admin writes it, with the French thousands separator: « 2 140 ». */ +export function formatCount(value: number): string { + return frenchNumber.format(value); +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index 97dd903f..70681f5e 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -157,6 +157,47 @@ export const messages = { error: "La liste n'a pas pu être chargée.", retry: 'Réessayer', }, + dashboard: { + error: "Le tableau de bord n'a pas pu être chargé.", + updated: (when: string) => `Mis à jour ${when}`, + refresh: 'Actualiser', + toHandle: { + title: 'À traiter', + nothing: 'Rien à traiter pour le moment.', + oldest: (time: string) => `Le plus ancien attend depuis ${time}`, + tiles: { + moderation: { label: 'Parcours à relire', none: 'Aucun parcours en attente' }, + reports: { label: 'Signalements ouverts', none: 'Aucun signalement ouvert' }, + support: { label: 'Messages de support', none: 'Aucun message à traiter' }, + aiDrafts: { label: 'Brouillons IA à relire', none: 'Aucun brouillon à relire' }, + translations: { label: 'Traductions en échec', none: 'Aucune traduction en échec' }, + }, + }, + catalog: { + title: 'Catalogue', + link: 'Analyses dans PostHog', + publishedRoutes: 'Parcours publiés', + publishedDetail: (premium: number, official: number) => + `dont ${premium} Premium et ${official} de Widoo`, + places: 'Lieux', + placesDetail: 'utilisés par les parcours', + cities: 'Villes', + citiesDetail: 'où des parcours existent', + creators: 'Créateurs actifs', + last30Days: 'sur les 30 derniers jours', + }, + users: { + title: 'Utilisateurs', + link: 'Abonnements dans RevenueCat', + registered: 'Inscrits', + registeredDetail: 'depuis le lancement', + newUsers: 'Nouveaux', + newUsersDetail: 'sur les 7 derniers jours', + premium: 'Premium actifs', + premiumDetail: 'abonnements et pass', + unlocks: 'Déblocages', + }, + }, moderation: { search: 'Rechercher un titre ou un auteur', total: (count: number) => `${count} parcours`, diff --git a/packages/ui/tailwind-preset.cjs b/packages/ui/tailwind-preset.cjs index 5b69bd35..19c1f6ac 100644 --- a/packages/ui/tailwind-preset.cjs +++ b/packages/ui/tailwind-preset.cjs @@ -99,6 +99,8 @@ module.exports = { // Text of a button of 36 px and of a navigation entry: `body-s` in 700. fontSize: { control: ['14px', { lineHeight: '20px', fontWeight: '700' }], + // Figure of the dashboard: 30/36 in 800. + figure: ['30px', { lineHeight: '36px', fontWeight: '800', letterSpacing: '-0.6px' }], // Header of a table column and text of a badge: 12/16 in 600. 'column-head': ['12px', { lineHeight: '16px', fontWeight: '600' }], }, From 0da8fd9bf38117ad7893a36497efaa138148dfb9 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:50:12 +0700 Subject: [PATCH 03/23] fix(api): name who decided in the history of a route, even without a first name Co-Authored-By: Claude Opus 5.5 --- .../src/components/moderation/sheet/HistoryCard.tsx | 2 +- apps/api/src/moderation/detail.ts | 6 +++--- apps/api/src/routes/admin-moderation.test.ts | 10 ++++++++-- packages/shared/src/schemas/moderation.ts | 7 +++++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/apps/admin/src/components/moderation/sheet/HistoryCard.tsx b/apps/admin/src/components/moderation/sheet/HistoryCard.tsx index bbd65d66..9064c154 100644 --- a/apps/admin/src/components/moderation/sheet/HistoryCard.tsx +++ b/apps/admin/src/components/moderation/sheet/HistoryCard.tsx @@ -16,7 +16,7 @@ export function HistoryCard({ history }: { history: ModerationHistoryEntry[] }) {history.map((entry) => (
  • - {texts.by(texts.actions[entry.action], entry.actorFirstName)} + {texts.by(texts.actions[entry.action], entry.actorName)} {formatRelativeDate(new Date(entry.createdAt))} diff --git a/apps/api/src/moderation/detail.ts b/apps/api/src/moderation/detail.ts index 91f93b3e..674341b3 100644 --- a/apps/api/src/moderation/detail.ts +++ b/apps/api/src/moderation/detail.ts @@ -46,7 +46,7 @@ type StepRow = { type HistoryRow = { action: ModerationHistoryEntry['action']; - actor_first_name: string | null; + actor_name: string | null; reason_code: ModerationHistoryEntry['reasonCode']; message: string | null; created_at: Date; @@ -136,7 +136,7 @@ export async function readModerationRoute( order by s.position`); const history = await db.execute(sql` - select a.action, u.first_name as actor_first_name, a.reason_code, a.message, a.created_at + select a.action, coalesce(u.first_name, split_part(u.email, '@', 1)) as actor_name, a.reason_code, a.message, a.created_at from moderation_actions a left join users u on u.id = a.actor_id where a.route_id = ${routeId} order by a.created_at desc`); @@ -193,7 +193,7 @@ export async function readModerationRoute( publishedVersionAt: null, history: history.map((entry) => ({ action: entry.action, - actorFirstName: entry.actor_first_name, + actorName: entry.actor_name, reasonCode: entry.reason_code, message: entry.message, createdAt: new Date(entry.created_at).toISOString(), diff --git a/apps/api/src/routes/admin-moderation.test.ts b/apps/api/src/routes/admin-moderation.test.ts index f293a962..d2e0dc14 100644 --- a/apps/api/src/routes/admin-moderation.test.ts +++ b/apps/api/src/routes/admin-moderation.test.ts @@ -50,7 +50,10 @@ afterAll(async () => { async function signIn(role: UserRole) { const { token, uid } = verifier.issue({ secondFactor: 'totp' }); createdUids.push(uid); - const [user] = await app.db.insert(users).values({ firebaseUid: uid, role }).returning(); + const [user] = await app.db + .insert(users) + .values({ firebaseUid: uid, role, email: 'julie.test@example.com' }) + .returning(); if (!user) throw new Error('User not created'); return { id: user.id, headers: { authorization: `Bearer ${token}` } }; } @@ -333,7 +336,10 @@ describe('GET /v1/admin/routes/:id', () => { const route = ModerationRouteDetail.parse((await sheet(moderator.headers, id)).json()); expect(route.status).toBe('rejected'); expect(route.queue).toBeNull(); - expect(route.history).toMatchObject([{ action: 'reject', ...refusal }]); + // The moderator of this test has no first name: the start of the address stands for it. + expect(route.history).toMatchObject([ + { action: 'reject', actorName: 'julie.test', ...refusal }, + ]); }); it('counts what the author already published and was refused', async () => { diff --git a/packages/shared/src/schemas/moderation.ts b/packages/shared/src/schemas/moderation.ts index 0b2fee8e..06169b03 100644 --- a/packages/shared/src/schemas/moderation.ts +++ b/packages/shared/src/schemas/moderation.ts @@ -164,8 +164,11 @@ export type ModerationStep = z.infer; /** A line of the history of a route in moderation. */ export const ModerationHistoryEntry = z.object({ action: z.enum(moderationActions), - /** First name of the member of the team; null once the account is purged. */ - actorFirstName: z.string().nullable(), + /** + * Who decided: the first name of the member of the team, or the start of their address when + * the account has none. Null once the account is purged. + */ + actorName: z.string().nullable(), reasonCode: z.enum(taxonomies.rejectionReasons).nullable(), message: z.string().nullable(), createdAt: z.iso.datetime(), From cab7cd82f5694ad4bb4ed788751f41a37b25e9e8 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:50:12 +0700 Subject: [PATCH 04/23] chore(api): share the page cursors of the admin lists Co-Authored-By: Claude Opus 5.5 --- apps/api/src/admin/page-cursor.test.ts | 24 +++++++++++++++++++++++ apps/api/src/admin/page-cursor.ts | 27 ++++++++++++++++++++++++++ apps/api/src/moderation/queue.ts | 24 +++-------------------- 3 files changed, 54 insertions(+), 21 deletions(-) create mode 100644 apps/api/src/admin/page-cursor.test.ts create mode 100644 apps/api/src/admin/page-cursor.ts diff --git a/apps/api/src/admin/page-cursor.test.ts b/apps/api/src/admin/page-cursor.test.ts new file mode 100644 index 00000000..3cd6bc85 --- /dev/null +++ b/apps/api/src/admin/page-cursor.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest'; +import { neighbourCursors, offsetOfCursor } from './page-cursor'; + +describe('page cursors', () => { + it('has no neighbour for a list that fits in one page', () => { + expect(neighbourCursors(0, 4, 50)).toEqual({ previousCursor: null, nextCursor: null }); + }); + + it('names the next page, which names the previous one back', () => { + const first = neighbourCursors(0, 120, 50); + expect(offsetOfCursor(first.nextCursor ?? undefined)).toBe(50); + const second = neighbourCursors(50, 120, 50); + expect(offsetOfCursor(second.previousCursor ?? undefined)).toBe(0); + expect(offsetOfCursor(second.nextCursor ?? undefined)).toBe(100); + expect(neighbourCursors(100, 120, 50).nextCursor).toBeNull(); + }); + + it.each([undefined, '', 'not-a-cursor', Buffer.from('-5').toString('base64url')])( + 'reads %s as the first page', + (cursor) => { + expect(offsetOfCursor(cursor)).toBe(0); + }, + ); +}); diff --git a/apps/api/src/admin/page-cursor.ts b/apps/api/src/admin/page-cursor.ts new file mode 100644 index 00000000..f6898c14 --- /dev/null +++ b/apps/api/src/admin/page-cursor.ts @@ -0,0 +1,27 @@ +/** + * Pages of an admin list. The cursor of a page is the number of rows before it, kept opaque for + * the client: these lists are short and sorted by several columns, so an offset is enough, and a + * row that leaves the list meanwhile only shifts the next page by one. + */ +const encode = (offset: number) => Buffer.from(String(offset)).toString('base64url'); + +/** Rows before the page a cursor names; 0 without a cursor, or for one that reads as nothing. */ +export function offsetOfCursor(cursor: string | undefined): number { + if (!cursor) { + return 0; + } + const offset = Number(Buffer.from(cursor, 'base64url').toString()); + return Number.isSafeInteger(offset) && offset > 0 ? offset : 0; +} + +/** Cursors of the pages before and after the one that starts at `offset`; null at either end. */ +export function neighbourCursors( + offset: number, + total: number, + pageSize: number, +): { previousCursor: string | null; nextCursor: string | null } { + return { + previousCursor: offset > 0 ? encode(Math.max(0, offset - pageSize)) : null, + nextCursor: offset + pageSize < total ? encode(offset + pageSize) : null, + }; +} diff --git a/apps/api/src/moderation/queue.ts b/apps/api/src/moderation/queue.ts index 45d5ae9f..387b6f93 100644 --- a/apps/api/src/moderation/queue.ts +++ b/apps/api/src/moderation/queue.ts @@ -9,6 +9,7 @@ import { type ModerationQueueStatus, } from '@widoo/shared'; import { sql, type SQL } from 'drizzle-orm'; +import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; import type { Db } from '../db/client'; type QueueRow = { @@ -84,21 +85,6 @@ const queueFrom = sql`from routes r join cities c on c.id = r.city_id left join users u on u.id = r.author_id and u.deleted_at is null`; -/** - * The cursor of a page is the number of rows before it, kept opaque for the client. The queue is - * short and sorted by several columns: an offset is enough, and a row decided meanwhile only - * shifts the next page by one. - */ -const encodeCursor = (offset: number) => Buffer.from(String(offset)).toString('base64url'); - -export function decodeCursor(cursor: string | undefined): number { - if (!cursor) { - return 0; - } - const offset = Number(Buffer.from(cursor, 'base64url').toString()); - return Number.isSafeInteger(offset) && offset > 0 ? offset : 0; -} - const toItem = (row: QueueRow): ModerationQueueItem => ({ id: row.id, title: row.title, @@ -125,7 +111,7 @@ export async function readModerationQueue( query: ModerationQueueQuery, now = new Date(), ): Promise { - const offset = decodeCursor(query.cursor); + const offset = offsetOfCursor(query.cursor); const where = filtersOf(query, now); const direction = query.direction === 'desc' ? sql`desc` : sql`asc`; @@ -177,10 +163,6 @@ export async function readModerationQueue( }, cities: cities.map(({ id, name }) => ({ id, name })), offset, - previousCursor: offset > 0 ? encodeCursor(Math.max(0, offset - moderationQueuePageSize)) : null, - nextCursor: - offset + moderationQueuePageSize < total - ? encodeCursor(offset + moderationQueuePageSize) - : null, + ...neighbourCursors(offset, total, moderationQueuePageSize), }; } From 15c9dce50692949b736757d898b6d88dd5ff2e80 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:50:12 +0700 Subject: [PATCH 05/23] feat(api): list the routes of the catalog, republish one and hand it to the team Co-Authored-By: Claude Opus 5.5 --- apps/api/src/app.ts | 2 + apps/api/src/catalog/route-actions.ts | 95 ++++++++++++++++++++++ apps/api/src/catalog/routes.ts | 104 +++++++++++++++++++++++++ apps/api/src/routes/admin-catalog.ts | 94 ++++++++++++++++++++++ packages/shared/src/index.ts | 1 + packages/shared/src/schemas/catalog.ts | 65 ++++++++++++++++ 6 files changed, 361 insertions(+) create mode 100644 apps/api/src/catalog/route-actions.ts create mode 100644 apps/api/src/catalog/routes.ts create mode 100644 apps/api/src/routes/admin-catalog.ts create mode 100644 packages/shared/src/schemas/catalog.ts diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index b4851cb5..f65667b7 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -17,6 +17,7 @@ import { createMapboxGeocoder } from './geocode/mapbox'; import { loggerOptions, requestIdOf, type LogStream } from './logger'; import { parseQueryString } from './query-string'; import { adminRoutes } from './routes/admin'; +import { adminCatalogRoutes } from './routes/admin-catalog'; import { adminModerationRoutes } from './routes/admin-moderation'; import { configRoutes } from './routes/config'; import { geocodeRoutes } from './routes/geocode'; @@ -113,6 +114,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(meRoutes, { prefix: '/v1' }); await app.register(adminRoutes, { prefix: '/v1' }); await app.register(adminModerationRoutes, { prefix: '/v1' }); + await app.register(adminCatalogRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(geocodeRoutes, { prefix: '/v1', diff --git a/apps/api/src/catalog/route-actions.ts b/apps/api/src/catalog/route-actions.ts new file mode 100644 index 00000000..708f6342 --- /dev/null +++ b/apps/api/src/catalog/route-actions.ts @@ -0,0 +1,95 @@ +import type { AlreadyDecidedDetails, CatalogRouteAction } from '@widoo/shared'; +import { eq } from 'drizzle-orm'; +import { recordAdminAction } from '../admin/actions'; +import type { Db } from '../db/client'; +import { moderationActionsTable, routes } from '../db/schema'; +import { httpError } from '../errors'; + +type RouteActionInput = { + routeId: string; + /** The member of the team who acts. */ + actorId: string; +}; + +/** + * Puts an unpublished route back online (A-04), with the reason kept in its history and in the + * journal of the admin. A route that is not unpublished answers 409 `already_decided` with its + * status: someone else acted first, or the route was never taken down. + */ +export async function republishRoute( + db: Db, + input: RouteActionInput & { message: string }, + now = new Date(), +): Promise { + return db.transaction(async (tx) => { + const [route] = await tx + .select({ status: routes.status, isOfficial: routes.isOfficial }) + .from(routes) + .where(eq(routes.id, input.routeId)) + .for('update'); + if (!route) { + throw httpError(404, 'Route not found'); + } + if (route.status !== 'unpublished') { + const details: AlreadyDecidedDetails = { status: route.status }; + throw httpError(409, 'Route is not unpublished', 'already_decided', details); + } + + await tx + .update(routes) + .set({ status: 'published', publishedAt: now }) + .where(eq(routes.id, input.routeId)); + await tx.insert(moderationActionsTable).values({ + routeId: input.routeId, + actorId: input.actorId, + action: 'publish', + message: input.message, + }); + await recordAdminAction(tx, { + actorId: input.actorId, + action: 'route.republish', + targetType: 'route', + targetId: input.routeId, + before: { status: route.status }, + after: { status: 'published' }, + reason: input.message, + }); + return { routeId: input.routeId, status: 'published', isOfficial: route.isOfficial }; + }); +} + +/** + * Hands a route over to the Widoo team (A-04): it becomes « Par Widoo » and loses its author, + * so that the team can maintain it in the editor of the app. Its status does not change. + * Reassigning a route of the team again changes nothing and writes nothing. + */ +export async function reassignRouteToTeam( + db: Db, + input: RouteActionInput, +): Promise { + return db.transaction(async (tx) => { + const [route] = await tx + .select({ status: routes.status, isOfficial: routes.isOfficial, authorId: routes.authorId }) + .from(routes) + .where(eq(routes.id, input.routeId)) + .for('update'); + if (!route) { + throw httpError(404, 'Route not found'); + } + if (!route.isOfficial || route.authorId !== null) { + await tx + .update(routes) + .set({ isOfficial: true, authorId: null }) + .where(eq(routes.id, input.routeId)); + await recordAdminAction(tx, { + actorId: input.actorId, + action: 'route.reassign', + targetType: 'route', + targetId: input.routeId, + before: { isOfficial: route.isOfficial, authorId: route.authorId }, + after: { isOfficial: true, authorId: null }, + }); + } + return { routeId: input.routeId, status: route.status, isOfficial: true }; + }); +} diff --git a/apps/api/src/catalog/routes.ts b/apps/api/src/catalog/routes.ts new file mode 100644 index 00000000..d1325c8e --- /dev/null +++ b/apps/api/src/catalog/routes.ts @@ -0,0 +1,104 @@ +import { + catalogPageSize, + type CatalogRoute, + type CatalogRoutes, + type CatalogRoutesQuery, + type CatalogRouteSort, +} from '@widoo/shared'; +import { sql, type SQL } from 'drizzle-orm'; +import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; +import type { Db } from '../db/client'; +import { ratingOf } from '../db/route-stats'; + +type RouteRow = { + id: string; + title: string; + status: CatalogRoute['status']; + access: CatalogRoute['access']; + is_official: boolean; + author_id: string | null; + author_first_name: string | null; + city_name: string; + stats: unknown; + updated_at: Date; +}; + +const sortExpressions: Record = { + updated: sql`r.updated_at`, + title: sql`lower(r.title)`, + author: sql`lower(coalesce(u.first_name, ''))`, + city: sql`lower(c.name)`, + // Routes without a rating last, whatever the direction. + rating: sql`(r.stats ->> 'rating_avg')::numeric`, +}; + +/** `%` and `_` typed in the search are searched as they are, not read as wildcards. */ +const likePattern = (text: string) => `%${text.replaceAll(/[\\%_]/g, '\\$&')}%`; + +export type CatalogScope = { + /** An editor only sees the routes of the team (wiki Admin-Web › Rôles). */ + isTeamOnly: boolean; +}; + +function filtersOf(query: CatalogRoutesQuery, scope: CatalogScope): SQL { + const filters: SQL[] = [sql`true`]; + if (scope.isTeamOnly) { + filters.push(sql`r.is_official`); + } + if (query.status) { + filters.push(sql`r.status = ${query.status}`); + } + if (query.access) { + filters.push(sql`r.access = ${query.access}`); + } + if (query.q) { + const pattern = likePattern(query.q); + filters.push(sql`(r.title ilike ${pattern} or u.first_name ilike ${pattern})`); + } + return sql.join(filters, sql` and `); +} + +const toRoute = (row: RouteRow): CatalogRoute => ({ + id: row.id, + title: row.title, + status: row.status, + access: row.access, + isOfficial: row.is_official, + author: row.author_id ? { id: row.author_id, firstName: row.author_first_name } : null, + cityName: row.city_name, + rating: ratingOf(row.stats), + updatedAt: new Date(row.updated_at).toISOString(), +}); + +/** A page of the routes of the catalog (A-04), whatever their status. */ +export async function readCatalogRoutes( + db: Db, + query: CatalogRoutesQuery, + scope: CatalogScope, +): Promise { + const offset = offsetOfCursor(query.cursor); + const where = filtersOf(query, scope); + const direction = query.direction === 'desc' ? sql`desc` : sql`asc`; + const from = sql`from routes r + join cities c on c.id = r.city_id + left join users u on u.id = r.author_id and u.deleted_at is null`; + + const rows = await db.execute(sql` + select r.id, r.title, r.status, r.access, r.is_official, r.stats, r.updated_at, + u.id as author_id, u.first_name as author_first_name, c.name as city_name + ${from} + where ${where} + order by ${sortExpressions[query.sort]} ${direction} nulls last, r.id + limit ${catalogPageSize} offset ${offset}`); + const [counted] = await db.execute<{ total: number }>( + sql`select count(*)::int as total ${from} where ${where}`, + ); + const total = counted?.total ?? 0; + + return { + items: rows.map(toRoute), + total, + offset, + ...neighbourCursors(offset, total, catalogPageSize), + }; +} diff --git a/apps/api/src/routes/admin-catalog.ts b/apps/api/src/routes/admin-catalog.ts new file mode 100644 index 00000000..de326d82 --- /dev/null +++ b/apps/api/src/routes/admin-catalog.ts @@ -0,0 +1,94 @@ +import { + CatalogRouteAction, + CatalogRoutes, + CatalogRoutesQuery, + RepublishBody, +} from '@widoo/shared'; +import type { FastifyRequest } from 'fastify'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { reassignRouteToTeam, republishRoute } from '../catalog/route-actions'; +import { readCatalogRoutes } from '../catalog/routes'; +import { httpError } from '../errors'; +import type { User } from '../users/repository'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; +const RouteParams = z.object({ id: z.uuid() }); + +/** Set by the guard of the section, which every route of this plugin runs first. */ +function memberOf(request: FastifyRequest): User { + if (!request.user) { + throw httpError(401, 'Missing bearer token'); + } + return request.user; +} + +/** + * Routes of the catalog (A-04, wiki API › Admin). Everyone who opens the catalog reads the list, + * an editor the routes of the team only; putting a route back online or handing it to the team + * is for admins. Unpublishing stays with the moderation decisions. Never cached. + */ +export const adminCatalogRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAdminSection('catalog')); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/admin/routes', + { + schema: { + tags, + summary: 'Routes of the catalog', + security, + querystring: CatalogRoutesQuery, + response: { 200: CatalogRoutes }, + }, + }, + async (request) => + readCatalogRoutes(app.db, request.query, { + isTeamOnly: memberOf(request).role === 'editor', + }), + ); + + app.post( + '/admin/routes/:id/republish', + { + onRequest: app.requireRole('admin'), + schema: { + tags, + summary: 'Put an unpublished route back online', + security, + params: RouteParams, + body: RepublishBody, + response: { 200: CatalogRouteAction }, + }, + }, + async (request) => + republishRoute(app.db, { + routeId: request.params.id, + actorId: memberOf(request).id, + message: request.body.message, + }), + ); + + app.post( + '/admin/routes/:id/reassign', + { + onRequest: app.requireRole('admin'), + schema: { + tags, + summary: 'Hand a route over to the Widoo team', + security, + params: RouteParams, + response: { 200: CatalogRouteAction }, + }, + }, + async (request) => + reassignRouteToTeam(app.db, { + routeId: request.params.id, + actorId: memberOf(request).id, + }), + ); +}; diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index efb9088d..52824686 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -12,6 +12,7 @@ export * from './thresholds'; export { hasRole } from './roles'; export { zoneMinSpanM, zoneSearchBox } from './zone'; export * from './schemas/admin'; +export * from './schemas/catalog'; export * from './schemas/common'; export * from './schemas/config'; export * from './schemas/error'; diff --git a/packages/shared/src/schemas/catalog.ts b/packages/shared/src/schemas/catalog.ts new file mode 100644 index 00000000..b3b7f1e3 --- /dev/null +++ b/packages/shared/src/schemas/catalog.ts @@ -0,0 +1,65 @@ +import { z } from 'zod'; +import { taxonomies } from '../taxonomies'; +import { MessageToAuthor } from './moderation'; + +/** Columns the list of routes of the catalog sorts by; the last change first by default. */ +export const catalogRouteSorts = ['updated', 'title', 'author', 'city', 'rating'] as const; +export type CatalogRouteSort = (typeof catalogRouteSorts)[number]; + +/** Rows of a page of the catalog. */ +export const catalogPageSize = 50; + +/** Query of `GET /admin/routes`: search, filters, sort and page (A-04). */ +export const CatalogRoutesQuery = z.strictObject({ + /** Text searched in the title and in the first name of the author. */ + q: z.string().trim().min(1).max(100).optional(), + status: z.enum(taxonomies.routeStatuses).optional(), + access: z.enum(taxonomies.plans).optional(), + sort: z.enum(catalogRouteSorts).default('updated'), + direction: z.enum(['asc', 'desc']).default('desc'), + cursor: z.string().max(200).optional(), +}); +export type CatalogRoutesQuery = z.infer; + +/** A route of the catalog, as its row shows it (A-04). */ +export const CatalogRoute = z.object({ + id: z.uuid(), + title: z.string(), + status: z.enum(taxonomies.routeStatuses), + access: z.enum(taxonomies.plans), + /** « Par Widoo »: a route of the team. */ + isOfficial: z.boolean(), + /** Null for a route of the team, or once the account of the author is purged. */ + author: z.object({ id: z.uuid(), firstName: z.string().nullable() }).nullable(), + cityName: z.string(), + rating: z.object({ + /** Null until the first rating. */ + average: z.number().min(1).max(5).nullable(), + count: z.number().int().nonnegative(), + }), + updatedAt: z.iso.datetime(), +}); +export type CatalogRoute = z.infer; + +/** Body of `GET /admin/routes`. */ +export const CatalogRoutes = z.object({ + items: z.array(CatalogRoute), + total: z.number().int().nonnegative(), + /** Rows before this page: the first row shown is number `offset + 1`. */ + offset: z.number().int().nonnegative(), + previousCursor: z.string().nullable(), + nextCursor: z.string().nullable(), +}); +export type CatalogRoutes = z.infer; + +/** Body of `POST /admin/routes/:id/republish`: why the route goes back online. */ +export const RepublishBody = z.strictObject({ message: MessageToAuthor }); +export type RepublishBody = z.infer; + +/** Answer of a catalog action on a route: the route as it stands afterwards. */ +export const CatalogRouteAction = z.object({ + routeId: z.uuid(), + status: z.enum(taxonomies.routeStatuses), + isOfficial: z.boolean(), +}); +export type CatalogRouteAction = z.infer; From 4d8e24dca166d2fa2521ab67f49b81369bf2e573 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:50:12 +0700 Subject: [PATCH 06/23] test(api): cover the catalog routes, their roles and their journal Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/admin-catalog.test.ts | 220 ++++++++++++++++++++++ 1 file changed, 220 insertions(+) create mode 100644 apps/api/src/routes/admin-catalog.test.ts diff --git a/apps/api/src/routes/admin-catalog.test.ts b/apps/api/src/routes/admin-catalog.test.ts new file mode 100644 index 00000000..96a29cef --- /dev/null +++ b/apps/api/src/routes/admin-catalog.test.ts @@ -0,0 +1,220 @@ +import { + ApiError, + CatalogRouteAction, + CatalogRoutes, + type RouteStatus, + type UserRole, +} from '@widoo/shared'; +import { eq, inArray } from 'drizzle-orm'; +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { buildApp } from '../app'; +import { TestTokenVerifier } from '../auth/test-verifier'; +import { adminActions, moderationActionsTable, routes, users } from '../db/schema'; +import { seed, seedId } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// Shared database, parallel files: unique users and routes, found by a marker in their title, far +// from every zone the search tests look at. +const verifier = new TestTokenVerifier(); +const createdUids: string[] = []; +const createdRouteIds: string[] = []; +const marker = `cattest-${uuidv7()}`; +const templateId = seedId('route:marais-gourmand'); +const farAway = { lat: 33, lng: 33 }; +const farAwayBounds = { west: 33, south: 33, east: 33.001, north: 33.001 }; + +let app: Awaited>; +let authorId: string; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); + await seed(app.db); + const uid = `test-${uuidv7()}`; + createdUids.push(uid); + const [author] = await app.db + .insert(users) + .values({ firebaseUid: uid, firstName: 'Nadia' }) + .returning(); + if (!author) throw new Error('Author not created'); + authorId = author.id; +}); +afterAll(async () => { + await app.db.delete(adminActions).where(inArray(adminActions.targetId, createdRouteIds)); + await app.db.delete(routes).where(inArray(routes.id, createdRouteIds)); + await app.db.delete(users).where(inArray(users.firebaseUid, createdUids)); + await app.close(); +}); + +async function signIn(role: UserRole) { + const { token, uid } = verifier.issue({ secondFactor: 'totp' }); + createdUids.push(uid); + const [user] = await app.db.insert(users).values({ firebaseUid: uid, role }).returning(); + if (!user) throw new Error('User not created'); + return { id: user.id, headers: { authorization: `Bearer ${token}` } }; +} + +type RouteOptions = { status?: RouteStatus; isOfficial?: boolean; rating?: number }; + +/** A route copied from the seed: of an author unless `isOfficial`, published unless told. */ +async function addRoute(title: string, options: RouteOptions = {}) { + const [template] = await app.db.select().from(routes).where(eq(routes.id, templateId)); + if (!template) throw new Error('Demo route missing from the seed'); + const id = uuidv7(); + createdRouteIds.push(id); + const isOfficial = options.isOfficial ?? false; + await app.db.insert(routes).values({ + ...template, + id, + shareToken: null, + startLocation: farAway, + bounds: farAwayBounds, + title: `${title} ${marker}`, + status: options.status ?? 'published', + isOfficial, + authorId: isOfficial ? null : authorId, + stats: options.rating ? { rating_avg: options.rating, rating_count: 4 } : {}, + }); + return id; +} + +const routeOf = async (id: string) => + (await app.db.select().from(routes).where(eq(routes.id, id)))[0]; +const list = (headers: Record, query = '') => + app.inject({ url: `/v1/admin/routes?q=${marker}${query}`, headers }); +const titles = async (headers: Record, query = '') => + CatalogRoutes.parse((await list(headers, query)).json()).items.map( + (route) => route.title.split(' ')[0], + ); +const act = (headers: Record, id: string, action: string, payload?: object) => + app.inject({ method: 'POST', url: `/v1/admin/routes/${id}/${action}`, headers, payload }); + +describe('GET /v1/admin/routes', () => { + it('lists the routes whatever their status, with author, city and rating', async () => { + const moderator = await signIn('moderator'); + await addRoute('Alpha', { rating: 4.5 }); + await addRoute('Bravo', { status: 'unpublished' }); + await addRoute('Charlie', { isOfficial: true, status: 'draft' }); + + const response = await list(moderator.headers, '&sort=title&direction=asc'); + expect(response.statusCode).toBe(200); + expect(response.headers['cache-control']).toBe('no-store'); + const body = CatalogRoutes.parse(response.json()); + expect(body.items.map((route) => route.title.split(' ')[0])).toEqual([ + 'Alpha', + 'Bravo', + 'Charlie', + ]); + expect(body.total).toBe(3); + expect(body.items[0]).toMatchObject({ + status: 'published', + isOfficial: false, + author: { id: authorId, firstName: 'Nadia' }, + rating: { average: 4.5, count: 4 }, + }); + expect(body.items[2]).toMatchObject({ isOfficial: true, author: null }); + }); + + it('filters by status and sorts by rating, unrated routes last', async () => { + const admin = await signIn('admin'); + expect(await titles(admin.headers, '&status=unpublished')).toEqual(['Bravo']); + expect((await titles(admin.headers, '&sort=rating&direction=desc'))[0]).toBe('Alpha'); + expect((await titles(admin.headers, '&sort=rating&direction=asc'))[0]).toBe('Alpha'); + }); + + it('shows an editor the routes of the team only', async () => { + const editor = await signIn('editor'); + expect(await titles(editor.headers)).toEqual(['Charlie']); + }); + + it('refuses an account outside the team, and a filter it does not know', async () => { + const admin = await signIn('admin'); + expect((await list((await signIn('user')).headers)).statusCode).toBe(403); + expect((await list(admin.headers, '&status=online')).statusCode).toBe(400); + }); +}); + +describe('POST /v1/admin/routes/:id/republish', () => { + const body = { message: 'Lieu rouvert, vérifié sur place.' }; + + it('puts an unpublished route back online, and keeps why in both journals', async () => { + const admin = await signIn('admin'); + const id = await addRoute('Retour', { status: 'unpublished' }); + + const response = await act(admin.headers, id, 'republish', body); + expect(response.statusCode).toBe(200); + expect(CatalogRouteAction.parse(response.json())).toMatchObject({ status: 'published' }); + expect((await routeOf(id))?.status).toBe('published'); + + const history = await app.db + .select() + .from(moderationActionsTable) + .where(eq(moderationActionsTable.routeId, id)); + expect(history).toMatchObject([{ action: 'publish', actorId: admin.id, ...body }]); + const journal = await app.db.select().from(adminActions).where(eq(adminActions.targetId, id)); + expect(journal).toMatchObject([ + { + action: 'route.republish', + before: { status: 'unpublished' }, + after: { status: 'published' }, + reason: body.message, + }, + ]); + }); + + it('answers 409 for a route that is not unpublished, and writes nothing', async () => { + const admin = await signIn('admin'); + const id = await addRoute('Encore', { status: 'published' }); + + const response = await act(admin.headers, id, 'republish', body); + expect(response.statusCode).toBe(409); + expect(ApiError.parse(response.json()).code).toBe('already_decided'); + const journal = await app.db.select().from(adminActions).where(eq(adminActions.targetId, id)); + expect(journal).toEqual([]); + }); + + it('is for admins only, and needs a reason', async () => { + const moderator = await signIn('moderator'); + const admin = await signIn('admin'); + const id = await addRoute('Garde', { status: 'unpublished' }); + + expect((await act(moderator.headers, id, 'republish', body)).statusCode).toBe(403); + expect((await act(admin.headers, id, 'republish', {})).statusCode).toBe(400); + expect((await routeOf(id))?.status).toBe('unpublished'); + }); +}); + +describe('POST /v1/admin/routes/:id/reassign', () => { + it('hands a route over to the team, once', async () => { + const admin = await signIn('admin'); + const id = await addRoute('Transfert'); + + const first = await act(admin.headers, id, 'reassign'); + expect(first.statusCode).toBe(200); + expect(CatalogRouteAction.parse(first.json())).toMatchObject({ + status: 'published', + isOfficial: true, + }); + expect(await routeOf(id)).toMatchObject({ isOfficial: true, authorId: null }); + + expect((await act(admin.headers, id, 'reassign')).statusCode).toBe(200); + const journal = await app.db.select().from(adminActions).where(eq(adminActions.targetId, id)); + expect(journal).toMatchObject([ + { + action: 'route.reassign', + actorId: admin.id, + before: { isOfficial: false, authorId }, + after: { isOfficial: true, authorId: null }, + }, + ]); + }); + + it('is for admins only, and answers 404 for an unknown route', async () => { + const moderator = await signIn('moderator'); + const admin = await signIn('admin'); + const id = await addRoute('Intact'); + + expect((await act(moderator.headers, id, 'reassign')).statusCode).toBe(403); + expect((await routeOf(id))?.isOfficial).toBe(false); + expect((await act(admin.headers, uuidv7(), 'reassign')).statusCode).toBe(404); + }); +}); From a8227a2e1afe0cce2d0c7fb059bc43630517c90e Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:50:12 +0700 Subject: [PATCH 07/23] feat(api-client): read the catalog routes and send their actions Co-Authored-By: Claude Opus 5.5 --- packages/api-client/src/catalog.test.ts | 21 ++++++++++++++ packages/api-client/src/catalog.ts | 13 +++++++++ packages/api-client/src/client.ts | 37 +++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 packages/api-client/src/catalog.test.ts create mode 100644 packages/api-client/src/catalog.ts diff --git a/packages/api-client/src/catalog.test.ts b/packages/api-client/src/catalog.test.ts new file mode 100644 index 00000000..b8e6aa53 --- /dev/null +++ b/packages/api-client/src/catalog.test.ts @@ -0,0 +1,21 @@ +import { describe, expect, it } from 'vitest'; +import { catalogQueryString } from './catalog'; + +describe('catalogQueryString', () => { + it('is empty for the default list', () => { + expect(catalogQueryString({})).toBe(''); + }); + + it('writes the search, the filters, the sort and the page', () => { + expect( + catalogQueryString({ + q: 'marais', + status: 'unpublished', + access: 'premium', + sort: 'rating', + direction: 'asc', + cursor: 'NTA', + }), + ).toBe('q=marais&status=unpublished&access=premium&sort=rating&direction=asc&cursor=NTA'); + }); +}); diff --git a/packages/api-client/src/catalog.ts b/packages/api-client/src/catalog.ts new file mode 100644 index 00000000..a773ebf1 --- /dev/null +++ b/packages/api-client/src/catalog.ts @@ -0,0 +1,13 @@ +import type { CatalogRoutesQuery } from '@widoo/shared'; + +/** Query string of the routes of the catalog, as the API reads it; what is not set is left out. */ +export function catalogQueryString(params: Partial): string { + const query = new URLSearchParams(); + for (const key of ['q', 'status', 'access', 'sort', 'direction', 'cursor'] as const) { + const value = params[key]; + if (value) { + query.set(key, value); + } + } + return query.toString(); +} diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 03b0da79..2bd8b721 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -2,6 +2,8 @@ import { AdminCounters, AdminStats, AppConfig, + CatalogRouteAction, + CatalogRoutes, GeocodeResult, Me, ModerationDecision, @@ -9,10 +11,13 @@ import { ModerationRouteDetail, RouteCount, RouteSearchResult, + type CatalogRoutesQuery, type RejectionBody, + type RepublishBody, type RequestChangesBody, type UpdateMe, } from '@widoo/shared'; +import { catalogQueryString } from './catalog'; import { moderationQueryString, type ModerationQueueParams } from './moderation'; import { countQueryString, @@ -159,6 +164,38 @@ export function createApiClient(options: ApiClientOptions) { auth: true, }), + /** + * `GET /v1/admin/routes`: a page of the routes of the catalog (A-04), whatever their status. + * An editor receives the routes of the team only. + */ + getCatalogRoutes: (params: Partial, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/routes?${catalogQueryString(params)}`, + schema: CatalogRoutes, + auth: true, + signal, + }), + + /** `POST /v1/admin/routes/:id/republish`: puts an unpublished route back online. Admins. */ + republishRoute: (routeId: string, body: RepublishBody) => + request({ + method: 'POST', + path: `/v1/admin/routes/${routeId}/republish`, + body, + schema: CatalogRouteAction, + auth: true, + }), + + /** `POST /v1/admin/routes/:id/reassign`: hands a route over to the Widoo team. Admins. */ + reassignRoute: (routeId: string) => + request({ + method: 'POST', + path: `/v1/admin/routes/${routeId}/reassign`, + schema: CatalogRouteAction, + auth: true, + }), + /** `DELETE /v1/me`: anonymizes the account; its token is refused afterwards. */ deleteMe: () => requestEmpty({ method: 'DELETE', path: '/v1/me', auth: true }), }; From b2b901b2b426fd7e183d165ae5ca8486052412ef Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 13 Oct 2026 15:50:12 +0700 Subject: [PATCH 08/23] chore(admin): share the route action hook, the status badge and the reason dialog Co-Authored-By: Claude Opus 5.5 --- .../moderation/sheet/SheetHeader.tsx | 5 +-- .../moderation/sheet/SheetNavigation.tsx | 11 ++--- .../RouteReasonDialog.tsx} | 23 ++++++---- .../components/routes/RouteStatusBadge.tsx | 23 ++++++++++ apps/admin/src/components/states/BackLink.tsx | 15 +++++++ .../containers/ModerationSheetContainer.tsx | 11 +++-- apps/admin/src/hooks/useModerationDecision.ts | 43 +++---------------- apps/admin/src/hooks/useRouteAction.ts | 42 ++++++++++++++++++ apps/admin/src/lib/rejection-reason.ts | 5 +++ 9 files changed, 115 insertions(+), 63 deletions(-) rename apps/admin/src/components/{moderation/sheet/RejectDialog.tsx => routes/RouteReasonDialog.tsx} (66%) create mode 100644 apps/admin/src/components/routes/RouteStatusBadge.tsx create mode 100644 apps/admin/src/components/states/BackLink.tsx create mode 100644 apps/admin/src/hooks/useRouteAction.ts create mode 100644 apps/admin/src/lib/rejection-reason.ts diff --git a/apps/admin/src/components/moderation/sheet/SheetHeader.tsx b/apps/admin/src/components/moderation/sheet/SheetHeader.tsx index e4b45ec0..26b5251c 100644 --- a/apps/admin/src/components/moderation/sheet/SheetHeader.tsx +++ b/apps/admin/src/components/moderation/sheet/SheetHeader.tsx @@ -1,5 +1,6 @@ import { CertifiedSeal } from '@/components/moderation/CertifiedSeal'; import { WaitingTime } from '@/components/moderation/WaitingTime'; +import { RouteStatusBadge } from '@/components/routes/RouteStatusBadge'; import { messages } from '@/messages/fr'; import { CrownSimpleIcon, MapPinIcon } from '@phosphor-icons/react/dist/ssr'; import type { ModerationRouteDetail } from '@widoo/shared'; @@ -19,9 +20,7 @@ export function SheetHeader({ route, isLate }: SheetHeaderProps) { return (
    - - {texts.statuses[route.status]} - + {route.isRevision ? ( {messages.moderation.revision} ) : null} diff --git a/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx b/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx index 74947af8..a66ef337 100644 --- a/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx +++ b/apps/admin/src/components/moderation/sheet/SheetNavigation.tsx @@ -1,6 +1,7 @@ +import { BackLink } from '@/components/states/BackLink'; import { sectionPaths } from '@/config/navigation'; import { messages } from '@/messages/fr'; -import { ArrowLeftIcon, CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react/dist/ssr'; +import { CaretLeftIcon, CaretRightIcon } from '@phosphor-icons/react/dist/ssr'; import type { ModerationRouteDetail } from '@widoo/shared'; import { cn } from '@widoo/ui'; import Link from 'next/link'; @@ -38,13 +39,7 @@ function NeighbourLink({ routeId, label, direction }: NeighbourLinkProps) { export function SheetNavigation({ queue }: { queue: ModerationRouteDetail['queue'] }) { return (