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.
This commit is contained in:
@@ -0,0 +1,343 @@
|
||||
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`;'))
|
||||
}
|
||||
Reference in New Issue
Block a user