@terreno/api 0.15.1 → 0.15.2

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.
package/src/consentApp.ts CHANGED
@@ -8,6 +8,7 @@
8
8
 
9
9
  import {type Application, Router} from "express";
10
10
  import {DateTime} from "luxon";
11
+ import type {CollectionActionConfig} from "./actions";
11
12
  import {asyncHandler, modelRouter} from "./api";
12
13
  import type {User} from "./auth";
13
14
  import {authenticateMiddleware} from "./auth";
@@ -40,6 +41,12 @@ export interface ConsentAppOptions {
40
41
  supportedLocales?: string[];
41
42
  }
42
43
 
44
+ const requireAdmin = (user: User | undefined): void => {
45
+ if (!user?.admin) {
46
+ throw new APIError({status: 403, title: "Admin access required"});
47
+ }
48
+ };
49
+
43
50
  export class ConsentApp implements TerrenoPlugin {
44
51
  private options: ConsentAppOptions;
45
52
 
@@ -50,79 +57,76 @@ export class ConsentApp implements TerrenoPlugin {
50
57
  register(app: Application): void {
51
58
  const {auditTrail, resolveConsentForms, aiConfig} = this.options;
52
59
 
53
- // Admin CRUD for consent forms
54
- app.use(
55
- "/consent-forms",
56
- modelRouter(ConsentForm, {
57
- endpoints: (router) => {
58
- if (aiConfig) {
59
- // POST /consent-forms/generate - generate consent form content with AI
60
- router.post(
61
- "/generate",
62
- authenticateMiddleware(),
63
- asyncHandler(async (req, res) => {
64
- const user = req.user as User | undefined;
65
- if (!user?.admin) {
66
- throw new APIError({status: 403, title: "Admin access required"});
67
- }
68
-
69
- const {type, description, locale = "en"} = req.body;
70
- if (!type) {
71
- throw new APIError({status: 400, title: "type is required"});
72
- }
73
- if (!description) {
74
- throw new APIError({status: 400, title: "description is required"});
75
- }
76
-
77
- const content = await aiConfig.generateContent({description, locale, type});
78
-
79
- logger.info("ConsentForm content generated", {locale, type});
80
-
81
- return res.json({data: {content}});
82
- })
83
- );
84
-
85
- // POST /consent-forms/translate - translate consent form content with AI
86
- router.post(
87
- "/translate",
88
- authenticateMiddleware(),
89
- asyncHandler(async (req, res) => {
90
- const user = req.user as User | undefined;
91
- if (!user?.admin) {
92
- throw new APIError({status: 403, title: "Admin access required"});
93
- }
94
-
95
- const {content, fromLocale, toLocale} = req.body;
96
- if (!content) {
97
- throw new APIError({status: 400, title: "content is required"});
98
- }
99
- if (!fromLocale) {
100
- throw new APIError({status: 400, title: "fromLocale is required"});
101
- }
102
- if (!toLocale) {
103
- throw new APIError({status: 400, title: "toLocale is required"});
104
- }
105
-
106
- const translated = await aiConfig.translateContent({content, fromLocale, toLocale});
60
+ const collectionActions: Record<string, CollectionActionConfig<unknown, unknown, unknown>> = {};
107
61
 
108
- logger.info("ConsentForm content translated", {fromLocale, toLocale});
109
-
110
- return res.json({data: {content: translated}});
111
- })
112
- );
62
+ if (aiConfig) {
63
+ collectionActions.generate = {
64
+ handler: async ({body, user}) => {
65
+ requireAdmin(user);
66
+ const typedBody = body as {type?: string; description?: string; locale?: string};
67
+ if (!typedBody.type) {
68
+ throw new APIError({status: 400, title: "type is required"});
113
69
  }
70
+ if (!typedBody.description) {
71
+ throw new APIError({status: 400, title: "description is required"});
72
+ }
73
+ const locale = typedBody.locale ?? "en";
74
+ const content = await aiConfig.generateContent({
75
+ description: typedBody.description,
76
+ locale,
77
+ type: typedBody.type,
78
+ });
79
+ logger.info("ConsentForm content generated", {locale, type: typedBody.type});
80
+ return {content};
81
+ },
82
+ method: "POST",
83
+ permissions: [Permissions.IsAuthenticated],
84
+ summary: "Generate consent form content with AI",
85
+ };
86
+
87
+ collectionActions.translate = {
88
+ handler: async ({body, user}) => {
89
+ requireAdmin(user);
90
+ const typedBody = body as {
91
+ content?: string;
92
+ fromLocale?: string;
93
+ toLocale?: string;
94
+ };
95
+ if (!typedBody.content) {
96
+ throw new APIError({status: 400, title: "content is required"});
97
+ }
98
+ if (!typedBody.fromLocale) {
99
+ throw new APIError({status: 400, title: "fromLocale is required"});
100
+ }
101
+ if (!typedBody.toLocale) {
102
+ throw new APIError({status: 400, title: "toLocale is required"});
103
+ }
104
+ const translated = await aiConfig.translateContent({
105
+ content: typedBody.content,
106
+ fromLocale: typedBody.fromLocale,
107
+ toLocale: typedBody.toLocale,
108
+ });
109
+ logger.info("ConsentForm content translated", {
110
+ fromLocale: typedBody.fromLocale,
111
+ toLocale: typedBody.toLocale,
112
+ });
113
+ return {content: translated};
114
+ },
115
+ method: "POST",
116
+ permissions: [Permissions.IsAuthenticated],
117
+ summary: "Translate consent form content with AI",
118
+ };
119
+ }
114
120
 
115
- // POST /consent-forms/:id/publish - clone form with incremented version and activate
116
- router.post(
117
- "/:id/publish",
118
- authenticateMiddleware(),
119
- asyncHandler(async (req, res) => {
120
- const user = req.user as User | undefined;
121
- if (!user?.admin) {
122
- throw new APIError({status: 403, title: "Admin access required"});
123
- }
124
-
125
- const form = await ConsentForm.findExactlyOne({_id: req.params.id});
121
+ app.use(
122
+ "/consent-forms",
123
+ modelRouter(ConsentForm, {
124
+ collectionActions,
125
+ instanceActions: {
126
+ publish: {
127
+ handler: async ({doc, user}) => {
128
+ requireAdmin(user);
129
+ const form = doc as ConsentFormDocument;
126
130
 
127
131
  const newFormData = {
128
132
  active: true,
@@ -144,7 +148,6 @@ export class ConsentApp implements TerrenoPlugin {
144
148
 
145
149
  const newForm = await ConsentForm.create(newFormData);
146
150
 
147
- // Deactivate all other versions of the same slug
148
151
  await ConsentForm.updateMany(
149
152
  {_id: {$ne: newForm._id}, slug: form.slug},
150
153
  {active: false}
@@ -155,9 +158,12 @@ export class ConsentApp implements TerrenoPlugin {
155
158
  slug: newForm.slug,
156
159
  });
157
160
 
158
- return res.json({data: newForm});
159
- })
160
- );
161
+ return newForm;
162
+ },
163
+ method: "POST",
164
+ permissions: [Permissions.IsAuthenticated],
165
+ summary: "Publish a new version of a consent form",
166
+ },
161
167
  },
162
168
  permissions: {
163
169
  create: [Permissions.IsAdmin],
@@ -229,7 +235,6 @@ export class ConsentApp implements TerrenoPlugin {
229
235
  respondedFormVersions.set(formId, response.formVersionSnapshot ?? 0);
230
236
  }
231
237
 
232
- // Fetch the forms corresponding to existing responses to check version matches
233
238
  const respondedFormIds = existingResponses.map((r) => r.consentFormId);
234
239
  const respondedForms = await ConsentForm.find({_id: {$in: respondedFormIds}});
235
240
  const formVersionByFormId = new Map<string, number>();
@@ -237,17 +242,14 @@ export class ConsentApp implements TerrenoPlugin {
237
242
  formVersionByFormId.set(form._id.toString(), form.version);
238
243
  }
239
244
 
240
- // Filter out forms where the user already has a response matching the current version
241
245
  const pendingForms = resolvedForms.filter((form) => {
242
246
  const formId = form._id.toString();
243
- // Find responses for this form
244
247
  const matchingResponses = existingResponses.filter(
245
248
  (r) => r.consentFormId.toString() === formId
246
249
  );
247
250
  if (matchingResponses.length === 0) {
248
251
  return true;
249
252
  }
250
- // Check if any response matches the current form version
251
253
  return !matchingResponses.some((r) => r.formVersionSnapshot === form.version);
252
254
  });
253
255
 
@@ -300,7 +302,6 @@ export class ConsentApp implements TerrenoPlugin {
300
302
  throw new APIError({status: 400, title: "Consent form is not active"});
301
303
  }
302
304
 
303
- // Validate signature requirement
304
305
  if (form.captureSignature && agreed && !signature) {
305
306
  throw new APIError({
306
307
  status: 400,
@@ -308,7 +309,6 @@ export class ConsentApp implements TerrenoPlugin {
308
309
  });
309
310
  }
310
311
 
311
- // Validate required checkboxes
312
312
  if (agreed && form.checkboxes.length > 0) {
313
313
  const values = checkboxValues ?? {};
314
314
  for (let i = 0; i < form.checkboxes.length; i++) {
@@ -409,7 +409,6 @@ export class ConsentApp implements TerrenoPlugin {
409
409
  })
410
410
  );
411
411
 
412
- // GET /consents/audit/:userId - admin audit trail for a specific user
413
412
  if (auditTrail) {
414
413
  router.get(
415
414
  "/audit/:userId",
@@ -0,0 +1,58 @@
1
+ // biome-ignore-all lint/suspicious/noExplicitAny: test mock typing
2
+ import {describe, expect, it, mock} from "bun:test";
3
+ import {loadDocOr404} from "./docLoader";
4
+ import {APIError} from "./errors";
5
+
6
+ describe("loadDocOr404", () => {
7
+ it("returns hidden reason metadata when document is deleted", async () => {
8
+ const model = {
9
+ collection: {findOne: mock(async () => ({deleted: true}))},
10
+ findById: mock(() => ({exec: mock(async () => null)})),
11
+ modelName: "MockModel",
12
+ } as any;
13
+
14
+ try {
15
+ await loadDocOr404(model, "507f1f77bcf86cd799439011");
16
+ expect.unreachable("expected loadDocOr404 to throw");
17
+ } catch (error) {
18
+ expect(error).toBeInstanceOf(APIError);
19
+ const apiError = error as APIError;
20
+ expect(apiError.status).toBe(404);
21
+ expect(apiError.meta).toEqual({deleted: "true"});
22
+ expect(apiError.disableExternalErrorTracking).toBe(true);
23
+ }
24
+ });
25
+
26
+ it("rethrows APIError from query execution without wrapping", async () => {
27
+ const original = new APIError({status: 400, title: "validation failed"});
28
+ const model = {
29
+ collection: {findOne: mock(async () => null)},
30
+ findById: mock(() => ({
31
+ exec: mock(async () => {
32
+ throw original;
33
+ }),
34
+ })),
35
+ modelName: "MockModel",
36
+ } as any;
37
+
38
+ await expect(loadDocOr404(model, "507f1f77bcf86cd799439011")).rejects.toBe(original);
39
+ });
40
+
41
+ it("returns plain not found when document does not exist", async () => {
42
+ const model = {
43
+ collection: {findOne: mock(async () => null)},
44
+ findById: mock(() => ({exec: mock(async () => null)})),
45
+ modelName: "MockModel",
46
+ } as any;
47
+
48
+ try {
49
+ await loadDocOr404(model, "507f1f77bcf86cd799439011");
50
+ expect.unreachable("expected loadDocOr404 to throw");
51
+ } catch (error) {
52
+ expect(error).toBeInstanceOf(APIError);
53
+ const apiError = error as APIError;
54
+ expect(apiError.status).toBe(404);
55
+ expect(apiError.meta).toBeUndefined();
56
+ }
57
+ });
58
+ });
@@ -0,0 +1,77 @@
1
+ import * as Sentry from "@sentry/bun";
2
+ import mongoose, {type Model} from "mongoose";
3
+
4
+ import {addPopulateToQuery} from "./api";
5
+ import {APIError, isAPIError} from "./errors";
6
+ import type {PopulatePath} from "./populate";
7
+
8
+ /**
9
+ * Loads a document by id or throws a 404 APIError.
10
+ * Matches permission middleware behavior including soft-delete metadata.
11
+ */
12
+ export const loadDocOr404 = async <T>(
13
+ model: Model<T>,
14
+ id: string,
15
+ populatePaths?: PopulatePath[]
16
+ ): Promise<T> => {
17
+ const builtQuery = model.findById(id);
18
+ const populatedQuery = addPopulateToQuery(
19
+ // biome-ignore lint/suspicious/noExplicitAny: Query types vary based on populate paths
20
+ builtQuery as any,
21
+ populatePaths
22
+ );
23
+ let data: T | null;
24
+ try {
25
+ data = (await populatedQuery.exec()) as T | null;
26
+ } catch (error: unknown) {
27
+ if (isAPIError(error)) {
28
+ throw error;
29
+ }
30
+ throw new APIError({
31
+ error: error as Error,
32
+ status: 500,
33
+ title: `GET failed on ${id}`,
34
+ });
35
+ }
36
+ if (!data) {
37
+ const hiddenDoc = await model.collection.findOne({
38
+ _id: new mongoose.Types.ObjectId(id),
39
+ });
40
+
41
+ if (!hiddenDoc) {
42
+ Sentry.captureMessage(`Document ${id} not found for model ${model.modelName}`);
43
+ const error = new APIError({
44
+ status: 404,
45
+ title: `Document ${id} not found for model ${model.modelName}`,
46
+ });
47
+ error.meta = undefined;
48
+ throw error;
49
+ }
50
+
51
+ let reason: {[key: string]: string} | null = null;
52
+ if (hiddenDoc.deleted) {
53
+ reason = {deleted: "true"};
54
+ } else if (hiddenDoc.disabled) {
55
+ reason = {disabled: "true"};
56
+ } else if (hiddenDoc.archived) {
57
+ reason = {archived: "true"};
58
+ }
59
+
60
+ if (!reason) {
61
+ const error = new APIError({
62
+ status: 404,
63
+ title: `Document ${id} not found for model ${model.modelName}`,
64
+ });
65
+ error.meta = undefined;
66
+ throw error;
67
+ }
68
+ throw new APIError({
69
+ disableExternalErrorTracking: true,
70
+ meta: reason,
71
+ status: 404,
72
+ title: `Document ${id} not found for model ${model.modelName}`,
73
+ });
74
+ }
75
+
76
+ return data;
77
+ };
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ export * from "./actions";
1
2
  export * from "./api";
2
3
  export * from "./auth";
3
4
  export * from "./betterAuth";
@@ -36,3 +37,4 @@ export * from "./types/consentForm";
36
37
  export * from "./types/consentResponse";
37
38
  export * from "./utils";
38
39
  export * from "./versionCheckPlugin";
40
+ export {z} from "./zodOpenApi";
@@ -1,10 +1,10 @@
1
- import * as Sentry from "@sentry/bun";
2
1
  import type express from "express";
3
2
  import type {NextFunction} from "express";
4
- import mongoose, {type Model} from "mongoose";
3
+ import type {Model} from "mongoose";
5
4
 
6
- import {addPopulateToQuery, type ModelRouterOptions, type RESTMethod} from "./api";
5
+ import type {ModelRouterOptions, RESTMethod} from "./api";
7
6
  import type {User} from "./auth";
7
+ import {loadDocOr404} from "./docLoader";
8
8
  import {APIError} from "./errors";
9
9
  import {logger} from "./logger";
10
10
 
@@ -145,65 +145,7 @@ export const permissionMiddleware = <T>(
145
145
  return next();
146
146
  }
147
147
 
148
- const builtQuery = model.findById(req.params.id);
149
- const populatedQuery = addPopulateToQuery(
150
- // biome-ignore lint/suspicious/noExplicitAny: Query types vary based on populate paths
151
- builtQuery as any,
152
- options.populatePaths
153
- );
154
- let data: T | null;
155
- try {
156
- data = (await populatedQuery.exec()) as T | null;
157
- } catch (error: unknown) {
158
- throw new APIError({
159
- error: error as Error,
160
- status: 500,
161
- title: `GET failed on ${req.params.id}`,
162
- });
163
- }
164
- if (!data) {
165
- // Check if document exists but is hidden. Completely skip plugins.
166
- const hiddenDoc = await model.collection.findOne({
167
- _id: new mongoose.Types.ObjectId(req.params.id as string),
168
- });
169
-
170
- if (!hiddenDoc) {
171
- Sentry.captureMessage(`Document ${req.params.id} not found for model ${model.modelName}`);
172
- const error = new APIError({
173
- status: 404,
174
- title: `Document ${req.params.id} not found for model ${model.modelName}`,
175
- });
176
- error.meta = undefined;
177
- throw error;
178
- }
179
-
180
- // Document exists but is hidden
181
- let reason: {[key: string]: string} | null = null;
182
- if (hiddenDoc.deleted) {
183
- reason = {deleted: "true"};
184
- } else if (hiddenDoc.disabled) {
185
- reason = {disabled: "true"};
186
- } else if (hiddenDoc.archived) {
187
- reason = {archived: "true"};
188
- }
189
-
190
- // If no reason found, treat as not found
191
- if (!reason) {
192
- const error = new APIError({
193
- status: 404,
194
- title: `Document ${req.params.id} not found for model ${model.modelName}`,
195
- });
196
- error.meta = undefined;
197
- throw error;
198
- }
199
- throw new APIError({
200
- // We don't want to send this to Sentry because it's expected behavior.
201
- disableExternalErrorTracking: true,
202
- meta: reason,
203
- status: 404,
204
- title: `Document ${req.params.id} not found for model ${model.modelName}`,
205
- });
206
- }
148
+ const data = await loadDocOr404<T>(model, req.params.id as string, options.populatePaths);
207
149
 
208
150
  if (!(await checkPermissions(method, options.permissions[method], req.user, data))) {
209
151
  throw new APIError({
@@ -0,0 +1,6 @@
1
+ import {extendZodWithOpenApi} from "@asteasolutions/zod-to-openapi";
2
+ import {z} from "zod";
3
+
4
+ extendZodWithOpenApi(z);
5
+
6
+ export {z};