diff --git a/apps/docs/src/assets/screens/activity-desktop-dark.webp b/apps/docs/src/assets/screens/activity-desktop-dark.webp new file mode 100644 index 00000000..8040c10e Binary files /dev/null and b/apps/docs/src/assets/screens/activity-desktop-dark.webp differ diff --git a/apps/docs/src/assets/screens/activity-desktop-light.webp b/apps/docs/src/assets/screens/activity-desktop-light.webp new file mode 100644 index 00000000..20153c34 Binary files /dev/null and b/apps/docs/src/assets/screens/activity-desktop-light.webp differ diff --git a/apps/docs/src/content/docs/users/everyday-use.md b/apps/docs/src/content/docs/users/everyday-use.md index a6555abe..bd4995ad 100644 --- a/apps/docs/src/content/docs/users/everyday-use.md +++ b/apps/docs/src/content/docs/users/everyday-use.md @@ -117,6 +117,13 @@ tracks: **Details**, **Paperwork and warranties**, **Value**, **Meters**, **Loan Trash. - **History** lists every change with who made it; most changes can be undone from there or from the toast that follows them. **Activity** in the menu is the same across your locations. + Each change is one sentence: "Put Galaxy S23 on the map in Office", "Captured Bosch drill, 18 V + from a photo", "Made an insurance report for Home". A field set for the first time shows its value; a + map arrangement with more than five changes says how many, and **Show** lists them. + + ![Activity in Home: Arranged the map with four changes listed under it, a capture showing the name and model it got, and an insurance report with 8 things and photos](../../../assets/screens/activity-desktop-light.webp) + + ![The same activity in the dark theme](../../../assets/screens/activity-desktop-dark.webp) - **Trash** keeps trashed things for 30 days; members and above restore, admins delete for good. - **Passwords and codes** (the Complete preset) are secret fields: hidden until someone allowed to reveals one, which is logged. diff --git a/apps/server/src/admin/backup-routes.ts b/apps/server/src/admin/backup-routes.ts index c70d17da..e8b7c4ad 100644 --- a/apps/server/src/admin/backup-routes.ts +++ b/apps/server/src/admin/backup-routes.ts @@ -1,4 +1,5 @@ import { + type AuditAction, BACKUP_PASSWORD_MAX, BACKUP_PASSWORD_MIN, BACKUP_RUN_KINDS, @@ -117,7 +118,7 @@ async function isInstanceAdmin(deps: InventoryDeps, req: FastifyRequest) { function auditInstance( tx: Tx, req: FastifyRequest, - action: string, + action: AuditAction, after: Record, ) { return audited(tx, { diff --git a/apps/server/src/admin/cli.ts b/apps/server/src/admin/cli.ts index fb0970b5..ead156c5 100644 --- a/apps/server/src/admin/cli.ts +++ b/apps/server/src/admin/cli.ts @@ -1,4 +1,5 @@ import { randomBytes } from 'node:crypto'; +import type { AuditAction } from '@kept/shared'; import { drizzle } from 'drizzle-orm/node-postgres'; import pg from 'pg'; import { audited } from '../audit/audited.js'; @@ -75,7 +76,7 @@ export async function asOwner( function auditSystem( tx: Tx, - action: string, + action: AuditAction, entity: { type: string; id: string | null }, extra: { locationId?: string; diff --git a/apps/server/src/admin/routes.ts b/apps/server/src/admin/routes.ts index cf1ecdbe..0cc75e53 100644 --- a/apps/server/src/admin/routes.ts +++ b/apps/server/src/admin/routes.ts @@ -1,3 +1,4 @@ +import type { AuditAction } from '@kept/shared'; import { sql } from 'drizzle-orm'; import type { FastifyRequest } from 'fastify'; import type { ZodTypeProvider } from 'fastify-type-provider-zod'; @@ -209,7 +210,7 @@ async function isInstanceAdmin(pools: Pick, req: FastifyRequest) { function auditInstance( tx: Tx, req: FastifyRequest, - action: string, + action: AuditAction, entity: { type: string; id?: string | null }, change: { before?: Record; after?: Record } = {}, ) { @@ -387,7 +388,7 @@ export async function adminRoutes(app: KeptApp, deps: AdminDeps): Promise const userAction = ( path: string, action: AdminAction, - auditAction: string, + auditAction: AuditAction, work: (userId: string) => Promise, guard?: (req: FastifyRequest, target: AuthUserRow) => void, ) => @@ -620,7 +621,7 @@ export async function adminRoutes(app: KeptApp, deps: AdminDeps): Promise /** Retry or discard one failed job: audited in the admin's transaction, done by pg-boss on * kept_system inside it, so a refused audit leaves the job as it was. */ - const jobAction = (path: 'retry' | 'discard', auditAction: string) => + const jobAction = (path: 'retry' | 'discard', auditAction: AuditAction) => admin.post( `/api/v1/admin/jobs/failed/:id/${path}`, { schema: { params: UserId } }, diff --git a/apps/server/src/audit/audited.test.ts b/apps/server/src/audit/audited.test.ts index 0e4064d3..616d9225 100644 --- a/apps/server/src/audit/audited.test.ts +++ b/apps/server/src/audit/audited.test.ts @@ -159,7 +159,7 @@ describe('audited()', () => { audited(tx, { locationId: t.locationId, actor: { type: 'user', id: t.userId }, - action: 'invite.update', + action: 'invite.create', entity: { type: 'invite', id: inviteId }, before: { tokenHash: oldHash, role: 'member' }, after: { tokenHash: newHash, role: 'viewer' }, @@ -181,7 +181,7 @@ describe('audited()', () => { audited(tx, { locationId: t.locationId, actor: { type: 'user', id: t.userId }, - action: 'invite.update', + action: 'invite.create', entity: { type: 'invite', id: newId() }, before: { tokenHash: 'a' }, after: { tokenHash: 'b' }, @@ -263,7 +263,7 @@ describe('audited()', () => { locationId: null, ownerAccountId: t.accountId, actor: { type: 'user', id: t.userId }, - action: 'account.update', + action: 'account.email_change', entity: { type: 'account', id: t.accountId }, before: { x: 1 }, after: { x: 2 }, diff --git a/apps/server/src/audit/audited.ts b/apps/server/src/audit/audited.ts index 77c8c1b6..97a45535 100644 --- a/apps/server/src/audit/audited.ts +++ b/apps/server/src/audit/audited.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from 'node:util'; -import { newId } from '@kept/shared'; +import { type AuditAction, newId } from '@kept/shared'; import { type SQL, sql } from 'drizzle-orm'; import { type ACTOR_TYPES, auditEventSubjects, auditEvents } from '../db/schema/index.js'; import type { Tx } from '../db/scope.js'; @@ -32,8 +32,8 @@ export type AuditEventInput = { /** Filled from the location when omitted. Pass it for account-level events (§7.13). */ ownerAccountId?: string | null; actor: AuditActor; - /** `.`, e.g. `location.update`. */ - action: string; + /** `.`, e.g. `location.update`: one of @kept/shared's AUDIT_ACTIONS (#140). */ + action: AuditAction; entity: { type: string; id?: string | null }; /** The row before the write (null or omitted for a create) and after it (null for a delete). * Top-level keys may be camelCase (Drizzle rows) or snake_case; the diff stores snake_case. */ diff --git a/apps/server/src/audit/undo.ts b/apps/server/src/audit/undo.ts index 7c5f4a22..1a574c01 100644 --- a/apps/server/src/audit/undo.ts +++ b/apps/server/src/audit/undo.ts @@ -1,5 +1,5 @@ import { isDeepStrictEqual } from 'node:util'; -import { can, type Role } from '@kept/shared'; +import { type AuditAction, can, type Role } from '@kept/shared'; import type { FastifyBaseLogger } from 'fastify'; import type pg from 'pg'; import { z } from 'zod'; @@ -55,7 +55,8 @@ export type UndoableEvent = { ownerAccountId: string | null; actorType: string; actorId: string | null; - action: string; + /** A registered handler's action, so an undo writes it back as it is (#140). */ + action: AuditAction; entityType: string; entityId: string | null; rootThingId: string | null; @@ -116,7 +117,7 @@ const HANDLERS = new Map(); const ACCOUNT_HANDLERS = new Map(); /** Registers the handler for an account-level `action` (e.g. `fx_rate.set`). One per action. */ -export function registerAccountUndo(action: string, handler: AccountUndoHandler): void { +export function registerAccountUndo(action: AuditAction, handler: AccountUndoHandler): void { if (ACCOUNT_HANDLERS.has(action) && ACCOUNT_HANDLERS.get(action) !== handler) { throw new Error(`account undo handler for ${action} is already registered`); } @@ -124,7 +125,7 @@ export function registerAccountUndo(action: string, handler: AccountUndoHandler) } /** Registers the handler for `action` (e.g. `place.update`). One handler per action. */ -export function registerUndo(action: string, handler: UndoHandler): void { +export function registerUndo(action: AuditAction, handler: UndoHandler): void { if (HANDLERS.has(action) && HANDLERS.get(action) !== handler) { throw new Error(`undo handler for ${action} is already registered`); } @@ -324,7 +325,8 @@ export async function undoEvent( ownerAccountId: e.owner_account_id, actorType: e.actor_type, actorId: e.actor_id, - action: e.action, + // Only an action with a handler gets here, and handlers are registered by AuditAction. + action: e.action as AuditAction, entityType: e.entity_type, entityId: e.entity_id, rootThingId: e.root_thing_id, @@ -406,7 +408,8 @@ async function undoAccountEvent( ownerAccountId: accountId, actorType: e.actor_type, actorId: e.actor_id, - action: e.action, + // Only an action with a handler gets here, and handlers are registered by AuditAction. + action: e.action as AuditAction, entityType: e.entity_type, entityId: e.entity_id, rootThingId: e.root_thing_id, diff --git a/apps/server/src/extraction/extraction.test.ts b/apps/server/src/extraction/extraction.test.ts index 7c5b485b..14017cdd 100644 --- a/apps/server/src/extraction/extraction.test.ts +++ b/apps/server/src/extraction/extraction.test.ts @@ -455,7 +455,9 @@ describe('a thing’s history shows its AI calls (step 6, T18; D206, §7.15)', ( ok(await call(t, `/api/v1/things/${thingId}/history${q}`, { as })) as unknown as Page; const all = await history(louis); expect(all.items.map((e) => e.action)).toContain('ai.call'); - expect(all.items.map((e) => e.action)).toContain('thing.extract'); + // #140: the extraction joins its capture's row, one row per action. + expect(all.items.map((e) => e.action)).toContain('thing.capture'); + expect(all.items.map((e) => e.action)).not.toContain('thing.extract'); const ai = await history(louis, '?kind=ai'); expect(ai.items).toHaveLength(1); expect(ai.items[0]).toMatchObject({ diff --git a/apps/server/src/history/arrange.ts b/apps/server/src/history/arrange.ts new file mode 100644 index 00000000..2d5a1ea6 --- /dev/null +++ b/apps/server/src/history/arrange.ts @@ -0,0 +1,131 @@ +import type { SummaryLine } from '@kept/shared'; + +// An arrange session in words (#140): the stored before/after layouts (map/service.ts LayoutImage) +// compared node by node, one line per change, so the activity row says "Put HD webcam on Corner +// desk" instead of two layouts of JSON. Names are whatever the reader may see (history/service.ts +// resolves them as the caller, under the policies); a node they can't see reads as "a thing" or +// "a place". Shapes, room kinds and floors travel as codes the web says in the reader's language. + +/** A layout row as place.arrange stores it (map/service.ts LayoutImage). */ +export type ArrangedNode = { + id: string; + s: 'place' | 'thing'; + x: number; + z: number; + w: number | null; + d: number | null; + rot: number; + model: string | null; + kind: string | null; + surface: string | null; + host: string | null; +}; + +/** What the reader may see of a node: its name, and the place it is in (for "in Study"). */ +export type NodeName = { name: string; room: string | null }; + +/** The layout of a place.arrange diff side, or null when it isn't one. */ +export function layoutOf(value: unknown): ArrangedNode[] | null { + if (!Array.isArray(value)) return null; + return value.filter( + (n): n is ArrangedNode => + !!n && typeof n === 'object' && typeof (n as ArrangedNode).id === 'string', + ); +} + +const isRoom = (n: ArrangedNode) => n.s === 'place' && n.kind !== null; + +/** Every change between two layouts, rooms first, then the rest, each in name order. */ +export function arrangeLines( + before: readonly ArrangedNode[], + after: readonly ArrangedNode[], + names: ReadonlyMap, +): SummaryLine[] { + const was = new Map(before.map((n) => [n.id, n])); + const now = new Map(after.map((n) => [n.id, n])); + const nameOf = (id: string, s: 'place' | 'thing') => + names.get(id)?.name ?? (s === 'place' ? 'a place' : 'a thing'); + const hostName = (id: string) => { + const host = now.get(id) ?? was.get(id); + return nameOf(id, host?.s ?? 'thing'); + }; + // A node whose host moved or turned moves with it: that is the host's line, not its own. + const hostShifted = (n: ArrangedNode) => { + const a = n.host ? was.get(n.host) : undefined; + const b = n.host ? now.get(n.host) : undefined; + return !!a && !!b && (a.x !== b.x || a.z !== b.z || a.rot !== b.rot); + }; + + const ids = [...new Set([...was.keys(), ...now.keys()])]; + const node = (id: string) => (now.get(id) ?? was.get(id)) as ArrangedNode; + ids.sort((a, b) => { + const ra = isRoom(node(a)) ? 0 : 1; + const rb = isRoom(node(b)) ? 0 : 1; + if (ra !== rb) return ra - rb; + const na = nameOf(a, node(a).s); + const nb = nameOf(b, node(b).s); + return na.localeCompare(nb) || (a < b ? -1 : 1); + }); + + const out: SummaryLine[] = []; + for (const id of ids) { + const a = was.get(id); + const b = now.get(id); + const n = node(id); + const name = nameOf(id, n.s); + if (!a && b) { + if (b.host) out.push({ key: 'map.on', params: { name, host: hostName(b.host) } }); + else { + const room = isRoom(b) ? null : (names.get(id)?.room ?? null); + out.push( + room + ? { key: 'map.put.room', params: { name, room } } + : { key: 'map.put', params: { name } }, + ); + } + continue; + } + if (a && !b) { + out.push({ key: 'map.remove', params: { name } }); + continue; + } + if (!a || !b) continue; + if (a.host !== b.host) { + if (b.host) out.push({ key: 'map.on', params: { name, host: hostName(b.host) } }); + else if (a.host) out.push({ key: 'map.off', params: { name, host: hostName(a.host) } }); + } else if ((a.x !== b.x || a.z !== b.z) && !(b.host && hostShifted(b))) { + out.push({ key: 'map.move', params: { name } }); + } + if (a.rot !== b.rot && !(b.host && a.host === b.host && hostShifted(b))) { + out.push({ key: 'map.turn', params: { name } }); + } + if (a.model !== b.model && a.model && b.model) { + out.push({ + key: 'map.shape', + params: { name, from: a.model, to: b.model, vocab: 'model' }, + }); + } + if (a.w !== b.w || a.d !== b.d) out.push({ key: 'map.resize', params: { name } }); + if (a.kind !== b.kind && a.kind && b.kind) { + out.push({ key: 'map.kind', params: { name, from: a.kind, to: b.kind, vocab: 'kind' } }); + } + if (a.surface !== b.surface && a.surface && b.surface) { + out.push({ + key: 'map.floor', + params: { name, from: a.surface, to: b.surface, vocab: 'surface' }, + }); + } + } + return out; +} + +/** The ids of the nodes an arrangement touched, by side, for resolving their names. */ +export function arrangedIds(diff: unknown): { things: string[]; places: string[] } { + const layout = (diff as { layout?: { before?: unknown; after?: unknown } } | null)?.layout; + const all = [...(layoutOf(layout?.before) ?? []), ...(layoutOf(layout?.after) ?? [])]; + const things = new Set(); + const places = new Set(); + // A host is a node of the layout too, so it is named with the rest. + for (const n of all) (n.s === 'place' ? places : things).add(n.id); + return { things: [...things], places: [...places] }; +} diff --git a/apps/server/src/history/history.test.ts b/apps/server/src/history/history.test.ts index db8da8ec..226d0a4a 100644 --- a/apps/server/src/history/history.test.ts +++ b/apps/server/src/history/history.test.ts @@ -131,6 +131,7 @@ type HistoryEvent = { summary: string; summaryKey: string; summaryParams: Record; + summaryLines?: { key: string; params: Record; text: string }[]; movedInFromElsewhere?: true; }; type Page = { items: HistoryEvent[]; next_cursor: string | null }; @@ -633,8 +634,8 @@ describe('GET /api/v1/activity (D174)', () => { const items = (await feed(as, '?limit=200')).items; expect(items.find((e) => e.id === brandEvent)).toMatchObject({ location_id: null, - summaryKey: 'event', - summaryParams: { action: 'brand.create' }, + // #140: every action has a sentence of its own; `event` is for unknown ones only. + summaryKey: 'brand.create', summary: 'Brand added', }); } @@ -731,3 +732,168 @@ describe('GET /api/v1/locations/:id/actors', () => { expect((await get(bob, `/api/v1/locations/${home.id}/actors`)).statusCode).toBe(404); }); }); + +describe('#140: plain sentences, not JSON and event codes', () => { + const plain = (before: unknown, after: unknown) => ({ before, after, class: 'plain' }); + const node = (id: string, s: 'thing' | 'place', more: Record = {}) => ({ + id, + s, + x: 100, + z: 100, + w: null, + d: null, + rot: 0, + model: s === 'thing' ? 'device' : 'desk', + kind: null, + surface: null, + host: null, + ...more, + }); + const arrange = (before: unknown[], after: unknown[]) => + event({ + loc: home, + actor: max, + action: 'place.arrange', + entity: { type: 'location', id: home.id }, + rootThingId: null, + diff: { layout: plain(before, after) }, + }); + const one = async (as: Person, id: string) => + (await feed(as, `?locationId=${home.id}&limit=200`)).items.find((e) => e.id === id); + + it('says an arrangement in words, and never sends its layout', async () => { + const study = await place(home, 'Study'); + const desk = await place(home, 'Corner desk'); + const webcam = newId(); + await own( + `INSERT INTO public.things (id, location_id, place_id, name) VALUES ($1, $2, $3, $4)`, + [webcam, home.id, study, 'HD webcam'], + ); + const lamp = await thing(home, 'Desk lamp'); + const theirs = await thing(bobs, 'Bob radio'); + const id = await arrange( + [node(desk, 'place'), node(lamp, 'thing', { model: 'lamp' })], + [ + node(desk, 'place'), + node(lamp, 'thing', { model: 'device', host: desk }), + node(webcam, 'thing'), + node(theirs, 'thing'), + ], + ); + const row = await one(max, id); + expect(row?.summaryKey).toBe('place.arrange'); + expect(row?.summary).toBe('Arranged the map'); + expect(row?.summaryLines?.map((l) => l.text)).toEqual([ + 'Put a thing on the map', + 'Put Desk lamp on Corner desk', + 'Changed the shape of Desk lamp: lamp → device', + 'Put HD webcam on the map in Study', + ]); + expect(row?.summaryLines?.[2]).toMatchObject({ + key: 'map.shape', + params: { from: 'lamp', to: 'device', vocab: 'model' }, + }); + // The layout is in the lines; neither it nor the other location's thing's name is sent. + expect(row?.diff).toEqual({}); + expect(JSON.stringify(row)).not.toContain('Bob radio'); + expect(JSON.stringify(row)).not.toContain('"layout"'); + // Bob sees no row of a location he can't see. + expect((await feed(bob, '?limit=200')).items.some((e) => e.id === id)).toBe(false); + }); + + it('counts a busy arrangement, with every change in its lines', async () => { + const things = await Promise.all( + Array.from({ length: 6 }, (_, i) => thing(home, `Box ${i + 1}`)), + ); + const id = await arrange( + things.map((x) => node(x, 'thing')), + things.map((x) => node(x, 'thing', { x: 300, rot: 90 })), + ); + const row = await one(ola, id); + expect(row?.summaryKey).toBe('place.arrange.many'); + expect(row?.summaryParams.count).toBe('12'); + expect(row?.summary).toBe('Arranged the map: 12 changes'); + expect(row?.summaryLines).toHaveLength(12); + expect(row?.summaryLines?.slice(0, 2).map((l) => l.text)).toEqual([ + 'Moved Box 1', + 'Turned Box 1', + ]); + }); + + it('folds an upload into the row that took it up, and an extraction into its capture', async () => { + const lamp = await thing(home, 'Reading lamp'); + const fileId = newId(); + const upload = await event({ + loc: home, + actor: max, + action: 'file.upload', + entity: { type: 'file', id: fileId }, + rootThingId: null, + diff: { mime: plain(null, 'image/jpeg'), bytes: plain(null, 1000) }, + }); + const attach = await event({ + loc: home, + actor: max, + action: 'attachment.create', + entity: { type: 'attachment', id: newId() }, + rootThingId: lamp, + diff: { role: plain(null, 'photo'), file_id: plain(null, fileId) }, + }); + const lone = newId(); + const alone = await event({ + loc: home, + actor: max, + action: 'file.upload', + entity: { type: 'file', id: lone }, + rootThingId: null, + }); + const keyboard = await thing(home, 'Wireless keyboard'); + const capture = await event({ + loc: home, + actor: max, + action: 'thing.capture', + entity: { type: 'thing', id: keyboard }, + diff: { + mode: plain(null, 'thing'), + file_ids: plain(null, [newId()]), + name: plain(null, null), + }, + }); + const extract = await event({ + loc: home, + actor: max, + action: 'thing.extract', + entity: { type: 'thing', id: keyboard }, + diff: { name: plain(null, 'Wireless keyboard') }, + }); + const items = (await feed(max, `?locationId=${home.id}&limit=200`)).items; + const ids = items.map((e) => e.id); + expect(ids).not.toContain(upload); + expect(ids).toContain(alone); + expect(ids).not.toContain(extract); + expect(items.find((e) => e.id === attach)?.summary).toBe('Added a photo to Reading lamp'); + const row = items.find((e) => e.id === capture); + expect(row?.summary).toBe('Captured Wireless keyboard from a photo'); + expect(row?.diff?.name).toEqual(plain(null, 'Wireless keyboard')); + }); + + it("says a report's kind and what it covered, by the location's name", async () => { + const id = await event({ + loc: home, + actor: ola, + action: 'report.generate', + entity: { type: 'report', id: newId() }, + rootThingId: null, + diff: { + kind: plain(null, 'insurance'), + include: plain(null, { photos: true }), + filters: plain(null, { thingIds: [newId(), newId()] }), + locale: plain(null, 'en'), + digits: plain(null, 'latn'), + }, + }); + const row = await one(ola, id); + expect(row?.summary).toBe('Made an insurance report for Home'); + expect(row?.summaryLines?.map((l) => l.text)).toEqual(['2 things', 'With photos']); + }); +}); diff --git a/apps/server/src/history/service.ts b/apps/server/src/history/service.ts index 94ecf1db..0083d36c 100644 --- a/apps/server/src/history/service.ts +++ b/apps/server/src/history/service.ts @@ -3,6 +3,7 @@ import { ACTIVITY_KINDS, LEDGER_TASKS, type LedgerTask, + SUMMARY_LINE_KEYS, tsQuery, } from '@kept/shared'; import type pg from 'pg'; @@ -22,6 +23,7 @@ import { invalid, notFound } from '../http/errors.js'; import { filterOf, lowerIds, manyOf, matchOf, notOf, When } from '../http/list-filters.js'; import { requireMembership } from '../locations/access.js'; import { gateFor } from '../serialize/gates.js'; +import { arrangedIds, type NodeName } from './arrange.js'; import { type Summary, summarise } from './summary.js'; // History and the activity feed (T21; D76, D110, D150, D174, D183; engineering spec §7.2, §7.5). @@ -88,6 +90,17 @@ export const HistoryEventSchema = z.object({ summary: z.string(), summaryKey: z.string(), summaryParams: z.record(z.string(), z.string()), + /** #140: what the action covered, one sentence each (an arrangement's changes, a report's + * counts), as a key and params the web localises, with the English. */ + summaryLines: z + .array( + z.object({ + key: z.enum(SUMMARY_LINE_KEYS), + params: z.record(z.string(), z.string()), + text: z.string(), + }), + ) + .optional(), movedInFromElsewhere: z.literal(true).optional(), /** A thing's history: an event of a thing merged into it (D36), "merged from " (T15). */ mergedFrom: z.object({ id: z.uuid(), name: z.string().nullable() }).optional(), @@ -181,6 +194,21 @@ const NOT_A_SECOND_COPY = `NOT (e.action = 'thing.move' AND e.diff ? 'location_i AND e.location_id::text = e.diff->'location_id'->>'before' AND (e.diff->'location_id'->>'after') IN (SELECT v::text FROM kept.visible_location_ids() v))`; +/** + * One row per action (#140): an upload that a capture, an attachment or a claim's file took up is + * part of that row ("Added a photo to Desk lamp"), not one of its own. The audit keeps both; the + * row that names the file (`file_id`, or a capture's `file_ids`) comes after the upload, within a + * day, in the same location, so the location's index finds it. + */ +const NOT_A_FOLDED_UPLOAD = `NOT (e.action = 'file.upload' AND e.entity_id IS NOT NULL + AND EXISTS (SELECT 1 FROM public.audit_events c + WHERE c.location_id = e.location_id + AND c.at >= e.at AND c.at < e.at + interval '1 day' + AND c.id <> e.id AND c.action <> 'file.upload' + AND (c.diff->'file_id'->>'after' = e.entity_id::text + OR (jsonb_typeof(c.diff->'file_ids'->'after') = 'array' + AND c.diff->'file_ids'->'after' ? e.entity_id::text))))`; + type Key = [string, string]; class Where { @@ -223,6 +251,8 @@ async function eventsPage( const after = afterKey(page.cursor); if (after) where.add((v) => `(e.at, e.id) < (${v(after[0])}::timestamptz, ${v(after[1])}::uuid)`); where.add(() => NOT_A_SECOND_COPY); + where.add(() => NOT_A_FOLDED_UPLOAD); + where.add(() => NOT_A_FOLDED_EXTRACTION); where.values.push(page.limit + 1); const { rows } = await client.query( `SELECT ${EVENT_COLUMNS} @@ -354,6 +384,7 @@ async function labelsOf( const NAME_SOURCES: Record = { thing: 'public.things', place: 'public.places', + location: 'public.locations', brand: 'public.brands', vendor: 'public.vendors', tag: 'public.tags', @@ -373,6 +404,102 @@ export function stockServiceOf(e: { return null; } +/** A vehicle history report's thing, from its diff. */ +function reportThingOf(e: { diff: unknown }): string | null { + const thing = (e.diff as Record | null)?.thing_id?.after; + return typeof thing === 'string' ? thing : null; +} + +/** + * The nodes of the page's arrangements (#140) as the caller may see them: each one's name, and + * the place it is in now, for "Put HD webcam on the map in Study". Read as the caller, so a thing + * or place they can't see has no entry and reads as "a thing" / "a place". + */ +async function arrangedNodesOf( + client: pg.ClientBase, + events: readonly EventRecord[], +): Promise> { + const things = new Set(); + const places = new Set(); + for (const e of events) { + if (e.action !== 'place.arrange') continue; + const ids = arrangedIds(e.diff); + for (const id of ids.things) things.add(id); + for (const id of ids.places) places.add(id); + } + const out = new Map(); + if (things.size === 0 && places.size === 0) return out; + const { rows } = await client.query<{ id: string; name: string | null; room: string | null }>( + `SELECT t.id, t.name, p.name AS room + FROM public.things t LEFT JOIN public.places p ON p.id = t.place_id + WHERE t.id = ANY ($1::uuid[]) + UNION ALL + SELECT c.id, c.name, p.name AS room + FROM public.places c LEFT JOIN public.places p ON p.id = c.parent_id + WHERE c.id = ANY ($2::uuid[])`, + [[...things], [...places]], + ); + for (const r of rows) if (r.name) out.set(r.id, { name: r.name, room: r.room }); + return out; +} + +/** + * One row per action (#140): a capture's extraction (the AI filling in the thing from the photo) + * is part of the capture's row, its changes shown there. The audit keeps both events. The page + * leaves out an extraction that follows its capture within half an hour (so paging never splits + * them), and the capture's row reads the extraction's diff, rendered for the same reader. A later + * re-read, or an undo, keeps its own row. + */ +const NOT_A_FOLDED_EXTRACTION = `NOT (e.action = 'thing.extract' AND e.undo_of IS NULL + AND e.entity_id IS NOT NULL + AND EXISTS (SELECT 1 FROM public.audit_events c + WHERE c.location_id = e.location_id AND c.action = 'thing.capture' + AND c.entity_id = e.entity_id AND c.undo_of IS NULL + AND c.at <= e.at AND c.at > e.at - interval '30 minutes'))`; + +/** The extractions folded into the page's captures, by capture event id. */ +async function extractionsOf( + client: pg.ClientBase, + events: readonly EventRecord[], +): Promise> { + const captures = events.filter( + (e) => e.action === 'thing.capture' && !e.undo_of && e.entity_id && e.location_id, + ); + const out = new Map(); + if (captures.length === 0) return out; + const { rows } = await client.query( + `SELECT ${EVENT_COLUMNS} + FROM public.audit_events e ${ACTOR_JOIN} + WHERE e.action = 'thing.extract' AND e.undo_of IS NULL + AND e.location_id = ANY ($1::uuid[]) AND e.entity_id = ANY ($2::uuid[]) + AND e.at >= $3::timestamptz AND e.at < $4::timestamptz + interval '30 minutes' + ORDER BY e.at`, + [ + [...new Set(captures.map((c) => c.location_id))], + [...new Set(captures.map((c) => c.entity_id))], + new Date(Math.min(...captures.map((c) => c.at.getTime()))).toISOString(), + new Date(Math.max(...captures.map((c) => c.at.getTime()))).toISOString(), + ], + ); + for (const c of captures) { + // The latest capture of that thing at or before the extraction: the one it belongs to. + for (const x of rows) { + const gap = x.at.getTime() - c.at.getTime(); + if (x.entity_id !== c.entity_id || x.location_id !== c.location_id) continue; + if (gap < 0 || gap >= 30 * 60 * 1000) continue; + const later = captures.some( + (o) => + o !== c && + o.entity_id === c.entity_id && + o.at.getTime() > c.at.getTime() && + o.at.getTime() <= x.at.getTime(), + ); + if (!later) out.set(c.id, x); + } + } + return out; +} + /** Current names of what the events are about, as the caller may see them. */ async function namesOf( client: pg.ClientBase, @@ -393,6 +520,12 @@ async function namesOf( want(e.entity_type, e.entity_id); const service = stockServiceOf(e); if (service) want(service.type, service.id); + // A report is about its location, or a vehicle report's thing (#140). + if (e.entity_type === 'report') { + const thing = reportThingOf(e); + if (thing) want('thing', thing); + else want('location', e.location_id); + } } const out = new Map(); for (const [type, ids] of wanted) { @@ -493,8 +626,10 @@ async function renderEvents( ], ); const names = await namesOf(client, events); + const nodes = await arrangedNodesOf(client, events); const codes = await shortCodesOf(client, events); const tokens = await tokenActorsOf(client, scope, events); + const extracted = await extractionsOf(client, events); return events.map((e) => { const row = rowOf(e); @@ -505,14 +640,38 @@ async function renderEvents( const owner = e.entity_type === 'place' ? e.entity_id : subjectThing(e); const byKey = owner ? labels.get(owner) : undefined; const rendered = renderAudit(row, viewer, { labelOf: (key) => byKey?.get(key) }); + const extraction = extracted.get(e.id); + if (extraction) { + // Its changes, redacted for this reader as its own row would have been. + const x = renderAudit(rowOf(extraction), viewer, { labelOf: (key) => byKey?.get(key) }); + rendered.diff = { ...(rendered.diff ?? {}), ...(x.diff ?? {}) }; + } const movedOut = crossingOf(row, visible) === 'out'; + const own = names.get(`${e.entity_type}:${e.entity_id}`); + const root = e.root_thing_id ? names.get(`thing:${e.root_thing_id}`) : undefined; + const report = + e.entity_type === 'report' + ? names.get( + reportThingOf(e) ? `thing:${reportThingOf(e)}` : `location:${e.location_id ?? ''}`, + ) + : undefined; const name = movedOut || rendered.movedInFromElsewhere ? null - : (names.get(`${e.entity_type}:${e.entity_id}`) ?? - (e.root_thing_id ? names.get(`thing:${e.root_thing_id}`) : undefined) ?? - null); + : // A merge is about the duplicate that went; its sentence names the thing it joined. + ((e.action === 'thing.merge' ? (root ?? own) : (own ?? root ?? report)) ?? null); const service = stockServiceOf(e); + const summary = summarise(rendered, { + name, + movedOut, + ...(service ? { service: names.get(`${service.type}:${service.id}`) ?? null } : {}), + ...(e.action === 'place.arrange' ? { nodes } : {}), + }); + // The layouts are in summaryLines now, in words: the JSON never reaches the page (#140). + if (rendered.diff && e.action === 'place.arrange') { + const { layout: _layout, ...rest } = rendered.diff; + rendered.diff = rest; + } return { ...rendered, actor: { @@ -525,11 +684,7 @@ async function renderEvents( // Gone to a location the reader can't see: no address to offer beyond the id. shortCode: movedOut ? null : (codes.get(`${e.entity_type}:${e.entity_id}`) ?? null), }, - ...summarise(rendered, { - name, - movedOut, - ...(service ? { service: names.get(`${service.type}:${service.id}`) ?? null } : {}), - }), + ...summary, }; }); } diff --git a/apps/server/src/history/summary.test.ts b/apps/server/src/history/summary.test.ts new file mode 100644 index 00000000..f0a34e3e --- /dev/null +++ b/apps/server/src/history/summary.test.ts @@ -0,0 +1,214 @@ +import { AUDIT_ACTIONS, LEGACY_AUDIT_ACTIONS, SUMMARY_KEYS, SUMMARY_LINE_KEYS } from '@kept/shared'; +import { describe, expect, it } from 'vitest'; +import type { RenderedAuditEvent } from '../audit/render.js'; +import { type ArrangedNode, arrangeLines } from './arrange.js'; +import { englishOf, linesOf, summarise } from './summary.js'; + +// #140: every action the server writes reads as a sentence of its own. AuditEventInput takes +// only @kept/shared's AUDIT_ACTIONS (a write with a new action fails the typecheck until it is +// listed), ENGLISH is typed by them (a listed action without English fails it too), and this +// checks what the types can't: that each one says something other than the fallback. + +const ev = (action: string, diff: RenderedAuditEvent['diff'] = null): RenderedAuditEvent => ({ + id: '00000000-0000-7000-8000-000000000001', + at: '2026-10-16T10:00:00.000Z', + location_id: '00000000-0000-7000-8000-000000000002', + action, + actor: { type: 'user', id: null }, + entity: { type: action.split('.')[0] as string, id: '00000000-0000-7000-8000-000000000003' }, + root_thing_id: null, + diff, + undo_of: null, + undoable_until: null, +}); +const plain = (before: unknown, after: unknown) => ({ before, after, class: 'plain' as const }); + +describe('summaries (#140)', () => { + it('gives every audit action a sentence of its own, never the fallback', () => { + for (const action of [...AUDIT_ACTIONS, ...LEGACY_AUDIT_ACTIONS]) { + const s = summarise(ev(action), { name: 'Desk lamp' }); + expect(s.summaryKey, action).not.toBe('event'); + expect(s.summaryKey, action).toBe(action); + expect(s.summary, action).not.toMatch(/\{|undefined|_/); + } + }); + + it('has English for every key and every line', () => { + for (const key of SUMMARY_KEYS) { + expect(englishOf(key, { name: 'Desk lamp', count: '2', action: 'x.y' }), key).toBeTruthy(); + } + expect(new Set(SUMMARY_KEYS).size).toBe(SUMMARY_KEYS.length); + expect(SUMMARY_LINE_KEYS.length).toBeGreaterThan(0); + }); + + it('keeps the fallback for an action this build does not know', () => { + const s = summarise(ev('gizmo.frobnicate'), { name: null }); + expect(s).toMatchObject({ + summaryKey: 'event', + summaryParams: { action: 'gizmo.frobnicate' }, + summary: 'Gizmo frobnicate', + }); + }); + + it('refines a capture, a photo, a report and a label batch by what the row says', () => { + const name = { name: 'Wireless keyboard' }; + expect( + summarise(ev('thing.capture', { file_ids: plain(null, ['f']), name: plain(null, 'x') }), name) + .summary, + ).toBe('Captured Wireless keyboard from a photo'); + expect( + summarise(ev('thing.capture', { attachment_ids: plain(null, ['a']) }), name).summary, + ).toBe('Added a photo to Wireless keyboard'); + expect(summarise(ev('attachment.create', { role: plain(null, 'photo') }), name).summary).toBe( + 'Added a photo to Wireless keyboard', + ); + expect( + summarise(ev('report.generate', { kind: plain(null, 'insurance') }), { name: 'Beach house' }) + .summary, + ).toBe('Made an insurance report for Beach house'); + expect( + summarise( + ev('label_batch.create', { kind: plain(null, 'blank'), code_count: plain(null, 24) }), + { name: null }, + ).summary, + ).toBe('Made 24 blank labels'); + expect(summarise(ev('inbox.discard'), { name: null }).summary).toBe('Discarded a draft'); + expect(summarise(ev('capture.batch_undo'), { name: null }).summary).toBe('Undid a capture'); + }); +}); + +describe('detail lines: the field table in words (#140)', () => { + it("names a report's things and photos, a trash's contents and an undone capture's things", () => { + const report = ev('report.generate', { + include: plain(null, { photos: true }), + filters: plain(null, { thingIds: ['a', 'b', 'c'] }), + locale: plain(null, 'en'), + digits: plain(null, 'arab'), + }); + expect(linesOf(report)).toEqual([ + { key: 'line.things', params: { count: '3' } }, + { key: 'line.photos', params: {} }, + ]); + const trash = ev('place.trash', { trashed: plain(null, 4), moved: plain(null, 0) }); + expect(summarise(trash, { name: 'Shelf 2' }).summaryLines?.map((l) => l.text)).toEqual([ + 'With 3 things inside', + ]); + const undone = ev('capture.batch_undo', { trashed: plain([], ['a']) }); + expect(summarise(undone, { name: null }).summaryLines?.map((l) => l.text)).toEqual(['1 thing']); + expect(linesOf(ev('thing.update', { name: plain('a', 'b') }))).toEqual([]); + }); +}); + +describe('arrangements in words (#140)', () => { + const n = (id: string, more: Partial = {}): ArrangedNode => ({ + id, + s: 'thing', + x: 0, + z: 0, + w: null, + d: null, + rot: 0, + model: 'device', + kind: null, + surface: null, + host: null, + ...more, + }); + const room = (id: string, more: Partial = {}) => + n(id, { s: 'place', model: null, kind: 'room', surface: 'wood', w: 300, d: 400, ...more }); + const names = new Map([ + ['webcam', { name: 'HD webcam', room: 'Study' }], + ['desk', { name: 'Corner desk', room: 'Study' }], + ['shelf', { name: 'Shelf 2', room: 'Study' }], + ['tv', { name: 'TV', room: 'Lounge' }], + ['study', { name: 'Study', room: null }], + ]); + const text = (lines: ReturnType) => + lines.map((l) => `${l.key} ${JSON.stringify(l.params)}`); + + it('puts on and takes off the map, and onto and off a host', () => { + expect(text(arrangeLines([], [n('webcam')], names))).toEqual([ + 'map.put.room {"name":"HD webcam","room":"Study"}', + ]); + expect(text(arrangeLines([n('webcam')], [], names))).toEqual([ + 'map.remove {"name":"HD webcam"}', + ]); + expect( + text( + arrangeLines([n('desk'), n('webcam')], [n('desk'), n('webcam', { host: 'desk' })], names), + ), + ).toEqual(['map.on {"name":"HD webcam","host":"Corner desk"}']); + expect( + text( + arrangeLines([n('desk'), n('webcam', { host: 'desk' })], [n('desk'), n('webcam')], names), + ), + ).toEqual(['map.off {"name":"HD webcam","host":"Corner desk"}']); + }); + + it('moves, turns and changes shape; a hosted thing moving with its host says nothing', () => { + expect( + text( + arrangeLines( + [n('desk'), n('webcam', { host: 'desk' }), n('tv'), n('shelf', { model: 'desk' })], + [ + n('desk', { x: 50 }), + n('webcam', { host: 'desk', x: 50 }), + n('tv', { rot: 90 }), + n('shelf', { model: 'webcam' }), + ], + names, + ), + ), + ).toEqual([ + 'map.move {"name":"Corner desk"}', + 'map.shape {"name":"Shelf 2","from":"desk","to":"webcam","vocab":"model"}', + 'map.turn {"name":"TV"}', + ]); + }); + + it('says what changed in a room', () => { + expect( + text( + arrangeLines( + [room('study')], + [room('study', { w: 350, kind: 'balcony', surface: 'tile' })], + names, + ), + ), + ).toEqual([ + 'map.resize {"name":"Study"}', + 'map.kind {"name":"Study","from":"room","to":"balcony","vocab":"kind"}', + 'map.floor {"name":"Study","from":"wood","to":"tile","vocab":"surface"}', + ]); + expect(text(arrangeLines([], [room('study')], names))).toEqual(['map.put {"name":"Study"}']); + }); + + it('names nothing the reader cannot see', () => { + expect(text(arrangeLines([], [n('hidden'), n('elsewhere', { s: 'place' })], names))).toEqual([ + 'map.put {"name":"a place"}', + 'map.put {"name":"a thing"}', + ]); + }); + + it('collapses more than five changes into a count', () => { + const many = ['a', 'b', 'c'].map((id) => n(id)); + const s = summarise( + ev('place.arrange', { + layout: plain( + many, + many.map((x) => ({ ...x, x: 9, rot: 90 })), + ), + }), + { name: 'Home', nodes: new Map() }, + ); + expect(s.summaryKey).toBe('place.arrange.many'); + expect(s.summary).toBe('Arranged the map: 6 changes'); + expect(s.summaryLines).toHaveLength(6); + const few = summarise(ev('place.arrange', { layout: plain([], [n('webcam')]) }), { + name: 'Home', + nodes: names, + }); + expect(few.summary).toBe('Arranged the map'); + expect(few.summaryLines?.map((l) => l.text)).toEqual(['Put HD webcam on the map in Study']); + }); +}); diff --git a/apps/server/src/history/summary.ts b/apps/server/src/history/summary.ts index 99c23be8..96baf21c 100644 --- a/apps/server/src/history/summary.ts +++ b/apps/server/src/history/summary.ts @@ -1,37 +1,65 @@ +import { + isAuditAction, + LEGACY_AUDIT_ACTIONS, + SUMMARY_LINES_INLINE, + type SummaryKey, + type SummaryLine, + type SummaryLineKey, +} from '@kept/shared'; import type { RenderedAuditEvent } from '../audit/render.js'; +import { arrangeLines, layoutOf, type NodeName } from './arrange.js'; -// One line per history or activity row (plan T21; T27 decision): a structured `summaryKey` with -// `summaryParams` that the web localises, and `summary`, the same sentence in English, as the +// One line per history or activity row (plan T21; T27 decision; #140): a structured `summaryKey` +// with `summaryParams` that the web localises, and `summary`, the same sentence in English, as the // fallback. It is built from the *rendered* event, after renderAudit() has redacted it, so it // can never say more than the row it sits on: money and secrets never reach the params (the -// only diff values read are the name, the lifecycle and a stock use's service ids, all plain), +// only diff values read are names, codes, counts and a stock use's service ids, all plain), // and a move from a location the viewer can't see has no diff to read at all (D183). // // Params are data, never English: names as stored, and codes (`lifecycle: 'given_away'`) the web -// localises. Keys are `.` in the audit's own vocabulary, with three refinements the action -// alone doesn't say: `thing.move.in` / `thing.move.out` (a move across the edge of what the -// viewer can see), `.restore` for an undone trash, and `undo` for any other undo. -// Anything without a sentence of its own gets `event` with `{action}`. +// localises. Keys are `.` in the audit's own vocabulary (@kept/shared AUDIT_ACTIONS: +// every action the server writes has a sentence of its own, and ENGLISH below is typed by them, so +// a new action without one fails the typecheck), refined where the row says more than the action: +// `thing.move.in` / `thing.move.out` (a move across the edge of what the viewer can see), +// `.restore` for an undone trash, `undo` for any other undo, a capture that only added a +// photo, a report's kind, a busy arrangement (SUMMARY_REFINEMENTS). Only an action this build +// doesn't know (an imported history's, a newer server's) gets `event` with `{action}`. +// +// `summaryLines` (#140) say what the action covered, one sentence each: an arrangement's changes +// (history/arrange.ts), and the counts a report, a trash or an undone capture would otherwise show +// as raw fields ("12 things", "With photos"). export type SummaryParams = Record; -export type Summary = { summaryKey: string; summaryParams: SummaryParams; summary: string }; +/** A detail line with its English, the fallback for a client that doesn't know its key. */ +export type RenderedLine = SummaryLine & { text: string }; +export type Summary = { + summaryKey: string; + summaryParams: SummaryParams; + summary: string; + summaryLines?: RenderedLine[]; +}; /** What the summary needs beyond the rendered event. */ export type SummaryContext = { - /** The current name of what the event is about (the thing, the place, the registry row), or - * null when the viewer can't see it any more. */ + /** The current name of what the event is about (the thing, the place, the registry row, the + * location of a location's own event), or null when the viewer can't see it any more. */ name: string | null; /** The event is a move out to a location the viewer can't see (crossingOf() = 'out'). */ movedOut?: boolean; /** A `thing.stock_use` (D236): the current name of the thing or place the service was for. */ service?: string | null; + /** A `place.arrange`: what the reader may see of each node it touched. */ + nodes?: ReadonlyMap; }; type Sentence = (p: SummaryParams) => string; -/** English for every key with a sentence of its own. `{name}` is always set (a fallback noun - * when the name is unknown). */ -const ENGLISH: Record = { +/** English plural: `#` is the count. */ +const plural = (count: string | undefined, one: string, other: string) => + (Number(count) === 1 ? one : other).replaceAll('#', count ?? '0'); + +/** English for every key. `{name}` is always set (a fallback noun when the name is unknown). */ +const ENGLISH: Record = { 'thing.create': (p) => `Added ${p.name}`, 'thing.update': (p) => `Edited ${p.name}`, 'thing.retype': (p) => `Changed the type of ${p.name}`, @@ -64,38 +92,320 @@ const ENGLISH: Record = { // D236: a service line took the consumable off the shelf, or gave it back. 'thing.stock_use': (p) => `Used ${p.name} in a service of ${p.service}`, 'thing.stock_return': (p) => `Put ${p.name} back from a service of ${p.service}`, - // 1.x (D237): an arrange session on the location map, about the location. + // 1.x (D237): an arrange session on the location map, about the location; its changes are lines. 'place.arrange': () => 'Arranged the map', + 'place.arrange.many': (p) => + plural(p.count, 'Arranged the map: # change', 'Arranged the map: # changes'), 'attachment.create': (p) => `Attached a file to ${p.name}`, 'attachment.update': (p) => `Changed a file on ${p.name}`, 'attachment.delete': (p) => `Removed a file from ${p.name}`, + // A capture that added a photo to a thing that was already there, or named one without a photo. + 'thing.capture.attach': (p) => `Added a photo to ${p.name}`, + 'thing.capture.named': (p) => `Added ${p.name}`, + 'report.generate.condition': (p) => `Made a condition report for ${p.name}`, + 'report.generate.move': (p) => `Made a moving report for ${p.name}`, undo: (p) => `Undid a change to ${p.name}`, event: (p) => fallback(p.action ?? ''), + 'brand.create': () => 'Brand added', + 'brand.update': () => 'Brand changed', + 'brand.delete': () => 'Brand deleted', + 'brand.merge': () => 'Brand merged', + 'vendor.create': () => 'Vendor added', + 'vendor.update': () => 'Vendor changed', + 'vendor.delete': () => 'Vendor deleted', + 'vendor.merge': () => 'Vendor merged', + 'person.create': () => 'Person added', + 'person.update': () => 'Person changed', + 'person.delete': () => 'Person deleted', + 'person.merge': () => 'Person merged', + 'person.contact_update': () => 'Contact details changed', + 'tag.create': () => 'Tag added', + 'tag.update': () => 'Tag changed', + 'tag.delete': () => 'Tag deleted', + 'tag.merge': () => 'Tag merged', + 'type.create': () => 'Type added', + 'type.update': () => 'Type changed', + 'type.delete': () => 'Type deleted', + 'type.merge': () => 'Type merged', + 'type.customise': () => 'Type customised', + 'type_field.create': () => 'Field added', + 'type_field.update': () => 'Field changed', + 'type_field.archive': () => 'Field archived', + 'type_field.restore': () => 'Field restored', + 'place_kind.create': () => 'Place kind added', + 'place_kind.update': () => 'Place kind changed', + 'place_kind.customise': () => 'Place kind customised', + 'file.upload': () => 'File uploaded', + 'file.delete_original': () => 'Original file deleted', + 'purchase.create': () => 'Purchase added', + 'purchase.update': () => 'Purchase changed', + 'purchase.delete': () => 'Purchase deleted', + 'purchase_line.create': () => 'Purchase line added', + 'purchase_line.update': () => 'Purchase line changed', + 'purchase_line.delete': () => 'Purchase line deleted', + 'thing.purchase_link': () => 'Linked to a purchase', + 'thing.purchase_unlink': () => 'Unlinked from a purchase', + 'thing.label': () => 'Label assigned', + 'meter.create': () => 'Meter added', + 'meter.update': () => 'Meter changed', + 'meter.replaced': () => 'Meter replaced', + 'reading.create': () => 'Reading logged', + 'reading.update': () => 'Reading changed', + 'reading.accept': () => 'Reading kept', + 'reading.delete': () => 'Reading discarded', + 'saved_view.create': () => 'Saved view added', + 'location.create': () => 'Location created', + 'location.update': () => 'Location settings changed', + 'location.modules': () => 'What to track changed', + 'location.delete': () => 'Location deleted', + 'location.restore': () => 'Location restored', + 'invite.create': () => 'Invite created', + 'invite.update': () => 'Invite changed', + 'invite.revoke': () => 'Invite revoked', + 'member.join': () => 'Member joined', + 'member.leave': () => 'Member left', + 'member.update': () => "Member's role changed", + 'member.expire': () => 'Membership ended', + 'managed_account.create': () => 'Managed account created', + 'saved_view.update': () => 'Saved view changed', + 'saved_view.delete': () => 'Saved view deleted', + 'secret.set': () => 'Secret changed', + 'secret.clear': () => 'Secret cleared', + 'secret.reveal': () => 'Secret revealed', + 'secret.copied': () => 'Secret copied', + 'secret_policy.update': () => 'Secret policy changed', + 'warranty.create': () => 'Warranty added', + 'warranty.update': () => 'Warranty changed', + 'warranty.delete': () => 'Warranty removed', + 'claim.create': () => 'Claim opened', + 'claim.update': () => 'Claim changed', + 'claim.delete': () => 'Claim deleted', + 'valuation.create': () => 'Valuation added', + 'valuation.update': () => 'Valuation changed', + 'valuation.delete': () => 'Valuation deleted', + 'loan.create': () => 'Loan added', + 'loan.update': () => 'Loan changed', + 'loan.return': () => 'Loan returned', + 'loan.delete': () => 'Loan deleted', + 'schedule.create': () => 'Schedule added', + 'schedule.update': () => 'Schedule changed', + 'schedule.delete': () => 'Schedule deleted', + 'schedule.snooze': () => 'Schedule snoozed', + 'schedule.unsnooze': () => 'Snooze cleared', + 'schedule.skip': () => 'Next one skipped', + 'service_record.create': () => 'Service logged', + 'service_record.update': () => 'Service changed', + 'service_record.delete': () => 'Service deleted', + 'service_record.draft': () => 'Service draft started from an invoice', + 'service_record.confirm': () => 'Service logged', + 'meter.delete': () => 'Meter removed', + 'fuel.create': () => 'Fuel logged', + 'fuel.update': () => 'Fuel changed', + 'fuel.delete': () => 'Fuel deleted', + 'schedule.starter': () => 'Starter schedules added', + 'document.create': () => 'Document added', + 'document.update': () => 'Document changed', + 'document.renew': () => 'Document renewed', + 'document.delete': () => 'Document deleted', + 'incident.create': () => 'Incident recorded', + 'incident.update': () => 'Incident changed', + 'incident.things': () => "Incident's things changed", + 'incident.delete': () => 'Incident deleted', + 'fx_rate.set': () => 'Exchange rate set', + 'fx_rate.delete': () => 'Exchange rate deleted', + 'brand.logo_set': () => 'Brand logo changed', + 'brand.logo_remove': () => 'Brand logo removed', + 'thing.stock_rule': () => 'Minimum to keep changed', + 'type.field_convert': () => 'Field converted', + 'things.export_csv': () => 'List exported as a CSV', + 'file.import': () => 'File imported', + 'webhook.create': () => 'Webhook added', + 'webhook.update': () => 'Webhook changed', + 'webhook.delete': () => 'Webhook removed', + 'webhook.rotate_secret': () => 'Webhook secret replaced', + 'share_link.create': () => 'Share link made', + 'share_link.revoke': () => 'Share link revoked', + 'move.create': () => 'Move started', + 'move.update': () => 'Move changed', + 'move.delete': () => 'Move deleted', + 'move_box.create': () => 'Added to a move as a box', + 'move_box.update': () => 'Moving box changed', + 'move_box.unpack': () => 'Box unpacked', + 'move_box.delete': () => 'Taken out of a move', + 'paperless.connect': () => 'Paperless connected', + 'paperless.update': () => 'Paperless connection changed', + 'paperless.disconnect': () => 'Paperless disconnected', + 'paperless_link.create': () => 'Paperless document linked', + 'paperless_link.delete': () => 'Paperless document unlinked', + 'paperless_link.copy': () => 'Paperless document copied into Kept', + 'purchase.reextract': () => 'Receipt read again', + 'thing.enrich': () => 'Other names added', + 'import.enrich': () => 'Other names requested for an import', + 'import.homebox_connect': () => 'Connected to Homebox', + 'instance.setup': () => 'Kept set up', + 'instance.settings_update': () => 'Instance settings changed', + 'instance.backup': () => 'Backup made', + 'instance.backup_run': () => 'Backup started by hand', + 'instance.backup_settings': () => 'Backup settings changed', + 'instance.backup_test': () => 'Backup target tested', + 'instance.restore': () => 'Restored from a backup', + 'instance.export': () => 'Whole instance exported', + 'instance.downgrade_forced': () => 'Started on an older version anyway', + 'instance.rotate_key': () => 'Encryption key replaced', + 'instance.drop_key': () => 'Old encryption key removed', + 'instance.embeddings_source': () => 'Semantic search source changed', + 'instance.recovery_kit_download': () => 'Recovery kit downloaded', + 'instance.recovery_kit_acknowledge': () => 'Recovery kit noted as kept', + 'instance.update_check': () => 'Checked for updates', + 'instance.setup_code_reissue': () => 'New setup code issued', + 'instance.oidc_changed': () => 'OIDC sign-in settings changed', + 'instance.smtp_changed': () => 'Email settings changed', + 'attachment.create.photo': (p) => `Added a photo to ${p.name}`, + 'thing.capture': (p) => `Captured ${p.name} from a photo`, + 'thing.capture.reading': (p) => `Logged a reading of ${p.name} from a photo`, + 'thing.extract': (p) => `Filled in ${p.name} from a photo`, + 'thing.reextract': (p) => `Read the photo of ${p.name} again`, + 'thing.merge': (p) => `Merged a duplicate into ${p.name}`, + 'box.check': (p) => `Checked what is in ${p.name}`, + 'label.claim': (p) => `Gave ${p.name} a label`, + 'location.own_codes': (p) => `Changed how ${p.name} numbers its codes`, + 'location.transfer_ownership': (p) => `Handed ${p.name} to a new owner`, + 'report.generate': (p) => `Made an inventory report of ${p.name}`, + 'report.generate.insurance': (p) => `Made an insurance report for ${p.name}`, + 'report.generate.vehicle': (p) => `Made a vehicle history report for ${p.name}`, + 'account.deletion_cancel': () => 'Cancelled the account deletion', + 'account.deletion_request': () => 'Asked to delete the account', + 'account.email_change': () => 'Changed the account email', + 'admin.cli_reset_password': () => 'Reset a password from the command line', + 'admin.cli_user_disable': () => 'Turned off an account from the command line', + 'admin.cli_user_enable': () => 'Turned an account back on from the command line', + 'admin.currency_disable': () => 'Turned off a currency', + 'admin.currency_enable': () => 'Turned on a currency', + 'admin.instance_admin_grant': () => 'Made someone an instance admin', + 'admin.instance_admin_revoke': () => 'Removed an instance admin', + 'admin.job_discard': () => 'Discarded a failed job', + 'admin.job_retry': () => 'Retried a failed job', + 'admin.user_disable': () => 'Turned off an account', + 'admin.user_enable': () => 'Turned an account back on', + 'admin.user_reset_2fa': () => 'Reset someone’s two-step sign-in', + 'admin.user_sign_out_everywhere': () => 'Signed someone out everywhere', + 'ai.cap_clear': () => 'Removed the AI spending cap', + 'ai.cap_set': () => 'Set the AI spending cap', + 'ai.pause': () => 'Paused AI', + 'ai.resume': () => 'Resumed AI', + 'ai.price_prefill': () => 'Looked up AI prices', + 'ai.price_recost': () => 'Worked out AI costs again with new prices', + 'ai.price_remove': () => 'Removed an AI price', + 'ai.price_set': () => 'Set an AI price', + 'ai.provider_models': () => 'Listed an AI provider’s models', + 'ai.provider_remove': () => 'Removed an AI provider', + 'ai.provider_set': () => 'Set up an AI provider', + 'ai.provider_test': () => 'Tested an AI provider', + 'ai.usage_export': () => 'Exported AI usage', + 'instance.google_changed': () => 'Google sign-in settings changed', + 'managed_account.password_set': () => 'Set a managed account’s password', + 'managed_account.reset_code': () => 'Made a reset code for a managed account', + 'me.notification_preferences': () => 'Changed notification settings', + 'me.notifications': () => 'Changed which notifications arrive', + 'me.preferences': () => 'Changed preferences', + 'session.revoke': () => 'Signed out a session', + 'oauth.deny': () => 'Turned down an app’s request to connect', + 'oauth.grant': () => 'Let an app connect', + 'token.create': () => 'Made an access token', + 'token.revoke': () => 'Revoked an access token', + 'token.update': () => 'Changed an access token', + 'push_subscription.create': () => 'Turned on notifications on a device', + 'push_subscription.delete': () => 'Turned off notifications on a device', + 'push_subscription.update': () => 'Changed notifications on a device', + 'hint.dismiss': () => 'Dismissed a tip', + 'hint.restore': () => 'Brought back a tip', + 'hint.seen': () => 'Read a tip', + 'saved_view_prefs.update': () => 'Changed how a list shows', + 'member.remove': () => 'Removed a member', + 'member.recap': () => 'Changed when guest access ends', + 'member.owner_notified': () => 'Told the owner about a new guest', + 'mailbox.create': () => 'Set up the mailbox', + 'mailbox.update': () => 'Changed the mailbox', + 'mailbox.delete': () => 'Removed the mailbox', + 'channel.create': () => 'Added a notification channel', + 'channel.delete': () => 'Removed a notification channel', + 'calendar_feed.create': () => 'Made a calendar feed', + 'calendar_feed.revoke': () => 'Revoked a calendar feed', + 'capture.batch_undo': () => 'Undid a capture', + 'inbox.discard': () => 'Discarded a draft', + 'inbox.resolve': () => 'Dealt with an inbox item', + 'inbox.restore': () => 'Brought back an inbox item', + 'inbox.bulk': () => 'Changed several inbox items at once', + 'labels.printed': () => 'Printed labels', + 'purchase.capture': () => 'Captured a receipt', + 'purchase.confirm': () => 'Confirmed a receipt', + 'purchase.extract': () => 'Read a receipt', + 'reading.extract': () => 'Read a meter from a photo', + 'file.crop': () => 'Cropped a photo', + 'file.display_set': () => 'Changed the photo shown', + 'template.create': () => 'Added a template', + 'template.update': () => 'Changed a template', + 'template.delete': () => 'Deleted a template', + 'claim.status': () => 'Changed a claim’s status', + 'claim_pack.create': () => 'Made a claim pack', + 'claim_pack.link': () => 'Shared a claim pack link', + 'claim_pack.link_revoke': () => 'Revoked a claim pack link', + 'condition_report.create': () => 'Started a condition report', + 'condition_report.update': () => 'Changed a condition report', + 'condition_report.finish': () => 'Finished a condition report', + 'condition_report.delete': () => 'Deleted a condition report', + 'condition_room.create': () => 'Added a room to a condition report', + 'condition_room.update': () => 'Changed a room in a condition report', + 'condition_room.delete': () => 'Removed a room from a condition report', + 'export.create': () => 'Started an export', + 'export.cancel': () => 'Cancelled an export', + 'export.download': () => 'Downloaded an export', + 'export.secrets': () => 'Put secrets in an export', + 'import.create': () => 'Uploaded a file to import', + 'import.archive': () => 'Uploaded an archive to import', + 'import.inspect': () => 'Opened an archive to import', + 'import.passphrase': () => 'Entered an import’s passphrase', + 'import.target': () => 'Chose where an import goes', + 'import.choices': () => 'Changed an import’s choices', + 'import.check': () => 'Checked an import', + 'import.start': () => 'Started an import', + 'import.run': () => 'Ran an import', + 'import.cancel': () => 'Cancelled an import', + 'report.export_csv': () => 'Exported a report as a CSV', + 'report.schedule': () => 'Scheduled a report', + 'report.unschedule': () => 'Stopped a scheduled report', + 'label_batch.create': (p) => plural(p.count, 'Made # blank label', 'Made # blank labels'), + 'label_batch.create.things': (p) => + plural(p.count, 'Made labels for # thing', 'Made labels for # things'), + 'label_batch.create.places': (p) => + plural(p.count, 'Made labels for # place', 'Made labels for # places'), }; -const PAST: Record = { - create: 'added', - update: 'changed', - delete: 'deleted', - merge: 'merged', - customise: 'customised', - archive: 'archived', - restore: 'restored', - trash: 'trashed', - revoke: 'revoked', - join: 'joined', - leave: 'left', - expire: 'expired', - upload: 'uploaded', - reveal: 'revealed', +/** English for every detail line. */ +const LINE_ENGLISH: Record = { + 'line.things': (p) => plural(p.count, '# thing', '# things'), + 'line.inside': (p) => plural(p.count, 'With # thing inside', 'With # things inside'), + 'line.moved_out': (p) => plural(p.count, '# thing taken out first', '# things taken out first'), + 'line.photos': () => 'With photos', + 'map.put.room': (p) => `Put ${p.name} on the map in ${p.room}`, + 'map.put': (p) => `Put ${p.name} on the map`, + 'map.remove': (p) => `Took ${p.name} off the map`, + 'map.on': (p) => `Put ${p.name} on ${p.host}`, + 'map.off': (p) => `Took ${p.name} off ${p.host}`, + 'map.move': (p) => `Moved ${p.name}`, + 'map.turn': (p) => `Turned ${p.name}`, + 'map.shape': (p) => `Changed the shape of ${p.name}: ${p.from} → ${p.to}`, + 'map.resize': (p) => `Resized ${p.name}`, + 'map.kind': (p) => `Changed the kind of ${p.name}: ${p.from} → ${p.to}`, + 'map.floor': (p) => `Changed the floor of ${p.name}: ${p.from} → ${p.to}`, }; -/** "Brand added", "Member joined": the English for an action with no sentence of its own. */ +/** "Brand added": the English for an action this build doesn't know. */ function fallback(action: string): string { const dot = action.indexOf('.'); const entity = words(dot < 0 ? action : action.slice(0, dot)); const verb = dot < 0 ? '' : action.slice(dot + 1); - const said = PAST[verb] ?? (verb ? `${words(verb)}` : 'changed'); + const said = verb ? words(verb) : 'changed'; const subject = entity ? entity[0]?.toUpperCase() + entity.slice(1) : 'Something'; return `${subject} ${said}`; } @@ -113,6 +423,7 @@ const NOUNS: Record = { person: 'a person', tag: 'a tag', location: 'the location', + report: 'the location', }; const words = (value: string) => value.replaceAll('_', ' '); @@ -124,8 +435,17 @@ function plainOf(event: RenderedAuditEvent, field: string): Plain | null { return change && change.class === 'plain' && 'before' in change ? (change as Plain) : null; } -/** The key of an event: its action, refined as the header says. */ -function keyOf(event: RenderedAuditEvent, ctx: SummaryContext): string { +const afterOf = (event: RenderedAuditEvent, field: string) => plainOf(event, field)?.after; + +const REPORT_KINDS: Record = { + insurance: 'report.generate.insurance', + vehicle_history: 'report.generate.vehicle', + condition: 'report.generate.condition', + move: 'report.generate.move', +}; + +/** The key of an event: its action, refined as the row says. */ +function keyOf(event: RenderedAuditEvent, ctx: SummaryContext): SummaryKey { const { action } = event; if (event.movedInFromElsewhere) return 'thing.move.in'; if (action === 'thing.move' && ctx.movedOut) return 'thing.move.out'; @@ -133,20 +453,100 @@ function keyOf(event: RenderedAuditEvent, ctx: SummaryContext): string { // An undone trash writes the trash action again, with deleted_at going back to null. if (action.endsWith('.trash') && event.undo_of) { const deleted = plainOf(event, 'deleted_at'); - if (deleted && deleted.after === null) return `${entity}.restore`; + if (deleted && deleted.after === null && (entity === 'thing' || entity === 'place')) { + return `${entity}.restore`; + } } if (event.undo_of) return 'undo'; - // D236: the stock went back up (the line shrank or went, the service was deleted or undone). - if (action === 'thing.stock_use') { - const q = plainOf(event, 'quantity'); - if (q && Number(q.after) > Number(q.before)) return 'thing.stock_return'; + switch (action) { + // D236: the stock went back up (the line shrank or went, the service was deleted or undone). + case 'thing.stock_use': { + const q = plainOf(event, 'quantity'); + return q && Number(q.after) > Number(q.before) ? 'thing.stock_return' : action; + } + case 'thing.capture': { + if (event.diff?.reading_id || event.diff?.meter_id) return 'thing.capture.reading'; + // A photo added to a thing that was there already: the row has no thing of its own. + if (event.diff?.attachment_ids && !event.diff?.name) return 'thing.capture.attach'; + const files = afterOf(event, 'file_ids'); + return Array.isArray(files) && files.length === 0 ? 'thing.capture.named' : action; + } + case 'attachment.create': + return afterOf(event, 'role') === 'photo' ? 'attachment.create.photo' : action; + case 'report.generate': + return REPORT_KINDS[String(afterOf(event, 'kind'))] ?? action; + case 'label_batch.create': { + const kind = afterOf(event, 'kind'); + return kind === 'things' + ? 'label_batch.create.things' + : kind === 'places' + ? 'label_batch.create.places' + : action; + } + default: + return isAuditAction(action) || LEGACY.has(action) ? (action as SummaryKey) : 'event'; } - return ENGLISH[action] ? action : 'event'; +} + +const LEGACY: ReadonlySet = new Set(LEGACY_AUDIT_ACTIONS); + +const countOf = (value: unknown): number | null => + Array.isArray(value) ? value.length : typeof value === 'number' ? value : null; + +/** What a report, a trash or an undone capture covered, in words (the raw fields stay hidden). */ +export function linesOf(event: RenderedAuditEvent): SummaryLine[] { + const out: SummaryLine[] = []; + const count = (key: SummaryLineKey, n: number | null) => { + if (n && n > 0) out.push({ key, params: { count: String(n) } }); + }; + switch (event.action) { + case 'report.generate': { + const filters = afterOf(event, 'filters') as Record | null | undefined; + count('line.things', countOf(filters?.thingIds)); + const include = afterOf(event, 'include') as Record | null | undefined; + if (include?.photos === true) out.push({ key: 'line.photos', params: {} }); + break; + } + case 'place.trash': { + const trashed = countOf(afterOf(event, 'trashed')); + // The place itself is one of them. + count('line.inside', trashed === null ? null : trashed - 1); + count('line.moved_out', countOf(afterOf(event, 'moved'))); + break; + } + case 'capture.batch_undo': + count('line.things', countOf(afterOf(event, 'trashed') ?? afterOf(event, 'restored'))); + break; + default: + break; + } + return out; +} + +function rendered(lines: readonly SummaryLine[]): RenderedLine[] { + return lines.map((l) => ({ ...l, text: LINE_ENGLISH[l.key](l.params) })); } export function summarise(event: RenderedAuditEvent, ctx: SummaryContext): Summary { - const key = keyOf(event, ctx); + let key = keyOf(event, ctx); const params: SummaryParams = {}; + let lines: SummaryLine[] = []; + if (event.action === 'place.arrange') { + const layout = event.diff?.layout; + if (layout && 'before' in layout) { + lines = arrangeLines( + layoutOf(layout.before) ?? [], + layoutOf(layout.after) ?? [], + ctx.nodes ?? new Map(), + ); + } + if (key === 'place.arrange' && lines.length > SUMMARY_LINES_INLINE) { + key = 'place.arrange.many'; + params.count = String(lines.length); + } + } else { + lines = linesOf(event); + } if (key === 'event') { params.action = event.action; } else if (key !== 'thing.move.in') { @@ -159,8 +559,8 @@ export function summarise(event: RenderedAuditEvent, ctx: SummaryContext): Summa if (key === 'thing.stock_use' || key === 'thing.stock_return') { // The service's subject: its name (a noun when the reader can't see it), and its id for the // link, read from the rendered diff (plain ids, never money). - const thing = plainOf(event, 'service_thing_id')?.after; - const place = plainOf(event, 'service_place_id')?.after; + const thing = afterOf(event, 'service_thing_id'); + const place = afterOf(event, 'service_place_id'); params.service = ctx.service ?? (typeof place === 'string' ? 'a place' : 'a thing'); if (typeof thing === 'string') params.serviceThingId = thing; else if (typeof place === 'string') params.servicePlaceId = place; @@ -168,10 +568,22 @@ export function summarise(event: RenderedAuditEvent, ctx: SummaryContext): Summa if (key === 'thing.lifecycle') { // The stored code (`given_away`), never English: the web says it in the reader's language. // Left out when the row shows no lifecycle change. - const lifecycle = plainOf(event, 'lifecycle')?.after; + const lifecycle = afterOf(event, 'lifecycle'); if (typeof lifecycle === 'string') params.lifecycle = lifecycle; } + if (key.startsWith('label_batch.create')) { + params.count = String(countOf(afterOf(event, 'code_count')) ?? 0); + } } - const sentence = ENGLISH[key] ?? ENGLISH.event; - return { summaryKey: key, summaryParams: params, summary: (sentence as Sentence)(params) }; + return { + summaryKey: key, + summaryParams: params, + summary: ENGLISH[key](params), + ...(lines.length ? { summaryLines: rendered(lines) } : {}), + }; +} + +/** The English of `key` with `params`: for the completeness test, and nothing else. */ +export function englishOf(key: SummaryKey, params: SummaryParams): string { + return ENGLISH[key](params); } diff --git a/apps/server/src/imports/archive.ts b/apps/server/src/imports/archive.ts index 35c283ca..af1e53ea 100644 --- a/apps/server/src/imports/archive.ts +++ b/apps/server/src/imports/archive.ts @@ -5,6 +5,7 @@ import { ARCHIVE_SOURCES, type ArchiveRefusal, type ArchiveSource, + type AuditAction, HomeboxChoices, IMPORT_ISSUE_CODES, IMPORT_STALE_MINUTES, @@ -260,7 +261,7 @@ type Ctx = { tx: Tx; client: pg.PoolClient; scope: Scope; requestId: string }; export async function auditRun( c: Ctx, run: Pick, - action: string, + action: AuditAction, after: Record, before: Record | null = null, ): Promise { @@ -335,7 +336,7 @@ async function refuse( req: FastifyRequest, run: ArchiveRunRow, err: ArchiveError | ArchiveContentError, - action: string, + action: AuditAction, ): Promise { // The detail may hold a name from the archive: logged, never answered. req.log.info( @@ -369,7 +370,7 @@ async function withArchive( req: FastifyRequest, files: FileStorage, run: ArchiveRunRow, - action: string, + action: AuditAction, fn: (archive: OpenArchive, importer: ArchiveImporter) => Promise, ): Promise { const importer = importerOf(run.source); diff --git a/apps/server/src/lending/service.ts b/apps/server/src/lending/service.ts index 008b01a5..5f7bbecf 100644 --- a/apps/server/src/lending/service.ts +++ b/apps/server/src/lending/service.ts @@ -1,4 +1,4 @@ -import { type LoanDirection, newId } from '@kept/shared'; +import { type AuditAction, type LoanDirection, newId } from '@kept/shared'; import type pg from 'pg'; import { actorOf } from '../audit/actor.js'; import { audited } from '../audit/audited.js'; @@ -219,7 +219,7 @@ async function thingRow(ctx: Ctx, id: string): Promise { async function auditLoan( ctx: Ctx, - action: string, + action: AuditAction, before: LoanRecord | null, after: LoanRecord | null, extra: { before?: Record; after?: Record } = {}, diff --git a/apps/server/src/locations/membership-jobs.ts b/apps/server/src/locations/membership-jobs.ts index 151af7aa..e992459c 100644 --- a/apps/server/src/locations/membership-jobs.ts +++ b/apps/server/src/locations/membership-jobs.ts @@ -1,3 +1,4 @@ +import type { AuditAction } from '@kept/shared'; import type pg from 'pg'; import { audited } from '../audit/audited.js'; import { isUndeliverableEmail } from '../auth/emails.js'; @@ -28,7 +29,7 @@ async function auditSystem( tx: Tx, client: pg.ClientBase, locationId: string, - action: string, + action: AuditAction, membershipId: string, detail: Record, ): Promise { diff --git a/apps/server/src/notices/transparency.ts b/apps/server/src/notices/transparency.ts index dec82756..5e5020a8 100644 --- a/apps/server/src/notices/transparency.ts +++ b/apps/server/src/notices/transparency.ts @@ -1,5 +1,5 @@ import { createHash } from 'node:crypto'; -import { PROVIDER_KINDS, type ProviderKind } from '@kept/shared'; +import { type AuditAction, PROVIDER_KINDS, type ProviderKind } from '@kept/shared'; import type pg from 'pg'; import { audited } from '../audit/audited.js'; import { isUndeliverableEmail } from '../auth/emails.js'; @@ -105,7 +105,7 @@ async function checkOne( deps: ConfigNoticeDeps, key: string, hash: string, - action: string, + action: AuditAction, facts: Record, ): Promise { return withSystem(deps.pools.system, async (tx, client) => { diff --git a/apps/server/src/notify/prefs.ts b/apps/server/src/notify/prefs.ts index 8d0eb94c..74f3a774 100644 --- a/apps/server/src/notify/prefs.ts +++ b/apps/server/src/notify/prefs.ts @@ -1,5 +1,6 @@ import { ACCOUNT_LEVEL_KINDS, + type AuditAction, defaultPreference, NOTIFY_KINDS, type NotifyKind, @@ -214,7 +215,7 @@ export async function auditMine( tx: Tx, userId: string, event: { - action: string; + action: AuditAction; entity: { type: string; id: string | null }; before?: Record | null; after?: Record | null; diff --git a/apps/server/src/registries/account.ts b/apps/server/src/registries/account.ts index 11c9b399..69ef4059 100644 --- a/apps/server/src/registries/account.ts +++ b/apps/server/src/registries/account.ts @@ -1,4 +1,4 @@ -import { type Action, can, type Role } from '@kept/shared'; +import { type Action, type AuditAction, can, type Role } from '@kept/shared'; import type pg from 'pg'; import { actorOf } from '../audit/actor.js'; import { audited } from '../audit/audited.js'; @@ -71,7 +71,7 @@ export async function auditRegistry( accountId: string; userId: string; tokenId?: string | null; - action: string; + action: AuditAction; entity: { type: string; id: string }; before?: Record | null; after?: Record | null; diff --git a/apps/server/src/schedules/service.ts b/apps/server/src/schedules/service.ts index 5bd7da5e..0cc6ea69 100644 --- a/apps/server/src/schedules/service.ts +++ b/apps/server/src/schedules/service.ts @@ -1,4 +1,4 @@ -import { type Action, type ModuleId, newId, type Role } from '@kept/shared'; +import { type Action, type AuditAction, type ModuleId, newId, type Role } from '@kept/shared'; import type pg from 'pg'; import { actorOf } from '../audit/actor.js'; import { audited } from '../audit/audited.js'; @@ -166,7 +166,7 @@ const subjectsOf = (r: { thing_id: string | null }) => (r.thing_id ? [r.thing_id async function auditSchedule( ctx: Ctx, - action: string, + action: AuditAction, before: ScheduleRow | null, after: ScheduleRow | null, undoable: boolean, diff --git a/apps/server/src/schedules/services.ts b/apps/server/src/schedules/services.ts index 490f4d2b..717cb458 100644 --- a/apps/server/src/schedules/services.ts +++ b/apps/server/src/schedules/services.ts @@ -1,4 +1,4 @@ -import { isDateFilterValue, newId, type ServiceLineKind } from '@kept/shared'; +import { type AuditAction, isDateFilterValue, newId, type ServiceLineKind } from '@kept/shared'; import type pg from 'pg'; import { actorOf } from '../audit/actor.js'; import { audited } from '../audit/audited.js'; @@ -418,7 +418,7 @@ export async function noonOf( export type CreateServiceOptions = { /** The action of its audit event: `schedule.complete` from POST …/schedules/:id/complete. */ - action?: string; + action?: AuditAction; }; /** diff --git a/apps/server/src/schedules/undo.ts b/apps/server/src/schedules/undo.ts index b4bd0495..d977a6fa 100644 --- a/apps/server/src/schedules/undo.ts +++ b/apps/server/src/schedules/undo.ts @@ -488,7 +488,7 @@ export function registerScheduleUndo(): void { 'schedule.snooze', 'schedule.skip', 'schedule.unsnooze', - ]) { + ] as const) { registerUndo(action, undoScheduleFields); } registerUndo('schedule.delete', undoScheduleDelete); diff --git a/apps/server/src/things/move.ts b/apps/server/src/things/move.ts index 4d73833c..5f998a82 100644 --- a/apps/server/src/things/move.ts +++ b/apps/server/src/things/move.ts @@ -626,7 +626,7 @@ async function moveAndAudit( const now = after.get(before.id) as Moving; const event = { actor, - action: 'thing.move', + action: 'thing.move' as const, entity: { type: 'thing', id: before.id }, before: image(before), after: image(now), diff --git a/apps/server/src/things/service.ts b/apps/server/src/things/service.ts index c25b432e..8c34c065 100644 --- a/apps/server/src/things/service.ts +++ b/apps/server/src/things/service.ts @@ -1,5 +1,6 @@ import { type Action, + type AuditAction, can, canonicalAmount, isShortCode, @@ -289,7 +290,7 @@ export type CaptureExtras = { fieldStatus?: Record; /** The caller checked the client id already (a sync op's 90-day window, Q2). */ idChecked?: boolean; - audit?: { action: string; after?: Record; undoableUntil?: Date }; + audit?: { action: AuditAction; after?: Record; undoableUntil?: Date }; }; /** CreateBody, with the name a draft may leave out. */ diff --git a/apps/web/src/api/inventory/mock/db.ts b/apps/web/src/api/inventory/mock/db.ts index ed3b2217..4fdf0481 100644 --- a/apps/web/src/api/inventory/mock/db.ts +++ b/apps/web/src/api/inventory/mock/db.ts @@ -4,6 +4,7 @@ * the way the server does), pagination over `next_cursor`, If-Match checks with the D156 412 * body, and a small Arabic-aware fold for search. Test and demo data only. */ +import { isAuditAction } from '@kept/shared'; import type { LocationDetail } from '../../types'; import type { AccountSummary, @@ -301,8 +302,11 @@ export function summaryOf( : { summaryKey: action, summaryParams: { name }, summary: `Changed the status of ${name}` }; } const sentence = SENTENCES[action]; + // #140: every action the server writes has a key of its own; `event` is for unknown ones. if (!sentence) - return { summaryKey: 'event', summaryParams: { action }, summary: action.replace('.', ' ') }; + return isAuditAction(action) + ? { summaryKey: action, summaryParams: { name }, summary: action.replace('.', ' ') } + : { summaryKey: 'event', summaryParams: { action }, summary: action.replace('.', ' ') }; return { summaryKey: action, summaryParams: action === 'thing.move.in' ? {} : { name }, diff --git a/apps/web/src/api/inventory/mock/fixtures.ts b/apps/web/src/api/inventory/mock/fixtures.ts index 669b99ba..4a167c83 100644 --- a/apps/web/src/api/inventory/mock/fixtures.ts +++ b/apps/web/src/api/inventory/mock/fixtures.ts @@ -11,6 +11,7 @@ import type { LocationDetail } from '../../types'; import type { Capability, FieldKind, + HistoryEvent, PlaceKindNode, ResolvedField, ThingView, @@ -964,6 +965,8 @@ export function inventoryFixtures(): InventoryState { event(4, 'thing.create', 'thing', I.thing.arHdmi, LOC.family, 'بروس', 'كابل HDMI', 3), event(5, 'place.create', 'place', I.place.office, LOC.home, 'Ibrahim', 'Office', 9), event(6, 'thing.trash', 'thing', I.thing.brokenLamp, LOC.home, 'Ibrahim', 'Broken lamp', 5), + // #140: an arrangement, a capture and a report, as the server says them. + ...said(), ], hints: [], checklistDismissed: false, @@ -971,6 +974,79 @@ export function inventoryFixtures(): InventoryState { }; } +/** An arrangement, a capture and a report in Home, in the server's words (#140). */ +function said(): HistoryEvent[] { + const at = (n: number, daysAgo: number) => ({ + id: `01926f00-0000-7000-8000-0000000ee${String(n).padStart(3, '0')}`, + at: ago(daysAgo), + location_id: LOC.home, + actor: { type: 'user' as const, id: null, displayName: 'Ibrahim' }, + undo_of: null, + undoable_until: null, + }); + const plain = (after: unknown) => ({ before: null, after, class: 'plain' as const }); + return [ + { + ...at(7, 10), + action: 'place.arrange', + entity: { type: 'location', id: LOC.home }, + root_thing_id: null, + diff: {}, + summaryKey: 'place.arrange', + summaryParams: { name: 'Home' }, + summary: 'Arranged the map', + summaryLines: [ + { + key: 'map.put.room', + params: { name: 'Galaxy S23', room: 'Office' }, + text: 'Put Galaxy S23 on the map in Office', + }, + { key: 'map.move', params: { name: 'Cable box' }, text: 'Moved Cable box' }, + { key: 'map.turn', params: { name: 'Samsung TV, 55″' }, text: 'Turned Samsung TV, 55″' }, + { + key: 'map.floor', + params: { name: 'Living room', from: 'wood', to: 'tile', vocab: 'surface' }, + text: 'Changed the floor of Living room: wood → tile', + }, + ], + }, + { + ...at(8, 10.1), + action: 'thing.capture', + entity: { type: 'thing', id: I.thing.drill }, + root_thing_id: I.thing.drill, + diff: { + name: plain('Bosch drill, 18 V'), + model: plain('GSR 18V-55'), + mode: plain('thing'), + file_ids: plain(['01926f00-0000-7000-8000-0000000ef001']), + }, + summaryKey: 'thing.capture', + summaryParams: { name: 'Bosch drill, 18 V' }, + summary: 'Captured Bosch drill, 18 V from a photo', + }, + { + ...at(9, 10.2), + action: 'report.generate', + entity: { type: 'report', id: '01926f00-0000-7000-8000-0000000ef002' }, + root_thing_id: null, + diff: { + kind: plain('insurance'), + locale: plain('en'), + digits: plain('latn'), + include: plain({ photos: true }), + }, + summaryKey: 'report.generate.insurance', + summaryParams: { name: 'Home' }, + summary: 'Made an insurance report for Home', + summaryLines: [ + { key: 'line.things', params: { count: '8' }, text: '8 things' }, + { key: 'line.photos', params: {}, text: 'With photos' }, + ], + }, + ]; +} + function event( n: number, action: string, diff --git a/apps/web/src/api/inventory/types.ts b/apps/web/src/api/inventory/types.ts index b8500387..270e9312 100644 --- a/apps/web/src/api/inventory/types.ts +++ b/apps/web/src/api/inventory/types.ts @@ -1271,6 +1271,9 @@ export type RenderedChange = ( * A history or activity row: the server's `RenderedAuditEvent` (snake_case, as `renderAudit` * returns it) plus the actor's display name and a one-line summary (plan task 21). */ +/** A history row's detail line (#140): a key and params the web says, and the server's English. */ +export type SummaryLineView = { key: string; params: Record; text: string }; + export type HistoryEvent = { id: string; at: string; @@ -1295,6 +1298,8 @@ export type HistoryEvent = { */ summaryKey: string; summaryParams: Record; + /** #140: what the action covered, one line each (an arrangement's changes, a report's counts). */ + summaryLines?: SummaryLineView[]; /** A `thing.move` arriving from a location the viewer can't see (D183): rendered with no diff. */ movedInFromElsewhere?: boolean; /** diff --git a/apps/web/src/components/history/fields.test.ts b/apps/web/src/components/history/fields.test.ts new file mode 100644 index 00000000..677855c5 --- /dev/null +++ b/apps/web/src/components/history/fields.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest'; +import { fieldShown, isNewValue } from './fields'; + +describe('fieldShown (#140)', () => { + it.each([ + ['name', 'thing', true], + ['notes', 'thing', true], + ['quantity', 'thing', true], + ['custom.insured_value', 'thing', true], + ['custom.serial_id', 'thing', true], + ['place_id', 'thing', false], + ['file_ids', 'thing', false], + ['thingIds', 'thing', false], + ['id', 'thing', false], + ['version', 'thing', false], + ['moved', 'place', false], + ['trashed', 'place', false], + ['digits', 'location', false], + ['locale', 'location', false], + ['include', 'report', false], + ['filters', 'report', false], + ['kind', 'report', false], + ['layout', 'location', false], + ['name', 'location', true], + ['mime', 'file', false], + ])('%s on a %s: %s', (key, entity, shown) => { + expect(fieldShown(key, entity)).toBe(shown); + }); + + it('treats a field set from nothing as new', () => { + for (const v of [null, undefined, '', [], {}]) expect(isNewValue(v)).toBe(true); + for (const v of ['Desk', 0, false, ['a']]) expect(isNewValue(v)).toBe(false); + }); +}); diff --git a/apps/web/src/components/history/fields.ts b/apps/web/src/components/history/fields.ts new file mode 100644 index 00000000..a476adc5 --- /dev/null +++ b/apps/web/src/components/history/fields.ts @@ -0,0 +1,79 @@ +/** + * Which fields of a rendered audit diff a reader sees (#140). The summary line says what happened + * and its detail lines say what it covered ("12 things", "With photos"); a field earns a line of + * its own only when it is something a person set and would recognise. Everything else stays in + * the audit, not on the page: + * + * - ids and id lists (`place_id`, `file_ids`, `thingIds`): a reader can't use a UUID; + * - bookkeeping: row versions, counters a trash or an undo keeps for itself, order, storage; + * - a report's or a print's settings (digits, locale, filters, include): its sentence and lines + * say what it was; + * - an arrangement's layout, which the server says change by change; + * - every field of a row whose sentence is the whole story (a report, a label batch, an inbox + * item, an upload, an undone capture). + */ +const HIDDEN = new Set([ + // Step 4 (T29): what a delete keeps so undo can put it back (its documents, a service's lines + // and the schedules it completed), not a change anyone made. + 'documents', + 'lines', + 'completes', + // Step 5: what a service or a fill keeps for its undo, and a starter set's or a draft's ids. + 'reading_created', + 'cleared_schedules', + 'surface', + 'sort', + 'class', + 'has_gps', + 'derivative_state', + 'sha256', + 'width', + 'height', + // #140: bookkeeping and settings. + 'version', + 'row_version', + 'meter_version', + 'created_via', + 'moved', + 'trashed', + 'restored', + 'contents', + 'deleted_at', + 'digits', + 'locale', + 'include', + 'filters', + 'scope', + 'layout', + 'mode', + 'meter_created', + 'token_hash', + 'unprinted', +]); + +/** Entities whose sentence says it all. */ +const SENTENCE_ONLY = new Set(['report', 'label_batch', 'capture_batch', 'inbox_item', 'file']); + +/** `id`, `place_id`, `file_ids`, `thingId`, `attachmentIds`. */ +const ID = /(^id$|_ids?$|[a-z]Ids?$)/; + +/** Whether a diff field gets a line of its own on `entity`'s row. */ +export function fieldShown(key: string, entity?: string): boolean { + if (entity && SENTENCE_ONLY.has(entity)) return false; + if (key.startsWith('custom.') || key.startsWith('archived_custom.')) return true; + return !HIDDEN.has(key) && !ID.test(key); +} + +const isEmpty = (v: unknown): boolean => + v === null || + v === undefined || + v === '' || + (Array.isArray(v) && v.length === 0) || + (typeof v === 'object' && !Array.isArray(v) && Object.keys(v as object).length === 0); + +/** New is new (#140): a field that had no value shows only the one it got, with no arrow. */ +export function isNewValue(before: unknown): boolean { + return isEmpty(before); +} + +export { isEmpty as isEmptyValue }; diff --git a/apps/web/src/components/history/labels.test.tsx b/apps/web/src/components/history/labels.test.tsx new file mode 100644 index 00000000..6d450c20 --- /dev/null +++ b/apps/web/src/components/history/labels.test.tsx @@ -0,0 +1,50 @@ +import { SUMMARY_KEYS, SUMMARY_LINE_KEYS } from '@kept/shared'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { AppProviders } from '@/app-providers'; +import { activateLocale } from '@/i18n/i18n'; +import { plainText, useSummary, useSummaryLine } from './labels'; + +// #140: every key the server can send reads as a sentence in the reader's language, never the +// server's English fallback. The sentence map is typed by SUMMARY_KEYS, so a missing key fails the +// typecheck; this checks each one renders, in English and in Arabic. + +const FALLBACK = 'English fallback'; +const params = { name: 'Desk lamp', count: '3', service: 'Car', host: 'Desk', room: 'Study' }; + +function All() { + const say = useSummary(); + const line = useSummaryLine(); + return ( +
    + {SUMMARY_KEYS.filter((k) => k !== 'event').map((key) => ( +
  • + {plainText(say({ summaryKey: key, summaryParams: params, summary: FALLBACK }))} +
  • + ))} + {SUMMARY_LINE_KEYS.map((key) => ( +
  • + {plainText(line({ key, params: { ...params, from: 'a', to: 'b' }, text: FALLBACK }))} +
  • + ))} +
+ ); +} + +describe.each(['en', 'ar'] as const)('every summary in %s', (locale) => { + it('is a sentence of its own', async () => { + await activateLocale(locale); + render( + + + , + ); + const items = await screen.findAllByRole('listitem'); + expect(items).toHaveLength(SUMMARY_KEYS.length - 1 + SUMMARY_LINE_KEYS.length); + for (const li of items) { + const text = li.textContent ?? ''; + expect(text, li.dataset.key).not.toBe(FALLBACK); + expect(text, li.dataset.key).not.toMatch(/^$|\{|undefined|[a-z]+\.[a-z_]+/); + } + }); +}); diff --git a/apps/web/src/components/history/labels.ts b/apps/web/src/components/history/labels.ts index 5d961fbb..2b09c231 100644 --- a/apps/web/src/components/history/labels.ts +++ b/apps/web/src/components/history/labels.ts @@ -4,8 +4,10 @@ * them. An unknown key is shown as itself, made readable ("insured_value" → "Insured value"): * a field the client doesn't know yet still says what changed. */ +import { SUMMARY_KEYS, type SummaryKey, type SummaryLineKey } from '@kept/shared'; +import { plural } from '@lingui/core/macro'; import { useLingui } from '@lingui/react/macro'; -import type { HistoryEvent, RenderedChange } from '@/api/inventory/types'; +import type { HistoryEvent, RenderedChange, SummaryLineView } from '@/api/inventory/types'; import { useClaimStatusLabels } from '@/components/claims/labels'; import { useDocumentKindLabels } from '@/components/documents/labels'; import { @@ -16,6 +18,7 @@ import { import { useFieldLabel as useTypeFieldLabel } from '@/components/things/names'; import { useWarrantyKindLabels } from '@/components/warranties/labels'; import { isolate } from '@/lib/bidi'; +import { useModelNames, useRoomKindNames, useSurfaceNames } from '@/map/labels'; /** "custom.insured_value" → "Insured value"; "safe_code" → "Safe code". */ export function readableKey(key: string): string { @@ -167,15 +170,373 @@ export { FSI, PDI, plainText } from '@/lib/bidi'; /** * The summary line of a history or activity row in the reader's language, from the server's - * `summaryKey` and `summaryParams` (apps/server/src/history/summary.ts). A key this build - * doesn't know falls back to the server's English `summary`. The name in it is wrapped in FSI…PDI - * (see `isolate`); EventRow renders those as and strips them from accessible names. + * `summaryKey` and `summaryParams` (apps/server/src/history/summary.ts). Every key the server + * sends has a sentence here (#140: the map is typed by @kept/shared's SUMMARY_KEYS, so a new + * action without one fails the typecheck); only a key this build doesn't know falls back to the + * server's English `summary`. The name in it is wrapped in FSI…PDI (see `isolate`); EventRow + * renders those as and strips them from accessible names. */ export function useSummary() { const { t } = useLingui(); const words = useValueWords(); - // The server's stand-in nouns when the name isn't visible to the reader. - const nouns: Record = { + const nouns = useNouns(); + /** What a stock use was for: its name, or the server's noun when the reader can't see it. */ + const serviceName = (raw: string | undefined) => nouns[raw ?? ''] ?? isolate(raw ?? ''); + return (e: Pick) => { + const p = e.summaryParams; + const raw = p.name ?? ''; + const name = nouns[raw] ?? isolate(raw); + // An older server sends `event` with the action for what it had no sentence for. + const key = e.summaryKey === 'event' ? (p.action ?? '') : e.summaryKey; + if (!isSummaryKey(key) || key === 'event') return e.summary; + if (key === 'thing.lifecycle') { + // The stored code (`given_away`), left out when the row shows no lifecycle change. + const code = p.lifecycle; + if (!code) return t`Changed the status of ${name}`; + const status = words('lifecycle', code).toLocaleLowerCase(); + return t`Marked ${name} as ${status}`; + } + const service = serviceName(p.service); + const count = Number(p.count ?? 0); + return sentences({ name, service, count })[key](); + }; + + /** Every key's sentence, built lazily: only the one asked for is translated. */ + function sentences({ + name, + service, + count, + }: Vars): Record, () => string> { + return { + 'thing.create': () => t`Added ${name}`, + 'place.create': () => t`Added ${name}`, + 'thing.update': () => t`Edited ${name}`, + 'place.update': () => t`Edited ${name}`, + 'thing.retype': () => t`Changed the type of ${name}`, + 'thing.move': () => t`Moved ${name}`, + 'place.move': () => t`Moved ${name}`, + 'thing.move.in': () => t`Moved in from another location`, + 'thing.move.out': () => t`Moved ${name} to another location`, + 'thing.trash': () => t`Trashed ${name}`, + 'place.trash': () => t`Trashed ${name}`, + 'thing.restore': () => t`Restored ${name}`, + 'place.restore': () => t`Restored ${name}`, + 'thing.delete': () => t`Deleted ${name} for good`, + 'place.delete': () => t`Deleted ${name} for good`, + 'thing.seen': () => t`Saw ${name}`, + 'thing.not_here': () => t`Marked ${name} as not here`, + 'thing.split': () => t`Split ${name}`, + 'thing.duplicate': () => t`Duplicated ${name}`, + 'thing.link': () => t`Linked ${name}`, + 'thing.unlink': () => t`Unlinked ${name}`, + 'thing.convert_to_place': () => t`Turned ${name} into a place`, + 'thing.codes': () => t`Changed the codes of ${name}`, + 'place.codes': () => t`Changed the codes of ${name}`, + 'place.merge': () => t`Merged ${name} into another place`, + 'place.convert_to_container': () => t`Turned ${name} into a container`, + 'place.label': () => t`Labelled ${name}`, + 'attachment.create': () => t`Attached a file to ${name}`, + 'attachment.update': () => t`Changed a file on ${name}`, + 'attachment.delete': () => t`Removed a file from ${name}`, + 'thing.stock_use': () => t`Used ${name} in a service of ${service}`, + 'thing.stock_return': () => t`Put ${name} back from a service of ${service}`, + undo: () => t`Undid a change to ${name}`, + 'thing.capture.attach': () => t`Added a photo to ${name}`, + 'thing.capture.named': () => t`Added ${name}`, + // Step 2 onwards: what used to fall back to the action's words. + 'brand.create': () => t`Brand added`, + 'brand.update': () => t`Brand changed`, + 'brand.delete': () => t`Brand deleted`, + 'brand.merge': () => t`Brand merged`, + 'vendor.create': () => t`Vendor added`, + 'vendor.update': () => t`Vendor changed`, + 'vendor.delete': () => t`Vendor deleted`, + 'vendor.merge': () => t`Vendor merged`, + 'person.create': () => t`Person added`, + 'person.update': () => t`Person changed`, + 'person.delete': () => t`Person deleted`, + 'person.merge': () => t`Person merged`, + 'person.contact_update': () => t`Contact details changed`, + 'tag.create': () => t`Tag added`, + 'tag.update': () => t`Tag changed`, + 'tag.delete': () => t`Tag deleted`, + 'tag.merge': () => t`Tag merged`, + 'type.create': () => t`Type added`, + 'type.update': () => t`Type changed`, + 'type.delete': () => t`Type deleted`, + 'type.merge': () => t`Type merged`, + 'type.customise': () => t`Type customised`, + 'type_field.create': () => t`Field added`, + 'type_field.update': () => t`Field changed`, + 'type_field.archive': () => t`Field archived`, + 'type_field.restore': () => t`Field restored`, + 'place_kind.create': () => t`Place kind added`, + 'place_kind.update': () => t`Place kind changed`, + 'place_kind.customise': () => t`Place kind customised`, + 'file.upload': () => t`File uploaded`, + 'file.delete_original': () => t`Original file deleted`, + 'purchase.create': () => t`Purchase added`, + 'purchase.update': () => t`Purchase changed`, + 'purchase.delete': () => t`Purchase deleted`, + 'purchase_line.create': () => t`Purchase line added`, + 'purchase_line.update': () => t`Purchase line changed`, + 'purchase_line.delete': () => t`Purchase line deleted`, + 'thing.purchase_link': () => t`Linked to a purchase`, + 'thing.purchase_unlink': () => t`Unlinked from a purchase`, + 'thing.label': () => t`Label assigned`, + 'meter.create': () => t`Meter added`, + 'meter.update': () => t`Meter changed`, + 'meter.replaced': () => t`Meter replaced`, + 'reading.create': () => t`Reading logged`, + 'reading.update': () => t`Reading changed`, + 'reading.accept': () => t`Reading kept`, + 'reading.delete': () => t`Reading discarded`, + 'saved_view.create': () => t`Saved view added`, + 'location.create': () => t`Location created`, + 'location.update': () => t`Location settings changed`, + 'location.modules': () => t`What to track changed`, + 'location.delete': () => t`Location deleted`, + 'location.restore': () => t`Location restored`, + 'invite.create': () => t`Invite created`, + 'invite.update': () => t`Invite changed`, + 'invite.revoke': () => t`Invite revoked`, + 'member.join': () => t`Member joined`, + 'member.leave': () => t`Member left`, + 'member.update': () => t`Member's role changed`, + 'member.expire': () => t`Membership ended`, + 'managed_account.create': () => t`Managed account created`, + 'saved_view.update': () => t`Saved view changed`, + 'saved_view.delete': () => t`Saved view deleted`, + 'secret.set': () => t`Secret changed`, + 'secret.clear': () => t`Secret cleared`, + 'secret.reveal': () => t`Secret revealed`, + 'secret.copied': () => t`Secret copied`, + 'secret_policy.update': () => t`Secret policy changed`, + 'warranty.create': () => t`Warranty added`, + 'warranty.update': () => t`Warranty changed`, + 'warranty.delete': () => t`Warranty removed`, + 'claim.create': () => t`Claim opened`, + 'claim.update': () => t`Claim changed`, + 'claim.delete': () => t`Claim deleted`, + 'valuation.create': () => t`Valuation added`, + 'valuation.update': () => t`Valuation changed`, + 'valuation.delete': () => t`Valuation deleted`, + 'loan.create': () => t`Loan added`, + 'loan.update': () => t`Loan changed`, + 'loan.return': () => t`Loan returned`, + 'loan.delete': () => t`Loan deleted`, + 'schedule.create': () => t`Schedule added`, + 'schedule.update': () => t`Schedule changed`, + 'schedule.delete': () => t`Schedule deleted`, + 'schedule.snooze': () => t`Schedule snoozed`, + 'schedule.unsnooze': () => t`Snooze cleared`, + 'schedule.skip': () => t`Next one skipped`, + 'service_record.create': () => t`Service logged`, + 'service_record.update': () => t`Service changed`, + 'service_record.delete': () => t`Service deleted`, + 'service_record.draft': () => t`Service draft started from an invoice`, + 'service_record.confirm': () => t`Service logged`, + 'meter.delete': () => t`Meter removed`, + 'fuel.create': () => t`Fuel logged`, + 'fuel.update': () => t`Fuel changed`, + 'fuel.delete': () => t`Fuel deleted`, + 'schedule.starter': () => t`Starter schedules added`, + 'document.create': () => t`Document added`, + 'document.update': () => t`Document changed`, + 'document.renew': () => t`Document renewed`, + 'document.delete': () => t`Document deleted`, + 'incident.create': () => t`Incident recorded`, + 'incident.update': () => t`Incident changed`, + 'incident.things': () => t`Incident's things changed`, + 'incident.delete': () => t`Incident deleted`, + 'fx_rate.set': () => t`Exchange rate set`, + 'fx_rate.delete': () => t`Exchange rate deleted`, + 'brand.logo_set': () => t`Brand logo changed`, + 'brand.logo_remove': () => t`Brand logo removed`, + 'thing.stock_rule': () => t`Minimum to keep changed`, + 'type.field_convert': () => t`Field converted`, + 'things.export_csv': () => t`List exported as a CSV`, + 'file.import': () => t`File imported`, + 'webhook.create': () => t`Webhook added`, + 'webhook.update': () => t`Webhook changed`, + 'webhook.delete': () => t`Webhook removed`, + 'webhook.rotate_secret': () => t`Webhook secret replaced`, + 'share_link.create': () => t`Share link made`, + 'share_link.revoke': () => t`Share link revoked`, + 'move.create': () => t`Move started`, + 'move.update': () => t`Move changed`, + 'move.delete': () => t`Move deleted`, + 'move_box.create': () => t`Added to a move as a box`, + 'move_box.update': () => t`Moving box changed`, + 'move_box.unpack': () => t`Box unpacked`, + 'move_box.delete': () => t`Taken out of a move`, + 'paperless.connect': () => t`Paperless connected`, + 'paperless.update': () => t`Paperless connection changed`, + 'paperless.disconnect': () => t`Paperless disconnected`, + 'paperless_link.create': () => t`Paperless document linked`, + 'paperless_link.delete': () => t`Paperless document unlinked`, + 'paperless_link.copy': () => t`Paperless document copied into Kept`, + 'purchase.reextract': () => t`Receipt read again`, + 'thing.enrich': () => t`Other names added`, + 'import.enrich': () => t`Other names requested for an import`, + 'import.homebox_connect': () => t`Connected to Homebox`, + 'instance.setup': () => t`Kept set up`, + 'instance.settings_update': () => t`Instance settings changed`, + 'instance.backup': () => t`Backup made`, + 'instance.backup_run': () => t`Backup started by hand`, + 'instance.backup_settings': () => t`Backup settings changed`, + 'instance.backup_test': () => t`Backup target tested`, + 'instance.restore': () => t`Restored from a backup`, + 'instance.export': () => t`Whole instance exported`, + 'instance.downgrade_forced': () => t`Started on an older version anyway`, + 'instance.rotate_key': () => t`Encryption key replaced`, + 'instance.drop_key': () => t`Old encryption key removed`, + 'instance.embeddings_source': () => t`Semantic search source changed`, + 'instance.recovery_kit_download': () => t`Recovery kit downloaded`, + 'instance.recovery_kit_acknowledge': () => t`Recovery kit noted as kept`, + 'instance.update_check': () => t`Checked for updates`, + 'instance.setup_code_reissue': () => t`New setup code issued`, + 'instance.oidc_changed': () => t`OIDC sign-in settings changed`, + 'instance.smtp_changed': () => t`Email settings changed`, + // #140: a sentence for every other action, and the refinements. + 'attachment.create.photo': () => t`Added a photo to ${name}`, + 'thing.capture': () => t`Captured ${name} from a photo`, + 'thing.capture.reading': () => t`Logged a reading of ${name} from a photo`, + 'thing.extract': () => t`Filled in ${name} from a photo`, + 'thing.reextract': () => t`Read the photo of ${name} again`, + 'thing.merge': () => t`Merged a duplicate into ${name}`, + 'place.arrange': () => t`Arranged the map`, + 'box.check': () => t`Checked what is in ${name}`, + 'label.claim': () => t`Gave ${name} a label`, + 'location.own_codes': () => t`Changed how ${name} numbers its codes`, + 'location.transfer_ownership': () => t`Handed ${name} to a new owner`, + 'report.generate': () => t`Made an inventory report of ${name}`, + 'report.generate.insurance': () => t`Made an insurance report for ${name}`, + 'report.generate.vehicle': () => t`Made a vehicle history report for ${name}`, + 'account.deletion_cancel': () => t`Cancelled the account deletion`, + 'account.deletion_request': () => t`Asked to delete the account`, + 'account.email_change': () => t`Changed the account email`, + 'admin.cli_reset_password': () => t`Reset a password from the command line`, + 'admin.cli_user_disable': () => t`Turned off an account from the command line`, + 'admin.cli_user_enable': () => t`Turned an account back on from the command line`, + 'admin.currency_disable': () => t`Turned off a currency`, + 'admin.currency_enable': () => t`Turned on a currency`, + 'admin.instance_admin_grant': () => t`Made someone an instance admin`, + 'admin.instance_admin_revoke': () => t`Removed an instance admin`, + 'admin.job_discard': () => t`Discarded a failed job`, + 'admin.job_retry': () => t`Retried a failed job`, + 'admin.user_disable': () => t`Turned off an account`, + 'admin.user_enable': () => t`Turned an account back on`, + 'admin.user_reset_2fa': () => t`Reset someone’s two-step sign-in`, + 'admin.user_sign_out_everywhere': () => t`Signed someone out everywhere`, + 'ai.cap_clear': () => t`Removed the AI spending cap`, + 'ai.cap_set': () => t`Set the AI spending cap`, + 'ai.pause': () => t`Paused AI`, + 'ai.resume': () => t`Resumed AI`, + 'ai.price_prefill': () => t`Looked up AI prices`, + 'ai.price_recost': () => t`Worked out AI costs again with new prices`, + 'ai.price_remove': () => t`Removed an AI price`, + 'ai.price_set': () => t`Set an AI price`, + 'ai.provider_models': () => t`Listed an AI provider’s models`, + 'ai.provider_remove': () => t`Removed an AI provider`, + 'ai.provider_set': () => t`Set up an AI provider`, + 'ai.provider_test': () => t`Tested an AI provider`, + 'ai.usage_export': () => t`Exported AI usage`, + 'instance.google_changed': () => t`Google sign-in settings changed`, + 'managed_account.password_set': () => t`Set a managed account’s password`, + 'managed_account.reset_code': () => t`Made a reset code for a managed account`, + 'me.notification_preferences': () => t`Changed notification settings`, + 'me.notifications': () => t`Changed which notifications arrive`, + 'me.preferences': () => t`Changed preferences`, + 'session.revoke': () => t`Signed out a session`, + 'oauth.deny': () => t`Turned down an app’s request to connect`, + 'oauth.grant': () => t`Let an app connect`, + 'token.create': () => t`Made an access token`, + 'token.revoke': () => t`Revoked an access token`, + 'token.update': () => t`Changed an access token`, + 'push_subscription.create': () => t`Turned on notifications on a device`, + 'push_subscription.delete': () => t`Turned off notifications on a device`, + 'push_subscription.update': () => t`Changed notifications on a device`, + 'hint.dismiss': () => t`Dismissed a tip`, + 'hint.restore': () => t`Brought back a tip`, + 'hint.seen': () => t`Read a tip`, + 'saved_view_prefs.update': () => t`Changed how a list shows`, + 'member.remove': () => t`Removed a member`, + 'member.recap': () => t`Changed when guest access ends`, + 'member.owner_notified': () => t`Told the owner about a new guest`, + 'mailbox.create': () => t`Set up the mailbox`, + 'mailbox.update': () => t`Changed the mailbox`, + 'mailbox.delete': () => t`Removed the mailbox`, + 'channel.create': () => t`Added a notification channel`, + 'channel.delete': () => t`Removed a notification channel`, + 'calendar_feed.create': () => t`Made a calendar feed`, + 'calendar_feed.revoke': () => t`Revoked a calendar feed`, + 'capture.batch_undo': () => t`Undid a capture`, + 'inbox.discard': () => t`Discarded a draft`, + 'inbox.resolve': () => t`Dealt with an inbox item`, + 'inbox.restore': () => t`Brought back an inbox item`, + 'inbox.bulk': () => t`Changed several inbox items at once`, + 'labels.printed': () => t`Printed labels`, + 'purchase.capture': () => t`Captured a receipt`, + 'purchase.confirm': () => t`Confirmed a receipt`, + 'purchase.extract': () => t`Read a receipt`, + 'reading.extract': () => t`Read a meter from a photo`, + 'file.crop': () => t`Cropped a photo`, + 'file.display_set': () => t`Changed the photo shown`, + 'template.create': () => t`Added a template`, + 'template.update': () => t`Changed a template`, + 'template.delete': () => t`Deleted a template`, + 'claim.status': () => t`Changed a claim’s status`, + 'claim_pack.create': () => t`Made a claim pack`, + 'claim_pack.link': () => t`Shared a claim pack link`, + 'claim_pack.link_revoke': () => t`Revoked a claim pack link`, + 'condition_report.create': () => t`Started a condition report`, + 'condition_report.update': () => t`Changed a condition report`, + 'condition_report.finish': () => t`Finished a condition report`, + 'condition_report.delete': () => t`Deleted a condition report`, + 'condition_room.create': () => t`Added a room to a condition report`, + 'condition_room.update': () => t`Changed a room in a condition report`, + 'condition_room.delete': () => t`Removed a room from a condition report`, + 'export.create': () => t`Started an export`, + 'export.cancel': () => t`Cancelled an export`, + 'export.download': () => t`Downloaded an export`, + 'export.secrets': () => t`Put secrets in an export`, + 'import.create': () => t`Uploaded a file to import`, + 'import.archive': () => t`Uploaded an archive to import`, + 'import.inspect': () => t`Opened an archive to import`, + 'import.passphrase': () => t`Entered an import’s passphrase`, + 'import.target': () => t`Chose where an import goes`, + 'import.choices': () => t`Changed an import’s choices`, + 'import.check': () => t`Checked an import`, + 'import.start': () => t`Started an import`, + 'import.run': () => t`Ran an import`, + 'import.cancel': () => t`Cancelled an import`, + 'report.export_csv': () => t`Exported a report as a CSV`, + 'report.schedule': () => t`Scheduled a report`, + 'report.unschedule': () => t`Stopped a scheduled report`, + 'report.generate.condition': () => t`Made a condition report for ${name}`, + 'report.generate.move': () => t`Made a moving report for ${name}`, + 'label_batch.create': () => + plural(count, { one: 'Made # blank label', other: 'Made # blank labels' }), + 'label_batch.create.things': () => + plural(count, { one: 'Made labels for # thing', other: 'Made labels for # things' }), + 'label_batch.create.places': () => + plural(count, { one: 'Made labels for # place', other: 'Made labels for # places' }), + 'place.arrange.many': () => + plural(count, { one: 'Arranged the map: # change', other: 'Arranged the map: # changes' }), + }; + } +} + +type Vars = { name: string; service: string; count: number }; + +const KEYS: ReadonlySet = new Set(SUMMARY_KEYS); +const isSummaryKey = (key: string): key is SummaryKey => KEYS.has(key); + +/** The server's stand-in nouns when the name isn't visible to the reader. */ +function useNouns(): Record { + const { t } = useLingui(); + return { 'a thing': t`a thing`, 'a place': t`a place`, 'a type': t`a type`, @@ -188,259 +549,54 @@ export function useSummary() { 'the location': t`the location`, something: t`something`, }; - /** What a stock use was for: its name, or the server's noun when the reader can't see it. */ - const serviceName = (raw: string | undefined) => nouns[raw ?? ''] ?? isolate(raw ?? ''); - return (e: Pick) => { - const raw = e.summaryParams.name ?? ''; - const name = nouns[raw] ?? isolate(raw); - switch (e.summaryKey) { - case 'thing.create': - case 'place.create': - return t`Added ${name}`; - case 'thing.update': - case 'place.update': - return t`Edited ${name}`; - case 'thing.retype': - return t`Changed the type of ${name}`; - case 'thing.lifecycle': { - // The stored code (`given_away`), left out when the row shows no lifecycle change. - const code = e.summaryParams.lifecycle; - if (!code) return t`Changed the status of ${name}`; - const status = words('lifecycle', code).toLocaleLowerCase(); - return t`Marked ${name} as ${status}`; - } - case 'thing.move': - case 'place.move': - return t`Moved ${name}`; - case 'thing.move.in': - return t`Moved in from another location`; - case 'thing.move.out': - return t`Moved ${name} to another location`; - case 'thing.trash': - case 'place.trash': - return t`Trashed ${name}`; - case 'thing.restore': - case 'place.restore': - return t`Restored ${name}`; - case 'thing.delete': - case 'place.delete': - return t`Deleted ${name} for good`; - case 'thing.seen': - return t`Saw ${name}`; - case 'thing.not_here': - return t`Marked ${name} as not here`; - case 'thing.split': - return t`Split ${name}`; - case 'thing.duplicate': - return t`Duplicated ${name}`; - case 'thing.link': - return t`Linked ${name}`; - case 'thing.unlink': - return t`Unlinked ${name}`; - case 'thing.convert_to_place': - return t`Turned ${name} into a place`; - case 'thing.codes': - case 'place.codes': - return t`Changed the codes of ${name}`; - case 'place.merge': - return t`Merged ${name} into another place`; - case 'place.convert_to_container': - return t`Turned ${name} into a container`; - case 'place.label': - return t`Labelled ${name}`; - case 'attachment.create': - return t`Attached a file to ${name}`; - case 'attachment.update': - return t`Changed a file on ${name}`; - case 'attachment.delete': - return t`Removed a file from ${name}`; - // D236: a service line took the consumable off the shelf, or gave it back. - case 'thing.stock_use': { - const service = serviceName(e.summaryParams.service); - return t`Used ${name} in a service of ${service}`; - } - case 'thing.stock_return': { - const service = serviceName(e.summaryParams.service); - return t`Put ${name} back from a service of ${service}`; - } - case 'undo': - return t`Undid a change to ${name}`; - case 'event': - return eventSentence(e.summaryParams.action ?? '') ?? e.summary; - default: - return e.summary; - } - }; +} - /** The events without a sentence of their own that step 2 writes. */ - function eventSentence(action: string): string | null { - const known: Record = { - 'brand.create': t`Brand added`, - 'brand.update': t`Brand changed`, - 'brand.delete': t`Brand deleted`, - 'brand.merge': t`Brand merged`, - 'vendor.create': t`Vendor added`, - 'vendor.update': t`Vendor changed`, - 'vendor.delete': t`Vendor deleted`, - 'vendor.merge': t`Vendor merged`, - 'person.create': t`Person added`, - 'person.update': t`Person changed`, - 'person.delete': t`Person deleted`, - 'person.merge': t`Person merged`, - 'person.contact_update': t`Contact details changed`, - 'tag.create': t`Tag added`, - 'tag.update': t`Tag changed`, - 'tag.delete': t`Tag deleted`, - 'tag.merge': t`Tag merged`, - 'type.create': t`Type added`, - 'type.update': t`Type changed`, - 'type.delete': t`Type deleted`, - 'type.merge': t`Type merged`, - 'type.customise': t`Type customised`, - 'type_field.create': t`Field added`, - 'type_field.update': t`Field changed`, - 'type_field.archive': t`Field archived`, - 'type_field.restore': t`Field restored`, - 'place_kind.create': t`Place kind added`, - 'place_kind.update': t`Place kind changed`, - 'place_kind.customise': t`Place kind customised`, - 'file.upload': t`File uploaded`, - 'file.delete_original': t`Original file deleted`, - 'purchase.create': t`Purchase added`, - 'purchase.update': t`Purchase changed`, - 'purchase.delete': t`Purchase deleted`, - 'purchase_line.create': t`Purchase line added`, - 'purchase_line.update': t`Purchase line changed`, - 'purchase_line.delete': t`Purchase line deleted`, - 'thing.purchase_link': t`Linked to a purchase`, - 'thing.purchase_unlink': t`Unlinked from a purchase`, - 'thing.label': t`Label assigned`, - 'meter.create': t`Meter added`, - 'meter.update': t`Meter changed`, - 'meter.replaced': t`Meter replaced`, - 'reading.create': t`Reading logged`, - 'reading.update': t`Reading changed`, - 'reading.accept': t`Reading kept`, - 'reading.delete': t`Reading discarded`, - 'saved_view.create': t`Saved view added`, - 'location.create': t`Location created`, - 'location.update': t`Location settings changed`, - 'location.modules': t`What to track changed`, - 'location.delete': t`Location deleted`, - 'location.restore': t`Location restored`, - 'invite.create': t`Invite created`, - 'invite.update': t`Invite changed`, - 'invite.revoke': t`Invite revoked`, - 'member.join': t`Member joined`, - 'member.leave': t`Member left`, - 'member.update': t`Member's role changed`, - 'member.expire': t`Membership ended`, - 'managed_account.create': t`Managed account created`, - 'saved_view.update': t`Saved view changed`, - 'saved_view.delete': t`Saved view deleted`, - 'secret.set': t`Secret changed`, - 'secret.clear': t`Secret cleared`, - 'secret.reveal': t`Secret revealed`, - 'secret.copied': t`Secret copied`, - 'secret_policy.update': t`Secret policy changed`, - // Step 4 (T29). - 'warranty.create': t`Warranty added`, - 'warranty.update': t`Warranty changed`, - 'warranty.delete': t`Warranty removed`, - 'claim.create': t`Claim opened`, - 'claim.update': t`Claim changed`, - 'claim.delete': t`Claim deleted`, - 'valuation.create': t`Valuation added`, - 'valuation.update': t`Valuation changed`, - 'valuation.delete': t`Valuation deleted`, - 'loan.create': t`Loan added`, - 'loan.update': t`Loan changed`, - 'loan.return': t`Loan returned`, - 'loan.delete': t`Loan deleted`, - 'schedule.create': t`Schedule added`, - 'schedule.update': t`Schedule changed`, - 'schedule.delete': t`Schedule deleted`, - 'schedule.snooze': t`Schedule snoozed`, - 'schedule.unsnooze': t`Snooze cleared`, - 'schedule.skip': t`Next one skipped`, - 'service_record.create': t`Service logged`, - 'service_record.update': t`Service changed`, - 'service_record.delete': t`Service deleted`, - // Step 5 (T20): an invoice attached first makes a draft; saving it confirms it. - 'service_record.draft': t`Service draft started from an invoice`, - 'service_record.confirm': t`Service logged`, - 'meter.delete': t`Meter removed`, - 'fuel.create': t`Fuel logged`, - 'fuel.update': t`Fuel changed`, - 'fuel.delete': t`Fuel deleted`, - 'schedule.starter': t`Starter schedules added`, - 'document.create': t`Document added`, - 'document.update': t`Document changed`, - 'document.renew': t`Document renewed`, - 'document.delete': t`Document deleted`, - 'incident.create': t`Incident recorded`, - 'incident.update': t`Incident changed`, - 'incident.things': t`Incident's things changed`, - 'incident.delete': t`Incident deleted`, - 'fx_rate.set': t`Exchange rate set`, - 'fx_rate.delete': t`Exchange rate deleted`, - 'brand.logo_set': t`Brand logo changed`, - 'brand.logo_remove': t`Brand logo removed`, - // Step 7 (T17, T18, T16, T8): a consumable's minimum, a field converted, a list exported - // as a CSV, and a file an archive import brought in. - 'thing.stock_rule': t`Minimum to keep changed`, - 'type.field_convert': t`Field converted`, - 'things.export_csv': t`List exported as a CSV`, - 'file.import': t`File imported`, - // Step 6: location webhooks and reading a receipt again; step 7: other names from AI and - // Homebox; step 8: the instance's own events (backups, keys, updates, setup). - 'webhook.create': t`Webhook added`, - 'webhook.update': t`Webhook changed`, - 'webhook.delete': t`Webhook removed`, - 'webhook.rotate_secret': t`Webhook secret replaced`, - // 1.5 (D233): read-only share links. - 'share_link.create': t`Share link made`, - 'share_link.revoke': t`Share link revoked`, - // 1.x (D251): moving mode, a move and its boxes. - 'move.create': t`Move started`, - 'move.update': t`Move changed`, - 'move.delete': t`Move deleted`, - 'move_box.create': t`Added to a move as a box`, - 'move_box.update': t`Moving box changed`, - 'move_box.unpack': t`Box unpacked`, - 'move_box.delete': t`Taken out of a move`, - // 1.x (D248): Paperless-ngx, the connection and the links to its documents. - 'paperless.connect': t`Paperless connected`, - 'paperless.update': t`Paperless connection changed`, - 'paperless.disconnect': t`Paperless disconnected`, - 'paperless_link.create': t`Paperless document linked`, - 'paperless_link.delete': t`Paperless document unlinked`, - 'paperless_link.copy': t`Paperless document copied into Kept`, - 'purchase.reextract': t`Receipt read again`, - 'thing.enrich': t`Other names added`, - 'import.enrich': t`Other names requested for an import`, - 'import.homebox_connect': t`Connected to Homebox`, - 'instance.setup': t`Kept set up`, - 'instance.settings_update': t`Instance settings changed`, - 'instance.backup': t`Backup made`, - 'instance.backup_run': t`Backup started by hand`, - 'instance.backup_settings': t`Backup settings changed`, - 'instance.backup_test': t`Backup target tested`, - 'instance.restore': t`Restored from a backup`, - 'instance.export': t`Whole instance exported`, - 'instance.downgrade_forced': t`Started on an older version anyway`, - 'instance.rotate_key': t`Encryption key replaced`, - 'instance.drop_key': t`Old encryption key removed`, - 'instance.embeddings_source': t`Semantic search source changed`, - 'instance.recovery_kit_download': t`Recovery kit downloaded`, - 'instance.recovery_kit_acknowledge': t`Recovery kit noted as kept`, - 'instance.update_check': t`Checked for updates`, - 'instance.setup_code_reissue': t`New setup code issued`, - 'instance.oidc_changed': t`OIDC sign-in settings changed`, - 'instance.smtp_changed': t`Email settings changed`, +/** + * A row's detail lines (#140) in the reader's language: an arrangement's changes and what a + * report or a trash covered. Names are isolated like the summary's; a map shape, room kind or + * floor arrives as its code and is said in the map's own words. A line this build doesn't know + * shows the server's English. + */ +export function useSummaryLine() { + const { t } = useLingui(); + const nouns = useNouns(); + const vocab: Record> = { + model: useModelNames(), + kind: useRoomKindNames(), + surface: useSurfaceNames(), + }; + return (line: SummaryLineView) => { + const p = line.params; + const named = (raw: string | undefined) => nouns[raw ?? ''] ?? isolate(raw ?? ''); + const said = (code: string | undefined) => + (p.vocab ? vocab[p.vocab]?.[code ?? ''] : undefined) ?? code ?? ''; + const name = named(p.name); + const host = named(p.host); + const room = named(p.room); + const from = said(p.from); + const to = said(p.to); + const count = Number(p.count ?? 0); + const all: Record string> = { + 'line.things': () => plural(count, { one: '# thing', other: '# things' }), + 'line.inside': () => + plural(count, { one: 'With # thing inside', other: 'With # things inside' }), + 'line.moved_out': () => + plural(count, { one: '# thing taken out first', other: '# things taken out first' }), + 'line.photos': () => t`With photos`, + 'map.put.room': () => t`Put ${name} on the map in ${room}`, + 'map.put': () => t`Put ${name} on the map`, + 'map.remove': () => t`Took ${name} off the map`, + 'map.on': () => t`Put ${name} on ${host}`, + 'map.off': () => t`Took ${name} off ${host}`, + 'map.move': () => t`Moved ${name}`, + 'map.turn': () => t`Turned ${name}`, + 'map.shape': () => t`Changed the shape of ${name}: ${from} → ${to}`, + 'map.resize': () => t`Resized ${name}`, + 'map.kind': () => t`Changed the kind of ${name}: ${from} → ${to}`, + 'map.floor': () => t`Changed the floor of ${name}: ${from} → ${to}`, }; - return known[action] ?? null; - } + return (all as Record string) | undefined>)[line.key]?.() ?? line.text; + }; } /** Lifecycle and condition values (D119, Q10) as words; anything else as it is. */ diff --git a/apps/web/src/components/history/timeline.tsx b/apps/web/src/components/history/timeline.tsx index ae2366f1..f97bc9b0 100644 --- a/apps/web/src/components/history/timeline.tsx +++ b/apps/web/src/components/history/timeline.tsx @@ -13,11 +13,11 @@ * can't see is its own row, "Moved in from another location", with no old path (D183). A thing's * AI calls (D206) are rows of their own, with the AI line (components/ai/ai-history-row.tsx). */ -import { printedCode } from '@kept/shared'; +import { printedCode, SUMMARY_LINES_INLINE } from '@kept/shared'; import { Trans, useLingui } from '@lingui/react/macro'; import { useInfiniteQuery } from '@tanstack/react-query'; import { Link } from '@tanstack/react-router'; -import { type ReactNode, useEffect, useMemo, useRef } from 'react'; +import { type ReactNode, useEffect, useId, useMemo, useRef, useState } from 'react'; import { useUndoable } from '@/api/capture/queries'; import { inventoryApi, inventoryKeys as k, nextCursor } from '@/api/inventory/queries'; import type { HistoryEvent, RenderedChange } from '@/api/inventory/types'; @@ -29,7 +29,16 @@ import { Button } from '@/components/ui/button'; import { addressOf } from '@/lib/address'; import { sep, useBytes, useFormat } from '@/lib/format'; import { cn } from '@/lib/utils'; -import { FSI, PDI, plainText, useFieldLabel, useSummary, useValueWords } from './labels'; +import { fieldShown, isNewValue } from './fields'; +import { + FSI, + PDI, + plainText, + useFieldLabel, + useSummary, + useSummaryLine, + useValueWords, +} from './labels'; import { useUndo } from './undo'; export type HistorySubject = { kind: 'thing' | 'place'; id: string }; @@ -200,7 +209,7 @@ export function EventRow({ moved || compact ? [] : Object.entries(event.diff ?? {}).filter( - (entry) => shown(entry) && !(created && startsAsDefault(entry)), + (entry) => shown(entry, event.entity.type) && !(created && startsAsDefault(entry)), ); return (
) : null} + {!compact && event.summaryLines?.length ? : null} {changes.length ? (
{changes.map(([key, change]) => ( @@ -280,6 +290,51 @@ export function EventRow({ ); } +/** + * What the action covered, one sentence a line (#140): an arrangement's changes ("Put HD webcam on + * Corner desk"), a report's things and photos. Up to five show as they are; a row with more says + * how many in its summary ("Arranged the map: 12 changes") and opens them with Show. + */ +function Lines({ event }: { event: HistoryEvent }) { + const { t } = useLingui(); + const say = useSummaryLine(); + const [open, setOpen] = useState(false); + const id = useId(); + const lines = event.summaryLines ?? []; + const folded = lines.length > SUMMARY_LINES_INLINE; + return ( +
+ {!folded || open ? ( +
    + {lines.map((line, i) => ( +
  • + +
  • + ))} +
+ ) : null} + {folded ? ( + + ) : null} +
+ ); +} + /** * "merged from " (D36, T15): an event of a thing that was merged into this one, in this * thing's history. A thing merged away without a name says so. @@ -381,6 +436,14 @@ function Change({