@teamscala/service-boot 0.23.481 → 0.23.492

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -261
  2. package/src/agreement-gate/signing-session.ts +1 -1
  3. package/src/build-client-assets/styles-build.ts +11 -1
  4. package/src/mount-orpc-router.ts +1 -1
  5. package/src/register-page-server-client.ts +1 -15
  6. package/src/register-ws-routes.ts +1 -1
  7. package/src/fn-routers/agreement-certificate.ts +0 -224
  8. package/src/fn-routers/agreement-completion-email.ts +0 -114
  9. package/src/fn-routers/agreement-send-router.ts +0 -303
  10. package/src/fn-routers/agreement-sign-side-effects.ts +0 -192
  11. package/src/fn-routers/agreement-signing-evidence.ts +0 -105
  12. package/src/fn-routers/assessment-report-data.test.ts +0 -404
  13. package/src/fn-routers/assessment-report-data.ts +0 -485
  14. package/src/fn-routers/assessment-report-email.ts +0 -256
  15. package/src/fn-routers/assessment-report-pair.test.ts +0 -136
  16. package/src/fn-routers/assessment-report-pair.ts +0 -155
  17. package/src/fn-routers/assessment-research-report-data.test.ts +0 -233
  18. package/src/fn-routers/assessment-research-report-data.ts +0 -424
  19. package/src/fn-routers/assessment-scoring.ts +0 -105
  20. package/src/fn-routers/auth-router.ts +0 -65
  21. package/src/fn-routers/chat-session-router-shared.ts +0 -122
  22. package/src/fn-routers/chat-session-router.ts +0 -356
  23. package/src/fn-routers/conversation-ownership.ts +0 -24
  24. package/src/fn-routers/email-defaults.ts +0 -28
  25. package/src/fn-routers/email-link-ttl.ts +0 -28
  26. package/src/fn-routers/index.ts +0 -76
  27. package/src/fn-routers/linkedin-identity.test.ts +0 -27
  28. package/src/fn-routers/linkedin-identity.ts +0 -36
  29. package/src/fn-routers/messaging-whatsapp-router.test.ts +0 -121
  30. package/src/fn-routers/messaging-whatsapp-router.ts +0 -200
  31. package/src/fn-routers/panel-data-router.ts +0 -104
  32. package/src/fn-routers/persist-chat-messages.test.ts +0 -86
  33. package/src/fn-routers/persist-chat-messages.ts +0 -98
  34. package/src/fn-routers/proposal-send-router.ts +0 -207
  35. package/src/fn-routers/public-agreement-router.ts +0 -407
  36. package/src/fn-routers/public-assessment-router.test.ts +0 -437
  37. package/src/fn-routers/public-assessment-router.ts +0 -268
  38. package/src/fn-routers/public-gpt-session-router.ts +0 -378
  39. package/src/fn-routers/resend-client.ts +0 -39
  40. package/src/fn-routers/sign-in-flow-router.ts +0 -196
  41. package/src/fn-routers/user-preferences-router.ts +0 -130
  42. package/src/fn-routers/work-items-router.ts +0 -342
  43. package/src/page-server-orpc/audit-revert-router.ts +0 -102
  44. package/src/page-server-orpc/puck-pages-router.ts +0 -170
  45. package/src/page-server-orpc/user-preferences-router.ts +0 -132
@@ -1,130 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * ORPC procedures for a GENERIC per-user preference store backed by the
7
- * `user_preferences` table. The user is resolved from the ORPC session context
8
- * (`authed` middleware → `context.user.id`); the client supplies the org + key
9
- * + value. This is the DB-durable, user-scoped key/value surface any feature
10
- * can use — the AI browser-control focus (key `ai_ui_focus`) is the first
11
- * consumer; it is NOT scala-projects-bespoke.
12
- *
13
- * Why this exists (over the generated `user_preferences` CRUD): the generated
14
- * create/update do NOT resolve `user_id` server-side (only `organisation_id`
15
- * from ctx.orgId), so a client cannot correctly write a per-user row through
16
- * them. This proc stamps `user_id` from the authenticated caller, making the
17
- * write safe + per-user. It also emits the `data_change` notify so the
18
- * realtime surface (regions.watchDataChange) pushes updates to subscribed
19
- * clients — the cross-region channel for the AI focus (the AiChat panel writes
20
- * a focus here; the project-list-view in a different region subscribes +
21
- * reacts, no shared React-context ancestor needed).
22
- *
23
- * Ownership is user_id: a caller reads/writes ONLY their own preferences. The
24
- * org is part of the compound key (per-org isolation) but is client-supplied
25
- * (the session carries the user, not the active org).
26
- *
27
- * DECISION — host-scoping of `user_preferences` is RETIRED: the table is a
28
- * shared `@teamscala/db` model now, so `Context.prisma` is the typed shared
29
- * PrismaClient and the proc is universally hostable — every host's generated
30
- * client declares the table. (Previously `Context.prisma` was `any`-typed as a
31
- * deliberate seam while only scala-projects declared the table.)
32
- */
33
-
34
- import * as v from "valibot";
35
- import { getAppLogger } from "@teamscala/logger/app-loggers";
36
- import { executeRawUnsafe } from "@teamscala/db/query-rows";
37
- import { authed, type Context } from "./auth-router.ts";
38
-
39
- interface SessionUser {
40
- id: string;
41
- }
42
-
43
- const upsertSchema = v.object({
44
- organisationId: v.string(),
45
- /** The preference key (e.g. `ai_ui_focus`). Namespaced by feature. */
46
- key: v.string(),
47
- /** The JSON value. Arbitrary per feature — typed by the caller. */
48
- value: v.any(),
49
- });
50
-
51
- const getSchema = v.object({
52
- organisationId: v.string(),
53
- key: v.string(),
54
- });
55
-
56
- /**
57
- * Emit a best-effort `data_change` notify so realtime subscribers
58
- * (regions.watchDataChange filtering on `user_preferences`) re-read + react.
59
- * Mirrors the notify the generated CRUD emits. Never throws — realtime must
60
- * never block a write (matches the generated create/update notify guard).
61
- */
62
- async function notifyDataChange(
63
- organisationId: string,
64
- op: string,
65
- ): Promise<void> {
66
- if (!organisationId) return;
67
- try {
68
- await (executeRawUnsafe as Function)(
69
- "SELECT pg_notify('data_change', $1)",
70
- JSON.stringify({ table: "user_preferences", op, org_id: organisationId }),
71
- );
72
- } catch {
73
- /* best-effort */
74
- }
75
- }
76
-
77
- export const userPreferencesRouter = {
78
- // upsert — write (create-or-replace) the caller's preference value for
79
- // (org, key). Stamps user_id from the session; the client never supplies it.
80
- upsert: authed.input(upsertSchema).handler(async ({ input, context }) => {
81
- const prisma = (context as Context).prisma;
82
- const user = context.user as SessionUser;
83
- const log = getAppLogger();
84
- const { organisationId, key, value } = input;
85
-
86
- try {
87
- await prisma.user_preferences.upsert({
88
- where: {
89
- user_id_organisation_id_key: {
90
- user_id: user.id,
91
- organisation_id: organisationId,
92
- key,
93
- },
94
- },
95
- create: {
96
- user_id: user.id,
97
- organisation_id: organisationId,
98
- key,
99
- value,
100
- },
101
- update: { value },
102
- });
103
- await notifyDataChange(organisationId, "upsert");
104
- return { success: true };
105
- } catch (err) {
106
- log.error(
107
- `[userPreferences.upsert] failed: ${err instanceof Error ? err.message : String(err)}`,
108
- );
109
- throw err;
110
- }
111
- }),
112
-
113
- // get — read the caller's preference value for (org, key), or null. The
114
- // initial read for a subscriber (the realtime notify then drives re-reads).
115
- get: authed.input(getSchema).handler(async ({ input, context }) => {
116
- const prisma = (context as Context).prisma;
117
- const user = context.user as SessionUser;
118
- const row = await prisma.user_preferences.findUnique({
119
- where: {
120
- user_id_organisation_id_key: {
121
- user_id: user.id,
122
- organisation_id: input.organisationId,
123
- key: input.key,
124
- },
125
- },
126
- select: { value: true, updated_at: true },
127
- });
128
- return { value: row?.value ?? null, updatedAt: row?.updated_at ?? null };
129
- }),
130
- };
@@ -1,342 +0,0 @@
1
- /**
2
- * @system service-boot
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * ORPC procedure for safely deleting a work_item (initiative/project/task).
7
- * Refuses the delete when the row still has child rows (parent_id = id), so a
8
- * delete can NEVER orphan its descendants — the tree must be emptied bottom-up.
9
- *
10
- * Initiatives, projects and tasks are all rows of the ONE `work_items` table
11
- * discriminated by `kind`, and `parent_id` carries no DB-level foreign key, so
12
- * a plain `work_items.delete` leaves dangling children with no referential
13
- * integrity to stop it. This one guard covers every level (you may only delete
14
- * a leaf — a node with no children), which is exactly the "empty it first"
15
- * behaviour the board wants.
16
- */
17
-
18
- /**
19
- * Org-scoped member list for the task-comments panel — @mention matching AND
20
- * comment-sender display (name + avatar). ChatPanel.tsx has no
21
- * serverLoader/orgId prop (client-only overlay content), so it can't do the
22
- * SSR raw-SQL org-scope join load-project-list-view.ts uses; this exposes
23
- * the same result via a custom procedure instead of widening service_schema
24
- * to the raw better_auth_member table.
25
- *
26
- * This is the SINGLE source of member display info for the comments panel:
27
- * it coalesces BOTH name/email/avatar families on the user model into one
28
- * shape (the `full_name`/`primary_email`/`avatar_url` family AND the
29
- * better-auth `name`/`email`/`image` family). Returning avatar here lets the
30
- * client resolve sender name+avatar from this one list and drop the separate
31
- * `user.findMany` enrichment fetch it previously ran as a second waterfall
32
- * hop after `comments.findMany` (loading-shape: one wave-parallel hit, not a
33
- * dependent client-side round-trip chain).
34
- */
35
-
36
- import { createEmailClient } from "@teamscala/email-client/client";
37
- import { getAppLogger } from "@teamscala/logger/app-loggers";
38
- import { authed, type Context } from "./auth-router.ts";
39
- import { resolveAppBaseUrl } from "../resolve-app-base-url.ts";
40
-
41
- const emailClient = createEmailClient();
42
-
43
- export const workItemsRouter = {
44
- /**
45
- * Fired client-side right after a task's status is set to
46
- * "pending_approval" (RaCI: the task's assignee is Responsible; the
47
- * project's Accountable person is the approver). Walks parent_id up to
48
- * the nearest project-kind ancestor, emails every accountable person on
49
- * it. Never throws to the caller — a notification failure must not block
50
- * the status change that already committed.
51
- */
52
- requestApproval: authed.handler(async ({ input, context }) => {
53
- const { id } = input as { id: string };
54
- const log = getAppLogger();
55
- const prisma = (context as Context).prisma as {
56
- work_items: {
57
- findUnique: (a: unknown) => Promise<{
58
- id: string;
59
- title: string;
60
- kind: string;
61
- parent_id: string | null;
62
- organisation_id: string | null;
63
- } | null>;
64
- };
65
- work_item_roles: {
66
- findMany: (a: unknown) => Promise<{ user_id: string }[]>;
67
- };
68
- user: {
69
- findUnique: (a: unknown) => Promise<{ id: string; email: string; name: string | null } | null>;
70
- };
71
- organisation_profile: {
72
- findFirst: (a: unknown) => Promise<{ company_name: string | null } | null>;
73
- };
74
- notifications: {
75
- create: (a: unknown) => Promise<unknown>;
76
- };
77
- };
78
-
79
- try {
80
- const task = await prisma.work_items.findUnique({
81
- where: { id },
82
- select: { id: true, title: true, kind: true, parent_id: true, organisation_id: true },
83
- });
84
- if (!task) return { notified: 0 };
85
-
86
- let project = task;
87
- while (project.kind !== "project" && project.parent_id) {
88
- const parent = await prisma.work_items.findUnique({
89
- where: { id: project.parent_id },
90
- select: { id: true, title: true, kind: true, parent_id: true, organisation_id: true },
91
- });
92
- if (!parent) break;
93
- project = parent;
94
- }
95
-
96
- const accountable = await prisma.work_item_roles.findMany({
97
- where: { work_item_id: project.id, role: "accountable" },
98
- });
99
- if (accountable.length === 0) {
100
- log.info("[work-items-router] requestApproval: no accountable person set", { taskId: id, projectId: project.id });
101
- return { notified: 0 };
102
- }
103
-
104
- const requester = context.user as { id: string; name?: string | null; email?: string | null } | null;
105
- const org = await prisma.organisation_profile.findFirst({
106
- where: { id: task.organisation_id },
107
- select: { company_name: true },
108
- });
109
- const linkUrl = `${await resolveAppBaseUrl()}/${task.organisation_id}?panel=task-comments&comment=${task.id}`;
110
- const requesterName = requester?.name ?? requester?.email ?? "Someone";
111
-
112
- let notified = 0;
113
- for (const role of accountable) {
114
- const user = await prisma.user.findUnique({ where: { id: role.user_id }, select: { id: true, email: true, name: true } });
115
- if (!user?.email) continue;
116
- const result = await emailClient.send({
117
- to: user.email,
118
- template: "task-awaiting-approval",
119
- vars: {
120
- recipient_name: user.name ?? user.email,
121
- requester_name: requesterName,
122
- project_title: project.title,
123
- task_title: task.title,
124
- task_url: linkUrl,
125
- org_name: org?.company_name ?? "your organisation",
126
- },
127
- });
128
- if (result.status !== "failed") notified++;
129
- try {
130
- await prisma.notifications.create({
131
- data: {
132
- organisation_id: task.organisation_id,
133
- user_id: role.user_id,
134
- type: "approval_request",
135
- title: `${requesterName} requested approval`,
136
- body: task.title,
137
- work_item_id: task.id,
138
- link_url: linkUrl,
139
- },
140
- });
141
- } catch (err) {
142
- log.error("[work-items-router] requestApproval: failed to write notification row", {
143
- taskId: id,
144
- userId: role.user_id,
145
- error: err instanceof Error ? err.message : String(err),
146
- });
147
- }
148
- }
149
- return { notified };
150
- } catch (err) {
151
- log.error("[work-items-router] requestApproval failed", { taskId: id, error: err instanceof Error ? err.message : String(err) });
152
- return { notified: 0 };
153
- }
154
- }),
155
- /**
156
- * Fired client-side right after a comment is created, when the comment
157
- * text contains an @mention the client already resolved to a team-member
158
- * id (client does the name matching against the people list it already
159
- * has loaded; this handler only sends). Never throws to the caller for
160
- * same reason as requestApproval -- a notification failure must not
161
- * surface as a failed comment post.
162
- */
163
- notifyMentions: authed.handler(async ({ input, context }) => {
164
- const { workItemId, mentionedUserIds, commentExcerpt } = input as {
165
- workItemId: string;
166
- mentionedUserIds: string[];
167
- commentExcerpt: string;
168
- };
169
- const log = getAppLogger();
170
- const prisma = (context as Context).prisma as {
171
- work_items: {
172
- findUnique: (a: unknown) => Promise<{ id: string; title: string; organisation_id: string | null } | null>;
173
- };
174
- user: {
175
- findUnique: (a: unknown) => Promise<{ id: string; email: string; name: string | null } | null>;
176
- };
177
- notifications: {
178
- create: (a: unknown) => Promise<unknown>;
179
- };
180
- };
181
- if (!mentionedUserIds || mentionedUserIds.length === 0) return { notified: 0 };
182
- try {
183
- const task = await prisma.work_items.findUnique({
184
- where: { id: workItemId },
185
- select: { id: true, title: true, organisation_id: true },
186
- });
187
- if (!task) return { notified: 0 };
188
- const author = context.user as { id: string; name?: string | null; email?: string | null } | null;
189
- const commentUrl = `${await resolveAppBaseUrl()}/${task.organisation_id}?panel=task-comments&comment=${task.id}`;
190
- const authorName = author?.name ?? author?.email ?? "Someone";
191
- const excerpt = commentExcerpt.slice(0, 280);
192
-
193
- let notified = 0;
194
- for (const userId of new Set(mentionedUserIds)) {
195
- if (userId === author?.id) continue;
196
- let user: { id: string; email: string; name: string | null } | null = null;
197
- try {
198
- user = await prisma.user.findUnique({ where: { id: userId }, select: { id: true, email: true, name: true } });
199
- } catch (err) {
200
- log.error("[work-items-router] notifyMentions: user lookup failed", { userId, error: err instanceof Error ? err.message : String(err) });
201
- continue;
202
- }
203
- if (!user?.email) continue;
204
- let result: { status: string };
205
- try {
206
- result = await emailClient.send({
207
- to: user.email,
208
- template: "comment-mention",
209
- vars: {
210
- recipient_name: user.name ?? user.email,
211
- author_name: authorName,
212
- task_title: task.title,
213
- comment_excerpt: excerpt,
214
- comment_url: commentUrl,
215
- },
216
- });
217
- } catch (err) {
218
- log.error("[work-items-router] notifyMentions: emailClient.send threw", { userId, error: err instanceof Error ? err.message : String(err) });
219
- result = { status: "failed" };
220
- }
221
- if (result.status !== "failed") notified++;
222
- try {
223
- await prisma.notifications.create({
224
- data: {
225
- organisation_id: task.organisation_id,
226
- user_id: userId,
227
- type: "mention",
228
- title: `${authorName} mentioned you`,
229
- body: excerpt,
230
- work_item_id: task.id,
231
- link_url: commentUrl,
232
- },
233
- });
234
- } catch (err) {
235
- log.error("[work-items-router] notifyMentions: failed to write notification row", {
236
- workItemId,
237
- userId,
238
- error: err instanceof Error ? err.message : String(err),
239
- });
240
- }
241
- }
242
- return { notified };
243
- } catch (err) {
244
- log.error("[work-items-router] notifyMentions failed", { workItemId, error: err instanceof Error ? err.message : String(err) });
245
- return { notified: 0 };
246
- }
247
- }),
248
- orgMembers: authed.handler(async ({ input, context }) => {
249
- const { orgId } = input as { orgId: string };
250
- const prisma = (context as Context).prisma as {
251
- $queryRawUnsafe: <T>(sql: string, ...params: unknown[]) => Promise<T>;
252
- user: {
253
- findMany: (a: unknown) => Promise<{
254
- id: string;
255
- name: string | null;
256
- email: string | null;
257
- image: string | null;
258
- full_name: string | null;
259
- primary_email: string | null;
260
- avatar_url: string | null;
261
- avatar_thumb_url: string | null;
262
- }[]>;
263
- };
264
- };
265
- if (!orgId) return [];
266
- const memberIds = (
267
- await prisma.$queryRawUnsafe<{ user_id: string }[]>(
268
- "SELECT user_id FROM better_auth_member WHERE organization_id = $1",
269
- orgId,
270
- )
271
- ).map((m) => m.user_id);
272
- if (memberIds.length === 0) return [];
273
- const users = await prisma.user.findMany({
274
- where: { id: { in: memberIds } },
275
- select: {
276
- id: true,
277
- name: true,
278
- email: true,
279
- image: true,
280
- full_name: true,
281
- primary_email: true,
282
- avatar_url: true,
283
- avatar_thumb_url: true,
284
- },
285
- });
286
- return users.map((u) => ({
287
- id: u.id,
288
- name: u.full_name || u.name || "",
289
- email: u.primary_email || u.email || "",
290
- avatar: u.avatar_url || u.image || u.avatar_thumb_url || null,
291
- }));
292
- }),
293
- deleteEmpty: authed.handler(async ({ input, context }) => {
294
- const { id } = input as { id: string };
295
- if (!id) throw new Error("id is required");
296
- const prisma = (context as Context).prisma as {
297
- work_items: {
298
- findUnique: (a: unknown) => Promise<{
299
- id: string;
300
- kind: string;
301
- organisation_id: string | null;
302
- } | null>;
303
- count: (a: unknown) => Promise<number>;
304
- delete: (a: unknown) => Promise<unknown>;
305
- };
306
- };
307
-
308
- const item = await prisma.work_items.findUnique({
309
- where: { id },
310
- select: { id: true, kind: true, organisation_id: true },
311
- });
312
- if (!item) throw new Error("Work item not found");
313
-
314
- // Org-scope parity with the generated CRUD: a non-super-admin may only
315
- // delete within their active organisation.
316
- const user = context.user as { id: string; isSuperAdmin?: boolean } | null;
317
- const orgId = (context as Context).orgId ?? null;
318
- if (
319
- !user?.isSuperAdmin &&
320
- item.organisation_id &&
321
- item.organisation_id !== orgId
322
- ) {
323
- const { ORPCError } = await import("@orpc/server");
324
- throw new ORPCError("FORBIDDEN", {
325
- message: "You can only delete items in your own organisation.",
326
- });
327
- }
328
-
329
- const childCount = await prisma.work_items.count({
330
- where: { parent_id: id },
331
- });
332
- if (childCount > 0) {
333
- const { ORPCError } = await import("@orpc/server");
334
- throw new ORPCError("CONFLICT", {
335
- message: `Can't delete this ${item.kind}: it still has ${childCount} item${childCount === 1 ? "" : "s"} inside it. Empty it first, then delete.`,
336
- });
337
- }
338
-
339
- await prisma.work_items.delete({ where: { id } });
340
- return { success: true };
341
- }),
342
- };
@@ -1,102 +0,0 @@
1
- /**
2
- * @system page-server
3
- * @status handwritten
4
- * @edit edit directly
5
- *
6
- * Audit revert procedure — re-applies an audit_trail row's changes_before to
7
- * the entity via the canonical generated Prisma procedure for the entity's
8
- * table: INSERT→delete the created row, UPDATE→restore the old values,
9
- * DELETE→recreate from the deleted snapshot. The revert write itself fires the
10
- * audit extension + DB trigger, recording a NEW forward audit_trail row —
11
- * history is never edited (audit-trail-permanent; the undo is itself audited).
12
- *
13
- * Gated to super-admin (reverting business data is privileged). The caller
14
- * must pass the audit_trail row id; the procedure resolves table + action +
15
- * before-snapshot from that row.
16
- */
17
-
18
- import { os } from "@orpc/server";
19
- import type { PrismaClient } from "@teamscala/db/client";
20
- import * as v from "valibot";
21
- import { getAppLogger } from "@teamscala/logger/app-loggers";
22
-
23
- const procedure = os;
24
-
25
- type C = {
26
- prisma?: unknown;
27
- user?: { id?: string; isSuperAdmin?: boolean } | null;
28
- };
29
-
30
- function getPrisma(context: unknown) {
31
- const db = (context as C).prisma;
32
- if (!db) throw new Error("auditRevert: context.prisma is required");
33
- return db as PrismaClient;
34
- }
35
-
36
- const revertInput = v.object({
37
- auditTrailId: v.string(),
38
- });
39
-
40
- export const auditRevertRouter = {
41
- /**
42
- * Revert a single audited change by re-applying changes_before through the
43
- * generated Prisma model for the entity's table. Returns the action taken.
44
- */
45
- revert: procedure.input(revertInput).handler(async ({ context, input }) => {
46
- const user = (context as C).user;
47
- if (!user?.isSuperAdmin) {
48
- throw new Error("auditRevert: requires super-admin");
49
- }
50
- const prisma = getPrisma(context) as unknown as Record<string, unknown>;
51
- const log = getAppLogger();
52
- const { auditTrailId } = input;
53
-
54
- const row = await prisma.audit_trail.findUnique({
55
- where: { id: auditTrailId },
56
- select: {
57
- entity_type: true,
58
- entity_id: true,
59
- action: true,
60
- changes_before: true,
61
- changes_after: true,
62
- },
63
- });
64
- if (!row) {
65
- throw new Error(`auditRevert: audit_trail ${auditTrailId} not found`);
66
- }
67
-
68
- const table = String(row.entity_type);
69
- const model = prisma[table];
70
- if (!model || typeof model.delete !== "function") {
71
- throw new Error(`auditRevert: table ${table} has no generated Prisma model`);
72
- }
73
- const entityId = String(row.entity_id ?? "");
74
- const act = String(row.action ?? "").toUpperCase();
75
- // changes_before is Json? — Prisma returns it parsed.
76
- const before = (row.changes_before ?? null) as Record<string, unknown> | null;
77
-
78
- let result: unknown;
79
- if (act === "INSERT" || act === "CREATE") {
80
- // Entity was created → revert = delete it.
81
- if (!entityId) throw new Error("auditRevert INSERT: no entity_id");
82
- result = await model.delete({ where: { id: entityId } });
83
- } else if (act === "UPDATE") {
84
- // Entity was updated → revert = restore changes_before (old values).
85
- if (!before) throw new Error("auditRevert UPDATE: no changes_before to restore");
86
- result = await model.update({ where: { id: entityId }, data: before });
87
- } else if (act === "DELETE") {
88
- // Entity was deleted → revert = recreate from changes_before.
89
- if (!before) throw new Error("auditRevert DELETE: no changes_before to recreate");
90
- result = await model.create({ data: before });
91
- } else {
92
- throw new Error(`auditRevert: unsupported action ${act}`);
93
- }
94
-
95
- log.info(
96
- `[auditRevert] ${act} ${table} ${entityId} reverted by ${user.id}`,
97
- );
98
- return { success: true as const, action: act, table, entityId };
99
- }),
100
- };
101
-
102
- export type AuditRevertRouter = typeof auditRevertRouter;