diff --git a/apps/admin/src/app/(shell)/support/page.tsx b/apps/admin/src/app/(shell)/support/page.tsx
new file mode 100644
index 00000000..946c373e
--- /dev/null
+++ b/apps/admin/src/app/(shell)/support/page.tsx
@@ -0,0 +1,11 @@
+import { SupportContainer } from '@/containers/SupportContainer';
+import { Suspense } from 'react';
+
+/** A-13, the messages of « Nous écrire ». The open message is read from the address, in the browser. */
+export default function SupportPage() {
+ return (
+
+
+
+ );
+}
diff --git a/apps/admin/src/components/support/SupportActions.tsx b/apps/admin/src/components/support/SupportActions.tsx
new file mode 100644
index 00000000..2632393e
--- /dev/null
+++ b/apps/admin/src/components/support/SupportActions.tsx
@@ -0,0 +1,16 @@
+import { messages } from '@/messages/fr';
+import { Button } from '@widoo/ui';
+
+type SupportActionsProps = {
+ onMarkHandled: () => void;
+ isBusy: boolean;
+};
+
+/** The action of the card of a message that is still to handle (A-13): mark it handled. */
+export function SupportActions({ onMarkHandled, isBusy }: SupportActionsProps) {
+ return (
+
+ {messages.support.panel.markHandled}
+
+ );
+}
diff --git a/apps/admin/src/components/support/SupportPanelContent.tsx b/apps/admin/src/components/support/SupportPanelContent.tsx
new file mode 100644
index 00000000..a00560be
--- /dev/null
+++ b/apps/admin/src/components/support/SupportPanelContent.tsx
@@ -0,0 +1,66 @@
+import { formatFullDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import type { AdminSupportMessage } from '@widoo/shared';
+import Link from 'next/link';
+import type { ReactNode } from 'react';
+import { SupportStatusBadge } from './SupportStatusBadge';
+
+const texts = messages.support.panel;
+
+function Line({ label, children }: { label: string; children: ReactNode }) {
+ return (
+
+
{label}
+ {children}
+
+ );
+}
+
+type SupportPanelContentProps = {
+ message: AdminSupportMessage;
+ /** Address of the card of an account (A-06), to open it from the message. */
+ accountPathOf: (userId: string) => string;
+};
+
+/** A message in the detail panel (A-13): what was written, who wrote it, and what the app joined. */
+export function SupportPanelContent({ message, accountPathOf }: SupportPanelContentProps) {
+ return (
+
+
+
+
+
+ {formatFullDate(new Date(message.createdAt))}
+ {message.handledAt ? (
+ {formatFullDate(new Date(message.handledAt))}
+ ) : null}
+
+ {message.author ? (
+
+ {message.author.firstName ?? messages.support.unnamedAccount}
+
+ ) : (
+ {messages.support.noAccount}
+ )}
+
+
+
+ {message.replyEmail}
+
+
+ {message.appVersion}
+ {message.device}
+ {texts.locales[message.locale]}
+
+
+ {texts.message}
+ {message.message}
+
+ {message.handledAt ? null : (
+
+ {texts.howToAnswer(message.locale)}
+
+ )}
+
+ );
+}
diff --git a/apps/admin/src/components/support/SupportStatusBadge.tsx b/apps/admin/src/components/support/SupportStatusBadge.tsx
new file mode 100644
index 00000000..f2f6c864
--- /dev/null
+++ b/apps/admin/src/components/support/SupportStatusBadge.tsx
@@ -0,0 +1,11 @@
+import { messages } from '@/messages/fr';
+import { StatusBadge } from '@widoo/ui';
+
+/** To handle in blue, handled in green. */
+export function SupportStatusBadge({ isHandled }: { isHandled: boolean }) {
+ return (
+
+ {isHandled ? messages.support.statuses.handled : messages.support.statuses.unhandled}
+
+ );
+}
diff --git a/apps/admin/src/components/support/SupportTable.tsx b/apps/admin/src/components/support/SupportTable.tsx
new file mode 100644
index 00000000..4afd1081
--- /dev/null
+++ b/apps/admin/src/components/support/SupportTable.tsx
@@ -0,0 +1,64 @@
+import { formatRelativeDate } from '@/lib/dates';
+import { messages } from '@/messages/fr';
+import type { AdminSupportItem } from '@widoo/shared';
+import { Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui';
+import { SupportStatusBadge } from './SupportStatusBadge';
+
+const texts = messages.support;
+const columnCount = 4;
+
+type SupportTableProps = {
+ /** The messages of the page; ignored while `isLoading`. */
+ items: AdminSupportItem[];
+ isLoading: boolean;
+ /** The message whose card is open in the panel. */
+ openId: string | null;
+ onOpen: (message: AdminSupportItem) => void;
+};
+
+/** The messages of « Nous écrire » as a table (A-13): subject, account, date, status. */
+export function SupportTable({ items, isLoading, openId, onOpen }: SupportTableProps) {
+ return (
+
+
+
+ {texts.columns.subject}
+ {texts.columns.account}
+ {texts.columns.date}
+ {texts.columns.status}
+
+
+
+ {isLoading ? (
+
+ ) : (
+ items.map((message) => (
+ onOpen(message)}
+ isOpen={message.id === openId}
+ >
+
+
+ {messages.users.topics[message.topic]}
+ {message.preview}
+
+
+
+ {message.author ? (
+ (message.author.firstName ?? texts.unnamedAccount)
+ ) : (
+ {texts.noAccount}
+ )}
+
+ {formatRelativeDate(new Date(message.createdAt))}
+
+
+
+
+ ))
+ )}
+
+
+ );
+}
diff --git a/apps/admin/src/config/support-list.test.ts b/apps/admin/src/config/support-list.test.ts
new file mode 100644
index 00000000..caecf6ca
--- /dev/null
+++ b/apps/admin/src/config/support-list.test.ts
@@ -0,0 +1,24 @@
+import { describe, expect, it } from 'vitest';
+import { toSupportParams } from './support-list';
+
+const base = { search: '', sort: { column: 'date', direction: 'asc' }, cursor: null } as const;
+
+describe('toSupportParams', () => {
+ it('asks for every message without a filter', () => {
+ expect(toSupportParams({ ...base, filters: {} })).toEqual({
+ status: undefined,
+ cursor: undefined,
+ });
+ });
+
+ it('reads the status and the page', () => {
+ expect(toSupportParams({ ...base, filters: { statut: 'unhandled' }, cursor: 'NTA' })).toEqual({
+ status: 'unhandled',
+ cursor: 'NTA',
+ });
+ });
+
+ it('drops a status it does not know', () => {
+ expect(toSupportParams({ ...base, filters: { statut: 'answered' } }).status).toBeUndefined();
+ });
+});
diff --git a/apps/admin/src/config/support-list.ts b/apps/admin/src/config/support-list.ts
new file mode 100644
index 00000000..87e23f34
--- /dev/null
+++ b/apps/admin/src/config/support-list.ts
@@ -0,0 +1,21 @@
+import type { ListQuery, ListQueryOptions } from '@/lib/list-query';
+import { adminSupportStatuses, type AdminSupportQuery } from '@widoo/shared';
+
+/** The messages are filtered by their status; the ones to handle come first, and cannot be sorted. */
+export type SupportFilter = 'statut';
+export type SupportColumn = 'date';
+export type SupportListQuery = ListQuery;
+
+export const supportListOptions: ListQueryOptions = {
+ filters: ['statut'],
+ sortableColumns: [],
+ defaultSort: { column: 'date', direction: 'asc' },
+};
+
+/** What to ask the API for the list shown at an address; a status it does not know is dropped. */
+export function toSupportParams(query: SupportListQuery): Partial {
+ return {
+ status: adminSupportStatuses.find((status) => status === query.filters.statut),
+ cursor: query.cursor ?? undefined,
+ };
+}
diff --git a/apps/admin/src/containers/SupportContainer.tsx b/apps/admin/src/containers/SupportContainer.tsx
new file mode 100644
index 00000000..67ed6689
--- /dev/null
+++ b/apps/admin/src/containers/SupportContainer.tsx
@@ -0,0 +1,166 @@
+'use client';
+
+import { PageMessage } from '@/components/states/PageMessage';
+import { SupportActions } from '@/components/support/SupportActions';
+import { SupportPanelContent } from '@/components/support/SupportPanelContent';
+import { SupportStatusBadge } from '@/components/support/SupportStatusBadge';
+import { SupportTable } from '@/components/support/SupportTable';
+import { sectionPaths } from '@/config/navigation';
+import { supportListOptions, toSupportParams } from '@/config/support-list';
+import { useCardOnScreen } from '@/hooks/useCardOnScreen';
+import { useListQuery } from '@/hooks/useListQuery';
+import { useSupportActions } from '@/hooks/useSupportActions';
+import { useRemoteData } from '@/hooks/useRemoteData';
+import { getSessionClients } from '@/lib/auth/clients';
+import { serializeListQuery } from '@/lib/list-query';
+import { messages } from '@/messages/fr';
+import { adminSupportStatuses, type AdminSupportMessage } from '@widoo/shared';
+import { Button, DetailPanel, DetailPanelSkeleton, FilterMenu, Pagination } from '@widoo/ui';
+import { useRouter, useSearchParams } from 'next/navigation';
+
+const texts = messages.support;
+/** Parameter of the address that holds the message whose card is open next to the list. */
+const openMessageParam = 'message';
+
+const statusOptions = adminSupportStatuses.map((status) => ({
+ value: status,
+ label: texts.statuses[status],
+}));
+
+/**
+ * A-13, the messages of « Nous écrire »: the list the address asks for, the ones to handle first,
+ * and next to it the card of the message the address names.
+ */
+export function SupportContainer() {
+ const router = useRouter();
+ const openId = useSearchParams().get(openMessageParam);
+ const { query, handleFilter, handlePage } = useListQuery(supportListOptions);
+ const listAddress = serializeListQuery(query, supportListOptions);
+ const messagesList = useRemoteData(listAddress, (signal) =>
+ getSessionClients().api.getAdminSupport(toSupportParams(query), signal),
+ );
+ const card = useRemoteData(`card:${openId ?? ''}`, (signal) =>
+ openId ? getSessionClients().api.getAdminSupportMessage(openId, signal) : Promise.resolve(null),
+ );
+ const { fresh: openMessage, isLoading: isCardLoading, stale } = useCardOnScreen(openId, card);
+ const data = messagesList.status === 'ready' ? messagesList.data : undefined;
+ const actions = useSupportActions(() => {
+ if (messagesList.status !== 'loading') messagesList.reload();
+ if (card.status !== 'loading') card.reload();
+ });
+
+ /** The address of the list as it is, with the card of `messageId` open, or none. */
+ const showMessage = (messageId: string | null) => {
+ const params = new URLSearchParams(listAddress);
+ if (messageId) {
+ params.set(openMessageParam, messageId);
+ }
+ const address = params.toString();
+ router.replace(address ? `${sectionPaths.support}?${address}` : sectionPaths.support, {
+ scroll: false,
+ });
+ };
+
+ const renderCard = (message: AdminSupportMessage) => (
+ }
+ closeLabel={messages.confirm.close}
+ onClose={() => showMessage(null)}
+ actions={
+ message.handledAt === null ? (
+ void actions.markHandled(message.id)}
+ isBusy={actions.isBusy}
+ />
+ ) : undefined
+ }
+ >
+
+ `${sectionPaths.users}?${new URLSearchParams({ compte: userId }).toString()}`
+ }
+ />
+
+ );
+
+ /** The card open next to the list: the fresh one, or the previous one while the next loads. */
+ const renderPanel = () => {
+ if (openMessage) {
+ return renderCard(openMessage);
+ }
+ if (!isCardLoading) {
+ return null;
+ }
+ return stale ? (
+
+ {renderCard(stale)}
+
+ ) : (
+
+ );
+ };
+
+ const renderList = () => {
+ if (messagesList.status === 'error') {
+ return (
+ {messages.list.retry}}
+ />
+ );
+ }
+ if (data?.items.length === 0) {
+ return ;
+ }
+ return (
+ showMessage(message.id)}
+ />
+ );
+ };
+
+ return (
+
+
+
+
+ handleFilter('statut', status ?? query.filters.statut ?? '')}
+ />
+
+ {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}
+
+ {renderPanel()}
+
+ );
+}
diff --git a/apps/admin/src/hooks/useSupportActions.ts b/apps/admin/src/hooks/useSupportActions.ts
new file mode 100644
index 00000000..9b8d889e
--- /dev/null
+++ b/apps/admin/src/hooks/useSupportActions.ts
@@ -0,0 +1,41 @@
+import { notifyCountersStale } from '@/hooks/useAdminCounters';
+import { getSessionClients } from '@/lib/auth/clients';
+import { messages } from '@/messages/fr';
+import { ApiRequestError } from '@widoo/api-client';
+import { showToast } from '@widoo/ui';
+import { useState } from 'react';
+
+const texts = messages.support.toasts;
+
+/**
+ * Marks a message of « Nous écrire » handled (A-13) and tells the result in a toast. `onSettled`
+ * is called once it is handled, by this action or by another member of the team who got there
+ * first (409 `already_decided`): the screen then reads the message again. The counter of the
+ * navigation is read again at once.
+ */
+export function useSupportActions(onSettled: () => void) {
+ const [isBusy, setIsBusy] = useState(false);
+ const { api } = getSessionClients();
+
+ const markHandled = async (messageId: string) => {
+ setIsBusy(true);
+ try {
+ await api.markSupportMessageHandled(messageId);
+ showToast('success', { title: texts.handled });
+ notifyCountersStale();
+ onSettled();
+ } catch (error) {
+ if (error instanceof ApiRequestError && error.code === 'already_decided') {
+ showToast('info', { title: texts.alreadyHandled });
+ notifyCountersStale();
+ onSettled();
+ } else {
+ showToast('error', { title: texts.failed });
+ }
+ } finally {
+ setIsBusy(false);
+ }
+ };
+
+ return { isBusy, markHandled };
+}
diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts
index ef42b47c..01425e46 100644
--- a/apps/admin/src/messages/fr.ts
+++ b/apps/admin/src/messages/fr.ts
@@ -1304,6 +1304,40 @@ export const messages = {
failed: "Le partenaire n'a pas pu être enregistré.",
},
},
+ support: {
+ total: (count: number) => (count > 1 ? `${count} messages` : `${count} message`),
+ columns: { subject: 'Sujet', account: 'Compte', date: 'Reçu', status: 'Statut' },
+ filters: { label: 'Filtres des messages', status: 'Statut' },
+ statuses: { unhandled: 'À traiter', handled: 'Traité' },
+ noAccount: 'Sans compte',
+ unnamedAccount: 'Compte sans prénom',
+ empty: {
+ title: 'Aucun message',
+ text: 'Les messages envoyés par « Nous écrire » apparaissent ici.',
+ },
+ error: 'Les messages ne se sont pas chargés.',
+ toasts: {
+ handled: 'Message marqué traité',
+ alreadyHandled: "Un autre membre de l'équipe l'a déjà traité.",
+ failed: "Le message n'a pas pu être marqué traité.",
+ },
+ panel: {
+ loading: 'Chargement du message',
+ status: 'Statut',
+ sentAt: 'Reçu',
+ handledAt: 'Traité',
+ account: 'Compte',
+ replyEmail: 'Répondre à',
+ appVersion: 'Version de l’app',
+ device: 'Appareil',
+ locale: 'Langue',
+ locales: { fr: 'Français', en: 'Anglais' },
+ message: 'Message',
+ markHandled: 'Marquer traité',
+ howToAnswer: (locale: 'fr' | 'en') =>
+ `La réponse par e-mail depuis l’admin n’est pas encore là. Écrivez à l’adresse ci-dessus depuis votre messagerie, en ${locale === 'fr' ? 'français' : 'anglais'}, puis marquez le message traité.`,
+ },
+ },
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 09231be8..34da6313 100644
--- a/apps/api/src/admin/counters.ts
+++ b/apps/api/src/admin/counters.ts
@@ -5,9 +5,9 @@ import {
type AdminCounters,
type UserRole,
} from '@widoo/shared';
-import { eq, sql } from 'drizzle-orm';
+import { eq, isNull, sql } from 'drizzle-orm';
import type { Db } from '../db/client';
-import { places, routeReports, routes } from '../db/schema';
+import { places, routeReports, routes, supportMessages } from '../db/schema';
/**
* Routes waiting for the team. Late after 72 hours without a change: until the history of a
@@ -50,8 +50,21 @@ async function countOpenReports(db: Db, now: Date): Promise {
};
}
+/** The messages of « Nous écrire » the team has not handled, late after 72 hours. */
+async function countUnhandledSupport(db: Db, now: Date): Promise {
+ const lateBefore = new Date(now.getTime() - adminLateAfterHours * 60 * 60 * 1000);
+ const [row] = await db
+ .select({
+ count: sql`count(*)::int`,
+ lateCount: sql`(count(*) filter (where ${supportMessages.createdAt} < ${lateBefore.toISOString()}::timestamptz))::int`,
+ })
+ .from(supportMessages)
+ .where(isNull(supportMessages.handledAt));
+ return row ?? { count: 0, lateCount: 0 };
+}
+
/**
- * Counters of the navigation of the admin, for the sections `role` opens. Support, AI drafts and
+ * Counters of the navigation of the admin, for the sections `role` opens. AI drafts and
* translations join as their tables arrive with their sections.
*/
export async function readAdminCounters(
@@ -66,5 +79,8 @@ export async function readAdminCounters(
if (canOpenAdminSection(role, 'reports')) {
counters.reports = await countOpenReports(db, now);
}
+ if (canOpenAdminSection(role, 'support')) {
+ counters.support = await countUnhandledSupport(db, now);
+ }
return counters;
}
diff --git a/apps/api/src/admin/stats.ts b/apps/api/src/admin/stats.ts
index 7bd78b66..40b4e5d9 100644
--- a/apps/api/src/admin/stats.ts
+++ b/apps/api/src/admin/stats.ts
@@ -18,6 +18,9 @@ type StatsRow = {
reports_late: number;
reports_oldest: Date | null;
flagged_places: number;
+ support: number;
+ support_late: number;
+ support_oldest: Date | null;
published_routes: number;
premium_routes: number;
official_routes: number;
@@ -54,6 +57,10 @@ export async function readAdminStats(
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 support_messages where handled_at is null) as support,
+ (select count(*)::int from support_messages
+ where handled_at is null and created_at < ${lateBefore}::timestamptz) as support_late,
+ (select min(created_at) from support_messages where handled_at is null) as support_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,
@@ -75,9 +82,9 @@ export async function readAdminStats(
throw new Error('Dashboard figures not read');
}
- // 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`.
+ // What waits in each section. 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: {
@@ -90,7 +97,11 @@ export async function readAdminStats(
lateCount: row.reports_late,
oldestSince: row.reports_oldest ? new Date(row.reports_oldest).toISOString() : null,
},
- support: nothing,
+ support: {
+ count: row.support,
+ lateCount: row.support_late,
+ oldestSince: row.support_oldest ? new Date(row.support_oldest).toISOString() : null,
+ },
aiDrafts: nothing,
translations: nothing,
};
diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts
index b7e90684..ea04ecfb 100644
--- a/apps/api/src/app.ts
+++ b/apps/api/src/app.ts
@@ -25,6 +25,7 @@ import { adminReportsRoutes } from './routes/admin-reports';
import { adminActionsRoutes } from './routes/admin-actions';
import { adminAffiliationRoutes } from './routes/admin-affiliation';
import { adminB2bRoutes } from './routes/admin-b2b';
+import { adminSupportRoutes } from './routes/admin-support';
import { publicPartnersRoutes } from './routes/public-partners';
import { adminSettingsRoutes } from './routes/admin-settings';
import { adminUsersRoutes } from './routes/admin-users';
@@ -167,6 +168,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) {
await app.register(adminActionsRoutes, { prefix: '/v1' });
await app.register(adminAffiliationRoutes, { prefix: '/v1' });
await app.register(adminB2bRoutes, { prefix: '/v1' });
+ await app.register(adminSupportRoutes, { prefix: '/v1' });
await app.register(publicPartnersRoutes, { prefix: '/v1' });
await app.register(goRoutes, { prefix: '/v1' });
await app.register(searchRoutesPlugin, { prefix: '/v1' });
diff --git a/apps/api/src/routes/admin-support.test.ts b/apps/api/src/routes/admin-support.test.ts
new file mode 100644
index 00000000..444f1816
--- /dev/null
+++ b/apps/api/src/routes/admin-support.test.ts
@@ -0,0 +1,272 @@
+import {
+ AdminSupportMessage,
+ AdminCounters,
+ AdminStats,
+ AdminSupportMessages,
+ ApiError,
+ SupportAlreadyHandledDetails,
+ type SupportTopic,
+ 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, supportMessages, users } from '../db/schema';
+import { uuidv7 } from '../db/uuid';
+import { testConfig } from '../test-config';
+
+// Shared database, parallel files: accounts and messages of their own, found by their ids.
+const verifier = new TestTokenVerifier();
+const teamUids: string[] = [];
+const authorIds: string[] = [];
+const messageIds: string[] = [];
+
+let app: Awaited>;
+beforeAll(async () => {
+ app = await buildApp(testConfig(), { tokenVerifier: verifier });
+});
+afterAll(async () => {
+ await app.db.delete(adminActions).where(inArray(adminActions.targetId, messageIds));
+ await app.db.delete(supportMessages).where(inArray(supportMessages.id, messageIds));
+ await app.db.delete(users).where(inArray(users.id, authorIds));
+ await app.db.delete(users).where(inArray(users.firebaseUid, teamUids));
+ await app.close();
+});
+
+async function signIn(role: UserRole) {
+ const { token, uid } = verifier.issue({ secondFactor: 'totp' });
+ teamUids.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 message sent `minutesAgo`, with an account named `firstName` or without one. */
+async function addMessage(
+ minutesAgo: number,
+ values: Partial & { firstName?: string } = {},
+) {
+ const { firstName, ...rest } = values;
+ let userId: string | null = null;
+ if (firstName) {
+ const [author] = await app.db
+ .insert(users)
+ .values({ firebaseUid: `support-${uuidv7()}`, firstName })
+ .returning();
+ if (!author) throw new Error('Author not created');
+ authorIds.push(author.id);
+ userId = author.id;
+ }
+ const [row] = await app.db
+ .insert(supportMessages)
+ .values({
+ userId,
+ replyEmail: 'camille@example.com',
+ topic: 'question' satisfies SupportTopic,
+ message: 'Une question sur mon compte.',
+ appVersion: '0.1.0',
+ device: 'iPhone 15, iOS 18.2',
+ locale: 'fr',
+ createdAt: new Date(Date.now() - minutesAgo * 60_000),
+ ...rest,
+ })
+ .returning();
+ if (!row) throw new Error('Message not created');
+ messageIds.push(row.id);
+ return row;
+}
+
+type Headers = Record;
+const get = (headers: Headers, url: string) =>
+ app.inject({ url: `/v1/admin/support${url}`, headers });
+const listed = async (headers: Headers, query = '') =>
+ AdminSupportMessages.parse((await get(headers, query)).json());
+
+describe('who may read the messages of the support', () => {
+ it('is for moderators and admins, not for an editor, nor for someone signed out', async () => {
+ const moderator = await signIn('moderator');
+ const admin = await signIn('admin');
+ const editor = await signIn('editor');
+
+ expect((await get(moderator.headers, '')).statusCode).toBe(200);
+ expect((await get(admin.headers, '')).statusCode).toBe(200);
+ expect((await get(editor.headers, '')).statusCode).toBe(403);
+ expect((await app.inject({ url: '/v1/admin/support' })).statusCode).toBe(401);
+ });
+});
+
+describe('GET /v1/admin/support', () => {
+ it('lists the messages to handle first, the oldest first, then the handled ones', async () => {
+ const moderator = await signIn('moderator');
+ const newer = await addMessage(10, { firstName: 'Léa' });
+ const older = await addMessage(60);
+ const done = await addMessage(30, { handledAt: new Date() });
+ const mine = new Set([newer.id, older.id, done.id]);
+
+ const page = await listed(moderator.headers);
+
+ expect(page.items.filter((item) => mine.has(item.id)).map((item) => item.id)).toEqual([
+ older.id,
+ newer.id,
+ done.id,
+ ]);
+ const row = page.items.find((item) => item.id === newer.id);
+ expect(row).toMatchObject({
+ topic: 'question',
+ preview: 'Une question sur mon compte.',
+ author: { firstName: 'Léa' },
+ handledAt: null,
+ });
+ expect(page.items.find((item) => item.id === older.id)?.author).toBeNull();
+ });
+
+ it('keeps the messages to handle, or the handled ones, when asked', async () => {
+ const moderator = await signIn('moderator');
+ const open = await addMessage(5);
+ const done = await addMessage(5, { handledAt: new Date() });
+
+ const unhandled = (await listed(moderator.headers, '?status=unhandled')).items.map(
+ (item) => item.id,
+ );
+ const handled = (await listed(moderator.headers, '?status=handled')).items.map(
+ (item) => item.id,
+ );
+
+ expect(unhandled).toContain(open.id);
+ expect(unhandled).not.toContain(done.id);
+ expect(handled).toContain(done.id);
+ expect(handled).not.toContain(open.id);
+ expect((await get(moderator.headers, '?status=answered')).statusCode).toBe(400);
+ });
+});
+
+describe('GET /v1/admin/support/:id', () => {
+ it('reads a message in full, with what the app joined to it', async () => {
+ const admin = await signIn('admin');
+ const row = await addMessage(5, {
+ firstName: 'Camille',
+ topic: 'problem',
+ message: "L'app se ferme.\nTrois fois de suite.",
+ replyEmail: 'reponse@example.com',
+ locale: 'en',
+ });
+
+ const response = await get(admin.headers, `/${row.id}`);
+
+ expect(response.statusCode).toBe(200);
+ expect(AdminSupportMessage.parse(response.json())).toMatchObject({
+ id: row.id,
+ topic: 'problem',
+ message: "L'app se ferme.\nTrois fois de suite.",
+ replyEmail: 'reponse@example.com',
+ appVersion: '0.1.0',
+ device: 'iPhone 15, iOS 18.2',
+ locale: 'en',
+ author: { firstName: 'Camille' },
+ });
+ });
+
+ it('answers 404 to an unknown message, and never serves it to an editor', async () => {
+ const admin = await signIn('admin');
+ const editor = await signIn('editor');
+ const row = await addMessage(5);
+
+ expect((await get(admin.headers, `/${uuidv7()}`)).statusCode).toBe(404);
+ expect((await get(editor.headers, `/${row.id}`)).statusCode).toBe(403);
+ const [stored] = await app.db
+ .select()
+ .from(supportMessages)
+ .where(eq(supportMessages.id, row.id));
+ expect(stored?.handledAt).toBeNull();
+ });
+});
+
+const handle = (headers: Headers, id: string) =>
+ app.inject({ method: 'POST', url: `/v1/admin/support/${id}/handled`, headers });
+
+describe('POST /v1/admin/support/:id/handled', () => {
+ it('marks a message handled, moves it down the list, and journals it', async () => {
+ const moderator = await signIn('moderator');
+ const row = await addMessage(20);
+
+ const response = await handle(moderator.headers, row.id);
+
+ expect(response.statusCode).toBe(200);
+ const card = AdminSupportMessage.parse(response.json());
+ expect(card.handledAt).not.toBeNull();
+ const unhandled = await listed(moderator.headers, '?status=unhandled');
+ expect(unhandled.items.map((item) => item.id)).not.toContain(row.id);
+ const [entry] = await app.db
+ .select()
+ .from(adminActions)
+ .where(eq(adminActions.targetId, row.id));
+ expect(entry).toMatchObject({
+ action: 'support_message.handle',
+ targetType: 'support_message',
+ actorId: moderator.id,
+ before: { handledAt: null },
+ });
+ });
+
+ it('answers 409 already_decided to a second decision, with the time of the first', async () => {
+ const admin = await signIn('admin');
+ const other = await signIn('moderator');
+ const row = await addMessage(20);
+ const first = AdminSupportMessage.parse((await handle(other.headers, row.id)).json());
+
+ const again = await handle(admin.headers, row.id);
+
+ expect(again.statusCode).toBe(409);
+ const error = ApiError.parse(again.json());
+ expect(error.code).toBe('already_decided');
+ expect(SupportAlreadyHandledDetails.parse(error.details)).toEqual({
+ handledAt: first.handledAt,
+ });
+ });
+
+ it('answers 404 to an unknown message, and refuses an editor without writing', async () => {
+ const admin = await signIn('admin');
+ const editor = await signIn('editor');
+ const row = await addMessage(20);
+
+ expect((await handle(admin.headers, uuidv7())).statusCode).toBe(404);
+ expect((await handle(editor.headers, row.id)).statusCode).toBe(403);
+ const [stored] = await app.db
+ .select()
+ .from(supportMessages)
+ .where(eq(supportMessages.id, row.id));
+ expect(stored?.handledAt).toBeNull();
+ });
+});
+
+describe('the counters of the support', () => {
+ const counterOf = async (headers: Headers) =>
+ AdminCounters.parse((await app.inject({ url: '/v1/admin/counters', headers })).json()).support;
+ const tileOf = async (headers: Headers) =>
+ AdminStats.parse((await app.inject({ url: '/v1/admin/stats', headers })).json()).toHandle
+ .support;
+
+ // Other test files write messages meanwhile: the figures are read as « at least ».
+ it('counts the messages to handle, and those waiting for more than 72 hours', async () => {
+ const moderator = await signIn('moderator');
+ await addMessage(10);
+ const old = await addMessage(80 * 60);
+
+ const counter = await counterOf(moderator.headers);
+ const tile = await tileOf(moderator.headers);
+
+ expect(counter?.count).toBeGreaterThanOrEqual(2);
+ expect(counter?.lateCount).toBeGreaterThanOrEqual(1);
+ expect(tile?.count).toBeGreaterThanOrEqual(2);
+ expect(tile?.lateCount).toBeGreaterThanOrEqual(1);
+ expect(new Date(tile?.oldestSince ?? '').getTime()).toBeLessThanOrEqual(
+ old.createdAt.getTime(),
+ );
+ });
+
+ it('gives an editor no counter of the support', async () => {
+ const editor = await signIn('editor');
+ expect(await counterOf(editor.headers)).toBeUndefined();
+ });
+});
diff --git a/apps/api/src/routes/admin-support.ts b/apps/api/src/routes/admin-support.ts
new file mode 100644
index 00000000..8a1a7747
--- /dev/null
+++ b/apps/api/src/routes/admin-support.ts
@@ -0,0 +1,81 @@
+import { AdminSupportMessage, AdminSupportMessages, AdminSupportQuery } 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 {
+ markSupportMessageHandled,
+ readSupportMessage,
+ readSupportMessages,
+} from '../support/admin';
+
+const security = [{ firebaseIdToken: [] }];
+const tags = ['admin'];
+const MessageParams = 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;
+}
+
+/**
+ * The messages of « Nous écrire » for the team (A-13, wiki API › Admin): moderators and admins
+ * only, since they hold what users wrote and the address they gave. Never cached.
+ */
+export const adminSupportRoutes: FastifyPluginAsyncZod = async (app) => {
+ app.addHook('onRequest', app.requireAdminSection('support'));
+ app.addHook('onSend', async (_request, reply) => {
+ reply.header('cache-control', 'no-store');
+ });
+
+ app.get(
+ '/admin/support',
+ {
+ schema: {
+ tags,
+ summary: 'The messages of « Nous écrire », the ones to handle first',
+ security,
+ querystring: AdminSupportQuery,
+ response: { 200: AdminSupportMessages },
+ },
+ },
+ async (request) => readSupportMessages(app.db, request.query),
+ );
+
+ app.get(
+ '/admin/support/:id',
+ {
+ schema: {
+ tags,
+ summary: 'A message of « Nous écrire » in full',
+ security,
+ params: MessageParams,
+ response: { 200: AdminSupportMessage },
+ },
+ },
+ async (request) => {
+ const message = await readSupportMessage(app.db, request.params.id);
+ if (!message) {
+ throw httpError(404, 'Message not found');
+ }
+ return message;
+ },
+ );
+
+ app.post(
+ '/admin/support/:id/handled',
+ {
+ schema: {
+ tags,
+ summary: 'Mark a message of « Nous écrire » handled',
+ security,
+ params: MessageParams,
+ response: { 200: AdminSupportMessage },
+ },
+ },
+ async (request) => markSupportMessageHandled(app.db, actorIdOf(request), request.params.id),
+ );
+};
diff --git a/apps/api/src/support/admin.test.ts b/apps/api/src/support/admin.test.ts
new file mode 100644
index 00000000..5d22e547
--- /dev/null
+++ b/apps/api/src/support/admin.test.ts
@@ -0,0 +1,16 @@
+import { describe, expect, it } from 'vitest';
+import { previewOf } from './admin';
+
+describe('previewOf', () => {
+ it('keeps a short message, on one line', () => {
+ expect(previewOf('Bonjour,\n\nune question.')).toBe('Bonjour, une question.');
+ });
+
+ it('cuts a long message on a character, with an ellipsis', () => {
+ const preview = previewOf(`${'a'.repeat(200)}`);
+ expect([...preview]).toHaveLength(121);
+ expect(preview.endsWith('…')).toBe(true);
+ const emojis = previewOf('😀'.repeat(200));
+ expect([...emojis].every((character) => character === '😀' || character === '…')).toBe(true);
+ });
+});
diff --git a/apps/api/src/support/admin.ts b/apps/api/src/support/admin.ts
new file mode 100644
index 00000000..d72543ab
--- /dev/null
+++ b/apps/api/src/support/admin.ts
@@ -0,0 +1,162 @@
+import {
+ adminSupportPageSize,
+ type AdminSupportMessage,
+ type AdminSupportMessages,
+ type AdminSupportQuery,
+ type SupportAlreadyHandledDetails,
+} from '@widoo/shared';
+import { and, count, desc, eq, isNotNull, isNull, sql, type SQL } from 'drizzle-orm';
+import { recordAdminAction } from '../admin/actions';
+import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor';
+import type { Db, Tx } from '../db/client';
+import { supportMessages, users } from '../db/schema';
+import { httpError } from '../errors';
+
+/** Characters of a message the list shows, on one line. */
+const previewLength = 120;
+
+/** The beginning of a message on one line, cut on a character and not in the middle of one. */
+export function previewOf(message: string): string {
+ const line = message.replaceAll(/\s+/g, ' ').trim();
+ const characters = [...line];
+ return characters.length <= previewLength
+ ? line
+ : `${characters.slice(0, previewLength).join('').trimEnd()}…`;
+}
+
+const authorOf = (row: { userId: string | null; firstName: string | null }) =>
+ row.userId ? { id: row.userId, firstName: row.firstName } : null;
+
+const filterOf = (query: AdminSupportQuery): SQL | undefined =>
+ query.status === 'unhandled'
+ ? isNull(supportMessages.handledAt)
+ : query.status === 'handled'
+ ? isNotNull(supportMessages.handledAt)
+ : undefined;
+
+/**
+ * A page of the messages of « Nous écrire » (A-13): the ones to handle first, the oldest first,
+ * since they wait for an answer; then the handled ones, the latest first.
+ */
+export async function readSupportMessages(
+ db: Db,
+ query: AdminSupportQuery,
+): Promise {
+ const offset = offsetOfCursor(query.cursor);
+ const where = and(filterOf(query));
+ const [totals] = await db.select({ value: count() }).from(supportMessages).where(where);
+ const rows = await db
+ .select({
+ id: supportMessages.id,
+ topic: supportMessages.topic,
+ message: supportMessages.message,
+ createdAt: supportMessages.createdAt,
+ handledAt: supportMessages.handledAt,
+ userId: supportMessages.userId,
+ firstName: users.firstName,
+ })
+ .from(supportMessages)
+ .leftJoin(users, eq(users.id, supportMessages.userId))
+ .where(where)
+ .orderBy(
+ sql`(${supportMessages.handledAt} is not null)`,
+ sql`case when ${supportMessages.handledAt} is null then ${supportMessages.createdAt} end asc`,
+ desc(supportMessages.createdAt),
+ desc(supportMessages.id),
+ )
+ .limit(adminSupportPageSize)
+ .offset(offset);
+ const total = totals?.value ?? 0;
+ return {
+ items: rows.map((row) => ({
+ id: row.id,
+ topic: row.topic,
+ preview: previewOf(row.message),
+ author: authorOf(row),
+ createdAt: row.createdAt.toISOString(),
+ handledAt: row.handledAt?.toISOString() ?? null,
+ })),
+ total,
+ offset,
+ ...neighbourCursors(offset, total, adminSupportPageSize),
+ };
+}
+
+/** A message in full, or null when it does not exist. */
+export async function readSupportMessage(
+ db: Db | Tx,
+ id: string,
+): Promise {
+ const [row] = await db
+ .select({
+ id: supportMessages.id,
+ topic: supportMessages.topic,
+ message: supportMessages.message,
+ replyEmail: supportMessages.replyEmail,
+ appVersion: supportMessages.appVersion,
+ device: supportMessages.device,
+ locale: supportMessages.locale,
+ createdAt: supportMessages.createdAt,
+ handledAt: supportMessages.handledAt,
+ userId: supportMessages.userId,
+ firstName: users.firstName,
+ })
+ .from(supportMessages)
+ .leftJoin(users, eq(users.id, supportMessages.userId))
+ .where(eq(supportMessages.id, id));
+ if (!row) {
+ return null;
+ }
+ return {
+ id: row.id,
+ topic: row.topic,
+ message: row.message,
+ replyEmail: row.replyEmail,
+ appVersion: row.appVersion,
+ device: row.device,
+ locale: row.locale,
+ author: authorOf(row),
+ createdAt: row.createdAt.toISOString(),
+ handledAt: row.handledAt?.toISOString() ?? null,
+ };
+}
+
+/**
+ * Marks a message handled by the team (A-13), with its journal line. 404 for an unknown message,
+ * 409 `already_decided` with the time when another member handled it first.
+ */
+export async function markSupportMessageHandled(
+ db: Db,
+ actorId: string,
+ id: string,
+ now = new Date(),
+): Promise {
+ return db.transaction(async (tx) => {
+ const [current] = await tx
+ .select({ handledAt: supportMessages.handledAt })
+ .from(supportMessages)
+ .where(eq(supportMessages.id, id))
+ .for('update');
+ if (!current) {
+ throw httpError(404, 'Message not found');
+ }
+ if (current.handledAt) {
+ const details: SupportAlreadyHandledDetails = { handledAt: current.handledAt.toISOString() };
+ throw httpError(409, 'Message already handled', 'already_decided', details);
+ }
+ await tx.update(supportMessages).set({ handledAt: now }).where(eq(supportMessages.id, id));
+ await recordAdminAction(tx, {
+ actorId,
+ action: 'support_message.handle',
+ targetType: 'support_message',
+ targetId: id,
+ before: { handledAt: null },
+ after: { handledAt: now.toISOString() },
+ });
+ const message = await readSupportMessage(tx, id);
+ if (!message) {
+ throw httpError(404, 'Message not found');
+ }
+ return message;
+ });
+}
diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts
index 49be03eb..c9aaa425 100644
--- a/packages/api-client/src/client.ts
+++ b/packages/api-client/src/client.ts
@@ -1,5 +1,7 @@
import {
AffiliateProgram,
+ AdminSupportMessage,
+ AdminSupportMessages,
AffiliatePrograms,
B2bClient,
B2bClients,
@@ -61,6 +63,7 @@ import {
type AdminActionsQuery,
type AffiliateProgramInput,
type AffiliateProgramPatch,
+ type AdminSupportQuery,
type B2bClientInput,
type B2bClientPatch,
type B2bClientRoutesInput,
@@ -113,7 +116,12 @@ import { catalogPlacesQueryString, catalogQueryString } from './catalog';
import { actionsQueryString } from './actions';
import { bookableStepsQueryString } from './affiliation';
import { usersQueryString } from './users';
-import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports';
+import {
+ ratingsQueryString,
+ reportsQueryString,
+ signalsQueryString,
+ supportQueryString,
+} from './reports';
/**
* Typed client of the API, one method per route, answers validated by the `shared` schemas.
@@ -848,6 +856,35 @@ export function createApiClient(options: ApiClientOptions) {
auth: true,
}),
+ /** `GET /v1/admin/support`: the messages of « Nous écrire », the ones to handle first (A-13). */
+ getAdminSupport: (params: Partial, signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: `/v1/admin/support?${supportQueryString(params)}`,
+ schema: AdminSupportMessages,
+ auth: true,
+ signal,
+ }),
+
+ /** `GET /v1/admin/support/:id`: a message in full. */
+ getAdminSupportMessage: (messageId: string, signal?: AbortSignal) =>
+ request({
+ method: 'GET',
+ path: `/v1/admin/support/${messageId}`,
+ schema: AdminSupportMessage,
+ auth: true,
+ signal,
+ }),
+
+ /** `POST /v1/admin/support/:id/handled`: marks a message handled. */
+ markSupportMessageHandled: (messageId: string) =>
+ request({
+ method: 'POST',
+ path: `/v1/admin/support/${messageId}/handled`,
+ schema: AdminSupportMessage,
+ auth: true,
+ }),
+
/** `GET /v1/admin/bookable-steps`: the steps that can be booked, with the link shown (A-09). */
getBookableSteps: (params: Partial, signal?: AbortSignal) =>
request({
diff --git a/packages/api-client/src/reports.test.ts b/packages/api-client/src/reports.test.ts
index 2ac21337..9c2176ea 100644
--- a/packages/api-client/src/reports.test.ts
+++ b/packages/api-client/src/reports.test.ts
@@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
-import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports';
+import {
+ ratingsQueryString,
+ reportsQueryString,
+ signalsQueryString,
+ supportQueryString,
+} from './reports';
describe('reportsQueryString', () => {
it('is empty for the whole list, and writes the reason and the page', () => {
@@ -23,3 +28,12 @@ describe('ratingsQueryString', () => {
);
});
});
+
+describe('supportQueryString', () => {
+ it('writes the status and the page, and nothing for the whole list', () => {
+ expect(supportQueryString({})).toBe('');
+ expect(supportQueryString({ status: 'unhandled', cursor: 'NTA' })).toBe(
+ 'status=unhandled&cursor=NTA',
+ );
+ });
+});
diff --git a/packages/api-client/src/reports.ts b/packages/api-client/src/reports.ts
index c43ee040..365c0646 100644
--- a/packages/api-client/src/reports.ts
+++ b/packages/api-client/src/reports.ts
@@ -1,4 +1,9 @@
-import type { AdminRatingsQuery, AdminReportsQuery, AdminSignalsQuery } from '@widoo/shared';
+import type {
+ AdminRatingsQuery,
+ AdminReportsQuery,
+ AdminSignalsQuery,
+ AdminSupportQuery,
+} from '@widoo/shared';
/** A query string from the values that are set, in the order of `keys`. */
function queryStringOf(
@@ -26,3 +31,7 @@ export const signalsQueryString = (params: Partial) =>
/** Query string of the ratings with a comment, as the API reads it. */
export const ratingsQueryString = (params: Partial) =>
queryStringOf(params, ['rating', 'routeId', 'cursor']);
+
+/** Query string of the support messages, as the API reads it. */
+export const supportQueryString = (params: Partial) =>
+ queryStringOf(params, ['status', 'cursor']);
diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts
index 08e94b65..0d488f4b 100644
--- a/packages/shared/src/index.ts
+++ b/packages/shared/src/index.ts
@@ -25,6 +25,7 @@ export * from './schemas/admin';
export * from './schemas/admin-actions';
export * from './schemas/admin-affiliation';
export * from './schemas/admin-b2b';
+export * from './schemas/admin-support';
export * from './schemas/admin-settings';
export * from './schemas/admin-reports';
export * from './schemas/admin-signals';
diff --git a/packages/shared/src/schemas/admin-support.test.ts b/packages/shared/src/schemas/admin-support.test.ts
new file mode 100644
index 00000000..5a50eda3
--- /dev/null
+++ b/packages/shared/src/schemas/admin-support.test.ts
@@ -0,0 +1,36 @@
+import { describe, expect, it } from 'vitest';
+import { AdminSupportMessage, AdminSupportQuery } from './admin-support';
+
+const message = {
+ id: '0198c1de-0000-7000-8000-000000000001',
+ topic: 'problem',
+ author: { id: '0198c1de-0000-7000-8000-000000000002', firstName: 'Camille' },
+ createdAt: '2026-10-20T09:00:00.000Z',
+ handledAt: null,
+ message: "L'app se ferme quand j'ouvre un parcours.",
+ replyEmail: 'camille@example.com',
+ appVersion: '0.1.0',
+ device: 'iPhone 15, iOS 18.2',
+ locale: 'fr',
+};
+
+describe('AdminSupportQuery', () => {
+ it('takes no filter, or one of the two statuses, and refuses another', () => {
+ expect(AdminSupportQuery.safeParse({}).success).toBe(true);
+ expect(AdminSupportQuery.parse({ status: 'unhandled' })).toEqual({ status: 'unhandled' });
+ expect(AdminSupportQuery.safeParse({ status: 'answered' }).success).toBe(false);
+ expect(AdminSupportQuery.safeParse({ extra: 1 }).success).toBe(false);
+ });
+});
+
+describe('AdminSupportMessage', () => {
+ it('reads a message with an account, and one sent without', () => {
+ expect(AdminSupportMessage.safeParse(message).success).toBe(true);
+ expect(AdminSupportMessage.safeParse({ ...message, author: null }).success).toBe(true);
+ });
+
+ it('refuses an unknown subject or language', () => {
+ expect(AdminSupportMessage.safeParse({ ...message, topic: 'complaint' }).success).toBe(false);
+ expect(AdminSupportMessage.safeParse({ ...message, locale: 'de' }).success).toBe(false);
+ });
+});
diff --git a/packages/shared/src/schemas/admin-support.ts b/packages/shared/src/schemas/admin-support.ts
new file mode 100644
index 00000000..99567866
--- /dev/null
+++ b/packages/shared/src/schemas/admin-support.ts
@@ -0,0 +1,62 @@
+import { z } from 'zod';
+import { AppLocale } from './me';
+import { SupportTopic } from './support';
+
+/** Rows of a page of the support messages of the admin. */
+export const adminSupportPageSize = 50;
+
+/** Which messages the list shows: the ones still to answer, the handled ones, or both. */
+export const adminSupportStatuses = ['unhandled', 'handled'] as const;
+
+/** Query of `GET /admin/support` (A-13): every message unless a status is asked. */
+export const AdminSupportQuery = z.strictObject({
+ status: z.enum(adminSupportStatuses).optional(),
+ cursor: z.string().max(200).optional(),
+});
+export type AdminSupportQuery = z.infer;
+
+/** The account that wrote a message: null for a message sent without one. */
+const SupportAuthor = z.object({ id: z.uuid(), firstName: z.string().nullable() }).nullable();
+
+/** A row of the list of messages (A-13): the subject, the account, the date, the status. */
+export const AdminSupportItem = z.object({
+ id: z.uuid(),
+ topic: SupportTopic,
+ /** The beginning of the message, to tell the rows apart; the whole one is on the card. */
+ preview: z.string(),
+ author: SupportAuthor,
+ createdAt: z.iso.datetime(),
+ /** Null while the team has not handled it. */
+ handledAt: z.iso.datetime().nullable(),
+});
+export type AdminSupportItem = z.infer;
+
+/** Body of `GET /admin/support`: the messages to handle first, the oldest first, then the others. */
+export const AdminSupportMessages = z.object({
+ items: z.array(AdminSupportItem),
+ total: z.int().nonnegative(),
+ /** Rows before this page: the first row shown is number `offset + 1`. */
+ offset: z.int().nonnegative(),
+ previousCursor: z.string().nullable(),
+ nextCursor: z.string().nullable(),
+});
+export type AdminSupportMessages = z.infer;
+
+/**
+ * A message in full (A-13): what the user wrote, the address to answer to, and what the app
+ * joined to help the support. Read by moderators and admins only.
+ */
+export const AdminSupportMessage = AdminSupportItem.omit({ preview: true }).extend({
+ message: z.string(),
+ /** The address the answer goes to: the one typed in the form, not always the one of the account. */
+ replyEmail: z.string(),
+ appVersion: z.string(),
+ device: z.string(),
+ /** Language of the app when the message was sent: the language of an answer. */
+ locale: AppLocale,
+});
+export type AdminSupportMessage = z.infer;
+
+/** `details` of a 409 `already_decided` on a message: when another member handled it. */
+export const SupportAlreadyHandledDetails = z.object({ handledAt: z.iso.datetime() });
+export type SupportAlreadyHandledDetails = z.infer;