@terreno/api 0.7.2 → 0.8.0

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 (50) hide show
  1. package/dist/__tests__/{versionCheck.test.js → versionCheckPlugin.test.js} +2 -2
  2. package/dist/api.d.ts +4 -2
  3. package/dist/api.js +7 -2
  4. package/dist/consentApp.d.ts +33 -0
  5. package/dist/consentApp.js +484 -0
  6. package/dist/consentApp.test.d.ts +1 -0
  7. package/dist/consentApp.test.js +1132 -0
  8. package/dist/index.d.ts +6 -0
  9. package/dist/index.js +6 -0
  10. package/dist/models/consentForm.d.ts +2 -0
  11. package/dist/models/consentForm.js +115 -0
  12. package/dist/models/consentResponse.d.ts +2 -0
  13. package/dist/models/consentResponse.js +73 -0
  14. package/dist/models/versionConfig.d.ts +1 -1
  15. package/dist/openApiValidator.js +2 -0
  16. package/dist/populate.d.ts +1 -0
  17. package/dist/populate.js +53 -13
  18. package/dist/syncConsents.d.ts +67 -0
  19. package/dist/syncConsents.js +334 -0
  20. package/dist/syncConsents.test.d.ts +1 -0
  21. package/dist/syncConsents.test.js +249 -0
  22. package/dist/terrenoApp.js +6 -5
  23. package/dist/terrenoPlugin.d.ts +1 -1
  24. package/dist/types/consentForm.d.ts +32 -0
  25. package/dist/types/consentForm.js +2 -0
  26. package/dist/types/consentResponse.d.ts +23 -0
  27. package/dist/types/consentResponse.js +2 -0
  28. package/dist/vendor/wesleytodd-openapi/lib/generate-doc.js +1 -1
  29. package/dist/versionCheckPlugin.d.ts +2 -0
  30. package/dist/versionCheckPlugin.js +3 -6
  31. package/package.json +1 -1
  32. package/src/__tests__/{versionCheck.test.ts → versionCheckPlugin.test.ts} +2 -2
  33. package/src/api.ts +11 -4
  34. package/src/consentApp.test.ts +749 -0
  35. package/src/consentApp.ts +463 -0
  36. package/src/index.ts +6 -0
  37. package/src/models/consentForm.ts +123 -0
  38. package/src/models/consentResponse.ts +78 -0
  39. package/src/models/versionConfig.ts +1 -1
  40. package/src/openApiValidator.ts +2 -0
  41. package/src/populate.ts +33 -0
  42. package/src/syncConsents.test.ts +124 -0
  43. package/src/syncConsents.ts +263 -0
  44. package/src/terrenoApp.ts +6 -6
  45. package/src/terrenoPlugin.ts +1 -1
  46. package/src/types/consentForm.ts +41 -0
  47. package/src/types/consentResponse.ts +34 -0
  48. package/src/vendor/wesleytodd-openapi/lib/generate-doc.js +1 -1
  49. package/src/versionCheckPlugin.ts +5 -6
  50. /package/dist/__tests__/{versionCheck.test.d.ts → versionCheckPlugin.test.d.ts} +0 -0
@@ -0,0 +1,463 @@
1
+ /**
2
+ * ConsentApp plugin for @terreno/api.
3
+ *
4
+ * Registers consent form management and user consent response routes as a TerrenoPlugin.
5
+ * Provides admin CRUD for consent forms, read-only access to responses, and user-facing
6
+ * endpoints for fetching pending consents and submitting responses.
7
+ */
8
+
9
+ import type express from "express";
10
+ import {DateTime} from "luxon";
11
+ import {asyncHandler, modelRouter} from "./api";
12
+ import type {User} from "./auth";
13
+ import {authenticateMiddleware} from "./auth";
14
+ import {APIError} from "./errors";
15
+ import {logger} from "./logger";
16
+ import {ConsentForm} from "./models/consentForm";
17
+ import {ConsentResponse} from "./models/consentResponse";
18
+ import {Permissions} from "./permissions";
19
+ import type {TerrenoPlugin} from "./terrenoPlugin";
20
+ import type {ConsentFormDocument} from "./types/consentForm";
21
+
22
+ export interface ConsentAppOptions {
23
+ auditTrail?: boolean;
24
+ aiConfig?: {
25
+ generateContent: (params: {
26
+ type: string;
27
+ description: string;
28
+ locale: string;
29
+ }) => Promise<string>;
30
+ translateContent: (params: {
31
+ content: string;
32
+ fromLocale: string;
33
+ toLocale: string;
34
+ }) => Promise<string>;
35
+ };
36
+ resolveConsentForms?: (
37
+ user: User,
38
+ forms: ConsentFormDocument[]
39
+ ) => ConsentFormDocument[] | Promise<ConsentFormDocument[]>;
40
+ supportedLocales?: string[];
41
+ }
42
+
43
+ export class ConsentApp implements TerrenoPlugin {
44
+ private options: ConsentAppOptions;
45
+
46
+ constructor(options: ConsentAppOptions = {}) {
47
+ this.options = options;
48
+ }
49
+
50
+ register(app: express.Application): void {
51
+ const {auditTrail, resolveConsentForms, aiConfig} = this.options;
52
+
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});
107
+
108
+ logger.info("ConsentForm content translated", {fromLocale, toLocale});
109
+
110
+ return res.json({data: {content: translated}});
111
+ })
112
+ );
113
+ }
114
+
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});
126
+
127
+ const newFormData = {
128
+ active: true,
129
+ agreeButtonText: form.agreeButtonText,
130
+ allowDecline: form.allowDecline,
131
+ captureSignature: form.captureSignature,
132
+ checkboxes: form.checkboxes,
133
+ content: form.content,
134
+ declineButtonText: form.declineButtonText,
135
+ defaultLocale: form.defaultLocale,
136
+ order: form.order,
137
+ required: form.required,
138
+ requireScrollToBottom: form.requireScrollToBottom,
139
+ slug: form.slug,
140
+ title: form.title,
141
+ type: form.type,
142
+ version: form.version + 1,
143
+ };
144
+
145
+ const newForm = await ConsentForm.create(newFormData);
146
+
147
+ // Deactivate all other versions of the same slug
148
+ await ConsentForm.updateMany(
149
+ {_id: {$ne: newForm._id}, slug: form.slug},
150
+ {active: false}
151
+ );
152
+
153
+ logger.info("ConsentForm published", {
154
+ newVersion: newForm.version,
155
+ slug: newForm.slug,
156
+ });
157
+
158
+ return res.json({data: newForm});
159
+ })
160
+ );
161
+ },
162
+ permissions: {
163
+ create: [Permissions.IsAdmin],
164
+ delete: [Permissions.IsAdmin],
165
+ list: [Permissions.IsAdmin],
166
+ read: [Permissions.IsAdmin],
167
+ update: [Permissions.IsAdmin],
168
+ },
169
+ queryFields: ["slug", "type", "active", "version"],
170
+ sort: "order",
171
+ })
172
+ );
173
+
174
+ // Admin read-only access to consent responses
175
+ app.use(
176
+ "/consent-responses",
177
+ modelRouter(ConsentResponse, {
178
+ permissions: {
179
+ create: [],
180
+ delete: [],
181
+ list: [Permissions.IsAdmin],
182
+ read: [Permissions.IsAdmin],
183
+ update: [],
184
+ },
185
+ populatePaths: [
186
+ {
187
+ fields: ["title", "slug", "version", "type"],
188
+ path: "consentFormId",
189
+ },
190
+ ],
191
+ })
192
+ );
193
+
194
+ // User-facing consent endpoints
195
+ const router = require("express").Router() as express.Router;
196
+
197
+ // GET /consents/pending - fetch pending consent forms for the current user
198
+ router.get(
199
+ "/pending",
200
+ authenticateMiddleware(),
201
+ asyncHandler(async (req, res) => {
202
+ const user = req.user as User | undefined;
203
+ if (!user) {
204
+ throw new APIError({status: 401, title: "Authentication required"});
205
+ }
206
+
207
+ logger.debug("Fetching pending consent forms", {userId: user.id});
208
+
209
+ const activeForms = await ConsentForm.find({active: true}).sort({order: 1});
210
+
211
+ let resolvedForms: ConsentFormDocument[];
212
+ if (resolveConsentForms) {
213
+ resolvedForms = await resolveConsentForms(user, activeForms);
214
+ logger.debug("resolveConsentForms applied", {
215
+ activeFormCount: activeForms.length,
216
+ resolvedFormCount: resolvedForms.length,
217
+ userAdmin: Boolean(user.admin),
218
+ userId: user.id,
219
+ });
220
+ } else {
221
+ resolvedForms = activeForms;
222
+ }
223
+
224
+ const existingResponses = await ConsentResponse.find({userId: user.id});
225
+
226
+ const respondedFormVersions = new Map<string, number>();
227
+ for (const response of existingResponses) {
228
+ const formId = response.consentFormId.toString();
229
+ respondedFormVersions.set(formId, response.formVersionSnapshot ?? 0);
230
+ }
231
+
232
+ // Fetch the forms corresponding to existing responses to check version matches
233
+ const respondedFormIds = existingResponses.map((r) => r.consentFormId);
234
+ const respondedForms = await ConsentForm.find({_id: {$in: respondedFormIds}});
235
+ const formVersionByFormId = new Map<string, number>();
236
+ for (const form of respondedForms) {
237
+ formVersionByFormId.set(form._id.toString(), form.version);
238
+ }
239
+
240
+ // Filter out forms where the user already has a response matching the current version
241
+ const pendingForms = resolvedForms.filter((form) => {
242
+ const formId = form._id.toString();
243
+ // Find responses for this form
244
+ const matchingResponses = existingResponses.filter(
245
+ (r) => r.consentFormId.toString() === formId
246
+ );
247
+ if (matchingResponses.length === 0) {
248
+ return true;
249
+ }
250
+ // Check if any response matches the current form version
251
+ return !matchingResponses.some((r) => r.formVersionSnapshot === form.version);
252
+ });
253
+
254
+ const filteredOutByResolverCount = Math.max(activeForms.length - resolvedForms.length, 0);
255
+ const filteredOutByResponsesCount = Math.max(resolvedForms.length - pendingForms.length, 0);
256
+
257
+ logger.info("Pending consent forms fetched", {
258
+ activeFormCount: activeForms.length,
259
+ filteredOutByResolverCount,
260
+ filteredOutByResponsesCount,
261
+ pendingFormCount: pendingForms.length,
262
+ resolvedFormCount: resolvedForms.length,
263
+ responseCount: existingResponses.length,
264
+ userAdmin: Boolean(user.admin),
265
+ userId: user.id,
266
+ });
267
+
268
+ return res.json({data: pendingForms});
269
+ })
270
+ );
271
+
272
+ // POST /consents/respond - submit a consent response
273
+ router.post(
274
+ "/respond",
275
+ authenticateMiddleware(),
276
+ asyncHandler(async (req, res) => {
277
+ const user = req.user as User | undefined;
278
+ if (!user) {
279
+ throw new APIError({status: 401, title: "Authentication required"});
280
+ }
281
+
282
+ const {agreed, checkboxValues, consentFormId, locale, signature} = req.body;
283
+
284
+ if (!consentFormId) {
285
+ throw new APIError({status: 400, title: "consentFormId is required"});
286
+ }
287
+ if (agreed === undefined || agreed === null) {
288
+ throw new APIError({status: 400, title: "agreed field is required"});
289
+ }
290
+ if (!locale) {
291
+ throw new APIError({status: 400, title: "locale is required"});
292
+ }
293
+
294
+ const form = await ConsentForm.findExactlyOne(
295
+ {_id: consentFormId},
296
+ {status: 404, title: "Consent form not found"}
297
+ );
298
+
299
+ if (!form.active) {
300
+ throw new APIError({status: 400, title: "Consent form is not active"});
301
+ }
302
+
303
+ // Validate signature requirement
304
+ if (form.captureSignature && agreed && !signature) {
305
+ throw new APIError({
306
+ status: 400,
307
+ title: "Signature is required for this consent form",
308
+ });
309
+ }
310
+
311
+ // Validate required checkboxes
312
+ if (agreed && form.checkboxes.length > 0) {
313
+ const values = checkboxValues ?? {};
314
+ for (let i = 0; i < form.checkboxes.length; i++) {
315
+ const checkbox = form.checkboxes[i];
316
+ if (checkbox.required && !values[i.toString()]) {
317
+ throw new APIError({
318
+ status: 400,
319
+ title: `Required checkbox "${checkbox.label}" must be checked`,
320
+ });
321
+ }
322
+ }
323
+ }
324
+
325
+ const responseData: Record<string, unknown> = {
326
+ agreed,
327
+ agreedAt: DateTime.now().toJSDate(),
328
+ consentFormId: form._id,
329
+ locale,
330
+ userId: user.id,
331
+ };
332
+
333
+ if (checkboxValues !== undefined) {
334
+ responseData.checkboxValues = checkboxValues;
335
+ }
336
+
337
+ if (signature) {
338
+ responseData.signature = signature;
339
+ responseData.signedAt = DateTime.now().toJSDate();
340
+ }
341
+
342
+ if (auditTrail) {
343
+ responseData.ipAddress = req.ip;
344
+ responseData.userAgent = req.headers["user-agent"];
345
+ responseData.contentSnapshot =
346
+ form.content.get(locale) ?? form.content.get(form.defaultLocale);
347
+ responseData.formVersionSnapshot = form.version;
348
+ } else {
349
+ responseData.formVersionSnapshot = form.version;
350
+ }
351
+
352
+ const response = await ConsentResponse.create(responseData);
353
+
354
+ logger.info("Consent response recorded", {
355
+ agreed,
356
+ consentFormId: form._id.toString(),
357
+ locale,
358
+ userId: user.id,
359
+ });
360
+
361
+ return res.json({data: response});
362
+ })
363
+ );
364
+
365
+ // GET /consents/my - fetch the current user's consent responses with form data
366
+ router.get(
367
+ "/my",
368
+ authenticateMiddleware(),
369
+ asyncHandler(async (req, res) => {
370
+ const user = req.user as User | undefined;
371
+ if (!user) {
372
+ throw new APIError({status: 401, title: "Authentication required"});
373
+ }
374
+
375
+ const responses = await ConsentResponse.find({userId: user.id}).sort({agreedAt: -1});
376
+
377
+ const formIds = responses.map((r) => r.consentFormId);
378
+ const forms = await ConsentForm.find({_id: {$in: formIds}});
379
+ const formMap = new Map(forms.map((f) => [f._id.toString(), f]));
380
+
381
+ const data = responses.map((response) => {
382
+ const form = formMap.get(response.consentFormId.toString());
383
+ return {
384
+ _id: response._id,
385
+ agreed: response.agreed,
386
+ agreedAt: response.agreedAt,
387
+ checkboxValues: response.checkboxValues,
388
+ contentSnapshot: response.contentSnapshot,
389
+ form: form
390
+ ? {
391
+ captureSignature: form.captureSignature,
392
+ checkboxes: form.checkboxes,
393
+ slug: form.slug,
394
+ title: form.title,
395
+ type: form.type,
396
+ version: form.version,
397
+ }
398
+ : null,
399
+ formVersionSnapshot: response.formVersionSnapshot,
400
+ ipAddress: response.ipAddress,
401
+ locale: response.locale,
402
+ signature: response.signature,
403
+ signedAt: response.signedAt,
404
+ userAgent: response.userAgent,
405
+ };
406
+ });
407
+
408
+ return res.json({data});
409
+ })
410
+ );
411
+
412
+ // GET /consents/audit/:userId - admin audit trail for a specific user
413
+ if (auditTrail) {
414
+ router.get(
415
+ "/audit/:userId",
416
+ authenticateMiddleware(),
417
+ asyncHandler(async (req, res) => {
418
+ const user = req.user as User | undefined;
419
+ if (!user?.admin) {
420
+ throw new APIError({status: 403, title: "Admin access required"});
421
+ }
422
+
423
+ const responses = await ConsentResponse.find({userId: req.params.userId}).sort({
424
+ agreedAt: -1,
425
+ });
426
+
427
+ const formIds = responses.map((r) => r.consentFormId);
428
+ const forms = await ConsentForm.find({_id: {$in: formIds}});
429
+ const formMap = new Map(forms.map((f) => [f._id.toString(), f]));
430
+
431
+ const auditEntries = responses.map((response) => {
432
+ const form = formMap.get(response.consentFormId.toString());
433
+ return {
434
+ agreed: response.agreed,
435
+ agreedAt: response.agreedAt,
436
+ contentSnapshot: response.contentSnapshot,
437
+ form: form
438
+ ? {
439
+ slug: form.slug,
440
+ title: form.title,
441
+ type: form.type,
442
+ version: form.version,
443
+ }
444
+ : null,
445
+ formVersionSnapshot: response.formVersionSnapshot,
446
+ ipAddress: response.ipAddress,
447
+ locale: response.locale,
448
+ responseId: response._id,
449
+ signedAt: response.signedAt,
450
+ userAgent: response.userAgent,
451
+ };
452
+ });
453
+
454
+ return res.json({data: auditEntries});
455
+ })
456
+ );
457
+ }
458
+
459
+ app.use("/consents", router);
460
+
461
+ logger.info("ConsentApp registered", {auditTrail: Boolean(auditTrail)});
462
+ }
463
+ }
package/src/index.ts CHANGED
@@ -5,11 +5,14 @@ export * from "./betterAuthApp";
5
5
  export * from "./betterAuthSetup";
6
6
  export * from "./configurationApp";
7
7
  export * from "./configurationPlugin";
8
+ export * from "./consentApp";
8
9
  export * from "./errors";
9
10
  export * from "./expressServer";
10
11
  export * from "./githubAuth";
11
12
  export * from "./logger";
12
13
  export * from "./middleware";
14
+ export * from "./models/consentForm";
15
+ export * from "./models/consentResponse";
13
16
  export * from "./models/versionConfig";
14
17
  export * from "./notifiers";
15
18
  export * from "./openApiBuilder";
@@ -21,8 +24,11 @@ export * from "./plugins";
21
24
  export * from "./populate";
22
25
  export * from "./scriptRunner";
23
26
  export * from "./secretProviders";
27
+ export * from "./syncConsents";
24
28
  export * from "./terrenoApp";
25
29
  export * from "./terrenoPlugin";
26
30
  export * from "./transformers";
31
+ export * from "./types/consentForm";
32
+ export * from "./types/consentResponse";
27
33
  export * from "./utils";
28
34
  export * from "./versionCheckPlugin";
@@ -0,0 +1,123 @@
1
+ import mongoose from "mongoose";
2
+ import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "../plugins";
3
+ import type {ConsentFormDocument, ConsentFormModel} from "../types/consentForm";
4
+
5
+ const consentFormSchema = new mongoose.Schema<ConsentFormDocument, ConsentFormModel>(
6
+ {
7
+ active: {
8
+ default: false,
9
+ description: "Whether this consent form is currently active and available to users",
10
+ type: Boolean,
11
+ },
12
+ agreeButtonText: {
13
+ default: "I Agree",
14
+ description: "Label text for the agreement button",
15
+ type: String,
16
+ },
17
+ allowDecline: {
18
+ default: false,
19
+ description: "Whether users are allowed to decline the consent form",
20
+ type: Boolean,
21
+ },
22
+ captureSignature: {
23
+ default: false,
24
+ description: "Whether to require a drawn or typed signature when the user agrees",
25
+ type: Boolean,
26
+ },
27
+ checkboxes: {
28
+ default: [],
29
+ description: "List of checkboxes the user must interact with before agreeing",
30
+ type: [
31
+ {
32
+ confirmationPrompt: {
33
+ description: "Optional prompt shown when the user checks this checkbox",
34
+ type: String,
35
+ },
36
+ label: {
37
+ description: "Display label for the checkbox",
38
+ required: true,
39
+ type: String,
40
+ },
41
+ required: {
42
+ default: false,
43
+ description: "Whether this checkbox must be checked before the user can agree",
44
+ type: Boolean,
45
+ },
46
+ },
47
+ ],
48
+ },
49
+ content: {
50
+ description:
51
+ 'Locale-keyed map of Markdown content for this form (e.g. {"en": "# Terms\\n..."})',
52
+ of: String,
53
+ required: true,
54
+ type: Map,
55
+ },
56
+ declineButtonText: {
57
+ default: "Decline",
58
+ description: "Label text for the decline button (only shown when allowDecline is true)",
59
+ type: String,
60
+ },
61
+ defaultLocale: {
62
+ default: "en",
63
+ description: "Default locale to use when the requested locale is not available",
64
+ type: String,
65
+ },
66
+ order: {
67
+ default: 0,
68
+ description: "Display order relative to other consent forms (lower numbers appear first)",
69
+ required: true,
70
+ type: Number,
71
+ },
72
+ required: {
73
+ default: true,
74
+ description: "Whether users must complete this form before accessing the application",
75
+ type: Boolean,
76
+ },
77
+ requireScrollToBottom: {
78
+ default: false,
79
+ description: "Whether users must scroll to the bottom of the form content before agreeing",
80
+ type: Boolean,
81
+ },
82
+ slug: {
83
+ description:
84
+ "URL-safe identifier for this form, combined with version to uniquely identify a form",
85
+ index: true,
86
+ required: true,
87
+ trim: true,
88
+ type: String,
89
+ },
90
+ title: {
91
+ description: "Human-readable title of the consent form",
92
+ required: true,
93
+ trim: true,
94
+ type: String,
95
+ },
96
+ type: {
97
+ description: "Category of consent form",
98
+ enum: ["agreement", "privacy", "hipaa", "research", "terms", "custom"],
99
+ required: true,
100
+ type: String,
101
+ },
102
+ version: {
103
+ default: 1,
104
+ description:
105
+ "Version number of this form. Incrementing the version requires users to re-consent",
106
+ required: true,
107
+ type: Number,
108
+ },
109
+ },
110
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
111
+ );
112
+
113
+ consentFormSchema.index({slug: 1, version: 1}, {unique: true});
114
+
115
+ consentFormSchema.plugin(createdUpdatedPlugin);
116
+ consentFormSchema.plugin(isDeletedPlugin);
117
+ consentFormSchema.plugin(findOneOrNone);
118
+ consentFormSchema.plugin(findExactlyOne);
119
+
120
+ export const ConsentForm = mongoose.model<ConsentFormDocument, ConsentFormModel>(
121
+ "ConsentForm",
122
+ consentFormSchema
123
+ );
@@ -0,0 +1,78 @@
1
+ import mongoose from "mongoose";
2
+ import {createdUpdatedPlugin, findExactlyOne, findOneOrNone, isDeletedPlugin} from "../plugins";
3
+ import type {ConsentResponseDocument, ConsentResponseModel} from "../types/consentResponse";
4
+
5
+ const consentResponseSchema = new mongoose.Schema<ConsentResponseDocument, ConsentResponseModel>(
6
+ {
7
+ agreed: {
8
+ description: "Whether the user agreed (true) or declined (false) the consent form",
9
+ required: true,
10
+ type: Boolean,
11
+ },
12
+ agreedAt: {
13
+ description: "Timestamp when the user submitted their agreement or declination",
14
+ required: true,
15
+ type: Date,
16
+ },
17
+ checkboxValues: {
18
+ description: "Map of checkbox index to boolean indicating whether each checkbox was checked",
19
+ of: Boolean,
20
+ type: Map,
21
+ },
22
+ consentFormId: {
23
+ description: "Reference to the ConsentForm that was responded to",
24
+ ref: "ConsentForm",
25
+ required: true,
26
+ type: mongoose.Schema.Types.ObjectId,
27
+ },
28
+ contentSnapshot: {
29
+ description: "Snapshot of the form content in the user's locale at the time of response",
30
+ type: String,
31
+ },
32
+ formVersionSnapshot: {
33
+ description: "Version number of the form at the time the user responded",
34
+ type: Number,
35
+ },
36
+ ipAddress: {
37
+ description: "IP address of the user at the time of response, captured for audit purposes",
38
+ type: String,
39
+ },
40
+ locale: {
41
+ description: "Locale code of the content version the user viewed when responding",
42
+ required: true,
43
+ type: String,
44
+ },
45
+ signature: {
46
+ description: "Base64-encoded signature image or typed signature text, if captured",
47
+ type: String,
48
+ },
49
+ signedAt: {
50
+ description: "Timestamp when the user provided their signature",
51
+ type: Date,
52
+ },
53
+ userAgent: {
54
+ description: "User-agent string of the browser or app used to submit the response",
55
+ type: String,
56
+ },
57
+ userId: {
58
+ description: "Reference to the User who submitted this response",
59
+ index: true,
60
+ ref: "User",
61
+ required: true,
62
+ type: mongoose.Schema.Types.ObjectId,
63
+ },
64
+ },
65
+ {strict: "throw", toJSON: {virtuals: true}, toObject: {virtuals: true}}
66
+ );
67
+
68
+ consentResponseSchema.index({consentFormId: 1, userId: 1});
69
+
70
+ consentResponseSchema.plugin(createdUpdatedPlugin);
71
+ consentResponseSchema.plugin(isDeletedPlugin);
72
+ consentResponseSchema.plugin(findOneOrNone);
73
+ consentResponseSchema.plugin(findExactlyOne);
74
+
75
+ export const ConsentResponse = mongoose.model<ConsentResponseDocument, ConsentResponseModel>(
76
+ "ConsentResponse",
77
+ consentResponseSchema
78
+ );
@@ -1,6 +1,6 @@
1
1
  import mongoose, {type Document} from "mongoose";
2
2
 
3
- import {type APIErrorConstructor} from "../errors";
3
+ import type {APIErrorConstructor} from "../errors";
4
4
  import {createdUpdatedPlugin, findOneOrNone} from "../plugins";
5
5
 
6
6
  export interface VersionConfigDocument extends mongoose.Document {