feat: configureerbare itemnamen per vragenlijst

Voeg item_label en item_label_plural toe aan questionnaires (migratie),
beheerformulier en dynamische teksten in UI en API-fouten. Standaard
blijft Activiteit/Activiteiten.

Negeer tsbuildinfo en geëmitteerde vite.config.js/.d.ts in .gitignore.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-05-10 15:29:44 +02:00
parent 6871126788
commit 93aedf319b
9 changed files with 234 additions and 42 deletions

View File

@@ -5,7 +5,14 @@ import multer, { FileFilterCallback } from 'multer';
import path from 'path';
import fs from 'fs';
import { fileURLToPath } from 'url';
import { userOps, questionnaireOps, activityOps, participantOps } from '../database.js';
import {
userOps,
questionnaireOps,
activityOps,
participantOps,
resolveItemLabels,
DEFAULT_ITEM_LABEL,
} from '../database.js';
import { requireAuth } from '../middleware/auth.js';
const __filename = fileURLToPath(import.meta.url);
@@ -74,9 +81,17 @@ function isValidSlug(slug: string): boolean {
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
}
function parseItemLabelsFromBody(itemLabel: unknown, itemLabelPlural: unknown): { singular: string; plural: string } {
return resolveItemLabels({
item_label: typeof itemLabel === 'string' ? itemLabel : null,
item_label_plural: typeof itemLabelPlural === 'string' ? itemLabelPlural : null,
});
}
// Create questionnaire
router.post('/questionnaires', (req: Request, res: Response) => {
const { title, description, slug, ogImage, isPrivate } = req.body;
const { title, description, slug, ogImage, isPrivate, itemLabel, itemLabelPlural } = req.body;
const { singular, plural } = parseItemLabelsFromBody(itemLabel, itemLabelPlural);
if (!title?.trim()) {
res.status(400).json({ error: 'Titel is verplicht' });
@@ -106,7 +121,17 @@ router.post('/questionnaires', (req: Request, res: Response) => {
}
const uuid = uuidv4();
const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, ogImage?.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,
singular,
plural
);
const questionnaire = questionnaireOps.findById(id);
res.json({ success: true, questionnaire });
@@ -127,7 +152,8 @@ router.get('/questionnaires/:id', (req: Request, res: Response) => {
// Update questionnaire
router.put('/questionnaires/:id', (req: Request, res: Response) => {
const { title, description, slug, ogImage, isPrivate } = req.body;
const { title, description, slug, ogImage, isPrivate, itemLabel, itemLabelPlural } = req.body;
const { singular, plural } = parseItemLabelsFromBody(itemLabel, itemLabelPlural);
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
if (!questionnaire) {
@@ -162,7 +188,16 @@ router.put('/questionnaires/:id', (req: Request, res: Response) => {
return;
}
questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, ogImage?.trim() || null, !!isPrivate);
questionnaireOps.update(
questionnaire.id,
cleanSlug,
title.trim(),
description?.trim() || null,
ogImage?.trim() || null,
!!isPrivate,
singular,
plural
);
res.json({ success: true });
});
@@ -267,12 +302,15 @@ router.put('/activities/:id', (req: Request, res: Response) => {
const activity = activityOps.findById(parseInt(req.params.id));
if (!activity) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
res.status(404).json({ error: `${DEFAULT_ITEM_LABEL} niet gevonden` });
return;
}
const q = questionnaireOps.findById(activity.questionnaire_id);
const itemSingular = q ? resolveItemLabels(q).singular : DEFAULT_ITEM_LABEL;
if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' });
res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` });
return;
}

View File

@@ -1,5 +1,14 @@
import { Router, Request, Response } from 'express';
import { questionnaireOps, activityOps, voteOps, commentOps, participantOps, Comment, Questionnaire } from '../database.js';
import {
questionnaireOps,
activityOps,
voteOps,
commentOps,
participantOps,
Comment,
Questionnaire,
resolveItemLabels,
} from '../database.js';
// Helper to check if user can participate (write access)
function canParticipate(questionnaire: Questionnaire, req: Request): { canWrite: boolean; participantName: string | null } {
@@ -152,8 +161,9 @@ router.post('/:slug/activities', (req: Request, res: Response) => {
return;
}
const { singular: itemSingular } = resolveItemLabels(questionnaire);
if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' });
res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` });
return;
}
@@ -186,20 +196,21 @@ router.put('/:slug/activities/:activityId', (req: Request, res: Response) => {
return;
}
const { singular: itemSingular, plural: itemPlural } = resolveItemLabels(questionnaire);
const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
res.status(404).json({ error: `${itemSingular} niet gevonden` });
return;
}
// Check if the user is the one who added the activity
if (activity.added_by !== participantName) {
res.status(403).json({ error: 'Je kunt alleen je eigen activiteiten bewerken' });
res.status(403).json({ error: `Je kunt alleen je eigen ${itemPlural.toLowerCase()} bewerken` });
return;
}
if (!name?.trim()) {
res.status(400).json({ error: 'Naam activiteit is verplicht' });
res.status(400).json({ error: `Naam ${itemSingular.toLowerCase()} is verplicht` });
return;
}
@@ -231,9 +242,10 @@ router.post('/:slug/activities/:activityId/vote', (req: Request, res: Response)
return;
}
const { singular: itemSingular } = resolveItemLabels(questionnaire);
const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
res.status(404).json({ error: `${itemSingular} niet gevonden` });
return;
}
@@ -286,9 +298,10 @@ router.get('/:slug/activities/:activityId/comments', (req: Request, res: Respons
return;
}
const { singular: itemSingular } = resolveItemLabels(questionnaire);
const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
res.status(404).json({ error: `${itemSingular} niet gevonden` });
return;
}
@@ -337,9 +350,10 @@ router.post('/:slug/activities/:activityId/comments', (req: Request, res: Respon
return;
}
const { singular: itemSingular } = resolveItemLabels(questionnaire);
const activity = activityOps.findById(parseInt(req.params.activityId));
if (!activity || activity.questionnaire_id !== questionnaire.id) {
res.status(404).json({ error: 'Activiteit niet gevonden' });
res.status(404).json({ error: `${itemSingular} niet gevonden` });
return;
}