From 5e223cd7fb6a50ff083df011f861884fa8fc6357 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Tue, 17 Feb 2026 16:22:58 +0000 Subject: [PATCH] feat: multi-tenant contact form refactoring - Add forms + form-submissions to multiTenantPlugin with tenant scoping - Inject tenant field into forms via formOverrides - Reorder plugins: formBuilderPlugin before multiTenantPlugin (fixes warning) - Refactor ContactFormBlock: form relationship replaces hardcoded recipientEmail - Add setSubmissionTenant hook to auto-copy tenant from form to submission - Add tenant field (read-only) to FormSubmissionsOverrides - Migration: tenant_id on forms/form_submissions, form_id on contact block Co-Authored-By: Claude Opus 4.6 --- src/blocks/ContactFormBlock.ts | 34 +- src/collections/FormSubmissionsOverrides.ts | 11 + src/hooks/setSubmissionTenant.ts | 39 ++ .../20260217_120000_add_tenant_to_forms.ts | 81 ++++ src/migrations/index.ts | 6 + src/payload-types.ts | 357 ++++++++++-------- src/payload.config.ts | 71 ++-- 7 files changed, 408 insertions(+), 191 deletions(-) create mode 100644 src/hooks/setSubmissionTenant.ts create mode 100644 src/migrations/20260217_120000_add_tenant_to_forms.ts diff --git a/src/blocks/ContactFormBlock.ts b/src/blocks/ContactFormBlock.ts index da0ab33..d7d28bc 100644 --- a/src/blocks/ContactFormBlock.ts +++ b/src/blocks/ContactFormBlock.ts @@ -7,6 +7,16 @@ export const ContactFormBlock: Block = { plural: 'Kontaktformulare', }, fields: [ + { + name: 'form', + type: 'relationship', + relationTo: 'forms', + required: true, + label: 'Formular', + admin: { + description: 'Wählen Sie ein Formular aus der Formulare-Sammlung', + }, + }, { name: 'headline', type: 'text', @@ -21,28 +31,44 @@ export const ContactFormBlock: Block = { localized: true, }, { - name: 'recipientEmail', - type: 'email', - defaultValue: 'info@porwoll.de', - label: 'Empfänger E-Mail', + name: 'successMessage', + type: 'textarea', + defaultValue: 'Vielen Dank! Wir melden uns schnellstmöglich.', + label: 'Erfolgsmeldung', + localized: true, + }, + { + name: 'showContactInfo', + type: 'checkbox', + defaultValue: true, + label: 'Kontaktinformationen anzeigen', }, { name: 'showPhone', type: 'checkbox', defaultValue: true, label: 'Telefon anzeigen', + admin: { + condition: (_, siblingData) => siblingData?.showContactInfo, + }, }, { name: 'showAddress', type: 'checkbox', defaultValue: true, label: 'Adresse anzeigen', + admin: { + condition: (_, siblingData) => siblingData?.showContactInfo, + }, }, { name: 'showSocials', type: 'checkbox', defaultValue: true, label: 'Social Media anzeigen', + admin: { + condition: (_, siblingData) => siblingData?.showContactInfo, + }, }, ], } diff --git a/src/collections/FormSubmissionsOverrides.ts b/src/collections/FormSubmissionsOverrides.ts index aa68c9d..ffe6545 100644 --- a/src/collections/FormSubmissionsOverrides.ts +++ b/src/collections/FormSubmissionsOverrides.ts @@ -24,6 +24,17 @@ export const formSubmissionOverrides: Partial = { plural: 'Formular-Einsendungen', }, fields: [ + // Tenant (automatisch vom Formular übernommen via setSubmissionTenant Hook) + { + name: 'tenant', + type: 'relationship', + relationTo: 'tenants', + admin: { + position: 'sidebar', + readOnly: true, + description: 'Wird automatisch vom Formular übernommen', + }, + }, // Status-Workflow { name: 'status', diff --git a/src/hooks/setSubmissionTenant.ts b/src/hooks/setSubmissionTenant.ts new file mode 100644 index 0000000..34373b6 --- /dev/null +++ b/src/hooks/setSubmissionTenant.ts @@ -0,0 +1,39 @@ +import type { CollectionBeforeChangeHook } from 'payload' + +interface FormWithTenant { + id: number | string + tenant?: { id: number } | number | null +} + +/** + * Hook: Kopiert den Tenant vom übergeordneten Formular auf die Einsendung. + * Wird als beforeChange auf form-submissions ausgeführt. + */ +export const setSubmissionTenant: CollectionBeforeChangeHook = async ({ + data, + req, + operation, +}) => { + // Nur bei neuen Einsendungen den Tenant setzen + if (operation !== 'create') return data + + const formId = data.form + if (!formId) return data + + try { + const form = (await req.payload.findByID({ + collection: 'forms', + id: formId, + depth: 0, + })) as unknown as FormWithTenant + + if (form?.tenant) { + const tenantId = typeof form.tenant === 'object' ? form.tenant.id : form.tenant + return { ...data, tenant: tenantId } + } + } catch (error) { + console.error('[Forms] Error reading tenant from form:', error) + } + + return data +} diff --git a/src/migrations/20260217_120000_add_tenant_to_forms.ts b/src/migrations/20260217_120000_add_tenant_to_forms.ts new file mode 100644 index 0000000..047852a --- /dev/null +++ b/src/migrations/20260217_120000_add_tenant_to_forms.ts @@ -0,0 +1,81 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db }: MigrateUpArgs): Promise { + // 1. Add tenant_id to forms + await db.execute(sql` + ALTER TABLE "forms" + ADD COLUMN IF NOT EXISTS "tenant_id" integer REFERENCES tenants(id) ON DELETE SET NULL; + `) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS "forms_tenant_idx" ON "forms" ("tenant_id"); + `) + + // 2. Add tenant_id to form_submissions + await db.execute(sql` + ALTER TABLE "form_submissions" + ADD COLUMN IF NOT EXISTS "tenant_id" integer REFERENCES tenants(id) ON DELETE SET NULL; + `) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS "form_submissions_tenant_idx" ON "form_submissions" ("tenant_id"); + `) + + // 3. ContactFormBlock: add form_id relationship column (replaces recipientEmail) + await db.execute(sql` + ALTER TABLE "pages_blocks_contact_form_block" + ADD COLUMN IF NOT EXISTS "form_id" integer REFERENCES forms(id) ON DELETE SET NULL, + ADD COLUMN IF NOT EXISTS "show_contact_info" boolean DEFAULT true; + `) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS "pages_blocks_contact_form_block_form_idx" + ON "pages_blocks_contact_form_block" ("form_id"); + `) + + // 4. ContactFormBlock locales: add successMessage + await db.execute(sql` + ALTER TABLE "pages_blocks_contact_form_block_locales" + ADD COLUMN IF NOT EXISTS "success_message" varchar; + `) + + // 5. pages_rels: add forms_id column for the block relationship + await db.execute(sql` + ALTER TABLE "pages_rels" + ADD COLUMN IF NOT EXISTS "forms_id" integer REFERENCES forms(id) ON DELETE CASCADE; + `) + await db.execute(sql` + CREATE INDEX IF NOT EXISTS "pages_rels_forms_idx" ON "pages_rels" ("forms_id"); + `) + + // 6. Drop old recipientEmail column (data migrated: it was just a default 'info@porwoll.de') + await db.execute(sql` + ALTER TABLE "pages_blocks_contact_form_block" + DROP COLUMN IF EXISTS "recipient_email"; + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + // Restore recipientEmail + await db.execute(sql` + ALTER TABLE "pages_blocks_contact_form_block" + ADD COLUMN IF NOT EXISTS "recipient_email" varchar DEFAULT 'info@porwoll.de'; + `) + + // Remove new columns + await db.execute(sql` + ALTER TABLE "pages_blocks_contact_form_block" + DROP COLUMN IF EXISTS "form_id", + DROP COLUMN IF EXISTS "show_contact_info"; + `) + await db.execute(sql` + ALTER TABLE "pages_blocks_contact_form_block_locales" + DROP COLUMN IF EXISTS "success_message"; + `) + await db.execute(sql` + ALTER TABLE "pages_rels" DROP COLUMN IF EXISTS "forms_id"; + `) + await db.execute(sql` + ALTER TABLE "forms" DROP COLUMN IF EXISTS "tenant_id"; + `) + await db.execute(sql` + ALTER TABLE "form_submissions" DROP COLUMN IF EXISTS "tenant_id"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index 81364b7..9e91be6 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -37,6 +37,7 @@ import * as migration_20260116_100000_add_token_notification_fields from './2026 import * as migration_20260116_120000_add_report_schedules from './20260116_120000_add_report_schedules'; import * as migration_20260215_120000_add_monitoring_collections from './20260215_120000_add_monitoring_collections'; import * as migration_20260216_150000_add_card_grid_icon_fields from './20260216_150000_add_card_grid_icon_fields'; +import * as migration_20260217_120000_add_tenant_to_forms from './20260217_120000_add_tenant_to_forms'; export const migrations = [ { @@ -234,4 +235,9 @@ export const migrations = [ down: migration_20260216_150000_add_card_grid_icon_fields.down, name: '20260216_150000_add_card_grid_icon_fields' }, + { + up: migration_20260217_120000_add_tenant_to_forms.up, + down: migration_20260217_120000_add_tenant_to_forms.down, + name: '20260217_120000_add_tenant_to_forms' + }, ]; diff --git a/src/payload-types.ts b/src/payload-types.ts index a64ee21..bdde704 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -846,9 +846,14 @@ export interface Page { blockType: 'cta-block'; } | { + /** + * Wählen Sie ein Formular aus der Formulare-Sammlung + */ + form: number | Form; headline?: string | null; description?: string | null; - recipientEmail?: string | null; + successMessage?: string | null; + showContactInfo?: boolean | null; showPhone?: boolean | null; showAddress?: boolean | null; showSocials?: boolean | null; @@ -3076,6 +3081,168 @@ export interface Page { updatedAt: string; createdAt: string; } +/** + * This interface was referenced by `Config`'s JSON-Schema + * via the `definition` "forms". + */ +export interface Form { + id: number; + title: string; + fields?: + | ( + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + defaultValue?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'checkbox'; + } + | { + name: string; + label?: string | null; + width?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'email'; + } + | { + message?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + id?: string | null; + blockName?: string | null; + blockType: 'message'; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: number | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'number'; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + placeholder?: string | null; + options?: + | { + label: string; + value: string; + id?: string | null; + }[] + | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'select'; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'text'; + } + | { + name: string; + label?: string | null; + width?: number | null; + defaultValue?: string | null; + required?: boolean | null; + id?: string | null; + blockName?: string | null; + blockType: 'textarea'; + } + )[] + | null; + submitButtonLabel?: string | null; + /** + * Choose whether to display an on-page message or redirect to a different page after they submit the form. + */ + confirmationType?: ('message' | 'redirect') | null; + confirmationMessage?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + redirect?: { + type?: ('reference' | 'custom') | null; + reference?: { + relationTo: 'pages'; + value: number | Page; + } | null; + url?: string | null; + }; + /** + * Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email. + */ + emails?: + | { + emailTo?: string | null; + cc?: string | null; + bcc?: string | null; + replyTo?: string | null; + emailFrom?: string | null; + subject: string; + /** + * Enter the message that should be sent in this email. + */ + message?: { + root: { + type: string; + children: { + type: any; + version: number; + [k: string]: unknown; + }[]; + direction: ('ltr' | 'rtl') | null; + format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; + indent: number; + version: number; + }; + [k: string]: unknown; + } | null; + id?: string | null; + }[] + | null; + tenant: number | Tenant; + updatedAt: string; + createdAt: string; +} /** * Video-Bibliothek für YouTube/Vimeo Embeds und hochgeladene Videos * @@ -4437,167 +4604,6 @@ export interface Job { updatedAt: string; createdAt: string; } -/** - * This interface was referenced by `Config`'s JSON-Schema - * via the `definition` "forms". - */ -export interface Form { - id: number; - title: string; - fields?: - | ( - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - defaultValue?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'checkbox'; - } - | { - name: string; - label?: string | null; - width?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'email'; - } - | { - message?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; - id?: string | null; - blockName?: string | null; - blockType: 'message'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: number | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'number'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - placeholder?: string | null; - options?: - | { - label: string; - value: string; - id?: string | null; - }[] - | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'select'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'text'; - } - | { - name: string; - label?: string | null; - width?: number | null; - defaultValue?: string | null; - required?: boolean | null; - id?: string | null; - blockName?: string | null; - blockType: 'textarea'; - } - )[] - | null; - submitButtonLabel?: string | null; - /** - * Choose whether to display an on-page message or redirect to a different page after they submit the form. - */ - confirmationType?: ('message' | 'redirect') | null; - confirmationMessage?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; - redirect?: { - type?: ('reference' | 'custom') | null; - reference?: { - relationTo: 'pages'; - value: number | Page; - } | null; - url?: string | null; - }; - /** - * Send custom emails when the form submits. Use comma separated lists to send the same email to multiple recipients. To reference a value from this form, wrap that field's name with double curly brackets, i.e. {{firstName}}. You can use a wildcard {{*}} to output all data and {{*:table}} to format it as an HTML table in the email. - */ - emails?: - | { - emailTo?: string | null; - cc?: string | null; - bcc?: string | null; - replyTo?: string | null; - emailFrom?: string | null; - subject: string; - /** - * Enter the message that should be sent in this email. - */ - message?: { - root: { - type: string; - children: { - type: any; - version: number; - [k: string]: unknown; - }[]; - direction: ('ltr' | 'rtl') | null; - format: 'left' | 'start' | 'center' | 'right' | 'end' | 'justify' | ''; - indent: number; - version: number; - }; - [k: string]: unknown; - } | null; - id?: string | null; - }[] - | null; - updatedAt: string; - createdAt: string; -} /** * Produkte und Artikel * @@ -7559,6 +7565,24 @@ export interface MonitoringSnapshot { | number | boolean | null; + secrets?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; + securityEvents?: + | { + [k: string]: unknown; + } + | unknown[] + | string + | number + | boolean + | null; }; /** * Performance-Metriken @@ -7582,7 +7606,7 @@ export interface MonitoringSnapshot { export interface MonitoringLog { id: number; level: 'debug' | 'info' | 'warn' | 'error' | 'fatal'; - source: 'payload' | 'queue-worker' | 'cron' | 'email' | 'oauth' | 'sync'; + source: 'payload' | 'queue-worker' | 'cron' | 'email' | 'oauth' | 'sync' | 'security'; message: string; /** * Strukturierte Metadaten @@ -8597,9 +8621,11 @@ export interface PagesSelect { 'contact-form-block'?: | T | { + form?: T; headline?: T; description?: T; - recipientEmail?: T; + successMessage?: T; + showContactInfo?: T; showPhone?: T; showAddress?: T; showSocials?: T; @@ -12580,6 +12606,8 @@ export interface MonitoringSnapshotsSelect { metaOAuth?: T; youtubeOAuth?: T; cronJobs?: T; + secrets?: T; + securityEvents?: T; }; performance?: | T @@ -12855,6 +12883,7 @@ export interface FormsSelect { message?: T; id?: T; }; + tenant?: T; updatedAt?: T; createdAt?: T; } diff --git a/src/payload.config.ts b/src/payload.config.ts index 997d7cd..6455abf 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -107,6 +107,7 @@ import { SEOSettings } from './globals/SEOSettings' // Hooks import { sendFormNotification } from './hooks/sendFormNotification' import { formSubmissionBeforeChange } from './hooks/formSubmissionHooks' +import { setSubmissionTenant } from './hooks/setSubmissionTenant' // Form Submissions Overrides import { formSubmissionOverrides } from './collections/FormSubmissionsOverrides' @@ -298,6 +299,50 @@ export default buildConfig({ // Sharp für Bildoptimierung sharp, plugins: [ + // formBuilderPlugin MUSS vor multiTenantPlugin stehen, da es die forms/form-submissions + // Collections erstellt, die multiTenantPlugin dann mit Tenant-Scoping erweitert. + formBuilderPlugin({ + fields: { + text: true, + textarea: true, + select: true, + email: true, + state: false, + country: false, + checkbox: true, + number: true, + message: true, + payment: false, + }, + // Fix für TypeScript Types Generation - das Plugin braucht explizite relationTo Angaben + redirectRelationships: ['pages'], + formOverrides: { + admin: { + group: 'Formulare', + }, + labels: { + singular: 'Formular', + plural: 'Formulare', + }, + fields: ({ defaultFields }) => [ + ...defaultFields, + { + name: 'tenant', + type: 'relationship', + relationTo: 'tenants', + required: true, + admin: { position: 'sidebar' }, + }, + ], + }, + formSubmissionOverrides: { + ...(formSubmissionOverrides as Record), + hooks: { + beforeChange: [setSubmissionTenant, formSubmissionBeforeChange], + afterChange: [sendFormNotification], + }, + } as Parameters[0]['formSubmissionOverrides'], + }), multiTenantPlugin({ tenantsSlug: 'tenants', collections: { @@ -344,6 +389,9 @@ export default buildConfig({ series: {}, // Debug: Minimal test collection - DISABLED // 'test-minimal': {}, + // Form Builder Plugin Collections - customTenantField: true weil tenant via formOverrides injiziert wird + forms: { customTenantField: true }, + 'form-submissions': { customTenantField: true }, // Consent Management Collections - customTenantField: true weil sie bereits ein tenant-Feld haben 'cookie-configurations': { customTenantField: true }, 'cookie-inventory': { customTenantField: true }, @@ -386,29 +434,6 @@ export default buildConfig({ generateLabel: (_, doc) => doc.title as string, generateURL: (docs) => docs.reduce((url, doc) => `${url}/${doc.slug}`, ''), }), - formBuilderPlugin({ - fields: { - text: true, - textarea: true, - select: true, - email: true, - state: false, - country: false, - checkbox: true, - number: true, - message: true, - payment: false, - }, - // Fix für TypeScript Types Generation - das Plugin braucht explizite relationTo Angaben - redirectRelationships: ['pages'], - formSubmissionOverrides: { - ...(formSubmissionOverrides as Record), - hooks: { - beforeChange: [formSubmissionBeforeChange], - afterChange: [sendFormNotification], - }, - } as Parameters[0]['formSubmissionOverrides'], - }), redirectsPlugin({ collections: ['pages'], overrides: {