Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
7 changes: 7 additions & 0 deletions apps/docs/src/content/docs/users/everyday-use.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/admin/backup-routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
type AuditAction,
BACKUP_PASSWORD_MAX,
BACKUP_PASSWORD_MIN,
BACKUP_RUN_KINDS,
Expand Down Expand Up @@ -117,7 +118,7 @@ async function isInstanceAdmin(deps: InventoryDeps, req: FastifyRequest) {
function auditInstance(
tx: Tx,
req: FastifyRequest,
action: string,
action: AuditAction,
after: Record<string, unknown>,
) {
return audited(tx, {
Expand Down
3 changes: 2 additions & 1 deletion apps/server/src/admin/cli.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -75,7 +76,7 @@ export async function asOwner<T>(

function auditSystem(
tx: Tx,
action: string,
action: AuditAction,
entity: { type: string; id: string | null },
extra: {
locationId?: string;
Expand Down
7 changes: 4 additions & 3 deletions apps/server/src/admin/routes.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -209,7 +210,7 @@ async function isInstanceAdmin(pools: Pick<Pools, 'app'>, req: FastifyRequest) {
function auditInstance(
tx: Tx,
req: FastifyRequest,
action: string,
action: AuditAction,
entity: { type: string; id?: string | null },
change: { before?: Record<string, unknown>; after?: Record<string, unknown> } = {},
) {
Expand Down Expand Up @@ -387,7 +388,7 @@ export async function adminRoutes(app: KeptApp, deps: AdminDeps): Promise<void>
const userAction = (
path: string,
action: AdminAction,
auditAction: string,
auditAction: AuditAction,
work: (userId: string) => Promise<void>,
guard?: (req: FastifyRequest, target: AuthUserRow) => void,
) =>
Expand Down Expand Up @@ -620,7 +621,7 @@ export async function adminRoutes(app: KeptApp, deps: AdminDeps): Promise<void>

/** 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 } },
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/audit/audited.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand All @@ -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' },
Expand Down Expand Up @@ -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 },
Expand Down
6 changes: 3 additions & 3 deletions apps/server/src/audit/audited.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
/** `<entity>.<verb>`, e.g. `location.update`. */
action: string;
/** `<entity>.<verb>`, 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. */
Expand Down
15 changes: 9 additions & 6 deletions apps/server/src/audit/undo.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -116,15 +117,15 @@ const HANDLERS = new Map<string, UndoHandler>();
const ACCOUNT_HANDLERS = new Map<string, AccountUndoHandler>();

/** 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`);
}
ACCOUNT_HANDLERS.set(action, handler);
}

/** 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`);
}
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/server/src/extraction/extraction.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
131 changes: 131 additions & 0 deletions apps/server/src/history/arrange.ts
Original file line number Diff line number Diff line change
@@ -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<string, NodeName>,
): 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<string>();
const places = new Set<string>();
// 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] };
}
Loading
Loading