Add OG image support for WhatsApp/social media link previews

This commit is contained in:
2026-01-06 02:03:51 +01:00
parent 50a3c7fe24
commit a7b2169ec8
4 changed files with 94 additions and 12 deletions

View File

@@ -34,6 +34,7 @@ export interface Questionnaire {
slug: string;
title: string;
description: string | null;
og_image: string | null;
is_private: boolean;
created_by: number;
created_at: string;
@@ -121,6 +122,13 @@ export function initializeDatabase(): void {
// Column already exists, ignore
}
// Migration: Add og_image column if it doesn't exist
try {
db.exec(`ALTER TABLE questionnaires ADD COLUMN og_image TEXT`);
} catch (e) {
// Column already exists, ignore
}
// Participants table
db.exec(`
CREATE TABLE IF NOT EXISTS participants (
@@ -251,10 +259,10 @@ export const userOps = {
// Questionnaire operations
export const questionnaireOps = {
create: (uuid: string, slug: string, title: string, description: string | null, isPrivate: boolean, createdBy: number): number => {
create: (uuid: string, slug: string, title: string, description: string | null, ogImage: string | null, isPrivate: boolean, createdBy: number): number => {
const result = db.prepare(
'INSERT INTO questionnaires (uuid, slug, title, description, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?)'
).run(uuid, slug, title, description, isPrivate ? 1 : 0, createdBy);
'INSERT INTO questionnaires (uuid, slug, title, description, og_image, is_private, created_by) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(uuid, slug, title, description, ogImage, isPrivate ? 1 : 0, createdBy);
return result.lastInsertRowid as number;
},
@@ -280,8 +288,8 @@ export const questionnaireOps = {
`).all() as Questionnaire[];
},
update: (id: number, slug: string, title: string, description: string | null, isPrivate: boolean): void => {
db.prepare('UPDATE questionnaires SET slug = ?, title = ?, description = ?, is_private = ? WHERE id = ?').run(slug, title, description, isPrivate ? 1 : 0, id);
update: (id: number, slug: string, title: string, description: string | null, ogImage: string | null, isPrivate: boolean): void => {
db.prepare('UPDATE questionnaires SET slug = ?, title = ?, description = ?, og_image = ?, is_private = ? WHERE id = ?').run(slug, title, description, ogImage, isPrivate ? 1 : 0, id);
},
delete: (id: number): void => {

View File

@@ -3,9 +3,10 @@ import session from 'express-session';
import cookieParser from 'cookie-parser';
import cors from 'cors';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { initializeDatabase } from './database.js';
import { initializeDatabase, questionnaireOps } from './database.js';
import authRoutes from './routes/auth.js';
import adminRoutes from './routes/admin.js';
import questionnaireRoutes from './routes/questionnaire.js';
@@ -63,7 +64,47 @@ if (isProduction) {
const clientPath = path.join(__dirname, '../client');
app.use(express.static(clientPath));
// SPA fallback
// Dynamic OG meta tags for questionnaire pages
app.get('/q/:slug', (req, res) => {
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
const indexPath = path.join(clientPath, 'index.html');
if (!questionnaire) {
res.sendFile(indexPath);
return;
}
// Read the index.html template
let html = fs.readFileSync(indexPath, 'utf-8');
// Build meta tags
const baseUrl = `${req.protocol}://${req.get('host')}`;
const pageUrl = `${baseUrl}/q/${questionnaire.slug}`;
const title = questionnaire.title;
const description = questionnaire.description || 'Activiteiten Inventaris - Voeg activiteiten toe en stem!';
const ogTags = `
<meta property="og:type" content="website" />
<meta property="og:url" content="${pageUrl}" />
<meta property="og:title" content="${title}" />
<meta property="og:description" content="${description}" />
${questionnaire.og_image ? `<meta property="og:image" content="${questionnaire.og_image}" />` : ''}
<meta name="twitter:card" content="summary_large_image" />
<meta name="twitter:title" content="${title}" />
<meta name="twitter:description" content="${description}" />
${questionnaire.og_image ? `<meta name="twitter:image" content="${questionnaire.og_image}" />` : ''}
`;
// Insert OG tags before </head>
html = html.replace('</head>', `${ogTags}</head>`);
// Update title
html = html.replace(/<title>.*?<\/title>/, `<title>${title} - Activiteiten Inventaris</title>`);
res.send(html);
});
// SPA fallback for other routes
app.get('*', (_req, res) => {
res.sendFile(path.join(clientPath, 'index.html'));
});

View File

@@ -22,7 +22,7 @@ function isValidSlug(slug: string): boolean {
// Create questionnaire
router.post('/questionnaires', (req: Request, res: Response) => {
const { title, description, slug, isPrivate } = req.body;
const { title, description, slug, ogImage, isPrivate } = req.body;
if (!title?.trim()) {
res.status(400).json({ error: 'Titel is verplicht' });
@@ -52,7 +52,7 @@ router.post('/questionnaires', (req: Request, res: Response) => {
}
const uuid = uuidv4();
const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, !!isPrivate, req.session.user!.id);
const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, ogImage?.trim() || null, !!isPrivate, req.session.user!.id);
const questionnaire = questionnaireOps.findById(id);
res.json({ success: true, questionnaire });
@@ -73,7 +73,7 @@ router.get('/questionnaires/:id', (req: Request, res: Response) => {
// Update questionnaire
router.put('/questionnaires/:id', (req: Request, res: Response) => {
const { title, description, slug, isPrivate } = req.body;
const { title, description, slug, ogImage, isPrivate } = req.body;
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
if (!questionnaire) {
@@ -108,7 +108,7 @@ router.put('/questionnaires/:id', (req: Request, res: Response) => {
return;
}
questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, !!isPrivate);
questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, ogImage?.trim() || null, !!isPrivate);
res.json({ success: true });
});