From 7d6cdbf1c95e9abc3c1f0359ee41afcf1797c4cd Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 15:09:19 +0700 Subject: [PATCH 1/7] feat(shared): describe the support messages of the admin, their list and their card (A-13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Types of the list and of the card of a message of « Nous écrire »: the subject, the account, the address to answer to, what the app joined, the status. A first line holds the beginning of the message, the card the whole of it. Co-Authored-By: Claude Sonnet 5.5 --- packages/shared/src/index.ts | 1 + .../shared/src/schemas/admin-support.test.ts | 36 +++++++++++ packages/shared/src/schemas/admin-support.ts | 62 +++++++++++++++++++ 3 files changed, 99 insertions(+) create mode 100644 packages/shared/src/schemas/admin-support.test.ts create mode 100644 packages/shared/src/schemas/admin-support.ts 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; From 19f708ad2f6543ad7a57e5f168750c9efad86119 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 15:09:19 +0700 Subject: [PATCH 2/7] feat(api): list the support messages and read one in full, for moderators and admins (A-13) GET /admin/support lists the messages to handle first, the oldest first, then the handled ones, with a filter on the status. GET /admin/support/:id reads one in full. Moderators and admins only: they hold what users wrote and the address they gave. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/app.ts | 2 + apps/api/src/routes/admin-support.test.ts | 178 ++++++++++++++++++++++ apps/api/src/routes/admin-support.ts | 54 +++++++ apps/api/src/support/admin.test.ts | 16 ++ apps/api/src/support/admin.ts | 116 ++++++++++++++ 5 files changed, 366 insertions(+) create mode 100644 apps/api/src/routes/admin-support.test.ts create mode 100644 apps/api/src/routes/admin-support.ts create mode 100644 apps/api/src/support/admin.test.ts create mode 100644 apps/api/src/support/admin.ts 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..8075a632 --- /dev/null +++ b/apps/api/src/routes/admin-support.test.ts @@ -0,0 +1,178 @@ +import { + AdminSupportMessage, + AdminSupportMessages, + 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 { 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(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(); + }); +}); diff --git a/apps/api/src/routes/admin-support.ts b/apps/api/src/routes/admin-support.ts new file mode 100644 index 00000000..f4e997b9 --- /dev/null +++ b/apps/api/src/routes/admin-support.ts @@ -0,0 +1,54 @@ +import { AdminSupportMessage, AdminSupportMessages, AdminSupportQuery } from '@widoo/shared'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; +import { httpError } from '../errors'; +import { readSupportMessage, readSupportMessages } from '../support/admin'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; +const MessageParams = z.object({ id: z.uuid() }); + +/** + * 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; + }, + ); +}; 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..c33e9845 --- /dev/null +++ b/apps/api/src/support/admin.ts @@ -0,0 +1,116 @@ +import { + adminSupportPageSize, + type AdminSupportMessage, + type AdminSupportMessages, + type AdminSupportQuery, +} from '@widoo/shared'; +import { and, count, desc, eq, isNotNull, isNull, sql, type SQL } from 'drizzle-orm'; +import { neighbourCursors, offsetOfCursor } from '../admin/page-cursor'; +import type { Db } from '../db/client'; +import { supportMessages, users } from '../db/schema'; + +/** 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, 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, + }; +} From 7a72b9f23cb9de76d47e4ca360f7d1199f434eb8 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 15:09:19 +0700 Subject: [PATCH 3/7] feat(api): mark a support message handled, with its journal line (A-13) POST /admin/support/:id/handled marks a message handled with its journal line, and answers 409 already_decided with the time of the first decision. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/routes/admin-support.test.ts | 63 ++++++++++++++++++++++- apps/api/src/routes/admin-support.ts | 29 ++++++++++- apps/api/src/support/admin.ts | 50 +++++++++++++++++- 3 files changed, 138 insertions(+), 4 deletions(-) diff --git a/apps/api/src/routes/admin-support.test.ts b/apps/api/src/routes/admin-support.test.ts index 8075a632..ee1e3fec 100644 --- a/apps/api/src/routes/admin-support.test.ts +++ b/apps/api/src/routes/admin-support.test.ts @@ -1,6 +1,8 @@ import { AdminSupportMessage, AdminSupportMessages, + ApiError, + SupportAlreadyHandledDetails, type SupportTopic, type UserRole, } from '@widoo/shared'; @@ -8,7 +10,7 @@ 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 { supportMessages, users } from '../db/schema'; +import { adminActions, supportMessages, users } from '../db/schema'; import { uuidv7 } from '../db/uuid'; import { testConfig } from '../test-config'; @@ -23,6 +25,7 @@ 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)); @@ -176,3 +179,61 @@ describe('GET /v1/admin/support/: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(); + }); +}); diff --git a/apps/api/src/routes/admin-support.ts b/apps/api/src/routes/admin-support.ts index f4e997b9..8a1a7747 100644 --- a/apps/api/src/routes/admin-support.ts +++ b/apps/api/src/routes/admin-support.ts @@ -1,13 +1,26 @@ 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 { readSupportMessage, readSupportMessages } from '../support/admin'; +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. @@ -51,4 +64,18 @@ export const adminSupportRoutes: FastifyPluginAsyncZod = async (app) => { 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.ts b/apps/api/src/support/admin.ts index c33e9845..d72543ab 100644 --- a/apps/api/src/support/admin.ts +++ b/apps/api/src/support/admin.ts @@ -3,11 +3,14 @@ import { 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 } from '../db/client'; +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; @@ -80,7 +83,10 @@ export async function readSupportMessages( } /** A message in full, or null when it does not exist. */ -export async function readSupportMessage(db: Db, id: string): Promise { +export async function readSupportMessage( + db: Db | Tx, + id: string, +): Promise { const [row] = await db .select({ id: supportMessages.id, @@ -114,3 +120,43 @@ export async function readSupportMessage(db: Db, id: string): 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; + }); +} From 393f5553c95d94bdc7134c81f363c20ba455cdf1 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 15:09:19 +0700 Subject: [PATCH 4/7] feat(api): count the support messages to handle in the navigation and the dashboard (A-13) The navigation and the tile of the dashboard count the messages to handle, late after 72 hours, as the code planned for this section. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/counters.ts | 22 ++++++++++++--- apps/api/src/admin/stats.ts | 19 ++++++++++--- apps/api/src/routes/admin-support.test.ts | 33 +++++++++++++++++++++++ 3 files changed, 67 insertions(+), 7 deletions(-) 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/routes/admin-support.test.ts b/apps/api/src/routes/admin-support.test.ts index ee1e3fec..444f1816 100644 --- a/apps/api/src/routes/admin-support.test.ts +++ b/apps/api/src/routes/admin-support.test.ts @@ -1,5 +1,7 @@ import { AdminSupportMessage, + AdminCounters, + AdminStats, AdminSupportMessages, ApiError, SupportAlreadyHandledDetails, @@ -237,3 +239,34 @@ describe('POST /v1/admin/support/:id/handled', () => { 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(); + }); +}); From 714fe55e607ff3cf7218cd088a52747e22cfaef5 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 15:09:19 +0700 Subject: [PATCH 5/7] feat(api-client): call the support messages of the admin (A-13) getAdminSupport, getAdminSupportMessage and markSupportMessageHandled. Co-Authored-By: Claude Sonnet 5.5 --- packages/api-client/src/client.ts | 39 ++++++++++++++++++++++++- packages/api-client/src/reports.test.ts | 16 +++++++++- packages/api-client/src/reports.ts | 11 ++++++- 3 files changed, 63 insertions(+), 3 deletions(-) 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']); From 93ef01a69a1a53bf524640316ae99e32e657cdb2 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 15:09:19 +0700 Subject: [PATCH 6/7] feat(admin): list the support messages and open one in a panel (A-13) A-13 opens on the list of the messages with a status filter, and the card of the one the address names: what was written, the account, the address, the version and the device. Until the answer by e-mail exists, the card says to answer from a mailbox. Co-Authored-By: Claude Sonnet 5.5 --- apps/admin/src/app/(shell)/support/page.tsx | 11 ++ .../support/SupportPanelContent.tsx | 66 ++++++++ .../components/support/SupportStatusBadge.tsx | 11 ++ .../src/components/support/SupportTable.tsx | 64 ++++++++ apps/admin/src/config/support-list.test.ts | 24 +++ apps/admin/src/config/support-list.ts | 21 +++ .../admin/src/containers/SupportContainer.tsx | 152 ++++++++++++++++++ apps/admin/src/messages/fr.ts | 28 ++++ 8 files changed, 377 insertions(+) create mode 100644 apps/admin/src/app/(shell)/support/page.tsx create mode 100644 apps/admin/src/components/support/SupportPanelContent.tsx create mode 100644 apps/admin/src/components/support/SupportStatusBadge.tsx create mode 100644 apps/admin/src/components/support/SupportTable.tsx create mode 100644 apps/admin/src/config/support-list.test.ts create mode 100644 apps/admin/src/config/support-list.ts create mode 100644 apps/admin/src/containers/SupportContainer.tsx 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/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..10399b54 --- /dev/null +++ b/apps/admin/src/containers/SupportContainer.tsx @@ -0,0 +1,152 @@ +'use client'; + +import { PageMessage } from '@/components/states/PageMessage'; +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 { 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; + + /** 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)} + > + + `${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/messages/fr.ts b/apps/admin/src/messages/fr.ts index ef42b47c..7a3a6c69 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -1304,6 +1304,34 @@ 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.', + 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', + 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.", From fabb5b9f75aa021cbaeaff48da3b6ecdf15363d8 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Tue, 20 Oct 2026 15:09:19 +0700 Subject: [PATCH 7/7] feat(admin): mark a support message handled (A-13) The button of the card of a message to handle, with its toasts and the counter of the navigation read again. Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/support/SupportActions.tsx | 16 ++++++++ .../admin/src/containers/SupportContainer.tsx | 14 +++++++ apps/admin/src/hooks/useSupportActions.ts | 41 +++++++++++++++++++ apps/admin/src/messages/fr.ts | 6 +++ 4 files changed, 77 insertions(+) create mode 100644 apps/admin/src/components/support/SupportActions.tsx create mode 100644 apps/admin/src/hooks/useSupportActions.ts 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 ( + + ); +} diff --git a/apps/admin/src/containers/SupportContainer.tsx b/apps/admin/src/containers/SupportContainer.tsx index 10399b54..67ed6689 100644 --- a/apps/admin/src/containers/SupportContainer.tsx +++ b/apps/admin/src/containers/SupportContainer.tsx @@ -1,6 +1,7 @@ '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'; @@ -8,6 +9,7 @@ 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'; @@ -42,6 +44,10 @@ export function SupportContainer() { ); 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) => { @@ -62,6 +68,14 @@ export function SupportContainer() { status={} closeLabel={messages.confirm.close} onClose={() => showMessage(null)} + actions={ + message.handledAt === null ? ( + void actions.markHandled(message.id)} + isBusy={actions.isBusy} + /> + ) : undefined + } > 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 7a3a6c69..01425e46 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -1316,6 +1316,11 @@ export const messages = { 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', @@ -1328,6 +1333,7 @@ export const messages = { 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é.`, },