Files
bmp-website-2026/src/migrations/20260713_133000_newsletter_form_and_subscriptions.ts
syntaxbullet bd2e99ca91 feat: implement newsletter subscription feature with new forms and database structure
- Added new newsletter subscription functionality to the participation page.
- Created a new `NewsletterForm` component to handle user subscriptions.
- Introduced `NewsletterSubscriptions` collection to manage subscriptions in the database.
- Developed migration script to set up necessary database tables and fields for the newsletter feature.
- Refactored participation content to remove deprecated newsletter phase data and integrate new structure.
- Updated the `Participation` page to utilize the new newsletter form and handle different application phases.
2026-07-13 13:37:03 +02:00

344 lines
15 KiB
TypeScript

import { type MigrateDownArgs, type MigrateUpArgs, sql } from '@payloadcms/db-sqlite'
import { defaultNewsletterFormData, type NewsletterInterestCopy } from '@/spa/newsletterForm'
type MigrationDB = MigrateUpArgs['db']
const ident = (value: string) => `\`${value.replace(/`/g, '``')}\``
const literal = (value: string) => `'${value.replace(/'/g, "''")}'`
const snake = (value: string) => value.replace(/[A-Z]/g, (letter) => `_${letter.toLowerCase()}`)
const scalarFields = [
'eyebrow',
'heading',
'body',
'formEyebrow',
'formTitle',
'formBody',
'emailLabel',
'emailPlaceholder',
'submitLabel',
'privacy',
'validationRequired',
'validationInvalid',
'successHeading',
'successBody',
'footnote',
'footnoteStrong',
] as const
const sharedScalarFields = [...scalarFields, 'submissionError'] as const
const pagePrefixes = [
'home_application_newsletter_phase_evaluation',
'home_application_newsletter_phase_completed',
'participation_application_form_newsletter_phase_evaluation',
'participation_application_form_newsletter_phase_completed',
] as const
const legacyBenefitTables = [
'home_news_eval_bens',
'home_news_done_bens',
'part_news_eval_bens',
'part_news_done_bens',
'_home_news_eval_bens_v',
'_home_news_done_bens_v',
'_part_news_eval_bens_v',
'_part_news_done_bens_v',
] as const
async function tableExists(db: MigrationDB, tableName: string) {
const rows = (await db.all(
sql.raw(`SELECT name FROM sqlite_master WHERE type = 'table' AND name = ${literal(tableName)}`),
)) as Array<{ name: string }>
return rows.length > 0
}
async function columnExists(db: MigrationDB, tableName: string, columnName: string) {
if (!(await tableExists(db, tableName))) return false
const columns = (await db.all(sql.raw(`PRAGMA table_info(${ident(tableName)})`))) as Array<{ name: string }>
return columns.some((column) => column.name === columnName)
}
async function addColumn(db: MigrationDB, tableName: string, columnName: string, definition = 'text') {
if (await columnExists(db, tableName, columnName)) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} ADD ${ident(columnName)} ${definition};`))
}
async function dropColumn(db: MigrationDB, tableName: string, columnName: string) {
if (!(await columnExists(db, tableName, columnName))) return
await db.run(sql.raw(`ALTER TABLE ${ident(tableName)} DROP COLUMN ${ident(columnName)};`))
}
function globalColumn(phase: 'evaluation' | 'completed', field: (typeof sharedScalarFields)[number]) {
return `${phase}_${snake(field)}`
}
function pageColumn(prefix: (typeof pagePrefixes)[number], field: (typeof scalarFields)[number]) {
return `${prefix}_${snake(field)}`
}
function globalTableColumns() {
const definitions: string[] = ['`id` integer PRIMARY KEY NOT NULL']
for (const phase of ['evaluation', 'completed'] as const) {
const defaults = defaultNewsletterFormData[phase]
for (const field of sharedScalarFields) {
definitions.push(`${ident(globalColumn(phase, field))} text DEFAULT ${literal(defaults[field])}`)
}
}
definitions.push('`updated_at` text', '`created_at` text')
return definitions.join(',\n')
}
async function createBenefitTable(db: MigrationDB, tableName: string, parentTable: string, idType: 'text' | 'integer') {
const versionUUID = parentTable === '_pages_v' ? ',\n `_uuid` text' : ''
await db.run(sql.raw(`
CREATE TABLE IF NOT EXISTS ${ident(tableName)} (
\`_order\` integer NOT NULL,
\`_parent_id\` integer NOT NULL,
\`id\` ${idType} PRIMARY KEY NOT NULL,
\`num\` text,
\`label\` text,
\`desc\` text${versionUUID},
FOREIGN KEY (\`_parent_id\`) REFERENCES ${ident(parentTable)}(\`id\`) ON UPDATE no action ON DELETE cascade
);
`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_order_idx`)} ON ${ident(tableName)} (\`_order\`);`))
await db.run(sql.raw(`CREATE INDEX IF NOT EXISTS ${ident(`${tableName}_parent_id_idx`)} ON ${ident(tableName)} (\`_parent_id\`);`))
}
async function seedGlobalScalarsFromHome(db: MigrationDB) {
await db.run(sql.raw(`
INSERT OR IGNORE INTO \`newsletter_form\` (\`id\`, \`updated_at\`, \`created_at\`)
VALUES (1, strftime('%Y-%m-%dT%H:%M:%fZ', 'now'), strftime('%Y-%m-%dT%H:%M:%fZ', 'now'));
`))
if (!(await columnExists(db, 'pages', pageColumn(pagePrefixes[0], 'eyebrow')))) return
const assignments: string[] = []
for (const phase of ['evaluation', 'completed'] as const) {
const prefix = phase === 'evaluation' ? pagePrefixes[0] : pagePrefixes[1]
for (const field of scalarFields) {
const target = globalColumn(phase, field)
const source = pageColumn(prefix, field)
assignments.push(
`${ident(target)} = COALESCE((SELECT ${ident(source)} FROM \`pages\` WHERE \`spa_path\` = '/' OR \`slug\` IN ('startseite', 'home') ORDER BY CASE WHEN \`spa_path\` = '/' THEN 0 ELSE 1 END LIMIT 1), ${ident(target)})`,
)
}
}
await db.run(sql.raw(`UPDATE \`newsletter_form\` SET ${assignments.join(',\n')} WHERE \`id\` = 1;`))
}
async function seedBenefits(db: MigrationDB, phase: 'evaluation' | 'completed') {
const target = phase === 'evaluation' ? 'newsletter_eval_bens' : 'newsletter_done_bens'
const source = phase === 'evaluation' ? 'home_news_eval_bens' : 'home_news_done_bens'
const existingCount = (await db.all(sql.raw(`SELECT COUNT(*) AS count FROM ${ident(target)} WHERE \`_parent_id\` = 1;`))) as Array<{
count: number
}>
if (Number(existingCount[0]?.count || 0) > 0) return
if (await tableExists(db, source)) {
await db.run(sql.raw(`
INSERT OR IGNORE INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, \`num\`, \`label\`, \`desc\`)
SELECT benefits.\`_order\`, 1, lower(hex(randomblob(16))), benefits.\`num\`, benefits.\`label\`, benefits.\`desc\`
FROM ${ident(source)} benefits
INNER JOIN \`pages\` page ON page.\`id\` = benefits.\`_parent_id\`
WHERE page.\`spa_path\` = '/' OR page.\`slug\` IN ('startseite', 'home')
ORDER BY benefits.\`_order\`;
`))
}
const count = (await db.all(sql.raw(`SELECT COUNT(*) AS count FROM ${ident(target)} WHERE \`_parent_id\` = 1;`))) as Array<{
count: number
}>
if (Number(count[0]?.count || 0) > 0) return
for (const [index, benefit] of defaultNewsletterFormData[phase].benefits.entries()) {
await db.run(sql.raw(`
INSERT INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, \`num\`, \`label\`, \`desc\`)
VALUES (${index + 1}, 1, lower(hex(randomblob(16))), ${literal(benefit.num)}, ${literal(benefit.label)}, ${literal(benefit.desc)});
`))
}
}
async function removeLegacyPageFields(db: MigrationDB) {
for (const prefix of pagePrefixes) {
for (const field of scalarFields) {
await dropColumn(db, 'pages', pageColumn(prefix, field))
await dropColumn(db, '_pages_v', `version_${pageColumn(prefix, field)}`)
}
}
for (const tableName of legacyBenefitTables) {
await db.run(sql.raw(`DROP TABLE IF EXISTS ${ident(tableName)};`))
}
}
async function restoreLegacyPageFields(db: MigrationDB) {
for (const prefix of pagePrefixes) {
for (const field of scalarFields) {
await addColumn(db, 'pages', pageColumn(prefix, field))
await addColumn(db, '_pages_v', `version_${pageColumn(prefix, field)}`)
}
}
await createBenefitTable(db, 'home_news_eval_bens', 'pages', 'text')
await createBenefitTable(db, 'home_news_done_bens', 'pages', 'text')
await createBenefitTable(db, 'part_news_eval_bens', 'pages', 'text')
await createBenefitTable(db, 'part_news_done_bens', 'pages', 'text')
await createBenefitTable(db, '_home_news_eval_bens_v', '_pages_v', 'integer')
await createBenefitTable(db, '_home_news_done_bens_v', '_pages_v', 'integer')
await createBenefitTable(db, '_part_news_eval_bens_v', '_pages_v', 'integer')
await createBenefitTable(db, '_part_news_done_bens_v', '_pages_v', 'integer')
if (!(await tableExists(db, 'newsletter_form'))) return
for (const [pageKind, pageFilter] of [
['home', "`spa_path` = '/' OR `slug` IN ('startseite', 'home')"],
['participation', "`spa_path` = '/teilnahme' OR `slug` IN ('teilnahme', 'participation')"],
] as const) {
const assignments: string[] = []
for (const phase of ['evaluation', 'completed'] as const) {
const prefix = pageKind === 'home'
? phase === 'evaluation' ? pagePrefixes[0] : pagePrefixes[1]
: phase === 'evaluation' ? pagePrefixes[2] : pagePrefixes[3]
for (const field of scalarFields) {
assignments.push(`${ident(pageColumn(prefix, field))} = (SELECT ${ident(globalColumn(phase, field))} FROM \`newsletter_form\` WHERE \`id\` = 1)`)
}
}
await db.run(sql.raw(`UPDATE \`pages\` SET ${assignments.join(',\n')} WHERE ${pageFilter};`))
}
for (const [target, source, filter] of [
['home_news_eval_bens', 'newsletter_eval_bens', "page.`spa_path` = '/' OR page.`slug` IN ('startseite', 'home')"],
['home_news_done_bens', 'newsletter_done_bens', "page.`spa_path` = '/' OR page.`slug` IN ('startseite', 'home')"],
['part_news_eval_bens', 'newsletter_eval_bens', "page.`spa_path` = '/teilnahme' OR page.`slug` IN ('teilnahme', 'participation')"],
['part_news_done_bens', 'newsletter_done_bens', "page.`spa_path` = '/teilnahme' OR page.`slug` IN ('teilnahme', 'participation')"],
] as const) {
await db.run(sql.raw(`
INSERT INTO ${ident(target)} (\`_order\`, \`_parent_id\`, \`id\`, \`num\`, \`label\`, \`desc\`)
SELECT benefit.\`_order\`, page.\`id\`, lower(hex(randomblob(16))), benefit.\`num\`, benefit.\`label\`, benefit.\`desc\`
FROM ${ident(source)} benefit CROSS JOIN \`pages\` page
WHERE ${filter};
`))
}
}
async function rebuildLockedDocumentRelations(db: MigrationDB, includeNewsletter: boolean) {
const tableName = 'payload_locked_documents_rels'
if (!(await tableExists(db, tableName))) return
const newsletterColumn = includeNewsletter ? ',\n `newsletter_subscriptions_id` integer' : ''
const newsletterForeignKey = includeNewsletter
? ',\n FOREIGN KEY (`newsletter_subscriptions_id`) REFERENCES `newsletter_subscriptions`(`id`) ON UPDATE no action ON DELETE cascade'
: ''
const existingNewsletterColumn = await columnExists(db, tableName, 'newsletter_subscriptions_id')
const commonColumns = [
'id',
'order',
'parent_id',
'path',
'pages_id',
'preistraeger_id',
'jury_mitglieder_id',
'partners_id',
'posts_id',
'events_id',
...(includeNewsletter && existingNewsletterColumn ? ['newsletter_subscriptions_id'] : []),
'media_id',
'users_id',
'payload_folders_id',
]
await db.run(sql.raw('DROP TABLE IF EXISTS `__new_payload_locked_documents_rels`;'))
await db.run(sql.raw(`
CREATE TABLE \`__new_payload_locked_documents_rels\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`order\` integer,
\`parent_id\` integer NOT NULL,
\`path\` text NOT NULL,
\`pages_id\` integer,
\`preistraeger_id\` integer,
\`jury_mitglieder_id\` integer,
\`partners_id\` integer,
\`posts_id\` integer,
\`events_id\` integer${newsletterColumn},
\`media_id\` integer,
\`users_id\` integer,
\`payload_folders_id\` integer,
FOREIGN KEY (\`parent_id\`) REFERENCES \`payload_locked_documents\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`pages_id\`) REFERENCES \`pages\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`preistraeger_id\`) REFERENCES \`preistraeger\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`jury_mitglieder_id\`) REFERENCES \`jury_mitglieder\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`partners_id\`) REFERENCES \`partners\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`posts_id\`) REFERENCES \`posts\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`events_id\`) REFERENCES \`events\`(\`id\`) ON UPDATE no action ON DELETE cascade${newsletterForeignKey},
FOREIGN KEY (\`media_id\`) REFERENCES \`media\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`users_id\`) REFERENCES \`users\`(\`id\`) ON UPDATE no action ON DELETE cascade,
FOREIGN KEY (\`payload_folders_id\`) REFERENCES \`payload_folders\`(\`id\`) ON UPDATE no action ON DELETE cascade
);
`))
const copiedColumns = commonColumns.map(ident).join(', ')
await db.run(sql.raw(`INSERT INTO \`__new_payload_locked_documents_rels\` (${copiedColumns}) SELECT ${copiedColumns} FROM ${ident(tableName)};`))
await db.run(sql.raw(`DROP TABLE ${ident(tableName)};`))
await db.run(sql.raw(`ALTER TABLE \`__new_payload_locked_documents_rels\` RENAME TO ${ident(tableName)};`))
for (const [suffix, column] of [
['order', 'order'],
['parent', 'parent_id'],
['path', 'path'],
['pages_id', 'pages_id'],
['preistraeger_id', 'preistraeger_id'],
['jury_mitglieder_id', 'jury_mitglieder_id'],
['partners_id', 'partners_id'],
['posts_id', 'posts_id'],
['events_id', 'events_id'],
...(includeNewsletter ? [['newsletter_subscriptions_i', 'newsletter_subscriptions_id']] : []),
['media_id', 'media_id'],
['users_id', 'users_id'],
['payload_folders_id', 'payload_folders_id'],
] as string[][]) {
await db.run(sql.raw(`CREATE INDEX ${ident(`${tableName}_${suffix}_idx`)} ON ${ident(tableName)} (${ident(column)});`))
}
}
export async function up({ db, payload: _payload, req: _req }: MigrateUpArgs): Promise<void> {
await db.run(sql.raw(`CREATE TABLE IF NOT EXISTS \`newsletter_form\` (${globalTableColumns()});`))
await createBenefitTable(db, 'newsletter_eval_bens', 'newsletter_form', 'text')
await createBenefitTable(db, 'newsletter_done_bens', 'newsletter_form', 'text')
await seedGlobalScalarsFromHome(db)
await seedBenefits(db, 'evaluation')
await seedBenefits(db, 'completed')
await db.run(sql.raw(`
CREATE TABLE IF NOT EXISTS \`newsletter_subscriptions\` (
\`id\` integer PRIMARY KEY NOT NULL,
\`email\` text NOT NULL,
\`source\` text NOT NULL,
\`phase\` text NOT NULL,
\`last_submitted_at\` text NOT NULL,
\`updated_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL,
\`created_at\` text DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) NOT NULL
);
`))
await db.run(sql.raw('CREATE UNIQUE INDEX IF NOT EXISTS `newsletter_subscriptions_email_idx` ON `newsletter_subscriptions` (`email`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `newsletter_subscriptions_updated_at_idx` ON `newsletter_subscriptions` (`updated_at`);'))
await db.run(sql.raw('CREATE INDEX IF NOT EXISTS `newsletter_subscriptions_created_at_idx` ON `newsletter_subscriptions` (`created_at`);'))
await rebuildLockedDocumentRelations(db, true)
await removeLegacyPageFields(db)
}
export async function down({ db, payload: _payload, req: _req }: MigrateDownArgs): Promise<void> {
await restoreLegacyPageFields(db)
await rebuildLockedDocumentRelations(db, false)
await db.run(sql.raw('DROP TABLE IF EXISTS `newsletter_subscriptions`;'))
await db.run(sql.raw('DROP TABLE IF EXISTS `newsletter_eval_bens`;'))
await db.run(sql.raw('DROP TABLE IF EXISTS `newsletter_done_bens`;'))
await db.run(sql.raw('DROP TABLE IF EXISTS `newsletter_form`;'))
}