@terreno/api 0.7.1 → 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.
- package/dist/__tests__/{versionCheck.test.js → versionCheckPlugin.test.js} +2 -2
- package/dist/api.d.ts +4 -2
- package/dist/api.js +7 -2
- package/dist/consentApp.d.ts +33 -0
- package/dist/consentApp.js +484 -0
- package/dist/consentApp.test.d.ts +1 -0
- package/dist/consentApp.test.js +1132 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/models/consentForm.d.ts +2 -0
- package/dist/models/consentForm.js +115 -0
- package/dist/models/consentResponse.d.ts +2 -0
- package/dist/models/consentResponse.js +73 -0
- package/dist/models/versionConfig.d.ts +1 -1
- package/dist/openApiValidator.js +2 -0
- package/dist/populate.d.ts +1 -0
- package/dist/populate.js +53 -13
- package/dist/syncConsents.d.ts +67 -0
- package/dist/syncConsents.js +334 -0
- package/dist/syncConsents.test.d.ts +1 -0
- package/dist/syncConsents.test.js +249 -0
- package/dist/terrenoApp.js +6 -5
- package/dist/terrenoPlugin.d.ts +1 -1
- package/dist/types/consentForm.d.ts +32 -0
- package/dist/types/consentForm.js +2 -0
- package/dist/types/consentResponse.d.ts +23 -0
- package/dist/types/consentResponse.js +2 -0
- package/dist/vendor/wesleytodd-openapi/lib/generate-doc.js +1 -1
- package/dist/versionCheckPlugin.d.ts +2 -0
- package/dist/versionCheckPlugin.js +3 -6
- package/package.json +1 -1
- package/src/__tests__/{versionCheck.test.ts → versionCheckPlugin.test.ts} +2 -2
- package/src/api.ts +11 -4
- package/src/consentApp.test.ts +749 -0
- package/src/consentApp.ts +463 -0
- package/src/index.ts +6 -0
- package/src/models/consentForm.ts +123 -0
- package/src/models/consentResponse.ts +78 -0
- package/src/models/versionConfig.ts +1 -1
- package/src/openApiValidator.ts +2 -0
- package/src/populate.ts +33 -0
- package/src/syncConsents.test.ts +124 -0
- package/src/syncConsents.ts +263 -0
- package/src/terrenoApp.ts +6 -6
- package/src/terrenoPlugin.ts +1 -1
- package/src/types/consentForm.ts +41 -0
- package/src/types/consentResponse.ts +34 -0
- package/src/vendor/wesleytodd-openapi/lib/generate-doc.js +1 -1
- package/src/versionCheckPlugin.ts +5 -6
- /package/dist/__tests__/{versionCheck.test.d.ts → versionCheckPlugin.test.d.ts} +0 -0
package/src/openApiValidator.ts
CHANGED
|
@@ -43,6 +43,7 @@ import m2s from "mongoose-to-swagger";
|
|
|
43
43
|
import {APIError} from "./errors";
|
|
44
44
|
import {logger} from "./logger";
|
|
45
45
|
import type {OpenApiSchema, OpenApiSchemaProperty} from "./openApiBuilder";
|
|
46
|
+
import {fixMixedFields} from "./populate";
|
|
46
47
|
|
|
47
48
|
/**
|
|
48
49
|
* Global configuration for OpenAPI validation.
|
|
@@ -713,6 +714,7 @@ const m2sOptions = {
|
|
|
713
714
|
*/
|
|
714
715
|
export function getSchemaFromModel<T>(model: Model<T>): Record<string, OpenApiSchemaProperty> {
|
|
715
716
|
const modelSwagger = m2s(model, m2sOptions);
|
|
717
|
+
fixMixedFields((model as any).schema, modelSwagger.properties);
|
|
716
718
|
return modelSwagger.properties as Record<string, OpenApiSchemaProperty>;
|
|
717
719
|
}
|
|
718
720
|
|
package/src/populate.ts
CHANGED
|
@@ -102,6 +102,37 @@ function getPathInSchema(schema: any, path: string): string {
|
|
|
102
102
|
}
|
|
103
103
|
|
|
104
104
|
// Replaces populated properties with the populated schema.
|
|
105
|
+
// Recursively walks a Mongoose schema and fixes any Mixed fields in the
|
|
106
|
+
// OpenAPI properties so they use an empty schema (accepts any type) instead
|
|
107
|
+
// of the `{type: "object", properties: {}}` that mongoose-to-swagger emits.
|
|
108
|
+
export const fixMixedFields = (schema: any, properties: Record<string, any>): void => {
|
|
109
|
+
if (!properties || !schema) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const key of Object.keys(properties)) {
|
|
114
|
+
const schemaPath = schema.path(key);
|
|
115
|
+
if (!schemaPath) {
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Direct Mixed field
|
|
120
|
+
if (schemaPath.instance === "Mixed") {
|
|
121
|
+
properties[key] = {description: properties[key]?.description};
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Array of sub-documents — check each sub-field for Mixed
|
|
126
|
+
if (
|
|
127
|
+
schemaPath.instance === "Array" &&
|
|
128
|
+
schemaPath.schema &&
|
|
129
|
+
properties[key]?.items?.properties
|
|
130
|
+
) {
|
|
131
|
+
fixMixedFields(schemaPath.schema, properties[key].items.properties);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
};
|
|
135
|
+
|
|
105
136
|
export function getOpenApiSpecForModel(
|
|
106
137
|
model: any,
|
|
107
138
|
{
|
|
@@ -113,6 +144,8 @@ export function getOpenApiSpecForModel(
|
|
|
113
144
|
props: ["required", "enum"],
|
|
114
145
|
});
|
|
115
146
|
|
|
147
|
+
fixMixedFields(model.schema, modelSwagger.properties);
|
|
148
|
+
|
|
116
149
|
if (populatePaths && isArray(populatePaths)) {
|
|
117
150
|
for (const populatePath of populatePaths) {
|
|
118
151
|
// Get the referenced populate model from the model schema
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import {afterEach, beforeEach, describe, expect, it} from "bun:test";
|
|
2
|
+
import {ConsentForm} from "./models/consentForm";
|
|
3
|
+
import type {ConsentFormDefinition} from "./syncConsents";
|
|
4
|
+
import {syncConsents} from "./syncConsents";
|
|
5
|
+
import {setupDb} from "./tests";
|
|
6
|
+
|
|
7
|
+
const baseDef: ConsentFormDefinition = {
|
|
8
|
+
content: {en: "# Terms\nPlease agree."},
|
|
9
|
+
order: 1,
|
|
10
|
+
required: true,
|
|
11
|
+
title: "Terms of Service",
|
|
12
|
+
type: "terms",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
describe("syncConsents", () => {
|
|
16
|
+
beforeEach(async () => {
|
|
17
|
+
await setupDb();
|
|
18
|
+
await ConsentForm.deleteMany({});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
await ConsentForm.deleteMany({});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it("creates a new consent form when none exists", async () => {
|
|
26
|
+
const result = await syncConsents({terms: baseDef});
|
|
27
|
+
|
|
28
|
+
expect(result.created).toEqual(["terms"]);
|
|
29
|
+
expect(result.updated).toHaveLength(0);
|
|
30
|
+
expect(result.unchanged).toHaveLength(0);
|
|
31
|
+
|
|
32
|
+
const forms = await ConsentForm.find({slug: "terms"});
|
|
33
|
+
expect(forms).toHaveLength(1);
|
|
34
|
+
expect(forms[0].active).toBe(true);
|
|
35
|
+
expect(forms[0].version).toBe(1);
|
|
36
|
+
expect(forms[0].title).toBe("Terms of Service");
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("leaves unchanged forms alone", async () => {
|
|
40
|
+
await syncConsents({terms: baseDef});
|
|
41
|
+
const result = await syncConsents({terms: baseDef});
|
|
42
|
+
|
|
43
|
+
expect(result.unchanged).toEqual(["terms"]);
|
|
44
|
+
expect(result.created).toHaveLength(0);
|
|
45
|
+
expect(result.updated).toHaveLength(0);
|
|
46
|
+
|
|
47
|
+
const forms = await ConsentForm.find({slug: "terms"});
|
|
48
|
+
expect(forms).toHaveLength(1);
|
|
49
|
+
expect(forms[0].version).toBe(1);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it("publishes a new version when content changes", async () => {
|
|
53
|
+
await syncConsents({terms: baseDef});
|
|
54
|
+
|
|
55
|
+
const updated = {...baseDef, content: {en: "# Updated Terms\nNew content."}};
|
|
56
|
+
const result = await syncConsents({terms: updated});
|
|
57
|
+
|
|
58
|
+
expect(result.updated).toEqual(["terms"]);
|
|
59
|
+
|
|
60
|
+
const forms = await ConsentForm.find({slug: "terms"}).sort({version: 1});
|
|
61
|
+
expect(forms).toHaveLength(2);
|
|
62
|
+
expect(forms[0].active).toBe(false);
|
|
63
|
+
expect(forms[0].version).toBe(1);
|
|
64
|
+
expect(forms[1].active).toBe(true);
|
|
65
|
+
expect(forms[1].version).toBe(2);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it("publishes a new version when title changes", async () => {
|
|
69
|
+
await syncConsents({terms: baseDef});
|
|
70
|
+
|
|
71
|
+
const updated = {...baseDef, title: "Updated Terms"};
|
|
72
|
+
const result = await syncConsents({terms: updated});
|
|
73
|
+
|
|
74
|
+
expect(result.updated).toEqual(["terms"]);
|
|
75
|
+
|
|
76
|
+
const active = await ConsentForm.findOne({active: true, slug: "terms"});
|
|
77
|
+
expect(active?.version).toBe(2);
|
|
78
|
+
expect(active?.title).toBe("Updated Terms");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it("deactivates removed forms when deactivateRemoved is true", async () => {
|
|
82
|
+
await syncConsents({privacy: {...baseDef, title: "Privacy", type: "privacy"}, terms: baseDef});
|
|
83
|
+
|
|
84
|
+
const result = await syncConsents({terms: baseDef}, {deactivateRemoved: true});
|
|
85
|
+
|
|
86
|
+
expect(result.deactivated).toEqual(["privacy"]);
|
|
87
|
+
expect(result.unchanged).toEqual(["terms"]);
|
|
88
|
+
|
|
89
|
+
const privacy = await ConsentForm.findOne({slug: "privacy"});
|
|
90
|
+
expect(privacy?.active).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it("does not deactivate removed forms by default", async () => {
|
|
94
|
+
await syncConsents({privacy: {...baseDef, title: "Privacy", type: "privacy"}, terms: baseDef});
|
|
95
|
+
|
|
96
|
+
const result = await syncConsents({terms: baseDef});
|
|
97
|
+
|
|
98
|
+
expect(result.deactivated).toHaveLength(0);
|
|
99
|
+
const privacy = await ConsentForm.findOne({slug: "privacy"});
|
|
100
|
+
expect(privacy?.active).toBe(true);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("does not write to the database in dry run mode", async () => {
|
|
104
|
+
const result = await syncConsents({terms: baseDef}, {dryRun: true});
|
|
105
|
+
|
|
106
|
+
expect(result.created).toEqual(["terms"]);
|
|
107
|
+
const forms = await ConsentForm.find({slug: "terms"});
|
|
108
|
+
expect(forms).toHaveLength(0);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
it("handles multiple forms in a single sync", async () => {
|
|
112
|
+
const result = await syncConsents({
|
|
113
|
+
privacy: {...baseDef, order: 2, title: "Privacy Policy", type: "privacy"},
|
|
114
|
+
terms: baseDef,
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
expect(result.created.sort()).toEqual(["privacy", "terms"]);
|
|
118
|
+
|
|
119
|
+
const forms = await ConsentForm.find({}).sort({order: 1});
|
|
120
|
+
expect(forms).toHaveLength(2);
|
|
121
|
+
expect(forms[0].slug).toBe("terms");
|
|
122
|
+
expect(forms[1].slug).toBe("privacy");
|
|
123
|
+
});
|
|
124
|
+
});
|
|
@@ -0,0 +1,263 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Sync consent form definitions from code to the database.
|
|
3
|
+
*
|
|
4
|
+
* Compares the provided definitions (keyed by slug) against what's in the database
|
|
5
|
+
* and creates, updates, or deactivates forms to match. When content changes, a new
|
|
6
|
+
* version is published so users are prompted to re-consent.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import {logger} from "./logger";
|
|
10
|
+
import {ConsentForm} from "./models/consentForm";
|
|
11
|
+
import type {ConsentFormType} from "./types/consentForm";
|
|
12
|
+
|
|
13
|
+
export interface ConsentFormDefinition {
|
|
14
|
+
title: string;
|
|
15
|
+
type: ConsentFormType;
|
|
16
|
+
content: Record<string, string>;
|
|
17
|
+
order?: number;
|
|
18
|
+
required?: boolean;
|
|
19
|
+
requireScrollToBottom?: boolean;
|
|
20
|
+
captureSignature?: boolean;
|
|
21
|
+
agreeButtonText?: string;
|
|
22
|
+
allowDecline?: boolean;
|
|
23
|
+
declineButtonText?: string;
|
|
24
|
+
defaultLocale?: string;
|
|
25
|
+
checkboxes?: Array<{
|
|
26
|
+
label: string;
|
|
27
|
+
required?: boolean;
|
|
28
|
+
confirmationPrompt?: string;
|
|
29
|
+
}>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface SyncConsentsOptions {
|
|
33
|
+
/** Deactivate database forms whose slugs are not in the definitions. Default: false */
|
|
34
|
+
deactivateRemoved?: boolean;
|
|
35
|
+
/** If true, log what would change without writing to the database. Default: false */
|
|
36
|
+
dryRun?: boolean;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SyncConsentsResult {
|
|
40
|
+
created: string[];
|
|
41
|
+
updated: string[];
|
|
42
|
+
deactivated: string[];
|
|
43
|
+
unchanged: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const contentEqual = (a: Map<string, string>, b: Record<string, string>): boolean => {
|
|
47
|
+
const aKeys = [...a.keys()].sort();
|
|
48
|
+
const bKeys = Object.keys(b).sort();
|
|
49
|
+
if (aKeys.length !== bKeys.length) {
|
|
50
|
+
return false;
|
|
51
|
+
}
|
|
52
|
+
return aKeys.every((key, i) => key === bKeys[i] && a.get(key) === b[key]);
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const formFieldsMatch = (
|
|
56
|
+
existing: {
|
|
57
|
+
title: string;
|
|
58
|
+
type: string;
|
|
59
|
+
order: number;
|
|
60
|
+
required: boolean;
|
|
61
|
+
requireScrollToBottom: boolean;
|
|
62
|
+
captureSignature: boolean;
|
|
63
|
+
agreeButtonText: string;
|
|
64
|
+
allowDecline: boolean;
|
|
65
|
+
declineButtonText: string;
|
|
66
|
+
defaultLocale: string;
|
|
67
|
+
checkboxes: Array<{label: string; required: boolean; confirmationPrompt?: string}>;
|
|
68
|
+
content: Map<string, string>;
|
|
69
|
+
},
|
|
70
|
+
def: ConsentFormDefinition
|
|
71
|
+
): boolean => {
|
|
72
|
+
if (existing.title !== def.title) {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
if (existing.type !== def.type) {
|
|
76
|
+
return false;
|
|
77
|
+
}
|
|
78
|
+
if (existing.order !== (def.order ?? 0)) {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
if (existing.required !== (def.required ?? true)) {
|
|
82
|
+
return false;
|
|
83
|
+
}
|
|
84
|
+
if (existing.requireScrollToBottom !== (def.requireScrollToBottom ?? false)) {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
if (existing.captureSignature !== (def.captureSignature ?? false)) {
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
if (existing.agreeButtonText !== (def.agreeButtonText ?? "I Agree")) {
|
|
91
|
+
return false;
|
|
92
|
+
}
|
|
93
|
+
if (existing.allowDecline !== (def.allowDecline ?? false)) {
|
|
94
|
+
return false;
|
|
95
|
+
}
|
|
96
|
+
if (existing.declineButtonText !== (def.declineButtonText ?? "Decline")) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
if (existing.defaultLocale !== (def.defaultLocale ?? "en")) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
if (!contentEqual(existing.content, def.content)) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const existingCheckboxes = existing.checkboxes ?? [];
|
|
107
|
+
const defCheckboxes = def.checkboxes ?? [];
|
|
108
|
+
if (existingCheckboxes.length !== defCheckboxes.length) {
|
|
109
|
+
return false;
|
|
110
|
+
}
|
|
111
|
+
for (let i = 0; i < existingCheckboxes.length; i++) {
|
|
112
|
+
const ec = existingCheckboxes[i];
|
|
113
|
+
const dc = defCheckboxes[i];
|
|
114
|
+
if (ec.label !== dc.label || ec.required !== (dc.required ?? false)) {
|
|
115
|
+
return false;
|
|
116
|
+
}
|
|
117
|
+
if ((ec.confirmationPrompt ?? undefined) !== (dc.confirmationPrompt ?? undefined)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return true;
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Sync consent form definitions to the database.
|
|
127
|
+
*
|
|
128
|
+
* @param definitions - Map of slug to consent form definition
|
|
129
|
+
* @param options - Sync options
|
|
130
|
+
* @returns Summary of what was created, updated, deactivated, or unchanged
|
|
131
|
+
*
|
|
132
|
+
* @example
|
|
133
|
+
* ```typescript
|
|
134
|
+
* import {syncConsents} from "@terreno/api";
|
|
135
|
+
*
|
|
136
|
+
* await syncConsents({
|
|
137
|
+
* "terms-of-service": {
|
|
138
|
+
* title: "Terms of Service",
|
|
139
|
+
* type: "terms",
|
|
140
|
+
* content: {"en": "# Terms\n...", "es": "# Términos\n..."},
|
|
141
|
+
* required: true,
|
|
142
|
+
* order: 1,
|
|
143
|
+
* },
|
|
144
|
+
* "privacy-policy": {
|
|
145
|
+
* title: "Privacy Policy",
|
|
146
|
+
* type: "privacy",
|
|
147
|
+
* content: {"en": "# Privacy\n..."},
|
|
148
|
+
* order: 2,
|
|
149
|
+
* },
|
|
150
|
+
* });
|
|
151
|
+
* ```
|
|
152
|
+
*/
|
|
153
|
+
export const syncConsents = async (
|
|
154
|
+
definitions: Record<string, ConsentFormDefinition>,
|
|
155
|
+
options: SyncConsentsOptions = {}
|
|
156
|
+
): Promise<SyncConsentsResult> => {
|
|
157
|
+
const {deactivateRemoved = false, dryRun = false} = options;
|
|
158
|
+
|
|
159
|
+
const result: SyncConsentsResult = {
|
|
160
|
+
created: [],
|
|
161
|
+
deactivated: [],
|
|
162
|
+
unchanged: [],
|
|
163
|
+
updated: [],
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const slugs = Object.keys(definitions);
|
|
167
|
+
|
|
168
|
+
// Fetch the current active form for each slug
|
|
169
|
+
const activeForms = await ConsentForm.find({active: true});
|
|
170
|
+
const activeBySlug = new Map(activeForms.map((f) => [f.slug, f]));
|
|
171
|
+
|
|
172
|
+
for (const slug of slugs) {
|
|
173
|
+
const def = definitions[slug];
|
|
174
|
+
const existing = activeBySlug.get(slug);
|
|
175
|
+
|
|
176
|
+
if (!existing) {
|
|
177
|
+
// No active form for this slug — create version 1
|
|
178
|
+
logger.info(`syncConsents: creating "${slug}"`, {dryRun});
|
|
179
|
+
if (!dryRun) {
|
|
180
|
+
await ConsentForm.create({
|
|
181
|
+
active: true,
|
|
182
|
+
agreeButtonText: def.agreeButtonText,
|
|
183
|
+
allowDecline: def.allowDecline,
|
|
184
|
+
captureSignature: def.captureSignature,
|
|
185
|
+
checkboxes: def.checkboxes,
|
|
186
|
+
content: new Map(Object.entries(def.content)),
|
|
187
|
+
declineButtonText: def.declineButtonText,
|
|
188
|
+
defaultLocale: def.defaultLocale,
|
|
189
|
+
order: def.order ?? 0,
|
|
190
|
+
required: def.required ?? true,
|
|
191
|
+
requireScrollToBottom: def.requireScrollToBottom,
|
|
192
|
+
slug,
|
|
193
|
+
title: def.title,
|
|
194
|
+
type: def.type,
|
|
195
|
+
version: 1,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
result.created.push(slug);
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (formFieldsMatch(existing, def)) {
|
|
203
|
+
result.unchanged.push(slug);
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// Content or config changed — publish a new version
|
|
208
|
+
const newVersion = existing.version + 1;
|
|
209
|
+
logger.info(`syncConsents: updating "${slug}" v${existing.version} -> v${newVersion}`, {
|
|
210
|
+
dryRun,
|
|
211
|
+
});
|
|
212
|
+
if (!dryRun) {
|
|
213
|
+
await ConsentForm.create({
|
|
214
|
+
active: true,
|
|
215
|
+
agreeButtonText: def.agreeButtonText,
|
|
216
|
+
allowDecline: def.allowDecline,
|
|
217
|
+
captureSignature: def.captureSignature,
|
|
218
|
+
checkboxes: def.checkboxes,
|
|
219
|
+
content: new Map(Object.entries(def.content)),
|
|
220
|
+
declineButtonText: def.declineButtonText,
|
|
221
|
+
defaultLocale: def.defaultLocale,
|
|
222
|
+
order: def.order ?? 0,
|
|
223
|
+
required: def.required ?? true,
|
|
224
|
+
requireScrollToBottom: def.requireScrollToBottom,
|
|
225
|
+
slug,
|
|
226
|
+
title: def.title,
|
|
227
|
+
type: def.type,
|
|
228
|
+
version: newVersion,
|
|
229
|
+
});
|
|
230
|
+
await ConsentForm.updateMany(
|
|
231
|
+
{_id: {$ne: undefined}, slug, version: {$lt: newVersion}},
|
|
232
|
+
{active: false}
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
result.updated.push(slug);
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Deactivate forms that are no longer in definitions
|
|
239
|
+
if (deactivateRemoved) {
|
|
240
|
+
for (const [slug, form] of activeBySlug) {
|
|
241
|
+
if (!definitions[slug]) {
|
|
242
|
+
logger.info(`syncConsents: deactivating "${slug}"`, {dryRun});
|
|
243
|
+
if (!dryRun) {
|
|
244
|
+
await ConsentForm.updateMany({slug}, {active: false});
|
|
245
|
+
}
|
|
246
|
+
result.deactivated.push(slug);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const summary = [
|
|
252
|
+
result.created.length > 0 ? `created: ${result.created.join(", ")}` : null,
|
|
253
|
+
result.updated.length > 0 ? `updated: ${result.updated.join(", ")}` : null,
|
|
254
|
+
result.deactivated.length > 0 ? `deactivated: ${result.deactivated.join(", ")}` : null,
|
|
255
|
+
result.unchanged.length > 0 ? `unchanged: ${result.unchanged.join(", ")}` : null,
|
|
256
|
+
]
|
|
257
|
+
.filter(Boolean)
|
|
258
|
+
.join(" | ");
|
|
259
|
+
|
|
260
|
+
logger.info(`syncConsents: ${dryRun ? "[DRY RUN] " : ""}${summary || "nothing to do"}`);
|
|
261
|
+
|
|
262
|
+
return result;
|
|
263
|
+
};
|
package/src/terrenoApp.ts
CHANGED
|
@@ -301,8 +301,6 @@ export class TerrenoApp {
|
|
|
301
301
|
app.use("/swagger", oapi.swaggerui());
|
|
302
302
|
}
|
|
303
303
|
|
|
304
|
-
addMeRoutes(app, options.userModel as any, options.authOptions);
|
|
305
|
-
|
|
306
304
|
// GitHub OAuth
|
|
307
305
|
if (options.githubAuth) {
|
|
308
306
|
setupGitHubAuth(app, options.userModel as any, options.githubAuth);
|
|
@@ -317,14 +315,16 @@ export class TerrenoApp {
|
|
|
317
315
|
// Mount registered model routers and plugins
|
|
318
316
|
for (const registration of this.registrations) {
|
|
319
317
|
if (this.isModelRouterRegistration(registration)) {
|
|
320
|
-
|
|
318
|
+
const router = registration._buildWithOpenApi(oapi);
|
|
319
|
+
app.use(registration.path, router);
|
|
321
320
|
} else {
|
|
322
|
-
registration.register(app);
|
|
321
|
+
registration.register(app, oapi);
|
|
323
322
|
}
|
|
324
323
|
}
|
|
325
324
|
|
|
326
|
-
//
|
|
327
|
-
//
|
|
325
|
+
// /auth/me must be registered after plugins so that session middleware
|
|
326
|
+
// (e.g. Better Auth) has a chance to populate req.user first.
|
|
327
|
+
addMeRoutes(app, options.userModel as any, options.authOptions);
|
|
328
328
|
|
|
329
329
|
Sentry.setupExpressErrorHandler(app);
|
|
330
330
|
|
package/src/terrenoPlugin.ts
CHANGED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type mongoose from "mongoose";
|
|
2
|
+
import type {FindExactlyOnePlugin, FindOneOrNonePlugin} from "../plugins";
|
|
3
|
+
|
|
4
|
+
export interface ConsentFormCheckbox {
|
|
5
|
+
label: string;
|
|
6
|
+
required: boolean;
|
|
7
|
+
confirmationPrompt?: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export type ConsentFormType = "agreement" | "privacy" | "hipaa" | "research" | "terms" | "custom";
|
|
11
|
+
|
|
12
|
+
// biome-ignore lint/complexity/noBannedTypes: No methods.
|
|
13
|
+
export type ConsentFormMethods = {};
|
|
14
|
+
|
|
15
|
+
export type ConsentFormStatics = FindExactlyOnePlugin<ConsentFormDocument> &
|
|
16
|
+
FindOneOrNonePlugin<ConsentFormDocument>;
|
|
17
|
+
|
|
18
|
+
export type ConsentFormModel = mongoose.Model<ConsentFormDocument, object, ConsentFormMethods> &
|
|
19
|
+
ConsentFormStatics;
|
|
20
|
+
|
|
21
|
+
export interface ConsentFormDocument extends mongoose.Document {
|
|
22
|
+
_id: mongoose.Types.ObjectId;
|
|
23
|
+
title: string;
|
|
24
|
+
slug: string;
|
|
25
|
+
version: number;
|
|
26
|
+
order: number;
|
|
27
|
+
type: ConsentFormType;
|
|
28
|
+
content: Map<string, string>;
|
|
29
|
+
defaultLocale: string;
|
|
30
|
+
active: boolean;
|
|
31
|
+
captureSignature: boolean;
|
|
32
|
+
requireScrollToBottom: boolean;
|
|
33
|
+
checkboxes: ConsentFormCheckbox[];
|
|
34
|
+
agreeButtonText: string;
|
|
35
|
+
allowDecline: boolean;
|
|
36
|
+
declineButtonText: string;
|
|
37
|
+
required: boolean;
|
|
38
|
+
created: Date;
|
|
39
|
+
updated: Date;
|
|
40
|
+
deleted: boolean;
|
|
41
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import type mongoose from "mongoose";
|
|
2
|
+
import type {FindExactlyOnePlugin, FindOneOrNonePlugin} from "../plugins";
|
|
3
|
+
|
|
4
|
+
// biome-ignore lint/complexity/noBannedTypes: No methods.
|
|
5
|
+
export type ConsentResponseMethods = {};
|
|
6
|
+
|
|
7
|
+
export type ConsentResponseStatics = FindExactlyOnePlugin<ConsentResponseDocument> &
|
|
8
|
+
FindOneOrNonePlugin<ConsentResponseDocument>;
|
|
9
|
+
|
|
10
|
+
export type ConsentResponseModel = mongoose.Model<
|
|
11
|
+
ConsentResponseDocument,
|
|
12
|
+
object,
|
|
13
|
+
ConsentResponseMethods
|
|
14
|
+
> &
|
|
15
|
+
ConsentResponseStatics;
|
|
16
|
+
|
|
17
|
+
export interface ConsentResponseDocument extends mongoose.Document {
|
|
18
|
+
_id: mongoose.Types.ObjectId;
|
|
19
|
+
userId: mongoose.Types.ObjectId;
|
|
20
|
+
consentFormId: mongoose.Types.ObjectId;
|
|
21
|
+
agreed: boolean;
|
|
22
|
+
agreedAt: Date;
|
|
23
|
+
checkboxValues?: Map<string, boolean>;
|
|
24
|
+
locale: string;
|
|
25
|
+
signature?: string;
|
|
26
|
+
signedAt?: Date;
|
|
27
|
+
ipAddress?: string;
|
|
28
|
+
userAgent?: string;
|
|
29
|
+
contentSnapshot?: string;
|
|
30
|
+
formVersionSnapshot?: number;
|
|
31
|
+
created: Date;
|
|
32
|
+
updated: Date;
|
|
33
|
+
deleted: boolean;
|
|
34
|
+
}
|
|
@@ -114,7 +114,7 @@ function processComplexMatch (thing, keys) {
|
|
|
114
114
|
// (i.e. /:id, /:name, etc...) with the name(s) of those parameter(s)
|
|
115
115
|
// This could have been accomplished with replaceAll for Node version 15 and above
|
|
116
116
|
// no-useless-escape is disabled since we need three backslashes
|
|
117
|
-
.replace(/\(\?\:\(\[\^\\\/\]\+\?\)\)/g, () => `{${keys[i++].name}}`)
|
|
117
|
+
.replace(/\(\?\:\(\[\^\\\/\]\+\?\)\)/g, () => `{${keys[i++].name}}`)
|
|
118
118
|
.replace(/\\(.)/g, '$1')
|
|
119
119
|
// The replace below removes the regex used at the start of the string and
|
|
120
120
|
// the regex used to match the query parameters
|
|
@@ -8,8 +8,10 @@ export type VersionCheckStatus = "ok" | "warning" | "required";
|
|
|
8
8
|
|
|
9
9
|
export interface VersionCheckResponse {
|
|
10
10
|
message?: string;
|
|
11
|
+
requiredVersion?: number;
|
|
11
12
|
status: VersionCheckStatus;
|
|
12
13
|
updateUrl?: string;
|
|
14
|
+
warningVersion?: number;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
const DEFAULT_WARNING_MESSAGE =
|
|
@@ -57,21 +59,18 @@ export class VersionCheckPlugin implements TerrenoPlugin {
|
|
|
57
59
|
: (config.mobileWarningVersion ?? 0);
|
|
58
60
|
|
|
59
61
|
const response: VersionCheckResponse = {
|
|
62
|
+
requiredVersion: requiredVersion > 0 ? requiredVersion : undefined,
|
|
60
63
|
status: "ok",
|
|
64
|
+
updateUrl: config.updateUrl || undefined,
|
|
65
|
+
warningVersion: warningVersion > 0 ? warningVersion : undefined,
|
|
61
66
|
};
|
|
62
67
|
|
|
63
68
|
if (requiredVersion > 0 && version < requiredVersion) {
|
|
64
69
|
response.status = "required";
|
|
65
70
|
response.message = config.requiredMessage ?? DEFAULT_REQUIRED_MESSAGE;
|
|
66
|
-
if (config.updateUrl) {
|
|
67
|
-
response.updateUrl = config.updateUrl;
|
|
68
|
-
}
|
|
69
71
|
} else if (warningVersion > 0 && version < warningVersion) {
|
|
70
72
|
response.status = "warning";
|
|
71
73
|
response.message = config.warningMessage ?? DEFAULT_WARNING_MESSAGE;
|
|
72
|
-
if (config.updateUrl) {
|
|
73
|
-
response.updateUrl = config.updateUrl;
|
|
74
|
-
}
|
|
75
74
|
}
|
|
76
75
|
|
|
77
76
|
return res.json(response);
|
|
File without changes
|