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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2026-02-17 16:22:58 +00:00
parent 130ab46ffb
commit 5e223cd7fb
7 changed files with 408 additions and 191 deletions

View file

@ -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,
},
},
],
}

View file

@ -24,6 +24,17 @@ export const formSubmissionOverrides: Partial<CollectionConfig> = {
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',

View file

@ -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
}

View file

@ -0,0 +1,81 @@
import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres'
export async function up({ db }: MigrateUpArgs): Promise<void> {
// 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<void> {
// 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";
`)
}

View file

@ -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'
},
];

View file

@ -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<T extends boolean = true> {
'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<T extends boolean = true> {
metaOAuth?: T;
youtubeOAuth?: T;
cronJobs?: T;
secrets?: T;
securityEvents?: T;
};
performance?:
| T
@ -12855,6 +12883,7 @@ export interface FormsSelect<T extends boolean = true> {
message?: T;
id?: T;
};
tenant?: T;
updatedAt?: T;
createdAt?: T;
}

View file

@ -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<string, unknown>),
hooks: {
beforeChange: [setSubmissionTenant, formSubmissionBeforeChange],
afterChange: [sendFormNotification],
},
} as Parameters<typeof formBuilderPlugin>[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<string, unknown>),
hooks: {
beforeChange: [formSubmissionBeforeChange],
afterChange: [sendFormNotification],
},
} as Parameters<typeof formBuilderPlugin>[0]['formSubmissionOverrides'],
}),
redirectsPlugin({
collections: ['pages'],
overrides: {