@aphexcms/plugin-forms 0.1.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/README.md ADDED
@@ -0,0 +1,52 @@
1
+ # @aphexcms/plugin-forms
2
+
3
+ Editor-composed forms for [AphexCMS](https://github.com/IcelandicIcecream/aphex) — contact forms, signups, surveys — built in the admin rather than in code, with submissions stored as documents.
4
+
5
+ 📚 **Documentation: [docs.getaphex.com/forms](https://docs.getaphex.com/forms)**
6
+
7
+ ## Install
8
+
9
+ ```bash
10
+ pnpm add @aphexcms/plugin-forms
11
+ ```
12
+
13
+ ```ts title="src/lib/plugins.ts"
14
+ import { formsPlugin } from '@aphexcms/plugin-forms';
15
+
16
+ export const plugins = [
17
+ formsPlugin({
18
+ // Address notification emails come from. Falls back to APHEX_EMAIL_FROM.
19
+ from: 'Acme <forms@example.com>'
20
+ })
21
+ ];
22
+ ```
23
+
24
+ The plugin is self-contained: it registers its own schemas, its submission endpoint, its admin tool, and its notification consumer. Nothing else in `aphex.config.ts` needs to know it exists.
25
+
26
+ After adding it, regenerate types with the plugins path as the third argument, or the `form` and `formSubmission` collections stay untyped:
27
+
28
+ ```bash
29
+ aphex generate:types ./src/lib/schemaTypes/index.ts ./src/lib/generated-types.ts ./src/lib/plugins.ts
30
+ ```
31
+
32
+ ## What you get
33
+
34
+ - A **form builder** in the admin — fields, labels, options, required flags, and confirmation behaviour, all editable by a non-developer.
35
+ - **Stored submissions** as ordinary documents, so they're searchable, exportable, and subject to the same access control as everything else.
36
+ - **Validation** derived from the form definition and enforced server-side, not just in the browser.
37
+ - **Spam handling** — a honeypot field plus rate limiting on the submission endpoint.
38
+ - **Email notifications** via an event consumer, so a failed send retries with backoff instead of vanishing.
39
+
40
+ ## Submissions carry personal data
41
+
42
+ They usually contain names and email addresses, so the default access rule on `formSubmission` is deliberately stricter than on ordinary content. Widen it knowingly, and check it before exposing submissions through the HTTP or GraphQL APIs.
43
+
44
+ ## Multi-tenancy
45
+
46
+ The endpoint derives the organization from the globally unique published form ID and never accepts
47
+ an organization ID from the visitor. The submission and its notification event then commit in one
48
+ transaction within that exact tenant.
49
+
50
+ ## License
51
+
52
+ MIT
@@ -0,0 +1,75 @@
1
+ import type { FormDefinition } from '@aphexcms/cms-core';
2
+ /**
3
+ * Turn a `form` document — what an editor composed in the studio — into a
4
+ * `FormDefinition`, the developer-authored form shape cms-core already knows how
5
+ * to validate.
6
+ *
7
+ * This is the whole reason the plugin is small. `validateFormData` runs a form's
8
+ * fields through the same engine the admin uses for documents, so "required",
9
+ * "is an email", "within this range" and "one of these options" are already
10
+ * implemented, already consistent with the rest of the CMS, and already returning
11
+ * errors in the shape every other surface uses. Hand-rolling a second validator
12
+ * for submissions would be a second set of rules to keep in step — and the one
13
+ * that runs against untrusted input is the worst place to have a divergent copy.
14
+ *
15
+ * The two layers meet here: `defineForm` is a form a developer writes in code;
16
+ * a `form` document is a form an editor drew in the studio. Compiling the second
17
+ * into the first means only the authoring differs.
18
+ */
19
+ /** One field as stored on a `form` document. */
20
+ export interface FormFieldValue {
21
+ _type: string;
22
+ _key?: string;
23
+ name?: string;
24
+ label?: string;
25
+ required?: boolean;
26
+ width?: 'full' | 'half';
27
+ placeholder?: string;
28
+ defaultValue?: string | boolean;
29
+ rows?: number;
30
+ min?: number;
31
+ max?: number;
32
+ options?: Array<{
33
+ label?: string;
34
+ value?: string;
35
+ }>;
36
+ message?: unknown;
37
+ }
38
+ /** A `form` document, as far as this plugin cares. */
39
+ export interface FormDocument {
40
+ id: string;
41
+ _meta?: {
42
+ publishedHash?: string | null;
43
+ status?: string | null;
44
+ };
45
+ title?: string;
46
+ fields?: FormFieldValue[];
47
+ submitButtonLabel?: string;
48
+ confirmationType?: 'message' | 'redirect';
49
+ confirmationMessage?: unknown;
50
+ redirectUrl?: string;
51
+ emails?: NotificationEmailValue[];
52
+ }
53
+ export interface NotificationEmailValue {
54
+ _type?: string;
55
+ _key?: string;
56
+ to?: string;
57
+ replyTo?: string;
58
+ subject?: string;
59
+ message?: string;
60
+ }
61
+ /** Field kinds that collect an answer. `formMessage` is display-only. */
62
+ export declare function isInputField(field: FormFieldValue): boolean;
63
+ /** The stored value for a select option — its `value`, or the label when blank. */
64
+ export declare function optionValue(option: {
65
+ label?: string;
66
+ value?: string;
67
+ }): string;
68
+ /**
69
+ * Compile a `form` document into a `FormDefinition` that `validateFormData` can
70
+ * run. `store` and `notifyEmail` are deliberately left off — this plugin stores
71
+ * submissions as documents and sends notifications from its own consumer, so the
72
+ * definition is used purely as a validation contract.
73
+ */
74
+ export declare function compileForm(doc: FormDocument): FormDefinition;
75
+ //# sourceMappingURL=compile.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile.d.ts","sourceRoot":"","sources":["../src/lib/compile.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,cAAc,EAAE,MAAM,oBAAoB,CAAC;AAEhE;;;;;;;;;;;;;;;;GAgBG;AAEH,gDAAgD;AAChD,MAAM,WAAW,cAAc;IAC9B,KAAK,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,KAAK,CAAC,EAAE,MAAM,GAAG,MAAM,CAAC;IACxB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,YAAY,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC;IAChC,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,OAAO,CAAC,EAAE,KAAK,CAAC;QAAE,KAAK,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACpD,OAAO,CAAC,EAAE,OAAO,CAAC;CAClB;AAED,sDAAsD;AACtD,MAAM,WAAW,YAAY;IAC5B,EAAE,EAAE,MAAM,CAAC;IACX,KAAK,CAAC,EAAE;QAAE,aAAa,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;QAAC,MAAM,CAAC,EAAE,MAAM,GAAG,IAAI,CAAA;KAAE,CAAC;IAClE,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,cAAc,EAAE,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,gBAAgB,CAAC,EAAE,SAAS,GAAG,UAAU,CAAC;IAC1C,mBAAmB,CAAC,EAAE,OAAO,CAAC;IAC9B,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,sBAAsB,EAAE,CAAC;CAClC;AAED,MAAM,WAAW,sBAAsB;IACtC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,EAAE,CAAC,EAAE,MAAM,CAAC;IACZ,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;CACjB;AAED,yEAAyE;AACzE,wBAAgB,YAAY,CAAC,KAAK,EAAE,cAAc,GAAG,OAAO,CAE3D;AAED,mFAAmF;AACnF,wBAAgB,WAAW,CAAC,MAAM,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,KAAK,CAAC,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAE9E;AA+FD;;;;;GAKG;AACH,wBAAgB,WAAW,CAAC,GAAG,EAAE,YAAY,GAAG,cAAc,CAkB7D"}
@@ -0,0 +1,119 @@
1
+ /** Field kinds that collect an answer. `formMessage` is display-only. */
2
+ export function isInputField(field) {
3
+ return field._type !== 'formMessage' && typeof field.name === 'string' && field.name.length > 0;
4
+ }
5
+ /** The stored value for a select option — its `value`, or the label when blank. */
6
+ export function optionValue(option) {
7
+ return (option.value ?? '').trim() || (option.label ?? '').trim();
8
+ }
9
+ /**
10
+ * Compile one authored field into a CMS `Field`.
11
+ *
12
+ * Returns `null` for anything that collects nothing, so the caller can filter
13
+ * rather than branch. An unrecognised `_type` also returns `null`: a form saved
14
+ * by a newer version of this plugin must not have an unknown field silently
15
+ * treated as free text.
16
+ */
17
+ function toField(field) {
18
+ if (!isInputField(field))
19
+ return null;
20
+ const name = field.name;
21
+ const title = field.label ?? name;
22
+ const required = field.required === true;
23
+ switch (field._type) {
24
+ case 'formText':
25
+ return {
26
+ name,
27
+ type: 'string',
28
+ title,
29
+ validation: (Rule) => (required ? Rule.required() : Rule.optional())
30
+ };
31
+ case 'formEmail':
32
+ return {
33
+ name,
34
+ type: 'string',
35
+ title,
36
+ // `.email()` on an optional field only judges values that are present,
37
+ // so a blank optional email stays valid.
38
+ validation: (Rule) => (required ? Rule.required().email() : Rule.optional().email())
39
+ };
40
+ case 'formTextarea':
41
+ return {
42
+ name,
43
+ type: 'text',
44
+ title,
45
+ validation: (Rule) => (required ? Rule.required() : Rule.optional())
46
+ };
47
+ case 'formNumber':
48
+ return {
49
+ name,
50
+ type: 'number',
51
+ title,
52
+ validation: (Rule) => {
53
+ let rule = required ? Rule.required() : Rule.optional();
54
+ if (typeof field.min === 'number')
55
+ rule = rule.min(field.min);
56
+ if (typeof field.max === 'number')
57
+ rule = rule.max(field.max);
58
+ return rule;
59
+ }
60
+ };
61
+ case 'formCheckbox':
62
+ return {
63
+ name,
64
+ type: 'boolean',
65
+ title,
66
+ /*
67
+ * "Required" on a checkbox means ticked, not present — it's the consent
68
+ * box. `Rule.required()` would accept `false`, which is exactly the case
69
+ * the editor is trying to prevent, so this is a custom rule instead.
70
+ */
71
+ validation: (Rule) => required
72
+ ? Rule.custom((value) => value === true || 'This must be ticked')
73
+ : Rule.optional()
74
+ };
75
+ case 'formSelect': {
76
+ const values = (field.options ?? []).map(optionValue).filter(Boolean);
77
+ return {
78
+ name,
79
+ type: 'string',
80
+ title,
81
+ list: values,
82
+ validation: (Rule) => (required ? Rule.required() : Rule.optional()).custom((value) => {
83
+ if (value === undefined || value === null || value === '')
84
+ return true;
85
+ // Never trust the posted value to be one the form offered: a select
86
+ // is a client-side constraint, and the request isn't.
87
+ return values.includes(String(value)) || 'Not one of the available options';
88
+ })
89
+ };
90
+ }
91
+ default:
92
+ return null;
93
+ }
94
+ }
95
+ /**
96
+ * Compile a `form` document into a `FormDefinition` that `validateFormData` can
97
+ * run. `store` and `notifyEmail` are deliberately left off — this plugin stores
98
+ * submissions as documents and sends notifications from its own consumer, so the
99
+ * definition is used purely as a validation contract.
100
+ */
101
+ export function compileForm(doc) {
102
+ const fields = (doc.fields ?? []).map(toField).filter((f) => f !== null);
103
+ return {
104
+ id: doc.id,
105
+ title: doc.title ?? 'Form',
106
+ fields,
107
+ // Trim every string before validation. This is the transform half — the one
108
+ // thing validation can't do — so " " fails a required check instead of
109
+ // passing it.
110
+ transform: (data) => {
111
+ const out = {};
112
+ for (const [key, value] of Object.entries(data)) {
113
+ out[key] = typeof value === 'string' ? value.trim() : value;
114
+ }
115
+ return out;
116
+ }
117
+ };
118
+ }
119
+ //# sourceMappingURL=compile.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"compile.js","sourceRoot":"","sources":["../src/lib/compile.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,18 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Emitted once a submission has been stored.
4
+ *
5
+ * Carries identifiers only, in line with the rest of the event catalog: the log
6
+ * is not a content mirror, and a submission is the one payload most likely to
7
+ * hold a person's name, email and message. Keeping those out of the immutable
8
+ * log means a deletion request can be honoured by deleting the submission
9
+ * document — an append-only log that quietly held a second copy would make that
10
+ * impossible.
11
+ *
12
+ * The notification consumer reads the submission back to build the email.
13
+ */
14
+ export declare const formSubmissionCreated: import("@aphexcms/cms-core").EventDefinition<"forms.submission.created", z.ZodObject<{
15
+ submissionId: z.ZodString;
16
+ formId: z.ZodString;
17
+ }, z.core.$strip>>;
18
+ //# sourceMappingURL=events.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.d.ts","sourceRoot":"","sources":["../src/lib/events.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,qBAAqB;;;kBAMjC,CAAC"}
package/dist/events.js ADDED
@@ -0,0 +1,19 @@
1
+ import { defineEvent } from '@aphexcms/cms-core';
2
+ import { z } from 'zod';
3
+ /**
4
+ * Emitted once a submission has been stored.
5
+ *
6
+ * Carries identifiers only, in line with the rest of the event catalog: the log
7
+ * is not a content mirror, and a submission is the one payload most likely to
8
+ * hold a person's name, email and message. Keeping those out of the immutable
9
+ * log means a deletion request can be honoured by deleting the submission
10
+ * document — an append-only log that quietly held a second copy would make that
11
+ * impossible.
12
+ *
13
+ * The notification consumer reads the submission back to build the email.
14
+ */
15
+ export const formSubmissionCreated = defineEvent('forms.submission.created', z.object({
16
+ submissionId: z.string(),
17
+ formId: z.string()
18
+ }));
19
+ //# sourceMappingURL=events.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"events.js","sourceRoot":"","sources":["../src/lib/events.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACjD,OAAO,EAAE,CAAC,EAAE,MAAM,KAAK,CAAC;AAExB;;;;;;;;;;;GAWG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAG,WAAW,CAC/C,0BAA0B,EAC1B,CAAC,CAAC,MAAM,CAAC;IACR,YAAY,EAAE,CAAC,CAAC,MAAM,EAAE;IACxB,MAAM,EAAE,CAAC,CAAC,MAAM,EAAE;CAClB,CAAC,CACF,CAAC"}
@@ -0,0 +1,25 @@
1
+ import type { SchemaAccess } from '@aphexcms/cms-core';
2
+ export interface FormsPluginOptions {
3
+ /**
4
+ * The address notification emails are sent from. The app owns its sending
5
+ * identity, so this is passed in rather than guessed; falls back to
6
+ * `APHEX_EMAIL_FROM`. With neither set, submissions are still stored and a
7
+ * configured notification retries, then dead-letters visibly in Activity.
8
+ */
9
+ from?: string;
10
+ /**
11
+ * Override submission collection access. Defaults to owner/admin read and
12
+ * delete access, with direct create/update/publish operations blocked. Partial
13
+ * overrides are merged with those defaults, so adding a custom reviewer role
14
+ * does not accidentally reopen writes.
15
+ */
16
+ submissionAccess?: SchemaAccess;
17
+ }
18
+ export declare function formsPlugin(options?: FormsPluginOptions): import("@aphexcms/cms-core").CMSPlugin;
19
+ export { defaultSubmissionAccess, formSchema, formSubmissionSchema, formFieldTypes } from './schema.js';
20
+ export { compileForm, isInputField, optionValue } from './compile.js';
21
+ export type { FormDocument, FormFieldValue, NotificationEmailValue } from './compile.js';
22
+ export { formSubmissionCreated } from './events.js';
23
+ export { renderTemplate, fieldLabels, toStoredValue } from './template.js';
24
+ export type { Answer } from './template.js';
25
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AA8BA,OAAO,KAAK,EAA0B,YAAY,EAAE,MAAM,oBAAoB,CAAC;AAQ/E,MAAM,WAAW,kBAAkB;IAClC;;;;;OAKG;IACH,IAAI,CAAC,EAAE,MAAM,CAAC;IAEd;;;;;OAKG;IACH,gBAAgB,CAAC,EAAE,YAAY,CAAC;CAChC;AAOD,wBAAgB,WAAW,CAAC,OAAO,GAAE,kBAAuB,0CAyC3D;AAED,OAAO,EACN,uBAAuB,EACvB,UAAU,EACV,oBAAoB,EACpB,cAAc,EACd,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AACnE,YAAY,EAAE,YAAY,EAAE,cAAc,EAAE,sBAAsB,EAAE,MAAM,WAAW,CAAC;AACtF,OAAO,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC;AACxE,YAAY,EAAE,MAAM,EAAE,MAAM,YAAY,CAAC"}
package/dist/index.js ADDED
@@ -0,0 +1,83 @@
1
+ /**
2
+ * First-party form-builder plugin — comparable to `@payloadcms/plugin-form-builder`.
3
+ *
4
+ * `formsPlugin()` contributes everything a form needs and nothing an app has to
5
+ * wire:
6
+ *
7
+ * - two collections, `form` and `formSubmission` (`aphex/schema`)
8
+ * - `POST /api/form-submissions`, public, with a honeypot and rate limiting
9
+ * (`aphex/server/route`)
10
+ * - a consumer that emails the form's notification list out of band
11
+ * (`aphex/event/consumer`)
12
+ *
13
+ * Add it to the plugins array and an editor can build a form; nothing goes in
14
+ * `aphex.config.ts` beyond that.
15
+ *
16
+ * ## What the app still owns
17
+ *
18
+ * Rendering. The plugin defines what a form *is* and what happens when one is
19
+ * submitted; how it looks belongs to the site, so the template ships a `FormBlock`
20
+ * component that reads a form document and posts to the endpoint. That split is
21
+ * deliberate — a plugin that shipped markup would have to ship a design with it.
22
+ *
23
+ * ## Layering
24
+ *
25
+ * cms-core already has `defineForm` — a form a *developer* writes in code. This
26
+ * plugin is the same idea with the authoring moved into the studio: a `form`
27
+ * document is compiled into a `FormDefinition` and validated by the identical
28
+ * engine (see `compile.ts`). One validator, two ways to author.
29
+ */
30
+ import { definePlugin, effectiveOrganizationRole } from '@aphexcms/cms-core';
31
+ import { formSchema, formSubmissionSchema } from './schema.js';
32
+ import { handleSubmit } from './submit.js';
33
+ import { notifyOnSubmission } from './notify.js';
34
+ import { formSubmissionCreated } from './events.js';
35
+ const PLUGIN_ID = '@aphexcms/plugin-forms';
36
+ function strictSubmissionRule(rule) {
37
+ if (!Array.isArray(rule))
38
+ return rule;
39
+ return ({ auth }) => rule.includes(effectiveOrganizationRole(auth) ?? '');
40
+ }
41
+ export function formsPlugin(options = {}) {
42
+ const mergedAccess = { ...formSubmissionSchema.access, ...options.submissionAccess };
43
+ const submissionSchema = {
44
+ ...formSubmissionSchema,
45
+ access: Object.fromEntries(Object.entries(mergedAccess).map(([operation, rule]) => [
46
+ operation,
47
+ strictSubmissionRule(rule)
48
+ ]))
49
+ };
50
+ const parts = [
51
+ {
52
+ implements: 'aphex/schema',
53
+ schemas: [formSchema, submissionSchema]
54
+ },
55
+ {
56
+ implements: 'aphex/server/route',
57
+ id: 'forms.submit',
58
+ method: 'POST',
59
+ path: '/form-submissions',
60
+ handler: handleSubmit,
61
+ /*
62
+ * The one endpoint here that must be open: a form is for people who
63
+ * don't have accounts. Opting out of the capability gate is deliberate
64
+ * and the handler does its own verification — it validates against the
65
+ * form's declared fields, drops undeclared keys, rate-limits per IP and
66
+ * carries a honeypot. See `submit.ts`.
67
+ */
68
+ requiredCapabilities: 'public'
69
+ },
70
+ {
71
+ implements: 'aphex/event/consumer',
72
+ id: 'forms.notify',
73
+ events: [formSubmissionCreated.type],
74
+ handler: notifyOnSubmission(options.from)
75
+ }
76
+ ];
77
+ return definePlugin({ name: PLUGIN_ID, version: '0.1.0', parts });
78
+ }
79
+ export { defaultSubmissionAccess, formSchema, formSubmissionSchema, formFieldTypes } from './schema.js';
80
+ export { compileForm, isInputField, optionValue } from './compile.js';
81
+ export { formSubmissionCreated } from './events.js';
82
+ export { renderTemplate, fieldLabels, toStoredValue } from './template.js';
83
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/lib/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,OAAO,EAAE,YAAY,EAAE,yBAAyB,EAAE,MAAM,oBAAoB,CAAC;AAE7E,OAAO,EAAE,UAAU,EAAE,oBAAoB,EAAE,MAAM,UAAU,CAAC;AAC5D,OAAO,EAAE,YAAY,EAAE,MAAM,UAAU,CAAC;AACxC,OAAO,EAAE,kBAAkB,EAAE,MAAM,UAAU,CAAC;AAC9C,OAAO,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AAEjD,MAAM,SAAS,GAAG,wBAAwB,CAAC;AAoB3C,SAAS,oBAAoB,CAAC,IAA4B;IACzD,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IACtC,OAAO,CAAC,EAAE,IAAI,EAAE,EAAE,EAAE,CAAC,IAAI,CAAC,QAAQ,CAAC,yBAAyB,CAAC,IAAI,CAAC,IAAI,EAAE,CAAC,CAAC;AAC3E,CAAC;AAED,MAAM,UAAU,WAAW,CAAC,UAA8B,EAAE;IAC3D,MAAM,YAAY,GAAG,EAAE,GAAG,oBAAoB,CAAC,MAAM,EAAE,GAAG,OAAO,CAAC,gBAAgB,EAAE,CAAC;IACrF,MAAM,gBAAgB,GAAgC;QACrD,GAAG,oBAAoB;QACvB,MAAM,EAAE,MAAM,CAAC,WAAW,CACzB,MAAM,CAAC,OAAO,CAAC,YAAY,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,SAAS,EAAE,IAAI,CAAC,EAAE,EAAE,CAAC;YACvD,SAAS;YACT,oBAAoB,CAAC,IAAI,CAAC;SAC1B,CAAC,CACc;KACjB,CAAC;IAEF,MAAM,KAAK,GAAiB;QAC3B;YACC,UAAU,EAAE,cAAc;YAC1B,OAAO,EAAE,CAAC,UAAU,EAAE,gBAAgB,CAAC;SACvC;QACD;YACC,UAAU,EAAE,oBAAoB;YAChC,EAAE,EAAE,cAAc;YAClB,MAAM,EAAE,MAAM;YACd,IAAI,EAAE,mBAAmB;YACzB,OAAO,EAAE,YAAY;YACrB;;;;;;eAMG;YACH,oBAAoB,EAAE,QAAQ;SAC9B;QACD;YACC,UAAU,EAAE,sBAAsB;YAClC,EAAE,EAAE,cAAc;YAClB,MAAM,EAAE,CAAC,qBAAqB,CAAC,IAAI,CAAC;YACpC,OAAO,EAAE,kBAAkB,CAAC,OAAO,CAAC,IAAI,CAAC;SACzC;KACD,CAAC;IAEF,OAAO,YAAY,CAAC,EAAE,IAAI,EAAE,SAAS,EAAE,OAAO,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,CAAC;AACnE,CAAC;AAED,OAAO,EACN,uBAAuB,EACvB,UAAU,EACV,oBAAoB,EACpB,cAAc,EACd,MAAM,UAAU,CAAC;AAClB,OAAO,EAAE,WAAW,EAAE,YAAY,EAAE,WAAW,EAAE,MAAM,WAAW,CAAC;AAEnE,OAAO,EAAE,qBAAqB,EAAE,MAAM,UAAU,CAAC;AACjD,OAAO,EAAE,cAAc,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,YAAY,CAAC"}
@@ -0,0 +1,17 @@
1
+ import type { EventConsumerHandler } from '@aphexcms/cms-core';
2
+ /**
3
+ * Sends a form's notification emails, out of band.
4
+ *
5
+ * This runs as a delivery job, which is the entire reason a mail outage can't
6
+ * fail a visitor's submit: the submission is already stored, and a throw here
7
+ * retries with backoff and eventually dead-letters, visible in Activity. Sending
8
+ * inline in the request would have coupled "did my message go through" to
9
+ * whether an SMTP host answered.
10
+ *
11
+ * At-least-once delivery means this can run twice for one submission. A duplicate
12
+ * notification email is the acceptable failure — the alternative, tracking sent
13
+ * state to make it exactly-once, would have to be written somewhere that itself
14
+ * can fail between the send and the write.
15
+ */
16
+ export declare function notifyOnSubmission(from?: string): EventConsumerHandler;
17
+ //# sourceMappingURL=notify.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify.d.ts","sourceRoot":"","sources":["../src/lib/notify.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,oBAAoB,CAAC;AAK/D;;;;;;;;;;;;;GAaG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,CAAC,EAAE,MAAM,GAAG,oBAAoB,CAoEtE"}
package/dist/notify.js ADDED
@@ -0,0 +1,76 @@
1
+ import { formSubmissionCreated } from './events.js';
2
+ import { renderTemplate } from './template.js';
3
+ /**
4
+ * Sends a form's notification emails, out of band.
5
+ *
6
+ * This runs as a delivery job, which is the entire reason a mail outage can't
7
+ * fail a visitor's submit: the submission is already stored, and a throw here
8
+ * retries with backoff and eventually dead-letters, visible in Activity. Sending
9
+ * inline in the request would have coupled "did my message go through" to
10
+ * whether an SMTP host answered.
11
+ *
12
+ * At-least-once delivery means this can run twice for one submission. A duplicate
13
+ * notification email is the acceptable failure — the alternative, tracking sent
14
+ * state to make it exactly-once, would have to be written somewhere that itself
15
+ * can fail between the send and the write.
16
+ */
17
+ export function notifyOnSubmission(from) {
18
+ return async ({ event, databaseAdapter, emailAdapter, logger }) => {
19
+ /*
20
+ * The sending identity belongs to the app, not to this plugin — it's the same
21
+ * address the CMS's own password-reset and invitation mail goes out as. Passed
22
+ * in via `formsPlugin({ from })`, falling back to the variable the templates
23
+ * already use. Missing configuration throws when this submission has recipients,
24
+ * making the delivery retry and eventually surface in Activity instead of being
25
+ * acknowledged without sending.
26
+ */
27
+ const { submissionId } = formSubmissionCreated.parse(event.payload);
28
+ const submissionRow = await databaseAdapter.findByDocIdAdvanced(event.organizationId, submissionId);
29
+ if (!submissionRow) {
30
+ // The document was deleted between the submit and this delivery. Nothing to
31
+ // send, and nothing that retrying would fix.
32
+ logger.warn(`[forms] submission ${submissionId} is gone; skipping notification`);
33
+ return;
34
+ }
35
+ // A submission is written as a draft and never published, so `draftData` is
36
+ // where both its answers and immutable notification snapshot live.
37
+ const submission = (submissionRow.draftData ?? {});
38
+ const emails = submission.notificationEmails ?? [];
39
+ if (emails.length === 0)
40
+ return;
41
+ if (!emailAdapter)
42
+ throw new Error('[forms] notification email adapter is not configured');
43
+ const sender = from ?? process.env.APHEX_EMAIL_FROM;
44
+ if (!sender)
45
+ throw new Error('[forms] notification from-address is not configured');
46
+ const answers = (submission.submissionData ?? []).map((a) => ({
47
+ field: String(a.field ?? ''),
48
+ value: String(a.value ?? '')
49
+ }));
50
+ const labels = new Map((submission.notificationFieldLabels ?? []).map((entry) => [
51
+ String(entry.field ?? ''),
52
+ String(entry.label ?? entry.field ?? '')
53
+ ]));
54
+ for (const email of emails) {
55
+ const to = (email.to ?? '')
56
+ .split(',')
57
+ .map((address) => address.trim())
58
+ .filter(Boolean);
59
+ if (to.length === 0)
60
+ continue;
61
+ const result = await emailAdapter.send({
62
+ from: sender,
63
+ to,
64
+ replyTo: email.replyTo,
65
+ subject: renderTemplate(email.subject ?? 'New form submission', answers, labels)
66
+ .replace(/[\r\n]+/g, ' ')
67
+ .slice(0, 998),
68
+ text: renderTemplate(email.message ?? '{{allFields}}', answers, labels)
69
+ });
70
+ if (result.error)
71
+ throw new Error(`[forms] notification email failed: ${result.error}`);
72
+ }
73
+ logger.info(`[forms] sent ${emails.length} notification(s) for submission ${submissionId}`);
74
+ };
75
+ }
76
+ //# sourceMappingURL=notify.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"notify.js","sourceRoot":"","sources":["../src/lib/notify.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,14 @@
1
+ import type { SchemaAccess, SchemaType, TypeReference } from '@aphexcms/cms-core';
2
+ /** Every field kind an editor can add, in the order they appear in the "add" menu. */
3
+ export declare const formFieldTypes: TypeReference[];
4
+ /** Field kinds that actually collect an answer — everything except `formMessage`. */
5
+ export declare const INPUT_FIELD_TYPES: string[];
6
+ export declare const formSchema: SchemaType;
7
+ /**
8
+ * Submissions can contain personal data, so the general document capabilities
9
+ * are too broad. The submit route writes with system access; humans may only
10
+ * read or erase submissions when their organization role explicitly allows it.
11
+ */
12
+ export declare const defaultSubmissionAccess: SchemaAccess;
13
+ export declare const formSubmissionSchema: SchemaType;
14
+ //# sourceMappingURL=schema.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"schema.d.ts","sourceRoot":"","sources":["../src/lib/schema.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAS,YAAY,EAAE,UAAU,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AA0PzF,sFAAsF;AACtF,eAAO,MAAM,cAAc,EAAE,aAAa,EAQzC,CAAC;AAEF,qFAAqF;AACrF,eAAO,MAAM,iBAAiB,UAEY,CAAC;AAE3C,eAAO,MAAM,UAAU,EAAE,UAwIxB,CAAC;AAsBF;;;;GAIG;AACH,eAAO,MAAM,uBAAuB,EAAE,YAOrC,CAAC;AAEF,eAAO,MAAM,oBAAoB,EAAE,UAqFlC,CAAC"}