From 17eb46a7872dad161b7f709472f0f21e3810dec0 Mon Sep 17 00:00:00 2001 From: Martin Porwoll Date: Wed, 10 Dec 2025 09:13:06 +0000 Subject: [PATCH] feat: enhance FormSubmissions with workflow and tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add status workflow: new → read → in-progress → waiting → completed → archived - Add priority levels (high, normal, low) - Add assignedTo field for team member assignment - Add internal notes array with author and timestamp - Add response tracking (responded, method, summary) - Add tags for categorization - Auto-mark as read on first view - Auto-set note author and timestamp - Improved admin view with better columns - Update documentation 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CLAUDE.md | 2 +- src/collections/FormSubmissionsOverrides.ts | 215 ++++++++++++++++++ src/hooks/formSubmissionHooks.ts | 132 +++++++++++ ...0251210_090000_enhance_form_submissions.ts | 115 ++++++++++ src/migrations/index.ts | 8 +- src/payload.config.ts | 6 + 6 files changed, 476 insertions(+), 2 deletions(-) create mode 100644 src/collections/FormSubmissionsOverrides.ts create mode 100644 src/hooks/formSubmissionHooks.ts create mode 100644 src/migrations/20251210_090000_enhance_form_submissions.ts diff --git a/CLAUDE.md b/CLAUDE.md index a155837..7e9363d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -411,7 +411,7 @@ SELECT * FROM audit_logs ORDER BY created_at DESC LIMIT 10; | NewsletterSubscribers | newsletter-subscribers | Newsletter mit Double Opt-In | | SocialLinks | social-links | Social Media Links | | Forms | forms | Formular-Builder | -| FormSubmissions | form-submissions | Formular-Einsendungen | +| FormSubmissions | form-submissions | Formular-Einsendungen mit Status-Workflow | | EmailLogs | email-logs | E-Mail-Protokollierung | | AuditLogs | audit-logs | Security Audit Trail | | CookieConfigurations | cookie-configurations | Cookie-Banner Konfiguration | diff --git a/src/collections/FormSubmissionsOverrides.ts b/src/collections/FormSubmissionsOverrides.ts new file mode 100644 index 0000000..aa68c9d --- /dev/null +++ b/src/collections/FormSubmissionsOverrides.ts @@ -0,0 +1,215 @@ +// src/collections/FormSubmissionsOverrides.ts + +import type { CollectionConfig } from 'payload' + +/** + * FormSubmissions Overrides + * + * Erweitert die vom formBuilderPlugin erstellte FormSubmissions Collection um: + * - Status-Workflow (neu → in Bearbeitung → erledigt) + * - Interne Notizen + * - Zuständigkeits-Zuweisung + * - Verbesserte Admin-Ansicht + */ +export const formSubmissionOverrides: Partial = { + admin: { + useAsTitle: 'id', + group: 'Formulare', + defaultColumns: ['id', 'form', 'status', 'assignedTo', 'createdAt'], + description: 'Eingegangene Formular-Einsendungen', + listSearchableFields: ['id', 'status'], + }, + labels: { + singular: 'Formular-Einsendung', + plural: 'Formular-Einsendungen', + }, + fields: [ + // Status-Workflow + { + name: 'status', + type: 'select', + defaultValue: 'new', + label: 'Status', + options: [ + { label: '🆕 Neu', value: 'new' }, + { label: '👀 Gelesen', value: 'read' }, + { label: '🔄 In Bearbeitung', value: 'in-progress' }, + { label: '⏳ Warten auf Rückmeldung', value: 'waiting' }, + { label: '✅ Erledigt', value: 'completed' }, + { label: '🗑️ Spam/Archiviert', value: 'archived' }, + ], + admin: { + position: 'sidebar', + }, + }, + // Priorität + { + name: 'priority', + type: 'select', + defaultValue: 'normal', + label: 'Priorität', + options: [ + { label: '🔴 Hoch', value: 'high' }, + { label: '🟡 Normal', value: 'normal' }, + { label: '🟢 Niedrig', value: 'low' }, + ], + admin: { + position: 'sidebar', + }, + }, + // Zuständigkeit + { + name: 'assignedTo', + type: 'relationship', + relationTo: 'users', + label: 'Zuständig', + admin: { + position: 'sidebar', + description: 'Wer bearbeitet diese Anfrage?', + }, + }, + // Interne Notizen + { + name: 'internalNotes', + type: 'array', + label: 'Interne Notizen', + admin: { + description: 'Interne Kommunikation zur Anfrage (nicht für Kunden sichtbar)', + initCollapsed: false, + }, + fields: [ + { + name: 'note', + type: 'textarea', + required: true, + label: 'Notiz', + }, + { + name: 'author', + type: 'relationship', + relationTo: 'users', + label: 'Autor', + admin: { + readOnly: true, + description: 'Wird automatisch gesetzt', + }, + }, + { + name: 'createdAt', + type: 'date', + label: 'Erstellt am', + admin: { + readOnly: true, + date: { + pickerAppearance: 'dayAndTime', + displayFormat: 'dd.MM.yyyy HH:mm', + }, + }, + }, + ], + }, + // Antwort-Tracking + { + name: 'responseTracking', + type: 'group', + label: 'Antwort-Tracking', + admin: { + description: 'Tracking der Kommunikation mit dem Absender', + }, + fields: [ + { + name: 'responded', + type: 'checkbox', + defaultValue: false, + label: 'Antwort gesendet', + }, + { + name: 'respondedAt', + type: 'date', + label: 'Antwort gesendet am', + admin: { + condition: (data, siblingData) => siblingData?.responded, + date: { + pickerAppearance: 'dayAndTime', + displayFormat: 'dd.MM.yyyy HH:mm', + }, + }, + }, + { + name: 'respondedBy', + type: 'relationship', + relationTo: 'users', + label: 'Antwort gesendet von', + admin: { + condition: (data, siblingData) => siblingData?.responded, + }, + }, + { + name: 'responseMethod', + type: 'select', + label: 'Antwort-Methode', + options: [ + { label: 'E-Mail', value: 'email' }, + { label: 'Telefon', value: 'phone' }, + { label: 'Persönlich', value: 'in-person' }, + { label: 'Brief', value: 'letter' }, + ], + admin: { + condition: (data, siblingData) => siblingData?.responded, + }, + }, + { + name: 'responseSummary', + type: 'textarea', + label: 'Zusammenfassung der Antwort', + admin: { + condition: (data, siblingData) => siblingData?.responded, + description: 'Kurze Zusammenfassung was geantwortet wurde', + }, + }, + ], + }, + // Tags für Kategorisierung + { + name: 'tags', + type: 'array', + label: 'Tags', + admin: { + description: 'Tags zur Kategorisierung (z.B. "Preisanfrage", "Support")', + initCollapsed: true, + }, + fields: [ + { + name: 'tag', + type: 'text', + required: true, + label: 'Tag', + }, + ], + }, + // Gelesen-Markierung + { + name: 'readAt', + type: 'date', + label: 'Gelesen am', + admin: { + position: 'sidebar', + readOnly: true, + date: { + pickerAppearance: 'dayAndTime', + displayFormat: 'dd.MM.yyyy HH:mm', + }, + }, + }, + { + name: 'readBy', + type: 'relationship', + relationTo: 'users', + label: 'Gelesen von', + admin: { + position: 'sidebar', + readOnly: true, + }, + }, + ], +} diff --git a/src/hooks/formSubmissionHooks.ts b/src/hooks/formSubmissionHooks.ts new file mode 100644 index 0000000..0910964 --- /dev/null +++ b/src/hooks/formSubmissionHooks.ts @@ -0,0 +1,132 @@ +// src/hooks/formSubmissionHooks.ts + +import type { + CollectionBeforeChangeHook, + CollectionAfterReadHook, + FieldHook, +} from 'payload' + +interface InternalNote { + note: string + author?: number | string | { id: number | string } + createdAt?: string +} + +interface FormSubmissionDoc { + id: number | string + status?: string + readAt?: string + readBy?: number | string | { id: number | string } + internalNotes?: InternalNote[] + [key: string]: unknown +} + +/** + * Hook: Setzt automatisch den Autor und Zeitstempel bei neuen Notizen + */ +export const setNoteMetadata: CollectionBeforeChangeHook = async ({ + data, + req, + originalDoc, +}) => { + if (!data.internalNotes || !req.user) return data + + const originalNotes = originalDoc?.internalNotes || [] + const newNotes = data.internalNotes || [] + + // Finde neue Notizen (die noch keinen Autor haben) + const updatedNotes = newNotes.map((note, index) => { + const isNew = !note.author && !note.createdAt + const isExisting = originalNotes[index] + + if (isNew || (!isExisting && !note.author)) { + return { + ...note, + author: req.user?.id, + createdAt: new Date().toISOString(), + } + } + + return note + }) + + return { + ...data, + internalNotes: updatedNotes, + } +} + +/** + * Hook: Markiert Einsendung als gelesen beim ersten Öffnen + */ +export const markAsRead: CollectionBeforeChangeHook = async ({ + data, + req, + originalDoc, + operation, +}) => { + // Nur beim Update und wenn noch nicht gelesen + if (operation !== 'update' || !req.user) return data + + // Wenn noch nicht gelesen, jetzt markieren + if (!originalDoc?.readAt) { + return { + ...data, + readAt: new Date().toISOString(), + readBy: req.user.id, + // Setze Status auf "gelesen" wenn noch "neu" + status: originalDoc?.status === 'new' ? 'read' : (data.status || originalDoc?.status), + } + } + + return data +} + +/** + * Hook: Setzt Antwort-Zeitstempel automatisch + */ +export const setResponseTimestamp: CollectionBeforeChangeHook = async ({ + data, + req, + originalDoc, +}) => { + // Prüfe ob "responded" gerade auf true gesetzt wurde + const wasResponded = originalDoc?.responseTracking?.responded + const isNowResponded = data?.responseTracking?.responded + + if (!wasResponded && isNowResponded && req.user) { + return { + ...data, + responseTracking: { + ...data.responseTracking, + respondedAt: new Date().toISOString(), + respondedBy: req.user.id, + }, + } + } + + return data +} + +/** + * Kombinierter BeforeChange Hook + */ +export const formSubmissionBeforeChange: CollectionBeforeChangeHook = async ( + args +) => { + let data = args.data + + // Notiz-Metadaten setzen + const result1 = await setNoteMetadata({ ...args, data }) + data = result1 as FormSubmissionDoc + + // Als gelesen markieren + const result2 = await markAsRead({ ...args, data }) + data = result2 as FormSubmissionDoc + + // Antwort-Zeitstempel setzen + const result3 = await setResponseTimestamp({ ...args, data }) + data = result3 as FormSubmissionDoc + + return data +} diff --git a/src/migrations/20251210_090000_enhance_form_submissions.ts b/src/migrations/20251210_090000_enhance_form_submissions.ts new file mode 100644 index 0000000..f307283 --- /dev/null +++ b/src/migrations/20251210_090000_enhance_form_submissions.ts @@ -0,0 +1,115 @@ +import { MigrateUpArgs, MigrateDownArgs, sql } from '@payloadcms/db-postgres' + +export async function up({ db }: MigrateUpArgs): Promise { + await db.execute(sql` + -- Status Enum + CREATE TYPE "public"."enum_form_submissions_status" AS ENUM('new', 'read', 'in-progress', 'waiting', 'completed', 'archived'); + + -- Priority Enum + CREATE TYPE "public"."enum_form_submissions_priority" AS ENUM('high', 'normal', 'low'); + + -- Response Method Enum + CREATE TYPE "public"."enum_form_submissions_response_tracking_response_method" AS ENUM('email', 'phone', 'in-person', 'letter'); + + -- Add new columns to form_submissions + ALTER TABLE "form_submissions" + ADD COLUMN "status" "enum_form_submissions_status" DEFAULT 'new', + ADD COLUMN "priority" "enum_form_submissions_priority" DEFAULT 'normal', + ADD COLUMN "assigned_to_id" integer, + ADD COLUMN "read_at" timestamp(3) with time zone, + ADD COLUMN "read_by_id" integer, + ADD COLUMN "response_tracking_responded" boolean DEFAULT false, + ADD COLUMN "response_tracking_responded_at" timestamp(3) with time zone, + ADD COLUMN "response_tracking_responded_by_id" integer, + ADD COLUMN "response_tracking_response_method" "enum_form_submissions_response_tracking_response_method", + ADD COLUMN "response_tracking_response_summary" varchar; + + -- Create internal notes table + CREATE TABLE "form_submissions_internal_notes" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "note" varchar NOT NULL, + "author_id" integer, + "created_at" timestamp(3) with time zone + ); + + -- Create tags table + CREATE TABLE "form_submissions_tags" ( + "_order" integer NOT NULL, + "_parent_id" integer NOT NULL, + "id" varchar PRIMARY KEY NOT NULL, + "tag" varchar NOT NULL + ); + + -- Add foreign keys + ALTER TABLE "form_submissions" + ADD CONSTRAINT "form_submissions_assigned_to_id_users_id_fk" + FOREIGN KEY ("assigned_to_id") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + + ALTER TABLE "form_submissions" + ADD CONSTRAINT "form_submissions_read_by_id_users_id_fk" + FOREIGN KEY ("read_by_id") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + + ALTER TABLE "form_submissions" + ADD CONSTRAINT "form_submissions_response_tracking_responded_by_id_users_id_fk" + FOREIGN KEY ("response_tracking_responded_by_id") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + + ALTER TABLE "form_submissions_internal_notes" + ADD CONSTRAINT "form_submissions_internal_notes_author_id_users_id_fk" + FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE SET NULL ON UPDATE NO ACTION; + + ALTER TABLE "form_submissions_internal_notes" + ADD CONSTRAINT "form_submissions_internal_notes_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."form_submissions"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + + ALTER TABLE "form_submissions_tags" + ADD CONSTRAINT "form_submissions_tags_parent_id_fk" + FOREIGN KEY ("_parent_id") REFERENCES "public"."form_submissions"("id") ON DELETE CASCADE ON UPDATE NO ACTION; + + -- Create indexes + CREATE INDEX "form_submissions_status_idx" ON "form_submissions" USING btree ("status"); + CREATE INDEX "form_submissions_priority_idx" ON "form_submissions" USING btree ("priority"); + CREATE INDEX "form_submissions_assigned_to_idx" ON "form_submissions" USING btree ("assigned_to_id"); + CREATE INDEX "form_submissions_internal_notes_order_idx" ON "form_submissions_internal_notes" USING btree ("_order"); + CREATE INDEX "form_submissions_internal_notes_parent_id_idx" ON "form_submissions_internal_notes" USING btree ("_parent_id"); + CREATE INDEX "form_submissions_tags_order_idx" ON "form_submissions_tags" USING btree ("_order"); + CREATE INDEX "form_submissions_tags_parent_id_idx" ON "form_submissions_tags" USING btree ("_parent_id"); + `) +} + +export async function down({ db }: MigrateDownArgs): Promise { + await db.execute(sql` + -- Drop tables + DROP TABLE IF EXISTS "form_submissions_tags" CASCADE; + DROP TABLE IF EXISTS "form_submissions_internal_notes" CASCADE; + + -- Drop indexes + DROP INDEX IF EXISTS "form_submissions_status_idx"; + DROP INDEX IF EXISTS "form_submissions_priority_idx"; + DROP INDEX IF EXISTS "form_submissions_assigned_to_idx"; + + -- Drop foreign keys and columns + ALTER TABLE "form_submissions" + DROP CONSTRAINT IF EXISTS "form_submissions_assigned_to_id_users_id_fk", + DROP CONSTRAINT IF EXISTS "form_submissions_read_by_id_users_id_fk", + DROP CONSTRAINT IF EXISTS "form_submissions_response_tracking_responded_by_id_users_id_fk"; + + ALTER TABLE "form_submissions" + DROP COLUMN IF EXISTS "status", + DROP COLUMN IF EXISTS "priority", + DROP COLUMN IF EXISTS "assigned_to_id", + DROP COLUMN IF EXISTS "read_at", + DROP COLUMN IF EXISTS "read_by_id", + DROP COLUMN IF EXISTS "response_tracking_responded", + DROP COLUMN IF EXISTS "response_tracking_responded_at", + DROP COLUMN IF EXISTS "response_tracking_responded_by_id", + DROP COLUMN IF EXISTS "response_tracking_response_method", + DROP COLUMN IF EXISTS "response_tracking_response_summary"; + + -- Drop enums + DROP TYPE IF EXISTS "public"."enum_form_submissions_status"; + DROP TYPE IF EXISTS "public"."enum_form_submissions_priority"; + DROP TYPE IF EXISTS "public"."enum_form_submissions_response_tracking_response_method"; + `) +} diff --git a/src/migrations/index.ts b/src/migrations/index.ts index a13bbfd..829e0ee 100644 --- a/src/migrations/index.ts +++ b/src/migrations/index.ts @@ -7,6 +7,7 @@ import * as migration_20251207_205727_audit_logs_collection from './20251207_205 import * as migration_20251210_052757_add_faqs_collection from './20251210_052757_add_faqs_collection'; import * as migration_20251210_071506_add_team_collection from './20251210_071506_add_team_collection'; import * as migration_20251210_073811_add_services_collections from './20251210_073811_add_services_collections'; +import * as migration_20251210_090000_enhance_form_submissions from './20251210_090000_enhance_form_submissions'; export const migrations = [ { @@ -52,6 +53,11 @@ export const migrations = [ { up: migration_20251210_073811_add_services_collections.up, down: migration_20251210_073811_add_services_collections.down, - name: '20251210_073811_add_services_collections' + name: '20251210_073811_add_services_collections', + }, + { + up: migration_20251210_090000_enhance_form_submissions.up, + down: migration_20251210_090000_enhance_form_submissions.down, + name: '20251210_090000_enhance_form_submissions', }, ]; diff --git a/src/payload.config.ts b/src/payload.config.ts index ae06061..57dff94 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -45,6 +45,10 @@ import { SEOSettings } from './globals/SEOSettings' // Hooks import { sendFormNotification } from './hooks/sendFormNotification' +import { formSubmissionBeforeChange } from './hooks/formSubmissionHooks' + +// Form Submissions Overrides +import { formSubmissionOverrides } from './collections/FormSubmissionsOverrides' // Email import { multiTenantEmailAdapter } from './lib/email/payload-email-adapter' @@ -240,7 +244,9 @@ export default buildConfig({ // Fix für TypeScript Types Generation - das Plugin braucht explizite relationTo Angaben redirectRelationships: ['pages'], formSubmissionOverrides: { + ...formSubmissionOverrides, hooks: { + beforeChange: [formSubmissionBeforeChange], afterChange: [sendFormNotification], }, },