feat: enhance FormSubmissions with workflow and tracking

- 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 <noreply@anthropic.com>
This commit is contained in:
Martin Porwoll 2025-12-10 09:13:06 +00:00
parent 8868a5be30
commit 17eb46a787
6 changed files with 476 additions and 2 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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