From 9da6dda4a12eb104d7d1f8080bbee5c0451f1131 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:58:37 +0700 Subject: [PATCH 01/10] feat(shared): describe the settings and the journal of the admin (A-11, A-12) Schemas of the settings (value in force, default, bounds, update with a reason) and of the journal (lines with author, target and its name, filters, periods). Co-Authored-By: Claude Sonnet 5.5 --- packages/shared/src/index.ts | 2 + packages/shared/src/schemas/admin-actions.ts | 64 +++++++++++++++++++ .../shared/src/schemas/admin-settings.test.ts | 26 ++++++++ packages/shared/src/schemas/admin-settings.ts | 54 ++++++++++++++++ 4 files changed, 146 insertions(+) create mode 100644 packages/shared/src/schemas/admin-actions.ts create mode 100644 packages/shared/src/schemas/admin-settings.test.ts create mode 100644 packages/shared/src/schemas/admin-settings.ts diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index 59a03edb..5b3fc841 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -21,6 +21,8 @@ export * from './thresholds'; export { hasRole } from './roles'; export { zoneMinSpanM, zoneSearchBox } from './zone'; export * from './schemas/admin'; +export * from './schemas/admin-actions'; +export * from './schemas/admin-settings'; export * from './schemas/admin-reports'; export * from './schemas/admin-signals'; export * from './schemas/admin-users'; diff --git a/packages/shared/src/schemas/admin-actions.ts b/packages/shared/src/schemas/admin-actions.ts new file mode 100644 index 00000000..674fbbf5 --- /dev/null +++ b/packages/shared/src/schemas/admin-actions.ts @@ -0,0 +1,64 @@ +import { z } from 'zod'; + +/** Rows of a page of the journal of the admin. */ +export const adminActionsPageSize = 50; + +/** Periods the journal filters by, counted back from now. */ +export const adminActionPeriods = ['24h', '7d', '30d'] as const; +export type AdminActionPeriod = (typeof adminActionPeriods)[number]; + +/** + * Query of `GET /admin/actions` (A-12): the writes of the admin, most recent first, filtered by + * author, kind of action, kind of target and period. `targetId` narrows to one object, such as + * the history of a setting. + */ +export const AdminActionsQuery = z.strictObject({ + actorId: z.uuid().optional(), + action: z.string().min(1).max(100).optional(), + targetType: z.string().min(1).max(50).optional(), + targetId: z.string().min(1).max(200).optional(), + period: z.enum(adminActionPeriods).optional(), + cursor: z.string().max(200).optional(), +}); +export type AdminActionsQuery = z.infer; + +/** One write of the admin: who, what, on which object, why, and the state before and after. */ +export const AdminActionItem = z.object({ + id: z.uuid(), + createdAt: z.iso.datetime(), + /** Null once the account of the author is purged: the journal outlives it. */ + actor: z.object({ id: z.uuid(), name: z.string().nullable() }).nullable(), + /** Stable name: `route.publish`, `user.suspend`, `setting.update`... */ + action: z.string(), + targetType: z.string(), + targetId: z.string(), + /** + * What the target is called today: the first name or e-mail of an account, the title of a route, + * the name of a place. Null for a kind without a name here, or once the object is gone. + */ + targetLabel: z.string().nullable(), + reason: z.string().nullable(), + /** What the action changed, limited to its fields; null when it has none to show. */ + before: z.unknown().nullable(), + after: z.unknown().nullable(), +}); +export type AdminActionItem = z.infer; + +/** Body of `GET /admin/actions`. */ +export const AdminActions = z.object({ + items: z.array(AdminActionItem), + total: z.number().int().nonnegative(), + /** Rows before this page: the first row shown is number `offset + 1`. */ + offset: z.number().int().nonnegative(), + previousCursor: z.string().nullable(), + nextCursor: z.string().nullable(), +}); +export type AdminActions = z.infer; + +/** Body of `GET /admin/actions/filters`: the values the filters offer, from the journal itself. */ +export const AdminActionFilters = z.object({ + actors: z.array(z.object({ id: z.uuid(), name: z.string().nullable() })), + actions: z.array(z.string()), + targetTypes: z.array(z.string()), +}); +export type AdminActionFilters = z.infer; diff --git a/packages/shared/src/schemas/admin-settings.test.ts b/packages/shared/src/schemas/admin-settings.test.ts new file mode 100644 index 00000000..e8639cd6 --- /dev/null +++ b/packages/shared/src/schemas/admin-settings.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { AdminActionsQuery } from './admin-actions'; +import { UpdateSettingBody } from './admin-settings'; + +describe('UpdateSettingBody', () => { + it('takes a number or an object of numbers, with a reason', () => { + expect(UpdateSettingBody.safeParse({ value: 5, reason: 'Plus exigeant' }).success).toBe(true); + expect(UpdateSettingBody.safeParse({ value: { proximity: 2 }, reason: 'Test' }).success).toBe( + true, + ); + }); + + it('refuses a missing or blank reason, a text value and an unknown field', () => { + expect(UpdateSettingBody.safeParse({ value: 5 }).success).toBe(false); + expect(UpdateSettingBody.safeParse({ value: 5, reason: ' ' }).success).toBe(false); + expect(UpdateSettingBody.safeParse({ value: '5', reason: 'x' }).success).toBe(false); + expect(UpdateSettingBody.safeParse({ value: 5, reason: 'x', extra: 1 }).success).toBe(false); + }); +}); + +describe('AdminActionsQuery', () => { + it('takes the filters of the journal and refuses an unknown period', () => { + expect(AdminActionsQuery.safeParse({ targetType: 'setting', period: '7d' }).success).toBe(true); + expect(AdminActionsQuery.safeParse({ period: '1y' }).success).toBe(false); + }); +}); diff --git a/packages/shared/src/schemas/admin-settings.ts b/packages/shared/src/schemas/admin-settings.ts new file mode 100644 index 00000000..b91ea4bb --- /dev/null +++ b/packages/shared/src/schemas/admin-settings.ts @@ -0,0 +1,54 @@ +import { z } from 'zod'; +import { plainText } from './common'; + +/** Groups of the settings page (A-11), in the order it shows them. */ +export const settingGroups = [ + 'premium', + 'submission', + 'certification', + 'search', + 'recommendation', +] as const; +export type SettingGroup = (typeof settingGroups)[number]; + +/** + * A setting is a whole count, a decimal number, or the weights of the recommendation (an object + * of numbers, one per component). + */ +export const settingKinds = ['integer', 'decimal', 'weights'] as const; +export type SettingKind = (typeof settingKinds)[number]; + +export const SettingValue = z.union([z.number(), z.record(z.string(), z.number())]); +export type SettingValue = z.infer; + +/** + * A setting as the team reads it (A-11): the value in force, which is the default while no valid + * row is stored, and the bounds the API holds it to. Labels and explanations belong to the admin. + */ +export const AdminSetting = z.object({ + /** Key of the `settings` row: `premium.min_steps`. */ + key: z.string(), + group: z.enum(settingGroups), + kind: z.enum(settingKinds), + /** Bounds of a number, or of each weight; null when the setting has no upper bound. */ + min: z.number(), + max: z.number().nullable(), + default: SettingValue, + value: SettingValue, + /** True when a valid row of `settings` holds the value; false when the default applies. */ + isCustom: z.boolean(), + /** When the row was last changed; null without a row. */ + updatedAt: z.iso.datetime().nullable(), +}); +export type AdminSetting = z.infer; + +/** Body of `GET /admin/settings`. */ +export const AdminSettings = z.object({ items: z.array(AdminSetting) }); +export type AdminSettings = z.infer; + +/** Body of `PATCH /admin/settings/:key`: the new value, and why, kept in the journal. */ +export const UpdateSettingBody = z.strictObject({ + value: SettingValue, + reason: plainText(500), +}); +export type UpdateSettingBody = z.infer; From e96f89588d1fa14693e2673252e8d2e20f9c7387 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:58:52 +0700 Subject: [PATCH 02/10] feat(api): list the settings the team can change, with the value in force (A-11) GET /admin/settings, admin only. The catalog holds the settings the API reads and applies (premium.min_steps, max_pending_routes, certification, search clustering, recommendation weights) with their bounds and default; a stored value outside them reads as the default, like the code that uses it. Settings no code reads are left out, as changing them would do nothing. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/settings-catalog.test.ts | 71 ++++++++ apps/api/src/admin/settings-catalog.ts | 176 ++++++++++++++++++++ apps/api/src/app.ts | 2 + apps/api/src/moderation/detail.ts | 2 +- apps/api/src/routes/admin-settings.test.ts | 80 +++++++++ apps/api/src/routes/admin-settings.ts | 30 ++++ 6 files changed, 360 insertions(+), 1 deletion(-) create mode 100644 apps/api/src/admin/settings-catalog.test.ts create mode 100644 apps/api/src/admin/settings-catalog.ts create mode 100644 apps/api/src/routes/admin-settings.test.ts create mode 100644 apps/api/src/routes/admin-settings.ts diff --git a/apps/api/src/admin/settings-catalog.test.ts b/apps/api/src/admin/settings-catalog.test.ts new file mode 100644 index 00000000..addae333 --- /dev/null +++ b/apps/api/src/admin/settings-catalog.test.ts @@ -0,0 +1,71 @@ +import { defaultWeights } from '../recommendation/weights'; +import { describe, expect, it } from 'vitest'; +import { + definitionOf, + effectiveValueOf, + isValidSettingValue, + settingDefinitions, +} from './settings-catalog'; + +const definition = (key: string) => { + const found = definitionOf(key); + if (!found) throw new Error(`No definition for ${key}`); + return found; +}; + +describe('isValidSettingValue', () => { + it('holds a count to whole numbers within its bounds', () => { + const steps = definition('premium.min_steps'); + expect(isValidSettingValue(steps, 5)).toBe(true); + expect(isValidSettingValue(steps, 0)).toBe(false); + expect(isValidSettingValue(steps, 21)).toBe(false); + expect(isValidSettingValue(steps, 4.5)).toBe(false); + expect(isValidSettingValue(steps, '5')).toBe(false); + expect(isValidSettingValue(steps, Number.NaN)).toBe(false); + }); + + it('lets a decimal setting hold a fraction, within its bounds', () => { + const rating = definition('certification.min_rating'); + expect(isValidSettingValue(rating, 4.5)).toBe(true); + expect(isValidSettingValue(rating, 5.1)).toBe(false); + expect(isValidSettingValue(rating, -1)).toBe(false); + }); + + it('accepts weights of known components, not all zero', () => { + const weights = definition('recommendation.weights'); + expect(isValidSettingValue(weights, { proximity: 2, quality: 0 })).toBe(true); + expect(isValidSettingValue(weights, { proximity: 0, quality: 0 })).toBe(false); + expect(isValidSettingValue(weights, { luck: 1 })).toBe(false); + expect(isValidSettingValue(weights, { proximity: 11 })).toBe(false); + expect(isValidSettingValue(weights, {})).toBe(false); + expect(isValidSettingValue(weights, [1])).toBe(false); + expect(isValidSettingValue(weights, 3)).toBe(false); + }); +}); + +describe('effectiveValueOf', () => { + it('falls back to the default for a missing or invalid row', () => { + const steps = definition('premium.min_steps'); + expect(effectiveValueOf(steps, undefined)).toBe(steps.default); + expect(effectiveValueOf(steps, 'many')).toBe(steps.default); + }); + + it('completes partial weights with the defaults', () => { + const weights = definition('recommendation.weights'); + expect(effectiveValueOf(weights, { proximity: 1 })).toEqual({ + ...defaultWeights, + proximity: 1, + }); + }); +}); + +describe('settingDefinitions', () => { + it('has a default valid for its own key, and each key once', () => { + for (const setting of settingDefinitions) { + expect(isValidSettingValue(setting, setting.default)).toBe(true); + } + expect(new Set(settingDefinitions.map((setting) => setting.key)).size).toBe( + settingDefinitions.length, + ); + }); +}); diff --git a/apps/api/src/admin/settings-catalog.ts b/apps/api/src/admin/settings-catalog.ts new file mode 100644 index 00000000..17d0f998 --- /dev/null +++ b/apps/api/src/admin/settings-catalog.ts @@ -0,0 +1,176 @@ +import { + defaultCertificationThresholds, + defaultMaxPendingRoutes, + defaultPremiumMinSteps, + maxPendingRoutesKey, + certificationSettingKeys, + type AdminSetting, + type SettingGroup, + type SettingKind, + type SettingValue, +} from '@widoo/shared'; +import { eq, inArray } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { settings } from '../db/schema'; +import { premiumMinStepsKey } from '../moderation/detail'; +import { componentNames, defaultWeights, weightsKey, weightsOf } from '../recommendation/weights'; +import { clusterAreaKey, defaultClusterAreaKm2 } from '../search/settings'; + +type Definition = { + key: string; + group: SettingGroup; + kind: SettingKind; + min: number; + /** Null when the code puts no upper bound on it. */ + max: number | null; + default: SettingValue; +}; + +/** + * The settings the team may change in A-11: the ones the API reads from `settings` and applies + * (a key no code reads would change nothing). Bounds are those of the code where it has some, + * otherwise a generous bound against a typo. The order is the one of the page. + */ +export const settingDefinitions: readonly Definition[] = [ + { + key: premiumMinStepsKey, + group: 'premium', + kind: 'integer', + min: 1, + max: 20, + default: defaultPremiumMinSteps, + }, + { + key: maxPendingRoutesKey, + group: 'submission', + kind: 'integer', + min: 1, + max: 20, + default: defaultMaxPendingRoutes, + }, + { + key: certificationSettingKeys.minVerifiedRoutes, + group: 'certification', + kind: 'integer', + min: 0, + max: 100, + default: defaultCertificationThresholds.minVerifiedRoutes, + }, + { + key: certificationSettingKeys.minRating, + group: 'certification', + kind: 'decimal', + min: 0, + max: 5, + default: defaultCertificationThresholds.minRating, + }, + { + key: certificationSettingKeys.minCompletedRoutes, + group: 'certification', + kind: 'integer', + min: 0, + max: 100, + default: defaultCertificationThresholds.minCompletedRoutes, + }, + { + key: clusterAreaKey, + group: 'search', + kind: 'decimal', + min: 0.1, + max: 10_000, + default: defaultClusterAreaKm2, + }, + { + key: weightsKey, + group: 'recommendation', + kind: 'weights', + min: 0, + max: 10, + default: defaultWeights, + }, +]; + +/** The definition of a key, undefined for a key the admin does not manage. */ +export function definitionOf(key: string): Definition | undefined { + return settingDefinitions.find((definition) => definition.key === key); +} + +const isNumberWithin = (value: unknown, definition: Definition): value is number => + typeof value === 'number' && + Number.isFinite(value) && + value >= definition.min && + (definition.max === null || value <= definition.max) && + (definition.kind !== 'integer' || Number.isInteger(value)); + +/** + * Whether `value` is one the setting accepts: a number within its bounds (whole for a count), or + * weights that name known components only, each within the bounds, not all zero. + */ +export function isValidSettingValue(definition: Definition, value: unknown): value is SettingValue { + if (definition.kind !== 'weights') { + return isNumberWithin(value, definition); + } + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return false; + } + const entries = Object.entries(value); + const known = (name: string) => (componentNames as readonly string[]).includes(name); + return ( + entries.length > 0 && + entries.every(([name, weight]) => known(name) && isNumberWithin(weight, definition)) && + entries.some(([, weight]) => weight > 0) + ); +} + +/** The value in force: the stored one when valid, the default otherwise (weights over defaults). */ +export function effectiveValueOf(definition: Definition, stored: unknown): SettingValue { + if (!isValidSettingValue(definition, stored)) { + return definition.default; + } + return definition.kind === 'weights' ? weightsOf(stored) : stored; +} + +type StoredRow = { value: unknown; updatedAt: Date }; + +const toAdminSetting = (definition: Definition, row: StoredRow | undefined): AdminSetting => { + const isCustom = row !== undefined && isValidSettingValue(definition, row.value); + return { + ...definition, + value: effectiveValueOf(definition, row?.value), + isCustom, + updatedAt: isCustom ? row.updatedAt.toISOString() : null, + }; +}; + +/** The settings of A-11 with the value in force and whether the team changed it. */ +export async function readAdminSettings(db: Db): Promise { + const rows = await db + .select({ key: settings.key, value: settings.value, updatedAt: settings.updatedAt }) + .from(settings) + .where( + inArray( + settings.key, + settingDefinitions.map((definition) => definition.key), + ), + ); + const byKey = new Map(rows.map((row) => [row.key, row])); + return settingDefinitions.map((definition) => + toAdminSetting(definition, byKey.get(definition.key)), + ); +} + +/** One setting of A-11, undefined for a key the admin does not manage. */ +export async function readAdminSetting( + db: Pick, + key: string, +): Promise { + const definition = definitionOf(key); + if (!definition) { + return undefined; + } + const [row] = await db + .select({ value: settings.value, updatedAt: settings.updatedAt }) + .from(settings) + .where(eq(settings.key, key)); + return toAdminSetting(definition, row); +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index 706d23c3..cd258b43 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -22,6 +22,7 @@ import { adminRoutes } from './routes/admin'; import { adminCatalogRoutes } from './routes/admin-catalog'; import { adminModerationRoutes } from './routes/admin-moderation'; import { adminReportsRoutes } from './routes/admin-reports'; +import { adminSettingsRoutes } from './routes/admin-settings'; import { adminUsersRoutes } from './routes/admin-users'; import { configRoutes } from './routes/config'; import { editorRoutes } from './routes/editor'; @@ -154,6 +155,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(adminCatalogRoutes, { prefix: '/v1' }); await app.register(adminReportsRoutes, { prefix: '/v1' }); await app.register(adminUsersRoutes, { prefix: '/v1' }); + await app.register(adminSettingsRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(routeRatingsPlugin, { prefix: '/v1' }); await app.register(routeSheetRoutes, { diff --git a/apps/api/src/moderation/detail.ts b/apps/api/src/moderation/detail.ts index 674341b3..8a191a1e 100644 --- a/apps/api/src/moderation/detail.ts +++ b/apps/api/src/moderation/detail.ts @@ -9,7 +9,7 @@ import { sql } from 'drizzle-orm'; import type { Db } from '../db/client'; import { readSetting } from '../search/settings'; -const premiumMinStepsKey = 'premium.min_steps'; +export const premiumMinStepsKey = 'premium.min_steps'; type RouteRow = { id: string; diff --git a/apps/api/src/routes/admin-settings.test.ts b/apps/api/src/routes/admin-settings.test.ts new file mode 100644 index 00000000..2522b290 --- /dev/null +++ b/apps/api/src/routes/admin-settings.test.ts @@ -0,0 +1,80 @@ +import { AdminSettings, ApiError, 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, settings, users } from '../db/schema'; +import { seed } from '../db/seed'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// `settings` is shared by every test file: only `premium.min_steps` is changed here, and the row +// is put back as it was found. +const key = 'premium.min_steps'; +const verifier = new TestTokenVerifier(); +const createdUids: string[] = []; +let originalRow: typeof settings.$inferSelect | undefined; + +let app: Awaited>; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); + await seed(app.db); + [originalRow] = await app.db.select().from(settings).where(eq(settings.key, key)); +}); +afterAll(async () => { + await app.db.delete(settings).where(eq(settings.key, key)); + if (originalRow) { + await app.db.insert(settings).values(originalRow); + } + const ids = await app.db + .select({ id: users.id }) + .from(users) + .where(inArray(users.firebaseUid, createdUids)); + await app.db.delete(adminActions).where( + inArray( + adminActions.actorId, + ids.map((row) => row.id), + ), + ); + await app.db.delete(users).where(inArray(users.firebaseUid, createdUids)); + await app.close(); +}); + +async function signIn(role: UserRole) { + const { token, uid } = verifier.issue({ secondFactor: 'totp' }); + createdUids.push(uid); + const [user] = await app.db + .insert(users) + .values({ id: uuidv7(), firebaseUid: uid, role }) + .returning(); + if (!user) throw new Error('User not created'); + return { id: user.id, headers: { authorization: `Bearer ${token}` } }; +} + +describe('GET /v1/admin/settings', () => { + it('is for admins only', async () => { + for (const role of ['editor', 'moderator'] as const) { + const member = await signIn(role); + const response = await app.inject({ url: '/v1/admin/settings', headers: member.headers }); + expect(response.statusCode).toBe(403); + expect(ApiError.parse(response.json()).code).toBe('forbidden'); + } + }); + + it('lists the settings the code reads, with the value in force', async () => { + const admin = await signIn('admin'); + const response = await app.inject({ url: '/v1/admin/settings', headers: admin.headers }); + expect(response.statusCode).toBe(200); + const { items } = AdminSettings.parse(response.json()); + expect(items.map((item) => item.key)).toEqual([ + 'premium.min_steps', + 'max_pending_routes', + 'certification.min_verified_routes', + 'certification.min_rating', + 'certification.min_completed_routes', + 'search.cluster_area_km2', + 'recommendation.weights', + ]); + expect(items.find((item) => item.key === 'recommendation.weights')?.kind).toBe('weights'); + }); +}); diff --git a/apps/api/src/routes/admin-settings.ts b/apps/api/src/routes/admin-settings.ts new file mode 100644 index 00000000..4394afc1 --- /dev/null +++ b/apps/api/src/routes/admin-settings.ts @@ -0,0 +1,30 @@ +import { AdminSettings } from '@widoo/shared'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { readAdminSettings } from '../admin/settings-catalog'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; + +/** + * Settings of the admin (A-11, wiki API › Admin): the values the team changes without a + * deployment. Admin only; each change is in the journal with its reason. Never cached. + */ +export const adminSettingsRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAdminSection('settings')); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/admin/settings', + { + schema: { + tags, + summary: 'The settings with the value in force', + security, + response: { 200: AdminSettings }, + }, + }, + async () => ({ items: await readAdminSettings(app.db) }), + ); +}; From 4b6a759b5043558b00ccc3980d64aa8477fc97ee Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:59:05 +0700 Subject: [PATCH 03/10] feat(api): change a setting with a reason, and journal it (A-11) PATCH /admin/settings/:key, admin only: the value is held to the bounds of its key (a count is whole, weights name known components and are not all zero), then written with its journal line (before, after, reason) in one transaction. The API reads settings again within a minute at most. An unknown key answers 404, a value out of bounds 400. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/settings-update.ts | 49 ++++++++++++++++ apps/api/src/routes/admin-settings.test.ts | 66 +++++++++++++++++++++- apps/api/src/routes/admin-settings.ts | 26 ++++++++- 3 files changed, 139 insertions(+), 2 deletions(-) create mode 100644 apps/api/src/admin/settings-update.ts diff --git a/apps/api/src/admin/settings-update.ts b/apps/api/src/admin/settings-update.ts new file mode 100644 index 00000000..ccca72d3 --- /dev/null +++ b/apps/api/src/admin/settings-update.ts @@ -0,0 +1,49 @@ +import type { AdminSetting, UpdateSettingBody } from '@widoo/shared'; +import type { Db } from '../db/client'; +import { settings } from '../db/schema'; +import { httpError } from '../errors'; +import { recordAdminAction } from './actions'; +import { definitionOf, isValidSettingValue, readAdminSetting } from './settings-catalog'; + +/** + * Changes a setting (A-11, admin only): the value is checked against the bounds of its key, then + * written with its journal entry (before, after and the reason) in one transaction. The API reads + * `settings` again within a minute at most, so the change applies without a deployment. A key the + * admin does not manage answers 404, a value out of bounds 400. + */ +export async function updateSetting( + db: Db, + actorId: string, + key: string, + body: UpdateSettingBody, + now = new Date(), +): Promise { + const definition = definitionOf(key); + if (!definition) { + throw httpError(404, 'Setting not found'); + } + if (!isValidSettingValue(definition, body.value)) { + throw httpError(400, 'The value is out of the bounds of this setting'); + } + return db.transaction(async (tx) => { + const before = await readAdminSetting(tx, key); + await tx + .insert(settings) + .values({ key, value: body.value, updatedAt: now }) + .onConflictDoUpdate({ target: settings.key, set: { value: body.value, updatedAt: now } }); + await recordAdminAction(tx, { + actorId, + action: 'setting.update', + targetType: 'setting', + targetId: key, + before: { value: before?.value, isCustom: before?.isCustom }, + after: { value: body.value }, + reason: body.reason, + }); + const after = await readAdminSetting(tx, key); + if (!after) { + throw httpError(404, 'Setting not found'); + } + return after; + }); +} diff --git a/apps/api/src/routes/admin-settings.test.ts b/apps/api/src/routes/admin-settings.test.ts index 2522b290..cc28e09e 100644 --- a/apps/api/src/routes/admin-settings.test.ts +++ b/apps/api/src/routes/admin-settings.test.ts @@ -1,4 +1,4 @@ -import { AdminSettings, ApiError, type UserRole } from '@widoo/shared'; +import { AdminSetting, AdminSettings, ApiError, type UserRole } from '@widoo/shared'; import { eq, inArray } from 'drizzle-orm'; import { afterAll, beforeAll, describe, expect, it } from 'vitest'; import { buildApp } from '../app'; @@ -51,6 +51,9 @@ async function signIn(role: UserRole) { return { id: user.id, headers: { authorization: `Bearer ${token}` } }; } +const patch = (headers: Record, settingKey: string, payload: object) => + app.inject({ method: 'PATCH', url: `/v1/admin/settings/${settingKey}`, headers, payload }); + describe('GET /v1/admin/settings', () => { it('is for admins only', async () => { for (const role of ['editor', 'moderator'] as const) { @@ -78,3 +81,64 @@ describe('GET /v1/admin/settings', () => { expect(items.find((item) => item.key === 'recommendation.weights')?.kind).toBe('weights'); }); }); + +describe('PATCH /v1/admin/settings/:key', () => { + it('changes a setting, tells the value in force and journals it with its reason', async () => { + const admin = await signIn('admin'); + const before = AdminSetting.parse( + (await app.inject({ url: '/v1/admin/settings', headers: admin.headers })) + .json() + .items.find((item: { key: string }) => item.key === key), + ); + const response = await patch(admin.headers, key, { value: 7, reason: 'Parcours plus riches' }); + expect(response.statusCode).toBe(200); + expect(AdminSetting.parse(response.json())).toMatchObject({ key, value: 7, isCustom: true }); + + const [entry] = await app.db + .select() + .from(adminActions) + .where(eq(adminActions.actorId, admin.id)); + expect(entry).toMatchObject({ + action: 'setting.update', + targetType: 'setting', + targetId: key, + reason: 'Parcours plus riches', + before: { value: before.value, isCustom: before.isCustom }, + after: { value: 7 }, + }); + const [row] = await app.db.select().from(settings).where(eq(settings.key, key)); + expect(row?.value).toBe(7); + }); + + it('is for admins only', async () => { + const moderator = await signIn('moderator'); + const response = await patch(moderator.headers, key, { value: 6, reason: 'Essai' }); + expect(response.statusCode).toBe(403); + }); + + it('refuses a value out of bounds, a fraction for a count and weights that all are zero', async () => { + const admin = await signIn('admin'); + for (const value of [0, 21, 4.5]) { + expect((await patch(admin.headers, key, { value, reason: 'Essai' })).statusCode).toBe(400); + } + const zero = await patch(admin.headers, 'recommendation.weights', { + value: { proximity: 0 }, + reason: 'Essai', + }); + expect(zero.statusCode).toBe(400); + const unknown = await patch(admin.headers, 'recommendation.weights', { + value: { luck: 1 }, + reason: 'Essai', + }); + expect(unknown.statusCode).toBe(400); + }); + + it('asks for a reason and a number, and knows its keys', async () => { + const admin = await signIn('admin'); + expect((await patch(admin.headers, key, { value: 6 })).statusCode).toBe(422); + expect((await patch(admin.headers, key, { value: '6', reason: 'Essai' })).statusCode).toBe(422); + expect( + (await patch(admin.headers, 'free.max_routes', { value: 2, reason: 'Essai' })).statusCode, + ).toBe(404); + }); +}); diff --git a/apps/api/src/routes/admin-settings.ts b/apps/api/src/routes/admin-settings.ts index 4394afc1..97b26265 100644 --- a/apps/api/src/routes/admin-settings.ts +++ b/apps/api/src/routes/admin-settings.ts @@ -1,9 +1,13 @@ -import { AdminSettings } from '@widoo/shared'; +import { AdminSetting, AdminSettings, UpdateSettingBody } from '@widoo/shared'; import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { z } from 'zod'; import { readAdminSettings } from '../admin/settings-catalog'; +import { updateSetting } from '../admin/settings-update'; +import { httpError } from '../errors'; const security = [{ firebaseIdToken: [] }]; const tags = ['admin']; +const SettingParams = z.object({ key: z.string().min(1).max(100) }); /** * Settings of the admin (A-11, wiki API › Admin): the values the team changes without a @@ -27,4 +31,24 @@ export const adminSettingsRoutes: FastifyPluginAsyncZod = async (app) => { }, async () => ({ items: await readAdminSettings(app.db) }), ); + + app.patch( + '/admin/settings/:key', + { + schema: { + tags, + summary: 'Change a setting, with a reason kept in the journal', + security, + params: SettingParams, + body: UpdateSettingBody, + response: { 200: AdminSetting }, + }, + }, + async (request) => { + if (!request.user) { + throw httpError(401, 'Missing bearer token'); + } + return updateSetting(app.db, request.user.id, request.params.key, request.body); + }, + ); }; From 551cd1ef767eb6b3328620d96936c4aa7953985e Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:59:05 +0700 Subject: [PATCH 04/10] feat(api): read the journal of the admin, filtered (A-12) GET /admin/actions, admin only: the writes of the team, most recent first, filtered by author, kind of action, kind of target, one object and period, with the name of each target (account, route, place) as it is today. GET /admin/actions/filters gives the values the filters offer. Read only. Co-Authored-By: Claude Sonnet 5.5 --- apps/api/src/admin/actions-read.ts | 159 ++++++++++++++++++++++ apps/api/src/app.ts | 2 + apps/api/src/routes/admin-actions.test.ts | 156 +++++++++++++++++++++ apps/api/src/routes/admin-actions.ts | 44 ++++++ 4 files changed, 361 insertions(+) create mode 100644 apps/api/src/admin/actions-read.ts create mode 100644 apps/api/src/routes/admin-actions.test.ts create mode 100644 apps/api/src/routes/admin-actions.ts diff --git a/apps/api/src/admin/actions-read.ts b/apps/api/src/admin/actions-read.ts new file mode 100644 index 00000000..2ca997fc --- /dev/null +++ b/apps/api/src/admin/actions-read.ts @@ -0,0 +1,159 @@ +import { + adminActionsPageSize, + type AdminActionFilters, + type AdminActionPeriod, + type AdminActions, + type AdminActionsQuery, +} from '@widoo/shared'; +import { and, asc, desc, eq, gte, inArray, sql, type SQL } from 'drizzle-orm'; +import type { Db } from '../db/client'; +import { adminActions, places, routes, users } from '../db/schema'; +import { neighbourCursors, offsetOfCursor } from './page-cursor'; + +const periodMs: Record = { + '24h': 24 * 60 * 60 * 1000, + '7d': 7 * 24 * 60 * 60 * 1000, + '30d': 30 * 24 * 60 * 60 * 1000, +}; + +/** The name a journal line gives an author: first name, else e-mail; null once purged. */ +const nameOf = (user: { firstName: string | null; email: string | null }) => + user.firstName ?? user.email; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +type TargetRow = { targetType: string; targetId: string }; + +/** + * What the targets of a page are called today, by `type:id`: accounts, routes and places, three + * queries at most for the whole page. A deleted account has no name left, so it has no label. + */ +async function targetLabelsOf(db: Db, rows: TargetRow[]): Promise> { + const idsOf = (type: string) => [ + ...new Set( + rows + .filter((row) => row.targetType === type && uuidPattern.test(row.targetId)) + .map((row) => row.targetId), + ), + ]; + const [userIds, routeIds, placeIds] = [idsOf('user'), idsOf('route'), idsOf('place')]; + const [userRows, routeRows, placeRows] = await Promise.all([ + userIds.length + ? db + .select({ id: users.id, firstName: users.firstName, email: users.email }) + .from(users) + .where(inArray(users.id, userIds)) + : [], + routeIds.length + ? db + .select({ id: routes.id, name: routes.title }) + .from(routes) + .where(inArray(routes.id, routeIds)) + : [], + placeIds.length + ? db + .select({ id: places.id, name: places.name }) + .from(places) + .where(inArray(places.id, placeIds)) + : [], + ]); + const labels = new Map(); + for (const row of userRows) { + const name = row.firstName ?? row.email; + if (name) labels.set(`user:${row.id}`, name); + } + for (const row of routeRows) labels.set(`route:${row.id}`, row.name); + for (const row of placeRows) labels.set(`place:${row.id}`, row.name); + return labels; +} + +/** + * A page of the journal of the admin (A-12), most recent first, narrowed by the filters. Read + * only: nothing in the journal is ever changed or deleted. + */ +export async function readAdminActions( + db: Db, + query: AdminActionsQuery, + now = new Date(), +): Promise { + const offset = offsetOfCursor(query.cursor); + const conditions: (SQL | undefined)[] = [ + query.actorId ? eq(adminActions.actorId, query.actorId) : undefined, + query.action ? eq(adminActions.action, query.action) : undefined, + query.targetType ? eq(adminActions.targetType, query.targetType) : undefined, + query.targetId ? eq(adminActions.targetId, query.targetId) : undefined, + query.period + ? gte(adminActions.createdAt, new Date(now.getTime() - periodMs[query.period])) + : undefined, + ]; + const where = and(...conditions); + + const rows = await db + .select({ + id: adminActions.id, + createdAt: adminActions.createdAt, + actorId: adminActions.actorId, + firstName: users.firstName, + email: users.email, + action: adminActions.action, + targetType: adminActions.targetType, + targetId: adminActions.targetId, + reason: adminActions.reason, + before: adminActions.before, + after: adminActions.after, + }) + .from(adminActions) + .leftJoin(users, eq(users.id, adminActions.actorId)) + .where(where) + .orderBy(desc(adminActions.createdAt), desc(adminActions.id)) + .limit(adminActionsPageSize) + .offset(offset); + const [counted] = await db + .select({ total: sql`count(*)::int` }) + .from(adminActions) + .where(where); + const total = counted?.total ?? 0; + const labels = await targetLabelsOf(db, rows); + return { + items: rows.map((row) => ({ + id: row.id, + createdAt: row.createdAt.toISOString(), + actor: row.actorId ? { id: row.actorId, name: nameOf(row) } : null, + action: row.action, + targetType: row.targetType, + targetId: row.targetId, + targetLabel: labels.get(`${row.targetType}:${row.targetId}`) ?? null, + reason: row.reason, + before: row.before, + after: row.after, + })), + total, + offset, + ...neighbourCursors(offset, total, adminActionsPageSize), + }; +} + +/** Authors, kinds of action and kinds of target the journal holds, for the filters of A-12. */ +export async function readAdminActionFilters(db: Db): Promise { + const [actors, actions, targetTypes] = await Promise.all([ + db + .selectDistinct({ id: users.id, firstName: users.firstName, email: users.email }) + .from(adminActions) + .innerJoin(users, eq(users.id, adminActions.actorId)) + .orderBy(asc(users.firstName), asc(users.id)) + .limit(200), + db + .selectDistinct({ action: adminActions.action }) + .from(adminActions) + .orderBy(asc(adminActions.action)), + db + .selectDistinct({ targetType: adminActions.targetType }) + .from(adminActions) + .orderBy(asc(adminActions.targetType)), + ]); + return { + actors: actors.map((actor) => ({ id: actor.id, name: nameOf(actor) })), + actions: actions.map((row) => row.action), + targetTypes: targetTypes.map((row) => row.targetType), + }; +} diff --git a/apps/api/src/app.ts b/apps/api/src/app.ts index cd258b43..4032b72b 100644 --- a/apps/api/src/app.ts +++ b/apps/api/src/app.ts @@ -22,6 +22,7 @@ import { adminRoutes } from './routes/admin'; import { adminCatalogRoutes } from './routes/admin-catalog'; import { adminModerationRoutes } from './routes/admin-moderation'; import { adminReportsRoutes } from './routes/admin-reports'; +import { adminActionsRoutes } from './routes/admin-actions'; import { adminSettingsRoutes } from './routes/admin-settings'; import { adminUsersRoutes } from './routes/admin-users'; import { configRoutes } from './routes/config'; @@ -156,6 +157,7 @@ export async function buildApp(config: Config, options: BuildAppOptions = {}) { await app.register(adminReportsRoutes, { prefix: '/v1' }); await app.register(adminUsersRoutes, { prefix: '/v1' }); await app.register(adminSettingsRoutes, { prefix: '/v1' }); + await app.register(adminActionsRoutes, { prefix: '/v1' }); await app.register(searchRoutesPlugin, { prefix: '/v1' }); await app.register(routeRatingsPlugin, { prefix: '/v1' }); await app.register(routeSheetRoutes, { diff --git a/apps/api/src/routes/admin-actions.test.ts b/apps/api/src/routes/admin-actions.test.ts new file mode 100644 index 00000000..58619cad --- /dev/null +++ b/apps/api/src/routes/admin-actions.test.ts @@ -0,0 +1,156 @@ +import { AdminActionFilters, AdminActions, ApiError, 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, users } from '../db/schema'; +import { uuidv7 } from '../db/uuid'; +import { testConfig } from '../test-config'; + +// Shared database, parallel files: the lines of this file carry their own kind of target, and +// every query narrows to it. +const verifier = new TestTokenVerifier(); +const createdUids: string[] = []; +const targetType = `journal-test-${uuidv7().slice(-12)}`; +const hour = 60 * 60 * 1000; + +let app: Awaited>; +let actorId: string; +let otherActorId: string; +beforeAll(async () => { + app = await buildApp(testConfig(), { tokenVerifier: verifier }); + const create = async (firstName: string) => { + const uid = `test-${uuidv7()}`; + createdUids.push(uid); + const [user] = await app.db + .insert(users) + .values({ id: uuidv7(), firebaseUid: uid, firstName, role: 'admin' }) + .returning(); + if (!user) throw new Error('User not created'); + return user.id; + }; + actorId = await create('Ilan'); + otherActorId = await create('Paul'); + const line = ( + actor: string, + action: string, + targetId: string, + hoursAgo: number, + extra: { reason?: string; before?: unknown; after?: unknown } = {}, + ) => ({ + actorId: actor, + action, + targetType, + targetId, + createdAt: new Date(Date.now() - hoursAgo * hour), + ...extra, + }); + await app.db.insert(adminActions).values([ + line(actorId, 'route.publish', 'a', 1, { reason: 'Conforme' }), + line(actorId, 'user.suspend', 'b', 48, { + before: { suspended: false }, + after: { suspended: true }, + }), + line(otherActorId, 'route.publish', 'c', 24 * 20), + ]); +}); +afterAll(async () => { + await app.db.delete(adminActions).where(eq(adminActions.targetType, targetType)); + await app.db.delete(users).where(inArray(users.firebaseUid, createdUids)); + await app.close(); +}); + +async function signIn(role: UserRole) { + const { token, uid } = verifier.issue({ secondFactor: 'totp' }); + createdUids.push(uid); + await app.db.insert(users).values({ id: uuidv7(), firebaseUid: uid, role }); + return { authorization: `Bearer ${token}` }; +} + +const list = async (query: string) => { + const headers = await signIn('admin'); + const response = await app.inject({ + url: `/v1/admin/actions?targetType=${targetType}${query}`, + headers, + }); + expect(response.statusCode).toBe(200); + return AdminActions.parse(response.json()); +}; + +describe('GET /v1/admin/actions', () => { + it('is for admins only', async () => { + for (const role of ['editor', 'moderator'] as const) { + const response = await app.inject({ + url: '/v1/admin/actions', + headers: await signIn(role), + }); + expect(response.statusCode).toBe(403); + expect(ApiError.parse(response.json()).code).toBe('forbidden'); + } + }); + + it('lists the lines most recent first, with their author, reason and states', async () => { + const page = await list(''); + expect(page.total).toBe(3); + expect(page.items.map((item) => item.targetId)).toEqual(['a', 'b', 'c']); + expect(page.items[0]).toMatchObject({ + action: 'route.publish', + reason: 'Conforme', + actor: { id: actorId, name: 'Ilan' }, + }); + expect(page.items[1]).toMatchObject({ + before: { suspended: false }, + after: { suspended: true }, + }); + }); + + it('filters by author, kind of action, object and period', async () => { + expect((await list(`&actorId=${otherActorId}`)).items.map((item) => item.targetId)).toEqual([ + 'c', + ]); + expect((await list('&action=user.suspend')).items.map((item) => item.targetId)).toEqual(['b']); + expect((await list('&targetId=a')).items).toHaveLength(1); + expect((await list('&period=24h')).items.map((item) => item.targetId)).toEqual(['a']); + expect((await list('&period=7d')).items.map((item) => item.targetId)).toEqual(['a', 'b']); + expect((await list('&period=30d')).total).toBe(3); + }); + + it('refuses an unknown period', async () => { + const headers = await signIn('admin'); + const response = await app.inject({ url: '/v1/admin/actions?period=1y', headers }); + expect(response.statusCode).toBe(400); + }); +}); + +describe('the name of the target', () => { + it('tells which account a line is about, and has none for a kind without a name', async () => { + const [named] = await app.db + .insert(adminActions) + .values({ actorId, action: 'user.suspend', targetType: 'user', targetId: otherActorId }) + .returning({ id: adminActions.id }); + try { + const headers = await signIn('admin'); + const response = await app.inject({ + url: `/v1/admin/actions?targetType=user&targetId=${otherActorId}`, + headers, + }); + expect(AdminActions.parse(response.json()).items[0]).toMatchObject({ targetLabel: 'Paul' }); + expect((await list('')).items.every((item) => item.targetLabel === null)).toBe(true); + } finally { + if (named) await app.db.delete(adminActions).where(eq(adminActions.id, named.id)); + } + }); +}); + +describe('GET /v1/admin/actions/filters', () => { + it('offers the authors, actions and kinds of target of the journal', async () => { + const headers = await signIn('admin'); + const response = await app.inject({ url: '/v1/admin/actions/filters', headers }); + const filters = AdminActionFilters.parse(response.json()); + expect(filters.actors.map((actor) => actor.name)).toEqual( + expect.arrayContaining(['Ilan', 'Paul']), + ); + expect(filters.actions).toEqual(expect.arrayContaining(['route.publish', 'user.suspend'])); + expect(filters.targetTypes).toContain(targetType); + }); +}); diff --git a/apps/api/src/routes/admin-actions.ts b/apps/api/src/routes/admin-actions.ts new file mode 100644 index 00000000..fea5bed8 --- /dev/null +++ b/apps/api/src/routes/admin-actions.ts @@ -0,0 +1,44 @@ +import { AdminActionFilters, AdminActions, AdminActionsQuery } from '@widoo/shared'; +import type { FastifyPluginAsyncZod } from 'fastify-type-provider-zod'; +import { readAdminActionFilters, readAdminActions } from '../admin/actions-read'; + +const security = [{ firebaseIdToken: [] }]; +const tags = ['admin']; + +/** + * The journal of the admin (A-12, wiki API › Admin): every write of the team, read only. Admin + * only. Never cached. + */ +export const adminActionsRoutes: FastifyPluginAsyncZod = async (app) => { + app.addHook('onRequest', app.requireAdminSection('actionLog')); + app.addHook('onSend', async (_request, reply) => { + reply.header('cache-control', 'no-store'); + }); + + app.get( + '/admin/actions', + { + schema: { + tags, + summary: 'The writes of the admin, most recent first', + security, + querystring: AdminActionsQuery, + response: { 200: AdminActions }, + }, + }, + async (request) => readAdminActions(app.db, request.query), + ); + + app.get( + '/admin/actions/filters', + { + schema: { + tags, + summary: 'The values the filters of the journal offer', + security, + response: { 200: AdminActionFilters }, + }, + }, + async () => readAdminActionFilters(app.db), + ); +}; From e265dfe1c7b8f8f635cd4888e792ca12ec5eb374 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:59:05 +0700 Subject: [PATCH 05/10] feat(api-client): call the settings and the journal of the admin (A-11, A-12) Co-Authored-By: Claude Sonnet 5.5 --- packages/api-client/src/actions.test.ts | 14 ++++++++ packages/api-client/src/actions.ts | 13 +++++++ packages/api-client/src/client.ts | 47 +++++++++++++++++++++++++ 3 files changed, 74 insertions(+) create mode 100644 packages/api-client/src/actions.test.ts create mode 100644 packages/api-client/src/actions.ts diff --git a/packages/api-client/src/actions.test.ts b/packages/api-client/src/actions.test.ts new file mode 100644 index 00000000..41392a29 --- /dev/null +++ b/packages/api-client/src/actions.test.ts @@ -0,0 +1,14 @@ +import { describe, expect, it } from 'vitest'; +import { actionsQueryString } from './actions'; + +describe('actionsQueryString', () => { + it('keeps the filters that are set, in a stable order', () => { + expect(actionsQueryString({ period: '7d', targetType: 'setting', targetId: 'a b' })).toBe( + 'targetType=setting&targetId=a+b&period=7d', + ); + }); + + it('leaves out what is empty', () => { + expect(actionsQueryString({ action: '', cursor: undefined })).toBe(''); + }); +}); diff --git a/packages/api-client/src/actions.ts b/packages/api-client/src/actions.ts new file mode 100644 index 00000000..66dae31f --- /dev/null +++ b/packages/api-client/src/actions.ts @@ -0,0 +1,13 @@ +import type { AdminActionsQuery } from '@widoo/shared'; + +/** Query string of the journal of the admin, as the API reads it. */ +export function actionsQueryString(params: Partial): string { + const query = new URLSearchParams(); + for (const key of ['actorId', 'action', 'targetType', 'targetId', 'period', 'cursor'] as const) { + const value = params[key]; + if (value !== undefined && value !== '') { + query.set(key, value); + } + } + return query.toString(); +} diff --git a/packages/api-client/src/client.ts b/packages/api-client/src/client.ts index 5e9608fa..95bbd800 100644 --- a/packages/api-client/src/client.ts +++ b/packages/api-client/src/client.ts @@ -1,6 +1,10 @@ import { + AdminActionFilters, + AdminActions, AdminRatings, RatingActionResult, + AdminSetting, + AdminSettings, AdminUserDetail, AdminUsers, AdminRouteReports, @@ -49,7 +53,9 @@ import { type RepublishBody, type AdminRatingsQuery, type AdminReportsQuery, + type AdminActionsQuery, type AdminSignalsQuery, + type UpdateSettingBody, type AdminUsersQuery, type CertificationBody, type ChangeUserRoleBody, @@ -89,6 +95,7 @@ import { myRoutesQueryString, type MyRoutesParams } from './my-routes'; import { createTransport, type ApiClientOptions } from './transport'; import { moderationQueryString, type ModerationQueueParams } from './moderation'; import { catalogPlacesQueryString, catalogQueryString } from './catalog'; +import { actionsQueryString } from './actions'; import { usersQueryString } from './users'; import { ratingsQueryString, reportsQueryString, signalsQueryString } from './reports'; @@ -666,6 +673,46 @@ export function createApiClient(options: ApiClientOptions) { deleteUser: (userId: string) => requestEmpty({ method: 'DELETE', path: `/v1/admin/users/${userId}`, auth: true }), + /** `GET /v1/admin/settings`: the settings the team changes, with the value in force (A-11). */ + getAdminSettings: (signal?: AbortSignal) => + request({ + method: 'GET', + path: '/v1/admin/settings', + schema: AdminSettings, + auth: true, + signal, + }), + + /** `PATCH /v1/admin/settings/:key`: changes a setting, with a reason (admin only). */ + updateAdminSetting: (key: string, body: UpdateSettingBody) => + request({ + method: 'PATCH', + path: `/v1/admin/settings/${encodeURIComponent(key)}`, + body, + schema: AdminSetting, + auth: true, + }), + + /** `GET /v1/admin/actions`: the journal of the admin, most recent first (A-12). */ + getAdminActions: (params: Partial, signal?: AbortSignal) => + request({ + method: 'GET', + path: `/v1/admin/actions?${actionsQueryString(params)}`, + schema: AdminActions, + auth: true, + signal, + }), + + /** `GET /v1/admin/actions/filters`: the authors, actions and targets the journal holds. */ + getAdminActionFilters: (signal?: AbortSignal) => + request({ + method: 'GET', + path: '/v1/admin/actions/filters', + schema: AdminActionFilters, + auth: true, + signal, + }), + /** `GET /v1/admin/ratings`: the ratings with a comment, filtered by note and route (A-03). */ getAdminRatings: (params: Partial, signal?: AbortSignal) => request({ From d7c205f41265db31ff31fb2859ba426e4bd2d934 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:59:15 +0700 Subject: [PATCH 06/10] feat(admin): texts of the journal and the settings, and how a line of the journal reads (A-11, A-12) French labels of the actions, targets and periods, of each setting and its explanation; the address filters of the journal; the name, label and link of the target of a line, none for an object that is gone. Co-Authored-By: Claude Sonnet 5.5 --- apps/admin/src/config/actions-list.test.ts | 40 ++++++ apps/admin/src/config/actions-list.ts | 37 +++++ apps/admin/src/lib/action-display.test.ts | 64 +++++++++ apps/admin/src/lib/action-display.ts | 56 ++++++++ apps/admin/src/messages/fr.ts | 157 +++++++++++++++++++++ 5 files changed, 354 insertions(+) create mode 100644 apps/admin/src/config/actions-list.test.ts create mode 100644 apps/admin/src/config/actions-list.ts create mode 100644 apps/admin/src/lib/action-display.test.ts create mode 100644 apps/admin/src/lib/action-display.ts diff --git a/apps/admin/src/config/actions-list.test.ts b/apps/admin/src/config/actions-list.test.ts new file mode 100644 index 00000000..652bf2c8 --- /dev/null +++ b/apps/admin/src/config/actions-list.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { toActionsParams, type ActionsListQuery } from './actions-list'; + +const query = (filters: ActionsListQuery['filters'], cursor: string | null = null) => + ({ + search: '', + filters, + sort: { column: 'date', direction: 'desc' }, + cursor, + }) as ActionsListQuery; + +describe('toActionsParams', () => { + it('asks for the filters of the address', () => { + const actorId = '0199d1ec-0000-7000-8000-000000000000'; + expect( + toActionsParams( + query( + { auteur: actorId, action: 'route.publish', cible: 'route', periode: 'semaine' }, + 'abc', + ), + ), + ).toEqual({ + actorId, + action: 'route.publish', + targetType: 'route', + period: '7d', + cursor: 'abc', + }); + }); + + it('drops an author that is no identifier and a period it does not know', () => { + expect(toActionsParams(query({ auteur: 'tout le monde', periode: 'siecle' }))).toEqual({ + actorId: undefined, + action: undefined, + targetType: undefined, + period: undefined, + cursor: undefined, + }); + }); +}); diff --git a/apps/admin/src/config/actions-list.ts b/apps/admin/src/config/actions-list.ts new file mode 100644 index 00000000..bdee9976 --- /dev/null +++ b/apps/admin/src/config/actions-list.ts @@ -0,0 +1,37 @@ +import type { ListQuery, ListQueryOptions } from '@/lib/list-query'; +import type { AdminActionPeriod, AdminActionsQuery } from '@widoo/shared'; + +/** The journal is read most recent first, with no column to sort by: only its four filters. */ +export type ActionFilter = 'auteur' | 'action' | 'cible' | 'periode'; +export type ActionColumn = 'date'; +export type ActionsListQuery = ListQuery; + +export const actionsListOptions: ListQueryOptions = { + filters: ['auteur', 'action', 'cible', 'periode'], + sortableColumns: [], + defaultSort: { column: 'date', direction: 'desc' }, +}; + +/** Values of the `periode` filter, and the period of the API each one asks for. */ +export const periodFilterValues = { + jour: '24h', + semaine: '7d', + mois: '30d', +} as const satisfies Record; + +const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** What to ask the API for the list shown at an address; an unknown value is dropped. */ +export function toActionsParams(query: ActionsListQuery): Partial { + const { auteur, action, cible, periode } = query.filters; + return { + actorId: auteur !== undefined && uuidPattern.test(auteur) ? auteur : undefined, + action, + targetType: cible, + period: + periode !== undefined && Object.hasOwn(periodFilterValues, periode) + ? periodFilterValues[periode as keyof typeof periodFilterValues] + : undefined, + cursor: query.cursor ?? undefined, + }; +} diff --git a/apps/admin/src/lib/action-display.test.ts b/apps/admin/src/lib/action-display.test.ts new file mode 100644 index 00000000..78aa474a --- /dev/null +++ b/apps/admin/src/lib/action-display.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest'; +import { + actionLabelOf, + isTargetGone, + targetNameOf, + targetPathOf, + targetTypeLabelOf, +} from './action-display'; + +describe('actionLabelOf and targetTypeLabelOf', () => { + it('translate what is known and show the rest as it is', () => { + expect(actionLabelOf('user.suspend')).toBe('Compte suspendu'); + expect(actionLabelOf('thing.done')).toBe('thing.done'); + expect(targetTypeLabelOf('route')).toBe('Parcours'); + expect(targetTypeLabelOf('thing')).toBe('thing'); + }); +}); + +describe('targetPathOf', () => { + it('points at the page of the object', () => { + expect(targetPathOf('route', 'r1')).toBe('/catalogue/parcours/r1'); + expect(targetPathOf('place', 'p1')).toBe('/catalogue/lieux?lieu=p1'); + expect(targetPathOf('user', 'u1')).toBe('/utilisateurs?compte=u1'); + expect(targetPathOf('setting', 'premium.min_steps')).toBe( + '/reglages?reglage=premium.min_steps', + ); + }); + + it('has none for a report or an unknown kind', () => { + expect(targetPathOf('route_report', 'x')).toBeNull(); + expect(targetPathOf('thing', 'x')).toBeNull(); + }); +}); + +describe('targetNameOf', () => { + it('prefers the name of the API, then the label of a setting', () => { + expect(targetNameOf({ targetType: 'user', targetId: 'u1', targetLabel: 'Camille' })).toBe( + 'Camille', + ); + expect( + targetNameOf({ targetType: 'setting', targetId: 'premium.min_steps', targetLabel: null }), + ).toBe("Étapes d'un parcours Premium"); + }); + + it('has none for an object that is gone or a kind without a name', () => { + expect(targetNameOf({ targetType: 'user', targetId: 'u1', targetLabel: null })).toBeNull(); + expect( + targetNameOf({ targetType: 'route_report', targetId: 'x', targetLabel: null }), + ).toBeNull(); + }); +}); + +describe('isTargetGone', () => { + it('is true for an account, route or place the API no longer names', () => { + expect(isTargetGone({ targetType: 'user', targetLabel: null })).toBe(true); + expect(isTargetGone({ targetType: 'route', targetLabel: null })).toBe(true); + expect(isTargetGone({ targetType: 'user', targetLabel: 'Camille' })).toBe(false); + }); + + it('is false for a kind that has no name to lose', () => { + expect(isTargetGone({ targetType: 'setting', targetLabel: null })).toBe(false); + expect(isTargetGone({ targetType: 'route_report', targetLabel: null })).toBe(false); + }); +}); diff --git a/apps/admin/src/lib/action-display.ts b/apps/admin/src/lib/action-display.ts new file mode 100644 index 00000000..40db53da --- /dev/null +++ b/apps/admin/src/lib/action-display.ts @@ -0,0 +1,56 @@ +import { sectionPaths } from '@/config/navigation'; +import { messages } from '@/messages/fr'; + +const texts = messages.actionLog; + +/** An action as the journal reads it: its label, or its stable name when it has none yet. */ +export const actionLabelOf = (action: string): string => texts.actions[action] ?? action; + +/** A kind of target as the journal reads it. */ +export const targetTypeLabelOf = (targetType: string): string => + texts.targetTypes[targetType] ?? targetType; + +/** + * Where the target of a line of the journal opens in the admin, or null for a kind without a page + * of its own (a report is decided and leaves its list). + */ +export function targetPathOf(targetType: string, targetId: string): string | null { + const id = encodeURIComponent(targetId); + switch (targetType) { + case 'route': + return `${sectionPaths.catalog}/parcours/${id}`; + case 'place': + return `${sectionPaths.catalog}/lieux?lieu=${id}`; + case 'user': + return `${sectionPaths.users}?compte=${id}`; + case 'setting': + return `${sectionPaths.settings}?reglage=${id}`; + default: + return null; + } +} + +/** + * What a line of the journal calls its target: the name the API found (first name, e-mail, title + * of a route, name of a place), the label of a setting, else null. + */ +export function targetNameOf(action: { + targetType: string; + targetId: string; + targetLabel: string | null; +}): string | null { + if (action.targetLabel) { + return action.targetLabel; + } + return action.targetType === 'setting' + ? (messages.settings.items[action.targetId]?.label ?? null) + : null; +} + +/** Kinds of target the API names while they exist: without a name, the object is gone. */ +const namedTargetTypes = ['user', 'route', 'place']; + +/** True when the target of a line was deleted since: its page would open on nothing. */ +export function isTargetGone(action: { targetType: string; targetLabel: string | null }): boolean { + return namedTargetTypes.includes(action.targetType) && action.targetLabel === null; +} diff --git a/apps/admin/src/messages/fr.ts b/apps/admin/src/messages/fr.ts index f1088d4b..1964b3b2 100644 --- a/apps/admin/src/messages/fr.ts +++ b/apps/admin/src/messages/fr.ts @@ -934,6 +934,163 @@ export const messages = { invalid: 'Le texte ou la date sont refusés.', }, }, + actionLog: { + total: (count: number) => (count > 1 ? `${count} actions` : `${count} action`), + filters: { + label: 'Filtres du journal', + actor: 'Auteur', + action: "Type d'action", + target: 'Type de cible', + period: 'Période', + }, + periods: { + jour: '24 dernières heures', + semaine: '7 derniers jours', + mois: '30 derniers jours', + }, + columns: { date: 'Date', actor: 'Auteur', action: 'Action', target: 'Cible', reason: 'Motif' }, + /** The journal keeps the stable name of each action; an unknown one is shown as it is. */ + actions: { + 'place.close': 'Lieu fermé', + 'place.update': 'Lieu modifié', + 'place.verify': 'Lieu vérifié', + 'report.resolve': 'Signalement traité', + 'route.publish': 'Parcours publié', + 'route.request_changes': 'Modifications demandées', + 'route.reject': 'Parcours refusé', + 'route.unpublish': 'Parcours dépublié', + 'route.republish': 'Parcours republié', + 'route.reassign': 'Parcours réassigné', + 'setting.update': 'Réglage modifié', + 'user.change_role': 'Rôle changé', + 'user.suspend': 'Compte suspendu', + 'user.reactivate': 'Compte réactivé', + 'user.gift_premium': 'Premium offert', + 'user.gift_route': 'Parcours offert', + 'user.certification': 'Badge Créateur certifié', + 'user.export': 'Données exportées', + 'user.delete': 'Compte supprimé', + } as Record, + targetTypes: { + route: 'Parcours', + place: 'Lieu', + route_report: 'Signalement', + user: 'Compte', + setting: 'Réglage', + } as Record, + unknownActor: 'Compte supprimé', + empty: { + title: 'Aucune action', + text: "Les actions de l'équipe apparaissent ici dès qu'elles modifient quelque chose.", + }, + error: "Le journal ne s'est pas chargé.", + panel: { + when: 'Date', + actor: 'Auteur', + action: 'Action', + target: 'Cible', + reason: 'Motif', + noReason: 'Aucun motif', + before: 'Avant', + after: 'Après', + noState: 'Rien à afficher', + openTarget: 'Ouvrir la cible', + targetGone: "Cette cible n'existe plus.", + }, + }, + settings: { + groups: { + premium: 'Premium', + submission: 'Soumission des parcours', + certification: 'Créateur certifié', + search: 'Recherche', + recommendation: 'Recommandation', + }, + columns: { setting: 'Réglage', value: 'Valeur', state: 'État' }, + /** Label and explanation of each setting the admin manages, by key of `settings`. */ + items: { + 'premium.min_steps': { + label: "Étapes d'un parcours Premium", + explanation: + "À partir de ce nombre d'étapes, un parcours est proposé en Premium dans la file de modération.", + unit: 'étapes', + }, + max_pending_routes: { + label: 'Parcours en relecture par auteur', + explanation: + "Nombre de parcours qu'un compte peut avoir en attente de modération en même temps.", + unit: 'parcours', + }, + 'certification.min_verified_routes': { + label: 'Parcours vérifiés pour être certifié', + explanation: + "Parcours vérifiés qu'un créateur doit avoir pour obtenir le badge Créateur certifié.", + unit: 'parcours', + }, + 'certification.min_rating': { + label: 'Note pour être certifié', + explanation: 'Note moyenne, sur 5, que les parcours du créateur doivent atteindre.', + unit: '/ 5', + }, + 'certification.min_completed_routes': { + label: 'Sorties terminées pour être certifié', + explanation: + 'Sorties terminées sur les parcours du créateur avant de pouvoir obtenir le badge.', + unit: 'sorties', + }, + 'search.cluster_area_km2': { + label: 'Surface avant regroupement', + explanation: + 'Au-delà de cette surface de carte, la recherche regroupe les parcours en clusters.', + unit: 'km²', + }, + 'recommendation.weights': { + label: 'Poids de la recommandation', + explanation: 'Importance de chaque critère dans le classement des parcours, de 0 à 10.', + unit: '', + }, + } as Record, + weights: { + proximity: 'Proximité', + quality: 'Qualité', + reliability: 'Fiabilité', + freshness: 'Fraîcheur', + context: 'Contexte', + official: 'Officiel', + } as Record, + state: { custom: "Modifié par l'équipe", default: 'Valeur par défaut' }, + error: 'Les réglages ne se sont pas chargés.', + panel: { + value: 'Valeur', + default: 'Par défaut', + bounds: 'Bornes', + boundsRange: (min: string, max: string) => `de ${min} à ${max}`, + boundsFrom: (min: string) => `à partir de ${min}`, + updated: 'Dernière modification', + never: 'Jamais modifié', + history: 'Historique', + noHistory: 'Aucune modification', + historyError: "L'historique ne s'est pas chargé.", + change: (before: string, after: string) => `${before} → ${after}`, + openJournal: 'Voir dans le journal', + }, + edit: { + button: 'Modifier', + title: (label: string) => `Modifier : ${label}`, + description: "Le changement s'applique à toute l'app sous une minute, sans mise en ligne.", + value: 'Nouvelle valeur', + range: (min: string, max: string) => `Entre ${min} et ${max}.`, + rangeFrom: (min: string) => `${min} au minimum.`, + reason: 'Motif', + reasonHint: "Gardé dans le journal, visible par l'équipe.", + confirm: 'Enregistrer', + }, + toasts: { + saved: "Réglage enregistré. Il s'applique sous une minute.", + invalid: 'Valeur ou motif refusés : vérifiez les bornes.', + failed: "Le réglage n'a pas pu être enregistré.", + }, + }, notFound: { title: 'Page introuvable', text: "Cette adresse n'existe pas dans l'admin.", From 035e1e275b60955ccafe0940c56adb016c3bc57c Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:59:15 +0700 Subject: [PATCH 07/10] feat(admin): show the journal as a table, and read and type the value of a setting (A-11, A-12) Co-Authored-By: Claude Sonnet 5.5 --- .../src/components/actions/ActionsTable.tsx | 60 +++++++++++++++++++ apps/admin/src/lib/setting-display.test.ts | 22 +++++++ apps/admin/src/lib/setting-display.ts | 30 ++++++++++ apps/admin/src/lib/setting-form.test.ts | 32 ++++++++++ apps/admin/src/lib/setting-form.ts | 47 +++++++++++++++ 5 files changed, 191 insertions(+) create mode 100644 apps/admin/src/components/actions/ActionsTable.tsx create mode 100644 apps/admin/src/lib/setting-display.test.ts create mode 100644 apps/admin/src/lib/setting-display.ts create mode 100644 apps/admin/src/lib/setting-form.test.ts create mode 100644 apps/admin/src/lib/setting-form.ts diff --git a/apps/admin/src/components/actions/ActionsTable.tsx b/apps/admin/src/components/actions/ActionsTable.tsx new file mode 100644 index 00000000..fae50eb3 --- /dev/null +++ b/apps/admin/src/components/actions/ActionsTable.tsx @@ -0,0 +1,60 @@ +import { actionLabelOf, targetNameOf, targetTypeLabelOf } from '@/lib/action-display'; +import { formatRelativeDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import type { AdminActionItem } from '@widoo/shared'; +import { Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui'; + +const texts = messages.actionLog; +const columnCount = 5; + +type ActionsTableProps = { + /** Rows of the page; ignored while `isLoading`. */ + items: AdminActionItem[]; + isLoading: boolean; + /** The line whose detail is open in the panel. */ + openId: string | null; + onOpen: (action: AdminActionItem) => void; +}; + +/** The journal of the admin as a table (A-12), most recent first; a row opens its detail. */ +export function ActionsTable({ items, isLoading, openId, onOpen }: ActionsTableProps) { + return ( + + + + {texts.columns.date} + {texts.columns.actor} + {texts.columns.action} + {texts.columns.target} + {texts.columns.reason} + + + + {isLoading ? ( + + ) : ( + items.map((action) => ( + onOpen(action)} isOpen={action.id === openId}> + {formatRelativeDate(new Date(action.createdAt))} + + {action.actor?.name ?? ( + {texts.unknownActor} + )} + + {actionLabelOf(action.action)} + + + {targetTypeLabelOf(action.targetType)} + + {targetNameOf(action) ?? action.targetId} + + + + {action.reason ?? ''} + + )) + )} + +
+ ); +} diff --git a/apps/admin/src/lib/setting-display.test.ts b/apps/admin/src/lib/setting-display.test.ts new file mode 100644 index 00000000..34ed5a1f --- /dev/null +++ b/apps/admin/src/lib/setting-display.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { boundsOf, formatSettingValue } from './setting-display'; + +describe('formatSettingValue', () => { + it('writes a number with its unit and a French decimal comma', () => { + expect(formatSettingValue({ key: 'premium.min_steps' }, 4)).toBe('4 étapes'); + expect(formatSettingValue({ key: 'certification.min_rating' }, 4.5)).toBe('4,5 / 5'); + }); + + it('names each weight', () => { + expect( + formatSettingValue({ key: 'recommendation.weights' }, { proximity: 0.3, quality: 2 }), + ).toBe('Proximité 0,3 · Qualité 2'); + }); +}); + +describe('boundsOf', () => { + it('says the range, or only the lower bound', () => { + expect(boundsOf({ min: 1, max: 20 })).toBe('de 1 à 20'); + expect(boundsOf({ min: 0.1, max: null })).toBe('à partir de 0,1'); + }); +}); diff --git a/apps/admin/src/lib/setting-display.ts b/apps/admin/src/lib/setting-display.ts new file mode 100644 index 00000000..4152d74e --- /dev/null +++ b/apps/admin/src/lib/setting-display.ts @@ -0,0 +1,30 @@ +import { messages } from '@/messages/fr'; +import type { AdminSetting, SettingValue } from '@widoo/shared'; + +const texts = messages.settings; +const numberFormat = new Intl.NumberFormat('fr-FR', { maximumFractionDigits: 3 }); + +/** A number as the settings page writes it: « 4,5 », « 10 000 ». */ +export const formatSettingNumber = (value: number): string => numberFormat.format(value); + +/** A value as the page shows it: a number with its unit, or each weight with its name. */ +export function formatSettingValue( + setting: Pick, + value: SettingValue, +): string { + if (typeof value === 'object') { + return Object.entries(value) + .map(([name, weight]) => `${texts.weights[name] ?? name} ${formatSettingNumber(weight)}`) + .join(' · '); + } + const unit = texts.items[setting.key]?.unit; + return unit ? `${formatSettingNumber(value)} ${unit}` : formatSettingNumber(value); +} + +/** The bounds of a setting in one phrase: « de 1 à 20 », « à partir de 0,1 ». */ +export function boundsOf(setting: Pick): string { + const min = formatSettingNumber(setting.min); + return setting.max === null + ? texts.panel.boundsFrom(min) + : texts.panel.boundsRange(min, formatSettingNumber(setting.max)); +} diff --git a/apps/admin/src/lib/setting-form.test.ts b/apps/admin/src/lib/setting-form.test.ts new file mode 100644 index 00000000..3cf19a6c --- /dev/null +++ b/apps/admin/src/lib/setting-form.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { fieldsOf, valueOfFields } from './setting-form'; + +const steps = { value: 4 }; +const weights = { value: { proximity: 0.3, quality: 0.25 } }; + +describe('fieldsOf', () => { + it('fills one field for a number and one per weight', () => { + expect(fieldsOf(steps)).toEqual({ value: '4' }); + expect(fieldsOf(weights)).toEqual({ proximity: '0,3', quality: '0,25' }); + }); +}); + +describe('valueOfFields', () => { + it('reads a number, with a French decimal comma', () => { + expect(valueOfFields(steps, { value: '5' })).toBe(5); + expect(valueOfFields({ value: 4.5 }, { value: '4,5' })).toBe(4.5); + }); + + it('reads all the weights', () => { + expect(valueOfFields(weights, { proximity: '1', quality: '2,5' })).toEqual({ + proximity: 1, + quality: 2.5, + }); + }); + + it('is null while a field is empty or not a number', () => { + expect(valueOfFields(steps, { value: '' })).toBeNull(); + expect(valueOfFields(steps, { value: 'abc' })).toBeNull(); + expect(valueOfFields(weights, { proximity: '1', quality: '' })).toBeNull(); + }); +}); diff --git a/apps/admin/src/lib/setting-form.ts b/apps/admin/src/lib/setting-form.ts new file mode 100644 index 00000000..01c1dd4f --- /dev/null +++ b/apps/admin/src/lib/setting-form.ts @@ -0,0 +1,47 @@ +import type { AdminSetting, SettingValue } from '@widoo/shared'; + +/** + * What the edit window holds while it is open: one text per field, as typed. A number has the + * single field `value`; the weights have one field per component. + */ +export type SettingFields = Record; + +const singleField = 'value'; + +/** A number as typed in France: « 0,3 ». */ +const typed = (value: number): string => String(value).replace('.', ','); + +/** The fields of the window, filled with the value in force. */ +export function fieldsOf(setting: Pick): SettingFields { + const { value } = setting; + if (typeof value === 'object') { + return Object.fromEntries(Object.entries(value).map(([name, weight]) => [name, typed(weight)])); + } + return { [singleField]: typed(value) }; +} + +const numberOf = (text: string | undefined): number | null => { + if (text === undefined || text.trim() === '') { + return null; + } + const value = Number(text.replace(',', '.')); + return Number.isFinite(value) ? value : null; +}; + +/** + * The value the fields stand for, or null while one is empty or not a number. Bounds are not + * checked here: the API holds each setting to its own. + */ +export function valueOfFields( + setting: Pick, + fields: SettingFields, +): SettingValue | null { + if (typeof setting.value !== 'object') { + return numberOf(fields[singleField]); + } + const entries = Object.keys(setting.value).map((name) => [name, numberOf(fields[name])] as const); + if (entries.some(([, weight]) => weight === null)) { + return null; + } + return Object.fromEntries(entries) as Record; +} From fad892382ae68a19cff367c87acf0b5a695b2a92 Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:59:15 +0700 Subject: [PATCH 08/10] feat(admin): open a line of the journal in a panel, with the state before and after (A-12) Journal, admins only: the lines the address asks for, filtered by author, action, target and period, and next to it the detail of one line with its reason, its target (a link while it exists) and the JSON before and after. Read only. Co-Authored-By: Claude Sonnet 5.5 --- apps/admin/src/app/(shell)/journal/page.tsx | 16 ++ .../components/actions/ActionPanelContent.tsx | 76 +++++++++ .../src/containers/ActionLogContainer.tsx | 156 ++++++++++++++++++ 3 files changed, 248 insertions(+) create mode 100644 apps/admin/src/app/(shell)/journal/page.tsx create mode 100644 apps/admin/src/components/actions/ActionPanelContent.tsx create mode 100644 apps/admin/src/containers/ActionLogContainer.tsx diff --git a/apps/admin/src/app/(shell)/journal/page.tsx b/apps/admin/src/app/(shell)/journal/page.tsx new file mode 100644 index 00000000..e87ad360 --- /dev/null +++ b/apps/admin/src/app/(shell)/journal/page.tsx @@ -0,0 +1,16 @@ +import { sectionPaths } from '@/config/navigation'; +import { ActionLogContainer } from '@/containers/ActionLogContainer'; +import { AdminOnlyGate } from '@/containers/AdminOnlyGate'; +import { messages } from '@/messages/fr'; +import { Suspense } from 'react'; + +/** A-12, admins only. The filters and the open line are read from the address, in the browser. */ +export default function ActionLogPage() { + return ( + + + + + + ); +} diff --git a/apps/admin/src/components/actions/ActionPanelContent.tsx b/apps/admin/src/components/actions/ActionPanelContent.tsx new file mode 100644 index 00000000..6aa7fd4a --- /dev/null +++ b/apps/admin/src/components/actions/ActionPanelContent.tsx @@ -0,0 +1,76 @@ +import { + actionLabelOf, + isTargetGone, + targetNameOf, + targetPathOf, + targetTypeLabelOf, +} from '@/lib/action-display'; +import { formatFullDate } from '@/lib/dates'; +import { messages } from '@/messages/fr'; +import type { AdminActionItem } from '@widoo/shared'; +import Link from 'next/link'; +import type { ReactNode } from 'react'; + +const texts = messages.actionLog.panel; + +function Line({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** A state of the target, as the journal kept it: the JSON, or « Rien à afficher ». */ +function State({ title, state }: { title: string; state: unknown }) { + return ( +
+

{title}

+ {state === null || state === undefined ? ( +

{texts.noState}

+ ) : ( +
+          {JSON.stringify(state, null, 2)}
+        
+ )} +
+ ); +} + +/** A line of the journal in the detail panel (A-12): what was done, and the state before and after. */ +export function ActionPanelContent({ action }: { action: AdminActionItem }) { + const targetPath = targetPathOf(action.targetType, action.targetId); + const targetName = targetNameOf(action); + const isGone = isTargetGone(action); + return ( +
+
+ {formatFullDate(new Date(action.createdAt))} + {action.actor?.name ?? messages.actionLog.unknownActor} + {actionLabelOf(action.action)} + + + {targetName ?? targetTypeLabelOf(action.targetType)} + + {targetName ? `${targetTypeLabelOf(action.targetType)} · ` : ''} + {action.targetId} + + {isGone ? ( + {texts.targetGone} + ) : targetPath ? ( + + {texts.openTarget} + + ) : null} + + + + {action.reason ?? {texts.noReason}} + +
+ + +
+ ); +} diff --git a/apps/admin/src/containers/ActionLogContainer.tsx b/apps/admin/src/containers/ActionLogContainer.tsx new file mode 100644 index 00000000..acf43668 --- /dev/null +++ b/apps/admin/src/containers/ActionLogContainer.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { ActionPanelContent } from '@/components/actions/ActionPanelContent'; +import { ActionsTable } from '@/components/actions/ActionsTable'; +import { PageMessage } from '@/components/states/PageMessage'; +import { actionsListOptions, periodFilterValues, toActionsParams } from '@/config/actions-list'; +import { sectionPaths } from '@/config/navigation'; +import { useListQuery } from '@/hooks/useListQuery'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { actionLabelOf, targetTypeLabelOf } from '@/lib/action-display'; +import { getSessionClients } from '@/lib/auth/clients'; +import { serializeListQuery } from '@/lib/list-query'; +import { messages } from '@/messages/fr'; +import { Button, DetailPanel, FilterMenu, Pagination } from '@widoo/ui'; +import { useRouter, useSearchParams } from 'next/navigation'; + +const texts = messages.actionLog; +/** Parameter of the address that holds the line whose detail is open next to the list. */ +const openActionParam = 'ligne'; + +const periodOptions = Object.keys(periodFilterValues).map((value) => ({ + value, + label: texts.periods[value as keyof typeof texts.periods], +})); + +/** + * A-12, the journal of the admin: the lines the address asks for, filtered by author, action, + * target and period, and next to it the detail of the line the address names. Read only. + */ +export function ActionLogContainer() { + const router = useRouter(); + const openId = useSearchParams().get(openActionParam); + const { query, handleFilter, handlePage } = useListQuery(actionsListOptions); + const listAddress = serializeListQuery(query, actionsListOptions); + const actions = useRemoteData(listAddress, (signal) => + getSessionClients().api.getAdminActions(toActionsParams(query), signal), + ); + const filters = useRemoteData('filters', (signal) => + getSessionClients().api.getAdminActionFilters(signal), + ); + const data = actions.status === 'ready' ? actions.data : undefined; + const filterValues = filters.status === 'ready' ? filters.data : undefined; + const openAction = data?.items.find((action) => action.id === openId); + + /** The address of the list as it is, with the detail of `actionId` open, or none. */ + const showAction = (actionId: string | null) => { + const params = new URLSearchParams(listAddress); + if (actionId) { + params.set(openActionParam, actionId); + } + const address = params.toString(); + router.replace(address ? `${sectionPaths.actionLog}?${address}` : sectionPaths.actionLog, { + scroll: false, + }); + }; + const filterChange = + (filter: 'auteur' | 'action' | 'cible' | 'periode') => (value: string | null) => + handleFilter(filter, value ?? query.filters[filter] ?? ''); + + const renderList = () => { + if (actions.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + if (data?.items.length === 0) { + return ; + } + return ( + showAction(action.id)} + /> + ); + }; + + return ( +
+
+
+
+ ({ + value: actor.id, + label: actor.name ?? texts.unknownActor, + }))} + value={query.filters.auteur ?? null} + onChange={filterChange('auteur')} + /> + ({ + value: action, + label: actionLabelOf(action), + }))} + value={query.filters.action ?? null} + onChange={filterChange('action')} + /> + ({ + value: targetType, + label: targetTypeLabelOf(targetType), + }))} + value={query.filters.cible ?? null} + onChange={filterChange('cible')} + /> + +
+ {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} +
+ {openAction ? ( + showAction(null)} + > + + + ) : null} +
+ ); +} From 68463f8212d9a2cc4c08d5866d66f5efe338060a Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:59:30 +0700 Subject: [PATCH 09/10] feat(admin): list the settings by theme and open one in a panel with its history (A-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Réglages, admins only: the settings grouped by theme with their value and whether the team changed it, and the detail of one: explanation, value, default, bounds, last change and its history from the journal. Co-Authored-By: Claude Sonnet 5.5 --- apps/admin/src/app/(shell)/reglages/page.tsx | 16 ++++ .../settings/SettingPanelContent.tsx | 89 +++++++++++++++++++ .../src/components/settings/SettingsTable.tsx | 72 +++++++++++++++ .../src/containers/SettingsContainer.tsx | 82 +++++++++++++++++ 4 files changed, 259 insertions(+) create mode 100644 apps/admin/src/app/(shell)/reglages/page.tsx create mode 100644 apps/admin/src/components/settings/SettingPanelContent.tsx create mode 100644 apps/admin/src/components/settings/SettingsTable.tsx create mode 100644 apps/admin/src/containers/SettingsContainer.tsx diff --git a/apps/admin/src/app/(shell)/reglages/page.tsx b/apps/admin/src/app/(shell)/reglages/page.tsx new file mode 100644 index 00000000..0155e820 --- /dev/null +++ b/apps/admin/src/app/(shell)/reglages/page.tsx @@ -0,0 +1,16 @@ +import { sectionPaths } from '@/config/navigation'; +import { AdminOnlyGate } from '@/containers/AdminOnlyGate'; +import { SettingsContainer } from '@/containers/SettingsContainer'; +import { messages } from '@/messages/fr'; +import { Suspense } from 'react'; + +/** A-11, admins only. The open setting is read from the address, in the browser only. */ +export default function SettingsPage() { + return ( + + + + + + ); +} diff --git a/apps/admin/src/components/settings/SettingPanelContent.tsx b/apps/admin/src/components/settings/SettingPanelContent.tsx new file mode 100644 index 00000000..d5fdb87e --- /dev/null +++ b/apps/admin/src/components/settings/SettingPanelContent.tsx @@ -0,0 +1,89 @@ +import { actionLabelOf } from '@/lib/action-display'; +import { formatFullDate, formatRelativeDate } from '@/lib/dates'; +import { boundsOf, formatSettingValue } from '@/lib/setting-display'; +import { messages } from '@/messages/fr'; +import type { AdminActionItem, AdminSetting, SettingValue } from '@widoo/shared'; +import type { ReactNode } from 'react'; + +const texts = messages.settings.panel; + +function Line({ label, children }: { label: string; children: ReactNode }) { + return ( +
+
{label}
+
{children}
+
+ ); +} + +/** The value a journal line kept in its `before` or `after`, or null when it has none. */ +function valueOfState(state: unknown): SettingValue | null { + if (typeof state !== 'object' || state === null || !('value' in state)) { + return null; + } + const { value } = state; + return typeof value === 'number' || (typeof value === 'object' && value !== null) + ? (value as SettingValue) + : null; +} + +type SettingPanelContentProps = { + setting: AdminSetting; + /** The lines of the journal about this setting, newest first; null while they load. */ + history: AdminActionItem[] | null; + hasHistoryError: boolean; +}; + +/** A setting in the detail panel (A-11): its value, default and bounds, and who changed it. */ +export function SettingPanelContent({ + setting, + history, + hasHistoryError, +}: SettingPanelContentProps) { + const written = (value: SettingValue | null) => + value === null ? '?' : formatSettingValue(setting, value); + return ( +
+

+ {messages.settings.items[setting.key]?.explanation} +

+
+ {formatSettingValue(setting, setting.value)} + {formatSettingValue(setting, setting.default)} + {boundsOf(setting)} + + {setting.updatedAt ? formatFullDate(new Date(setting.updatedAt)) : texts.never} + +
+
+

{texts.history}

+ {hasHistoryError ? ( +

{texts.historyError}

+ ) : history === null ? null : history.length === 0 ? ( +

{texts.noHistory}

+ ) : ( +
    + {history.map((line) => ( +
  • + + {texts.change( + written(valueOfState(line.before)), + written(valueOfState(line.after)), + )} + + + {actionLabelOf(line.action)} ·{' '} + {line.actor?.name ?? messages.actionLog.unknownActor} ·{' '} + {formatRelativeDate(new Date(line.createdAt))} + + {line.reason ? ( + {line.reason} + ) : null} +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/apps/admin/src/components/settings/SettingsTable.tsx b/apps/admin/src/components/settings/SettingsTable.tsx new file mode 100644 index 00000000..6745ae2e --- /dev/null +++ b/apps/admin/src/components/settings/SettingsTable.tsx @@ -0,0 +1,72 @@ +import { formatSettingValue } from '@/lib/setting-display'; +import { messages } from '@/messages/fr'; +import { settingGroups, type AdminSetting } from '@widoo/shared'; +import { StatusBadge, Table, TableCell, TableHeaderCell, TableRow, TableSkeleton } from '@widoo/ui'; + +const texts = messages.settings; +const columnCount = 3; + +type SettingsTableProps = { + /** The settings, in the order of the page; ignored while `isLoading`. */ + items: AdminSetting[]; + isLoading: boolean; + /** The setting whose detail is open in the panel. */ + openKey: string | null; + onOpen: (setting: AdminSetting) => void; +}; + +/** The settings as a table grouped by theme (A-11); a row opens its detail. */ +export function SettingsTable({ items, isLoading, openKey, onOpen }: SettingsTableProps) { + return ( + + + + {texts.columns.setting} + {texts.columns.value} + {texts.columns.state} + + + + {isLoading ? ( + + ) : ( + settingGroups.flatMap((group) => { + const inGroup = items.filter((setting) => setting.group === group); + if (inGroup.length === 0) { + return []; + } + return [ + + + , + ...inGroup.map((setting) => ( + onOpen(setting)} + isOpen={setting.key === openKey} + > + + + {texts.items[setting.key]?.label} + + {texts.items[setting.key]?.explanation} + + + + {formatSettingValue(setting, setting.value)} + + + {setting.isCustom ? texts.state.custom : texts.state.default} + + + + )), + ]; + }) + )} + +
+ {texts.groups[group]} +
+ ); +} diff --git a/apps/admin/src/containers/SettingsContainer.tsx b/apps/admin/src/containers/SettingsContainer.tsx new file mode 100644 index 00000000..972ad78f --- /dev/null +++ b/apps/admin/src/containers/SettingsContainer.tsx @@ -0,0 +1,82 @@ +'use client'; + +import { SettingPanelContent } from '@/components/settings/SettingPanelContent'; +import { SettingsTable } from '@/components/settings/SettingsTable'; +import { PageMessage } from '@/components/states/PageMessage'; +import { sectionPaths } from '@/config/navigation'; +import { useRemoteData } from '@/hooks/useRemoteData'; +import { getSessionClients } from '@/lib/auth/clients'; +import { messages } from '@/messages/fr'; +import { Button, DetailPanel } from '@widoo/ui'; +import { useRouter, useSearchParams } from 'next/navigation'; + +const texts = messages.settings; +/** Parameter of the address that holds the setting whose detail is open next to the list. */ +const openSettingParam = 'reglage'; + +/** + * A-11, the settings the team changes without a deployment: the list, and next to it the detail + * of the setting the address names, with its history from the journal and its « Modifier ». + */ +export function SettingsContainer() { + const router = useRouter(); + const openKey = useSearchParams().get(openSettingParam); + const settings = useRemoteData('settings', (signal) => + getSessionClients().api.getAdminSettings(signal), + ); + const history = useRemoteData(`history:${openKey ?? ''}`, (signal) => + openKey + ? getSessionClients().api.getAdminActions( + { targetType: 'setting', targetId: openKey }, + signal, + ) + : Promise.resolve(null), + ); + const items = settings.status === 'ready' ? settings.data.items : undefined; + const openSetting = items?.find((setting) => setting.key === openKey); + + const showSetting = (key: string | null) => + router.replace( + key + ? `${sectionPaths.settings}?${new URLSearchParams({ [openSettingParam]: key }).toString()}` + : sectionPaths.settings, + { scroll: false }, + ); + + if (settings.status === 'error') { + return ( + {messages.list.retry}} + /> + ); + } + + return ( +
+
+ showSetting(setting.key)} + /> +
+ {openSetting ? ( + showSetting(null)} + > + + + ) : null} +
+ ); +} From de365f3071616b9dc8991b556bf1596a214d68fa Mon Sep 17 00:00:00 2001 From: Nascimento95 Date: Fri, 16 Oct 2026 14:59:30 +0700 Subject: [PATCH 10/10] feat(admin): change a setting from its panel, with a reason (A-11) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit « Modifier » opens a window with the value (one field, or one per weight, typed with a decimal comma), the bounds, and the required reason kept in the journal. A refused value is told in a toast; once saved, the list and the history are read again. Co-Authored-By: Claude Sonnet 5.5 --- .../components/settings/SettingEditDialog.tsx | 87 +++++++++++++++++++ .../src/containers/SettingEditContainer.tsx | 55 ++++++++++++ .../src/containers/SettingsContainer.tsx | 6 ++ apps/admin/src/hooks/useSettingUpdate.ts | 34 ++++++++ 4 files changed, 182 insertions(+) create mode 100644 apps/admin/src/components/settings/SettingEditDialog.tsx create mode 100644 apps/admin/src/containers/SettingEditContainer.tsx create mode 100644 apps/admin/src/hooks/useSettingUpdate.ts diff --git a/apps/admin/src/components/settings/SettingEditDialog.tsx b/apps/admin/src/components/settings/SettingEditDialog.tsx new file mode 100644 index 00000000..f6bc0031 --- /dev/null +++ b/apps/admin/src/components/settings/SettingEditDialog.tsx @@ -0,0 +1,87 @@ +import { formatSettingNumber } from '@/lib/setting-display'; +import type { SettingFields } from '@/lib/setting-form'; +import { messages } from '@/messages/fr'; +import type { AdminSetting } from '@widoo/shared'; +import { ConfirmDialog, Field, Input, Textarea } from '@widoo/ui'; + +const texts = messages.settings.edit; + +type SettingEditDialogProps = { + isOpen: boolean; + setting: AdminSetting; + fields: SettingFields; + onFieldChange: (name: string, text: string) => void; + reason: string; + onReasonChange: (reason: string) => void; + onCancel: () => void; + onConfirm: () => void; + isBusy: boolean; +}; + +/** + * « Modifier » a setting: its value (one field, or one per weight) and the required reason kept + * in the journal. The API holds the value to the bounds, which the window tells beforehand. + */ +export function SettingEditDialog({ + isOpen, + setting, + fields, + onFieldChange, + reason, + onReasonChange, + onCancel, + onConfirm, + isBusy, +}: SettingEditDialogProps) { + const item = messages.settings.items[setting.key]; + const hint = + setting.max === null + ? texts.rangeFrom(formatSettingNumber(setting.min)) + : texts.range(formatSettingNumber(setting.min), formatSettingNumber(setting.max)); + return ( + + {Object.keys(fields).map((name) => { + const id = `setting-${name}`; + return ( + + onFieldChange(name, event.target.value)} + /> + + ); + })} + +