@actuate-media/cms-core 0.29.0 → 0.31.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__/actions/seo-fields-update.test.d.ts +2 -0
- package/dist/__tests__/actions/seo-fields-update.test.d.ts.map +1 -0
- package/dist/__tests__/actions/seo-fields-update.test.js +93 -0
- package/dist/__tests__/actions/seo-fields-update.test.js.map +1 -0
- package/dist/__tests__/api/admin-contracts.test.js +10 -2
- package/dist/__tests__/api/admin-contracts.test.js.map +1 -1
- package/dist/__tests__/api/llms-txt.test.d.ts +2 -0
- package/dist/__tests__/api/llms-txt.test.d.ts.map +1 -0
- package/dist/__tests__/api/llms-txt.test.js +77 -0
- package/dist/__tests__/api/llms-txt.test.js.map +1 -0
- package/dist/__tests__/api/public-seo.test.js +2 -0
- package/dist/__tests__/api/public-seo.test.js.map +1 -1
- package/dist/__tests__/api/seo-read-access.test.d.ts +2 -0
- package/dist/__tests__/api/seo-read-access.test.d.ts.map +1 -0
- package/dist/__tests__/api/seo-read-access.test.js +101 -0
- package/dist/__tests__/api/seo-read-access.test.js.map +1 -0
- package/dist/__tests__/api/seo-summary-ssrf.test.d.ts +2 -0
- package/dist/__tests__/api/seo-summary-ssrf.test.d.ts.map +1 -0
- package/dist/__tests__/api/seo-summary-ssrf.test.js +80 -0
- package/dist/__tests__/api/seo-summary-ssrf.test.js.map +1 -0
- package/dist/__tests__/api/sitemap-pagination.test.d.ts +2 -0
- package/dist/__tests__/api/sitemap-pagination.test.d.ts.map +1 -0
- package/dist/__tests__/api/sitemap-pagination.test.js +100 -0
- package/dist/__tests__/api/sitemap-pagination.test.js.map +1 -0
- package/dist/__tests__/forms/forms-service.test.d.ts +2 -0
- package/dist/__tests__/forms/forms-service.test.d.ts.map +1 -0
- package/dist/__tests__/forms/forms-service.test.js +193 -0
- package/dist/__tests__/forms/forms-service.test.js.map +1 -0
- package/dist/__tests__/middleware.test.d.ts +2 -0
- package/dist/__tests__/middleware.test.d.ts.map +1 -0
- package/dist/__tests__/middleware.test.js +50 -0
- package/dist/__tests__/middleware.test.js.map +1 -0
- package/dist/__tests__/seo/analysis-markdown.test.d.ts +2 -0
- package/dist/__tests__/seo/analysis-markdown.test.d.ts.map +1 -0
- package/dist/__tests__/seo/analysis-markdown.test.js +57 -0
- package/dist/__tests__/seo/analysis-markdown.test.js.map +1 -0
- package/dist/__tests__/seo/audit-runner.test.d.ts +2 -0
- package/dist/__tests__/seo/audit-runner.test.d.ts.map +1 -0
- package/dist/__tests__/seo/audit-runner.test.js +135 -0
- package/dist/__tests__/seo/audit-runner.test.js.map +1 -0
- package/dist/__tests__/seo/internal-collections.test.d.ts +2 -0
- package/dist/__tests__/seo/internal-collections.test.d.ts.map +1 -0
- package/dist/__tests__/seo/internal-collections.test.js +27 -0
- package/dist/__tests__/seo/internal-collections.test.js.map +1 -0
- package/dist/__tests__/seo/page-meta.test.js +34 -2
- package/dist/__tests__/seo/page-meta.test.js.map +1 -1
- package/dist/__tests__/seo/robots.test.js +23 -2
- package/dist/__tests__/seo/robots.test.js.map +1 -1
- package/dist/actions.d.ts +24 -0
- package/dist/actions.d.ts.map +1 -1
- package/dist/actions.js +63 -0
- package/dist/actions.js.map +1 -1
- package/dist/api/handlers.d.ts.map +1 -1
- package/dist/api/handlers.js +744 -196
- package/dist/api/handlers.js.map +1 -1
- package/dist/config/types.d.ts +49 -0
- package/dist/config/types.d.ts.map +1 -1
- package/dist/cron/index.d.ts +1 -0
- package/dist/cron/index.d.ts.map +1 -1
- package/dist/cron/index.js +10 -1
- package/dist/cron/index.js.map +1 -1
- package/dist/forms/entries.d.ts +45 -0
- package/dist/forms/entries.d.ts.map +1 -0
- package/dist/forms/entries.js +348 -0
- package/dist/forms/entries.js.map +1 -0
- package/dist/forms/index.d.ts +4 -0
- package/dist/forms/index.d.ts.map +1 -1
- package/dist/forms/index.js +3 -0
- package/dist/forms/index.js.map +1 -1
- package/dist/forms/service.d.ts +92 -0
- package/dist/forms/service.d.ts.map +1 -0
- package/dist/forms/service.js +579 -0
- package/dist/forms/service.js.map +1 -0
- package/dist/forms/types.d.ts +244 -0
- package/dist/forms/types.d.ts.map +1 -0
- package/dist/forms/types.js +36 -0
- package/dist/forms/types.js.map +1 -0
- package/dist/index.d.ts +2 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -1
- package/dist/index.js.map +1 -1
- package/dist/middleware.js +12 -2
- package/dist/middleware.js.map +1 -1
- package/dist/seo/analysis.d.ts +9 -0
- package/dist/seo/analysis.d.ts.map +1 -1
- package/dist/seo/analysis.js +45 -6
- package/dist/seo/analysis.js.map +1 -1
- package/dist/seo/audit-runner.d.ts +43 -2
- package/dist/seo/audit-runner.d.ts.map +1 -1
- package/dist/seo/audit-runner.js +86 -7
- package/dist/seo/audit-runner.js.map +1 -1
- package/dist/seo/index.d.ts +3 -2
- package/dist/seo/index.d.ts.map +1 -1
- package/dist/seo/index.js +2 -2
- package/dist/seo/index.js.map +1 -1
- package/dist/seo/page-meta.d.ts +6 -0
- package/dist/seo/page-meta.d.ts.map +1 -1
- package/dist/seo/page-meta.js +22 -5
- package/dist/seo/page-meta.js.map +1 -1
- package/dist/seo/robots.d.ts +7 -0
- package/dist/seo/robots.d.ts.map +1 -1
- package/dist/seo/robots.js +10 -1
- package/dist/seo/robots.js.map +1 -1
- package/dist/seo/score.d.ts +5 -1
- package/dist/seo/score.d.ts.map +1 -1
- package/dist/seo/score.js +7 -4
- package/dist/seo/score.js.map +1 -1
- package/package.json +1 -1
- package/prisma/cms-schema.prisma +81 -7
- package/prisma/migrations/0010_forms/migration.sql +130 -0
- package/prisma/schema.prisma +130 -35
package/dist/forms/index.js
CHANGED
|
@@ -1,3 +1,6 @@
|
|
|
1
1
|
export { captureAttribution, storeAttribution, getStoredAttribution, trackPageView, getAttributionForSubmission, } from './attribution.js';
|
|
2
2
|
export { trackFormStart, trackFormSubmit, trackFormError, pushDataLayerEvent, getDefaultConfirmation, getDefaultAnalyticsConfig, } from './analytics.js';
|
|
3
|
+
export { INPUT_FIELD_TYPES, NON_INPUT_FIELD_TYPES } from './types.js';
|
|
4
|
+
export * from './service.js';
|
|
5
|
+
export * from './entries.js';
|
|
3
6
|
//# sourceMappingURL=index.js.map
|
package/dist/forms/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/forms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,2BAA2B,GAC5B,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EACL,cAAc,EACd,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,sBAAsB,EACtB,yBAAyB,GAC1B,MAAM,gBAAgB,CAAA"}
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/forms/index.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,kBAAkB,EAClB,gBAAgB,EAChB,oBAAoB,EACpB,aAAa,EACb,2BAA2B,GAC5B,MAAM,kBAAkB,CAAA;AAGzB,OAAO,EACL,cAAc,EACd,eAAe,EACf,cAAc,EACd,kBAAkB,EAClB,sBAAsB,EACtB,yBAAyB,GAC1B,MAAM,gBAAgB,CAAA;AAGvB,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,MAAM,YAAY,CAAA;AA8BrE,cAAc,cAAc,CAAA;AAC5B,cAAc,cAAc,CAAA"}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { type ActionContext } from '../actions.js';
|
|
2
|
+
import { type FetchFormsParams, type FormDefinition, type FormEmbedSettings, type FormField, type FormNotificationSettings, type FormSchemaVersion, type FormSpamSettings, type FormStats, type FormStatus, type FormsSidebarCount } from './types.js';
|
|
3
|
+
export declare const FORMS_COLLECTION = "forms";
|
|
4
|
+
/** Parse a raw `forms` document row into the typed `FormDefinition` shape. */
|
|
5
|
+
export declare function parseFormDefinition(doc: any): FormDefinition;
|
|
6
|
+
export interface SchemaValidationResult {
|
|
7
|
+
valid: boolean;
|
|
8
|
+
errors: string[];
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* Validate a set of fields against config limits and structural rules. This is
|
|
12
|
+
* the single gate used by both the admin "Save Schema" flow and form creation.
|
|
13
|
+
*/
|
|
14
|
+
export declare function validateFormSchema(fields: unknown): SchemaValidationResult;
|
|
15
|
+
export declare function listSchemaVersions(formId: string): Promise<FormSchemaVersion[]>;
|
|
16
|
+
export declare function getSchemaVersion(versionId: string): Promise<FormSchemaVersion | null>;
|
|
17
|
+
export interface ListFormsResult {
|
|
18
|
+
forms: FormDefinition[];
|
|
19
|
+
total: number;
|
|
20
|
+
page: number;
|
|
21
|
+
pageSize: number;
|
|
22
|
+
}
|
|
23
|
+
export declare function listForms(params?: FetchFormsParams): Promise<ListFormsResult>;
|
|
24
|
+
export declare function getFormById(id: string): Promise<FormDefinition | null>;
|
|
25
|
+
export declare function getFormBySlug(slug: string): Promise<FormDefinition | null>;
|
|
26
|
+
export interface CreateFormInput {
|
|
27
|
+
name: string;
|
|
28
|
+
slug?: string;
|
|
29
|
+
description?: string;
|
|
30
|
+
status?: FormStatus;
|
|
31
|
+
fields?: FormField[];
|
|
32
|
+
notificationSettings?: FormNotificationSettings;
|
|
33
|
+
embedSettings?: FormEmbedSettings;
|
|
34
|
+
spamSettings?: FormSpamSettings;
|
|
35
|
+
successMessage?: string;
|
|
36
|
+
redirectUrl?: string;
|
|
37
|
+
}
|
|
38
|
+
export declare function createForm(ctx: ActionContext, input: CreateFormInput): Promise<FormDefinition>;
|
|
39
|
+
export interface UpdateFormInput {
|
|
40
|
+
name?: string;
|
|
41
|
+
description?: string;
|
|
42
|
+
status?: FormStatus;
|
|
43
|
+
successMessage?: string;
|
|
44
|
+
redirectUrl?: string;
|
|
45
|
+
spamSettings?: FormSpamSettings;
|
|
46
|
+
embedSettings?: FormEmbedSettings;
|
|
47
|
+
}
|
|
48
|
+
export declare function updateForm(ctx: ActionContext, id: string, patch: UpdateFormInput): Promise<FormDefinition>;
|
|
49
|
+
export declare function archiveForm(ctx: ActionContext, id: string): Promise<FormDefinition>;
|
|
50
|
+
export declare function restoreForm(ctx: ActionContext, id: string): Promise<FormDefinition>;
|
|
51
|
+
export declare function duplicateForm(ctx: ActionContext, id: string): Promise<FormDefinition>;
|
|
52
|
+
export interface DeleteFormOptions {
|
|
53
|
+
force?: boolean;
|
|
54
|
+
}
|
|
55
|
+
export declare function deleteForm(ctx: ActionContext, id: string, options?: DeleteFormOptions): Promise<void>;
|
|
56
|
+
export declare function saveFormSchema(ctx: ActionContext, id: string, fields: FormField[], notes?: string): Promise<{
|
|
57
|
+
form: FormDefinition;
|
|
58
|
+
version: FormSchemaVersion;
|
|
59
|
+
}>;
|
|
60
|
+
export declare function getFormSchema(id: string): Promise<{
|
|
61
|
+
fields: FormField[];
|
|
62
|
+
activeVersionId: string | null;
|
|
63
|
+
versions: FormSchemaVersion[];
|
|
64
|
+
}>;
|
|
65
|
+
export declare function getNotificationSettings(id: string): Promise<FormNotificationSettings>;
|
|
66
|
+
export declare function updateNotificationSettings(ctx: ActionContext, id: string, patch: Partial<FormNotificationSettings>): Promise<FormNotificationSettings>;
|
|
67
|
+
/** Toggle the notify flag (maps to the Notify switch in the forms table). */
|
|
68
|
+
export declare function setNotifyEnabled(ctx: ActionContext, id: string, enabled: boolean): Promise<FormNotificationSettings>;
|
|
69
|
+
export declare function getEmbedSettings(id: string): Promise<FormEmbedSettings>;
|
|
70
|
+
export declare function updateEmbedSettings(ctx: ActionContext, id: string, patch: Partial<FormEmbedSettings>): Promise<FormEmbedSettings>;
|
|
71
|
+
export declare function getFormStats(): Promise<FormStats>;
|
|
72
|
+
/**
|
|
73
|
+
* Unread counts for the sidebar. Only active, non-hidden forms contribute to
|
|
74
|
+
* the parent badge; archived/draft forms are excluded so the rail reflects
|
|
75
|
+
* live, publicly-collecting forms.
|
|
76
|
+
*/
|
|
77
|
+
export declare function getFormsSidebarCounts(): Promise<FormsSidebarCount[]>;
|
|
78
|
+
export declare class FormNotFoundError extends Error {
|
|
79
|
+
constructor(id: string);
|
|
80
|
+
}
|
|
81
|
+
export declare class FormValidationError extends Error {
|
|
82
|
+
errors: string[];
|
|
83
|
+
constructor(errors: string[]);
|
|
84
|
+
}
|
|
85
|
+
export declare class FormConflictError extends Error {
|
|
86
|
+
constructor(message: string);
|
|
87
|
+
}
|
|
88
|
+
export declare class FormHasSubmissionsError extends Error {
|
|
89
|
+
submissionCount: number;
|
|
90
|
+
constructor(submissionCount: number);
|
|
91
|
+
}
|
|
92
|
+
//# sourceMappingURL=service.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"service.d.ts","sourceRoot":"","sources":["../../src/forms/service.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Forms service — definitions, schema versioning, and per-form settings.
|
|
3
|
+
*
|
|
4
|
+
* Form *definitions* are stored as documents in the `forms` collection, so we
|
|
5
|
+
* reuse the standard data-layer actions (`createDocument` / `updateDocument` /
|
|
6
|
+
* `deleteDocument`) to inherit version history, cache invalidation, and plugin
|
|
7
|
+
* hooks. Field *schema* is additionally snapshotted into `FormSchemaVersion`
|
|
8
|
+
* rows so historical submissions always render against the schema they were
|
|
9
|
+
* captured with.
|
|
10
|
+
*
|
|
11
|
+
* Submissions/entries live in the dedicated `FormSubmission` table and are
|
|
12
|
+
* handled in `./entries.ts`.
|
|
13
|
+
*/
|
|
14
|
+
import { getDB } from '../db.js';
|
|
15
|
+
import { createDocument, updateDocument, deleteDocument } from '../actions.js';
|
|
16
|
+
import { logEvent } from '../security/audit.js';
|
|
17
|
+
import { getActuateConfig } from '../config/runtime.js';
|
|
18
|
+
import { INPUT_FIELD_TYPES, NON_INPUT_FIELD_TYPES, } from './types.js';
|
|
19
|
+
export const FORMS_COLLECTION = 'forms';
|
|
20
|
+
const ALL_FIELD_TYPES = [...INPUT_FIELD_TYPES, ...NON_INPUT_FIELD_TYPES];
|
|
21
|
+
const DEFAULT_MAX_FIELDS = 60;
|
|
22
|
+
const KEY_PATTERN = /^[a-zA-Z][a-zA-Z0-9_]*$/;
|
|
23
|
+
function rawDb() {
|
|
24
|
+
return getDB();
|
|
25
|
+
}
|
|
26
|
+
function toISO(value) {
|
|
27
|
+
if (!value)
|
|
28
|
+
return undefined;
|
|
29
|
+
if (value instanceof Date)
|
|
30
|
+
return value.toISOString();
|
|
31
|
+
if (typeof value === 'string')
|
|
32
|
+
return value;
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
function normalizeStatus(value) {
|
|
36
|
+
return value === 'active' || value === 'archived' ? value : 'draft';
|
|
37
|
+
}
|
|
38
|
+
function asRecord(value) {
|
|
39
|
+
return value && typeof value === 'object' && !Array.isArray(value)
|
|
40
|
+
? value
|
|
41
|
+
: {};
|
|
42
|
+
}
|
|
43
|
+
// ─── Parsing ──────────────────────────────────────────────────────────────
|
|
44
|
+
/** Parse a raw `forms` document row into the typed `FormDefinition` shape. */
|
|
45
|
+
export function parseFormDefinition(doc) {
|
|
46
|
+
const data = asRecord(doc?.data);
|
|
47
|
+
const notif = asRecord(data.notificationSettings);
|
|
48
|
+
const fields = Array.isArray(data.fields) ? data.fields : [];
|
|
49
|
+
return {
|
|
50
|
+
id: String(doc.id),
|
|
51
|
+
name: String(data.name ?? doc.title ?? 'Untitled form'),
|
|
52
|
+
slug: String(data.slug ?? doc.slug ?? ''),
|
|
53
|
+
description: typeof data.description === 'string' ? data.description : '',
|
|
54
|
+
status: normalizeStatus(data.status),
|
|
55
|
+
activeSchemaVersionId: data.activeSchemaVersionId ?? null,
|
|
56
|
+
fields,
|
|
57
|
+
notificationSettings: data.notificationSettings
|
|
58
|
+
? data.notificationSettings
|
|
59
|
+
: undefined,
|
|
60
|
+
embedSettings: data.embedSettings ? data.embedSettings : undefined,
|
|
61
|
+
spamSettings: data.spamSettings ? data.spamSettings : undefined,
|
|
62
|
+
successMessage: typeof data.successMessage === 'string' ? data.successMessage : undefined,
|
|
63
|
+
redirectUrl: typeof data.redirectUrl === 'string' ? data.redirectUrl : undefined,
|
|
64
|
+
notifyEnabled: Boolean(notif.enabled ?? data.notifyEnabled ?? false),
|
|
65
|
+
createdBy: doc.createdById ? String(doc.createdById) : undefined,
|
|
66
|
+
createdAt: toISO(doc.createdAt),
|
|
67
|
+
updatedAt: toISO(doc.updatedAt),
|
|
68
|
+
archivedAt: data.archivedAt ?? null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Validate a set of fields against config limits and structural rules. This is
|
|
73
|
+
* the single gate used by both the admin "Save Schema" flow and form creation.
|
|
74
|
+
*/
|
|
75
|
+
export function validateFormSchema(fields) {
|
|
76
|
+
const errors = [];
|
|
77
|
+
if (!Array.isArray(fields)) {
|
|
78
|
+
return { valid: false, errors: ['Fields must be an array'] };
|
|
79
|
+
}
|
|
80
|
+
const cfg = getActuateConfig()?.forms;
|
|
81
|
+
const allowed = cfg?.allowedFieldTypes ?? ALL_FIELD_TYPES;
|
|
82
|
+
const maxFields = cfg?.maxFieldsPerForm ?? DEFAULT_MAX_FIELDS;
|
|
83
|
+
if (fields.length > maxFields) {
|
|
84
|
+
errors.push(`Too many fields (max ${maxFields}).`);
|
|
85
|
+
}
|
|
86
|
+
const seenKeys = new Set();
|
|
87
|
+
for (const raw of fields) {
|
|
88
|
+
const key = raw?.key;
|
|
89
|
+
if (!key || !KEY_PATTERN.test(key)) {
|
|
90
|
+
errors.push(`Invalid field key "${key ?? ''}". Use letters, numbers, and underscores.`);
|
|
91
|
+
}
|
|
92
|
+
else if (seenKeys.has(key)) {
|
|
93
|
+
errors.push(`Duplicate field key "${key}".`);
|
|
94
|
+
}
|
|
95
|
+
if (key)
|
|
96
|
+
seenKeys.add(key);
|
|
97
|
+
if (!raw?.label || typeof raw.label !== 'string') {
|
|
98
|
+
errors.push(`Field "${key ?? ''}" is missing a label.`);
|
|
99
|
+
}
|
|
100
|
+
if (!allowed.includes(raw?.type)) {
|
|
101
|
+
errors.push(`Field type "${raw?.type ?? ''}" is not allowed.`);
|
|
102
|
+
}
|
|
103
|
+
if ((raw?.type === 'select' || raw?.type === 'multiselect' || raw?.type === 'radio') &&
|
|
104
|
+
(!Array.isArray(raw.options) || raw.options.length === 0)) {
|
|
105
|
+
errors.push(`Field "${key ?? ''}" of type ${raw.type} requires at least one option.`);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return { valid: errors.length === 0, errors };
|
|
109
|
+
}
|
|
110
|
+
/** Normalize incoming field objects: assign ids and sortOrder, coerce required. */
|
|
111
|
+
function normalizeFields(fields) {
|
|
112
|
+
return fields.map((f, i) => ({
|
|
113
|
+
...f,
|
|
114
|
+
id: f.id || `fld_${Math.random().toString(36).slice(2, 10)}`,
|
|
115
|
+
required: Boolean(f.required),
|
|
116
|
+
sortOrder: typeof f.sortOrder === 'number' ? f.sortOrder : i,
|
|
117
|
+
}));
|
|
118
|
+
}
|
|
119
|
+
// ─── Schema versions ─────────────────────────────────────────────────────
|
|
120
|
+
function parseSchemaVersion(row) {
|
|
121
|
+
return {
|
|
122
|
+
id: String(row.id),
|
|
123
|
+
formId: String(row.formId),
|
|
124
|
+
version: Number(row.version),
|
|
125
|
+
fields: Array.isArray(row.fields) ? row.fields : [],
|
|
126
|
+
notes: row.notes ?? null,
|
|
127
|
+
createdById: row.createdById ?? null,
|
|
128
|
+
createdAt: toISO(row.createdAt) ?? new Date().toISOString(),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
async function createSchemaVersionRow(formId, fields, createdById, notes) {
|
|
132
|
+
const db = rawDb();
|
|
133
|
+
const latest = await db.formSchemaVersion.findFirst({
|
|
134
|
+
where: { formId },
|
|
135
|
+
orderBy: { version: 'desc' },
|
|
136
|
+
select: { version: true },
|
|
137
|
+
});
|
|
138
|
+
const nextVersion = (latest?.version ?? 0) + 1;
|
|
139
|
+
const row = await db.formSchemaVersion.create({
|
|
140
|
+
data: {
|
|
141
|
+
formId,
|
|
142
|
+
version: nextVersion,
|
|
143
|
+
fields,
|
|
144
|
+
notes: notes ?? null,
|
|
145
|
+
createdById: createdById ?? null,
|
|
146
|
+
},
|
|
147
|
+
});
|
|
148
|
+
return parseSchemaVersion(row);
|
|
149
|
+
}
|
|
150
|
+
export async function listSchemaVersions(formId) {
|
|
151
|
+
const rows = await rawDb().formSchemaVersion.findMany({
|
|
152
|
+
where: { formId },
|
|
153
|
+
orderBy: { version: 'desc' },
|
|
154
|
+
});
|
|
155
|
+
return rows.map(parseSchemaVersion);
|
|
156
|
+
}
|
|
157
|
+
export async function getSchemaVersion(versionId) {
|
|
158
|
+
const row = await rawDb().formSchemaVersion.findUnique({ where: { id: versionId } });
|
|
159
|
+
return row ? parseSchemaVersion(row) : null;
|
|
160
|
+
}
|
|
161
|
+
/** Compute per-form entry counters for the supplied form ids in three grouped queries. */
|
|
162
|
+
async function computeFormCounters(formIds) {
|
|
163
|
+
const out = new Map();
|
|
164
|
+
for (const id of formIds)
|
|
165
|
+
out.set(id, { total: 0, unread: 0, starred: 0, lastEntryAt: null });
|
|
166
|
+
if (formIds.length === 0)
|
|
167
|
+
return out;
|
|
168
|
+
const db = rawDb();
|
|
169
|
+
const where = { formId: { in: formIds }, spamStatus: { not: 'spam' } };
|
|
170
|
+
const [totals, unreads, starreds] = await Promise.all([
|
|
171
|
+
db.formSubmission.groupBy({
|
|
172
|
+
by: ['formId'],
|
|
173
|
+
where,
|
|
174
|
+
_count: { _all: true },
|
|
175
|
+
_max: { submittedAt: true },
|
|
176
|
+
}),
|
|
177
|
+
db.formSubmission.groupBy({
|
|
178
|
+
by: ['formId'],
|
|
179
|
+
where: { ...where, unread: true, archived: false },
|
|
180
|
+
_count: { _all: true },
|
|
181
|
+
}),
|
|
182
|
+
db.formSubmission.groupBy({
|
|
183
|
+
by: ['formId'],
|
|
184
|
+
where: { ...where, starred: true },
|
|
185
|
+
_count: { _all: true },
|
|
186
|
+
}),
|
|
187
|
+
]);
|
|
188
|
+
for (const g of totals) {
|
|
189
|
+
const c = out.get(g.formId);
|
|
190
|
+
if (c) {
|
|
191
|
+
c.total = g._count._all;
|
|
192
|
+
c.lastEntryAt = g._max?.submittedAt ? new Date(g._max.submittedAt).toISOString() : null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
for (const g of unreads) {
|
|
196
|
+
const c = out.get(g.formId);
|
|
197
|
+
if (c)
|
|
198
|
+
c.unread = g._count._all;
|
|
199
|
+
}
|
|
200
|
+
for (const g of starreds) {
|
|
201
|
+
const c = out.get(g.formId);
|
|
202
|
+
if (c)
|
|
203
|
+
c.starred = g._count._all;
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
// ─── Forms CRUD ──────────────────────────────────────────────────────────
|
|
208
|
+
function matchesSearch(form, term) {
|
|
209
|
+
const haystack = [
|
|
210
|
+
form.name,
|
|
211
|
+
form.slug,
|
|
212
|
+
form.description ?? '',
|
|
213
|
+
form.status,
|
|
214
|
+
...(form.fields ?? []).flatMap((f) => [f.label, f.key, f.type]),
|
|
215
|
+
...(form.notificationSettings?.recipients ?? []),
|
|
216
|
+
...(form.embedSettings?.allowedDomains ?? []),
|
|
217
|
+
]
|
|
218
|
+
.join(' ')
|
|
219
|
+
.toLowerCase();
|
|
220
|
+
return haystack.includes(term.toLowerCase());
|
|
221
|
+
}
|
|
222
|
+
export async function listForms(params = {}) {
|
|
223
|
+
const db = rawDb();
|
|
224
|
+
const rows = await db.document.findMany({
|
|
225
|
+
where: { collection: FORMS_COLLECTION, deletedAt: null },
|
|
226
|
+
orderBy: { createdAt: 'desc' },
|
|
227
|
+
});
|
|
228
|
+
let forms = rows.map((r) => parseFormDefinition(r));
|
|
229
|
+
const counters = await computeFormCounters(forms.map((f) => f.id));
|
|
230
|
+
forms = forms.map((f) => {
|
|
231
|
+
const c = counters.get(f.id);
|
|
232
|
+
return {
|
|
233
|
+
...f,
|
|
234
|
+
totalEntries: c?.total ?? 0,
|
|
235
|
+
unreadEntries: c?.unread ?? 0,
|
|
236
|
+
starredEntries: c?.starred ?? 0,
|
|
237
|
+
lastEntryAt: c?.lastEntryAt ?? null,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
if (params.status)
|
|
241
|
+
forms = forms.filter((f) => f.status === params.status);
|
|
242
|
+
if (typeof params.notifyEnabled === 'boolean') {
|
|
243
|
+
forms = forms.filter((f) => Boolean(f.notifyEnabled) === params.notifyEnabled);
|
|
244
|
+
}
|
|
245
|
+
if (params.search?.trim()) {
|
|
246
|
+
const term = params.search.trim();
|
|
247
|
+
forms = forms.filter((f) => matchesSearch(f, term));
|
|
248
|
+
}
|
|
249
|
+
const sortBy = params.sortBy ?? 'createdAt';
|
|
250
|
+
const dir = params.sortDirection === 'asc' ? 1 : -1;
|
|
251
|
+
forms.sort((a, b) => {
|
|
252
|
+
const av = a[sortBy];
|
|
253
|
+
const bv = b[sortBy];
|
|
254
|
+
if (av == null && bv == null)
|
|
255
|
+
return 0;
|
|
256
|
+
if (av == null)
|
|
257
|
+
return 1;
|
|
258
|
+
if (bv == null)
|
|
259
|
+
return -1;
|
|
260
|
+
return av > bv ? dir : av < bv ? -dir : 0;
|
|
261
|
+
});
|
|
262
|
+
const total = forms.length;
|
|
263
|
+
const page = Math.max(1, params.page ?? 1);
|
|
264
|
+
const pageSize = Math.min(100, Math.max(1, params.pageSize ?? 50));
|
|
265
|
+
const start = (page - 1) * pageSize;
|
|
266
|
+
return { forms: forms.slice(start, start + pageSize), total, page, pageSize };
|
|
267
|
+
}
|
|
268
|
+
export async function getFormById(id) {
|
|
269
|
+
const row = await rawDb().document.findFirst({
|
|
270
|
+
where: { id, collection: FORMS_COLLECTION, deletedAt: null },
|
|
271
|
+
});
|
|
272
|
+
if (!row)
|
|
273
|
+
return null;
|
|
274
|
+
const form = parseFormDefinition(row);
|
|
275
|
+
const counters = await computeFormCounters([id]);
|
|
276
|
+
const c = counters.get(id);
|
|
277
|
+
return {
|
|
278
|
+
...form,
|
|
279
|
+
totalEntries: c?.total ?? 0,
|
|
280
|
+
unreadEntries: c?.unread ?? 0,
|
|
281
|
+
starredEntries: c?.starred ?? 0,
|
|
282
|
+
lastEntryAt: c?.lastEntryAt ?? null,
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
export async function getFormBySlug(slug) {
|
|
286
|
+
const row = await rawDb().document.findFirst({
|
|
287
|
+
where: { collection: FORMS_COLLECTION, slug, deletedAt: null },
|
|
288
|
+
});
|
|
289
|
+
return row ? parseFormDefinition(row) : null;
|
|
290
|
+
}
|
|
291
|
+
async function slugExists(slug, excludeId) {
|
|
292
|
+
const row = await rawDb().document.findFirst({
|
|
293
|
+
where: {
|
|
294
|
+
collection: FORMS_COLLECTION,
|
|
295
|
+
slug,
|
|
296
|
+
deletedAt: null,
|
|
297
|
+
...(excludeId ? { id: { not: excludeId } } : {}),
|
|
298
|
+
},
|
|
299
|
+
select: { id: true },
|
|
300
|
+
});
|
|
301
|
+
return Boolean(row);
|
|
302
|
+
}
|
|
303
|
+
function slugify(input) {
|
|
304
|
+
return input
|
|
305
|
+
.toLowerCase()
|
|
306
|
+
.trim()
|
|
307
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
308
|
+
.replace(/^-+|-+$/g, '')
|
|
309
|
+
.slice(0, 64);
|
|
310
|
+
}
|
|
311
|
+
export async function createForm(ctx, input) {
|
|
312
|
+
const fields = normalizeFields(input.fields ?? []);
|
|
313
|
+
const validation = validateFormSchema(fields);
|
|
314
|
+
if (!validation.valid) {
|
|
315
|
+
throw new FormValidationError(validation.errors);
|
|
316
|
+
}
|
|
317
|
+
let slug = input.slug?.trim() ? slugify(input.slug) : slugify(input.name);
|
|
318
|
+
if (!slug)
|
|
319
|
+
slug = `form-${Date.now()}`;
|
|
320
|
+
if (await slugExists(slug)) {
|
|
321
|
+
throw new FormConflictError(`A form with slug "${slug}" already exists.`);
|
|
322
|
+
}
|
|
323
|
+
const data = {
|
|
324
|
+
name: input.name,
|
|
325
|
+
slug,
|
|
326
|
+
description: input.description ?? '',
|
|
327
|
+
status: normalizeStatus(input.status),
|
|
328
|
+
fields,
|
|
329
|
+
notificationSettings: input.notificationSettings ?? { enabled: false, recipients: [] },
|
|
330
|
+
embedSettings: input.embedSettings ?? {},
|
|
331
|
+
spamSettings: input.spamSettings ?? {},
|
|
332
|
+
successMessage: input.successMessage ?? getActuateConfig()?.forms?.defaultSuccessMessage,
|
|
333
|
+
redirectUrl: input.redirectUrl,
|
|
334
|
+
};
|
|
335
|
+
const doc = (await createDocument(FORMS_COLLECTION, data, ctx));
|
|
336
|
+
const version = await createSchemaVersionRow(doc.id, fields, ctx.userId, 'Initial schema');
|
|
337
|
+
await rawDb().document.update({
|
|
338
|
+
where: { id: doc.id },
|
|
339
|
+
data: { data: { ...asRecord(doc.data), activeSchemaVersionId: version.id } },
|
|
340
|
+
});
|
|
341
|
+
await logEvent({
|
|
342
|
+
event: 'form.created',
|
|
343
|
+
userId: ctx.userId,
|
|
344
|
+
details: { formId: doc.id, name: input.name, slug },
|
|
345
|
+
});
|
|
346
|
+
const created = await getFormById(doc.id);
|
|
347
|
+
if (!created)
|
|
348
|
+
throw new Error('Form created but could not be reloaded');
|
|
349
|
+
return created;
|
|
350
|
+
}
|
|
351
|
+
export async function updateForm(ctx, id, patch) {
|
|
352
|
+
const existing = await getFormById(id);
|
|
353
|
+
if (!existing)
|
|
354
|
+
throw new FormNotFoundError(id);
|
|
355
|
+
const data = {};
|
|
356
|
+
if (patch.name !== undefined)
|
|
357
|
+
data.name = patch.name;
|
|
358
|
+
if (patch.description !== undefined)
|
|
359
|
+
data.description = patch.description;
|
|
360
|
+
if (patch.successMessage !== undefined)
|
|
361
|
+
data.successMessage = patch.successMessage;
|
|
362
|
+
if (patch.redirectUrl !== undefined)
|
|
363
|
+
data.redirectUrl = patch.redirectUrl;
|
|
364
|
+
if (patch.spamSettings !== undefined)
|
|
365
|
+
data.spamSettings = patch.spamSettings;
|
|
366
|
+
if (patch.embedSettings !== undefined)
|
|
367
|
+
data.embedSettings = patch.embedSettings;
|
|
368
|
+
let statusChanged = false;
|
|
369
|
+
if (patch.status !== undefined && patch.status !== existing.status) {
|
|
370
|
+
statusChanged = true;
|
|
371
|
+
data.status = patch.status;
|
|
372
|
+
data.archivedAt = patch.status === 'archived' ? new Date().toISOString() : null;
|
|
373
|
+
}
|
|
374
|
+
await updateDocument(FORMS_COLLECTION, id, data, ctx);
|
|
375
|
+
await logEvent({
|
|
376
|
+
event: statusChanged
|
|
377
|
+
? patch.status === 'archived'
|
|
378
|
+
? 'form.archived'
|
|
379
|
+
: existing.status === 'archived'
|
|
380
|
+
? 'form.restored'
|
|
381
|
+
: 'form.updated'
|
|
382
|
+
: 'form.updated',
|
|
383
|
+
userId: ctx.userId,
|
|
384
|
+
details: { formId: id, ...(statusChanged ? { status: patch.status } : {}) },
|
|
385
|
+
});
|
|
386
|
+
const updated = await getFormById(id);
|
|
387
|
+
if (!updated)
|
|
388
|
+
throw new FormNotFoundError(id);
|
|
389
|
+
return updated;
|
|
390
|
+
}
|
|
391
|
+
export async function archiveForm(ctx, id) {
|
|
392
|
+
return updateForm(ctx, id, { status: 'archived' });
|
|
393
|
+
}
|
|
394
|
+
export async function restoreForm(ctx, id) {
|
|
395
|
+
return updateForm(ctx, id, { status: 'draft' });
|
|
396
|
+
}
|
|
397
|
+
export async function duplicateForm(ctx, id) {
|
|
398
|
+
const source = await getFormById(id);
|
|
399
|
+
if (!source)
|
|
400
|
+
throw new FormNotFoundError(id);
|
|
401
|
+
let slug = slugify(`${source.slug || source.name}-copy`);
|
|
402
|
+
let n = 2;
|
|
403
|
+
while (await slugExists(slug)) {
|
|
404
|
+
slug = slugify(`${source.slug || source.name}-copy-${n++}`);
|
|
405
|
+
}
|
|
406
|
+
return createForm(ctx, {
|
|
407
|
+
name: `${source.name} (copy)`,
|
|
408
|
+
slug,
|
|
409
|
+
description: source.description,
|
|
410
|
+
status: 'draft',
|
|
411
|
+
fields: source.fields,
|
|
412
|
+
notificationSettings: source.notificationSettings,
|
|
413
|
+
embedSettings: {},
|
|
414
|
+
spamSettings: source.spamSettings,
|
|
415
|
+
successMessage: source.successMessage,
|
|
416
|
+
redirectUrl: source.redirectUrl,
|
|
417
|
+
});
|
|
418
|
+
}
|
|
419
|
+
export async function deleteForm(ctx, id, options = {}) {
|
|
420
|
+
const existing = await getFormById(id);
|
|
421
|
+
if (!existing)
|
|
422
|
+
throw new FormNotFoundError(id);
|
|
423
|
+
const submissionCount = await rawDb().formSubmission.count({ where: { formId: id } });
|
|
424
|
+
if (submissionCount > 0 && !options.force) {
|
|
425
|
+
throw new FormHasSubmissionsError(submissionCount);
|
|
426
|
+
}
|
|
427
|
+
await deleteDocument(FORMS_COLLECTION, id, ctx);
|
|
428
|
+
await logEvent({
|
|
429
|
+
event: 'form.deleted',
|
|
430
|
+
userId: ctx.userId,
|
|
431
|
+
details: { formId: id, submissionCount, forced: Boolean(options.force) },
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
// ─── Schema save ───────────────────────────────────────────────────────
|
|
435
|
+
export async function saveFormSchema(ctx, id, fields, notes) {
|
|
436
|
+
const existing = await getFormById(id);
|
|
437
|
+
if (!existing)
|
|
438
|
+
throw new FormNotFoundError(id);
|
|
439
|
+
const normalized = normalizeFields(fields);
|
|
440
|
+
const validation = validateFormSchema(normalized);
|
|
441
|
+
if (!validation.valid)
|
|
442
|
+
throw new FormValidationError(validation.errors);
|
|
443
|
+
const version = await createSchemaVersionRow(id, normalized, ctx.userId, notes);
|
|
444
|
+
await updateDocument(FORMS_COLLECTION, id, { fields: normalized, activeSchemaVersionId: version.id }, ctx);
|
|
445
|
+
await logEvent({
|
|
446
|
+
event: 'form.schema.updated',
|
|
447
|
+
userId: ctx.userId,
|
|
448
|
+
details: { formId: id, version: version.version, fieldCount: normalized.length },
|
|
449
|
+
});
|
|
450
|
+
const form = await getFormById(id);
|
|
451
|
+
if (!form)
|
|
452
|
+
throw new FormNotFoundError(id);
|
|
453
|
+
return { form, version };
|
|
454
|
+
}
|
|
455
|
+
export async function getFormSchema(id) {
|
|
456
|
+
const form = await getFormById(id);
|
|
457
|
+
if (!form)
|
|
458
|
+
throw new FormNotFoundError(id);
|
|
459
|
+
const versions = await listSchemaVersions(id);
|
|
460
|
+
return { fields: form.fields, activeVersionId: form.activeSchemaVersionId ?? null, versions };
|
|
461
|
+
}
|
|
462
|
+
// ─── Settings ──────────────────────────────────────────────────────────
|
|
463
|
+
const DEFAULT_NOTIFICATION_SETTINGS = {
|
|
464
|
+
enabled: false,
|
|
465
|
+
recipients: [],
|
|
466
|
+
includeFields: true,
|
|
467
|
+
excludeSensitiveFields: false,
|
|
468
|
+
autoresponderEnabled: false,
|
|
469
|
+
webhookEnabled: false,
|
|
470
|
+
};
|
|
471
|
+
export async function getNotificationSettings(id) {
|
|
472
|
+
const form = await getFormById(id);
|
|
473
|
+
if (!form)
|
|
474
|
+
throw new FormNotFoundError(id);
|
|
475
|
+
return { ...DEFAULT_NOTIFICATION_SETTINGS, ...(form.notificationSettings ?? {}) };
|
|
476
|
+
}
|
|
477
|
+
export async function updateNotificationSettings(ctx, id, patch) {
|
|
478
|
+
const current = await getNotificationSettings(id);
|
|
479
|
+
const next = { ...current, ...patch };
|
|
480
|
+
await updateDocument(FORMS_COLLECTION, id, { notificationSettings: next, notifyEnabled: next.enabled }, ctx);
|
|
481
|
+
await logEvent({
|
|
482
|
+
event: 'form.notifications.changed',
|
|
483
|
+
userId: ctx.userId,
|
|
484
|
+
details: { formId: id, enabled: next.enabled },
|
|
485
|
+
});
|
|
486
|
+
return next;
|
|
487
|
+
}
|
|
488
|
+
/** Toggle the notify flag (maps to the Notify switch in the forms table). */
|
|
489
|
+
export async function setNotifyEnabled(ctx, id, enabled) {
|
|
490
|
+
return updateNotificationSettings(ctx, id, { enabled });
|
|
491
|
+
}
|
|
492
|
+
export async function getEmbedSettings(id) {
|
|
493
|
+
const form = await getFormById(id);
|
|
494
|
+
if (!form)
|
|
495
|
+
throw new FormNotFoundError(id);
|
|
496
|
+
return form.embedSettings ?? {};
|
|
497
|
+
}
|
|
498
|
+
export async function updateEmbedSettings(ctx, id, patch) {
|
|
499
|
+
const current = await getEmbedSettings(id);
|
|
500
|
+
const next = { ...current, ...patch };
|
|
501
|
+
await updateDocument(FORMS_COLLECTION, id, { embedSettings: next }, ctx);
|
|
502
|
+
await logEvent({
|
|
503
|
+
event: 'form.updated',
|
|
504
|
+
userId: ctx.userId,
|
|
505
|
+
details: { formId: id, embed: true },
|
|
506
|
+
});
|
|
507
|
+
return next;
|
|
508
|
+
}
|
|
509
|
+
// ─── Stats + sidebar ─────────────────────────────────────────────────────
|
|
510
|
+
export async function getFormStats() {
|
|
511
|
+
const db = rawDb();
|
|
512
|
+
const forms = (await db.document.findMany({
|
|
513
|
+
where: { collection: FORMS_COLLECTION, deletedAt: null },
|
|
514
|
+
select: { data: true },
|
|
515
|
+
})).map((r) => normalizeStatus(asRecord(r.data).status));
|
|
516
|
+
const [totalEntries, unreadEntries] = await Promise.all([
|
|
517
|
+
db.formSubmission.count({ where: { spamStatus: { not: 'spam' } } }),
|
|
518
|
+
db.formSubmission.count({
|
|
519
|
+
where: { unread: true, archived: false, spamStatus: { not: 'spam' } },
|
|
520
|
+
}),
|
|
521
|
+
]);
|
|
522
|
+
return {
|
|
523
|
+
totalForms: forms.length,
|
|
524
|
+
activeForms: forms.filter((s) => s === 'active').length,
|
|
525
|
+
totalEntries,
|
|
526
|
+
unreadEntries,
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Unread counts for the sidebar. Only active, non-hidden forms contribute to
|
|
531
|
+
* the parent badge; archived/draft forms are excluded so the rail reflects
|
|
532
|
+
* live, publicly-collecting forms.
|
|
533
|
+
*/
|
|
534
|
+
export async function getFormsSidebarCounts() {
|
|
535
|
+
const db = rawDb();
|
|
536
|
+
const rows = await db.document.findMany({
|
|
537
|
+
where: { collection: FORMS_COLLECTION, deletedAt: null },
|
|
538
|
+
orderBy: { createdAt: 'asc' },
|
|
539
|
+
});
|
|
540
|
+
const forms = rows.map(parseFormDefinition).filter((f) => f.status === 'active');
|
|
541
|
+
const counters = await computeFormCounters(forms.map((f) => f.id));
|
|
542
|
+
return forms.map((f) => ({
|
|
543
|
+
formId: f.id,
|
|
544
|
+
name: f.name,
|
|
545
|
+
slug: f.slug,
|
|
546
|
+
status: f.status,
|
|
547
|
+
unread: counters.get(f.id)?.unread ?? 0,
|
|
548
|
+
}));
|
|
549
|
+
}
|
|
550
|
+
// ─── Errors ──────────────────────────────────────────────────────────────
|
|
551
|
+
export class FormNotFoundError extends Error {
|
|
552
|
+
constructor(id) {
|
|
553
|
+
super(`Form "${id}" not found`);
|
|
554
|
+
this.name = 'FormNotFoundError';
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
export class FormValidationError extends Error {
|
|
558
|
+
errors;
|
|
559
|
+
constructor(errors) {
|
|
560
|
+
super(`Invalid form schema: ${errors.join('; ')}`);
|
|
561
|
+
this.name = 'FormValidationError';
|
|
562
|
+
this.errors = errors;
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
export class FormConflictError extends Error {
|
|
566
|
+
constructor(message) {
|
|
567
|
+
super(message);
|
|
568
|
+
this.name = 'FormConflictError';
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
export class FormHasSubmissionsError extends Error {
|
|
572
|
+
submissionCount;
|
|
573
|
+
constructor(submissionCount) {
|
|
574
|
+
super(`Form has ${submissionCount} submission(s); deletion requires force or export first.`);
|
|
575
|
+
this.name = 'FormHasSubmissionsError';
|
|
576
|
+
this.submissionCount = submissionCount;
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
//# sourceMappingURL=service.js.map
|