Initial commit: Activiteiten Inventaris applicatie
This commit is contained in:
438
server/database.ts
Normal file
438
server/database.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
import Database from 'better-sqlite3';
|
||||
import bcrypt from 'bcrypt';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
const dbPath = process.env.DB_PATH || path.join(__dirname, '..', 'data', 'questionnaire.db');
|
||||
|
||||
// Ensure data directory exists
|
||||
const dataDir = path.dirname(dbPath);
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true });
|
||||
}
|
||||
|
||||
export const db = new Database(dbPath);
|
||||
|
||||
// Enable foreign keys
|
||||
db.pragma('foreign_keys = ON');
|
||||
|
||||
// Types
|
||||
export interface User {
|
||||
id: number;
|
||||
username: string;
|
||||
password_hash: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Questionnaire {
|
||||
id: number;
|
||||
uuid: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
is_private: boolean;
|
||||
created_by: number;
|
||||
created_at: string;
|
||||
creator_name?: string;
|
||||
activity_count?: number;
|
||||
}
|
||||
|
||||
export interface Participant {
|
||||
id: number;
|
||||
questionnaire_id: number;
|
||||
name: string;
|
||||
token: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Activity {
|
||||
id: number;
|
||||
questionnaire_id: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
added_by: string;
|
||||
created_at: string;
|
||||
upvotes?: number;
|
||||
downvotes?: number;
|
||||
net_votes?: number;
|
||||
comment_count?: number;
|
||||
}
|
||||
|
||||
export interface Vote {
|
||||
id: number;
|
||||
activity_id: number;
|
||||
voter_name: string;
|
||||
vote_type: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface Comment {
|
||||
id: number;
|
||||
activity_id: number;
|
||||
parent_id: number | null;
|
||||
author_name: string;
|
||||
content: string;
|
||||
created_at: string;
|
||||
replies?: Comment[];
|
||||
}
|
||||
|
||||
// Initialize database schema
|
||||
export function initializeDatabase(): void {
|
||||
// Users table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
password_hash TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
`);
|
||||
|
||||
// Questionnaires table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS questionnaires (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
uuid TEXT UNIQUE NOT NULL,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
description TEXT,
|
||||
created_by INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (created_by) REFERENCES users(id)
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add slug column if it doesn't exist
|
||||
try {
|
||||
db.exec(`ALTER TABLE questionnaires ADD COLUMN slug TEXT`);
|
||||
db.exec(`UPDATE questionnaires SET slug = uuid WHERE slug IS NULL`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore
|
||||
}
|
||||
|
||||
// Migration: Add is_private column if it doesn't exist
|
||||
try {
|
||||
db.exec(`ALTER TABLE questionnaires ADD COLUMN is_private INTEGER DEFAULT 0`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore
|
||||
}
|
||||
|
||||
// Participants table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS participants (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
questionnaire_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
token TEXT UNIQUE NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (questionnaire_id) REFERENCES questionnaires(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_questionnaire ON participants(questionnaire_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_participants_token ON participants(token);
|
||||
`);
|
||||
|
||||
// Activities table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS activities (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
questionnaire_id INTEGER NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
added_by TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (questionnaire_id) REFERENCES questionnaires(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Migration: Add description column to activities if it doesn't exist
|
||||
try {
|
||||
db.exec(`ALTER TABLE activities ADD COLUMN description TEXT`);
|
||||
} catch (e) {
|
||||
// Column already exists, ignore
|
||||
}
|
||||
|
||||
// Votes table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS votes (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL,
|
||||
voter_name TEXT NOT NULL,
|
||||
vote_type INTEGER NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||
UNIQUE(activity_id, voter_name)
|
||||
)
|
||||
`);
|
||||
|
||||
// Comments table
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS comments (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
activity_id INTEGER NOT NULL,
|
||||
parent_id INTEGER,
|
||||
author_name TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (activity_id) REFERENCES activities(id) ON DELETE CASCADE,
|
||||
FOREIGN KEY (parent_id) REFERENCES comments(id) ON DELETE CASCADE
|
||||
)
|
||||
`);
|
||||
|
||||
// Create indexes
|
||||
db.exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_questionnaires_uuid ON questionnaires(uuid);
|
||||
CREATE INDEX IF NOT EXISTS idx_activities_questionnaire ON activities(questionnaire_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_votes_activity ON votes(activity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_activity ON comments(activity_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_comments_parent ON comments(parent_id);
|
||||
`);
|
||||
|
||||
// Create default admin user if none exists
|
||||
createDefaultAdmin();
|
||||
}
|
||||
|
||||
function createDefaultAdmin(): void {
|
||||
const adminUser = process.env.DEFAULT_ADMIN_USER || 'admin';
|
||||
const adminPass = process.env.DEFAULT_ADMIN_PASS || 'admin123';
|
||||
|
||||
const existingUser = db.prepare('SELECT id FROM users WHERE username = ?').get(adminUser);
|
||||
|
||||
if (!existingUser) {
|
||||
const hash = bcrypt.hashSync(adminPass, 10);
|
||||
db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(adminUser, hash);
|
||||
console.log(`Default admin user '${adminUser}' created.`);
|
||||
}
|
||||
}
|
||||
|
||||
// User operations
|
||||
export const userOps = {
|
||||
findByUsername: (username: string): User | undefined => {
|
||||
return db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
|
||||
},
|
||||
|
||||
findById: (id: number): Omit<User, 'password_hash'> | undefined => {
|
||||
return db.prepare('SELECT id, username, created_at FROM users WHERE id = ?').get(id) as Omit<User, 'password_hash'> | undefined;
|
||||
},
|
||||
|
||||
getAll: (): Omit<User, 'password_hash'>[] => {
|
||||
return db.prepare('SELECT id, username, created_at FROM users ORDER BY created_at DESC').all() as Omit<User, 'password_hash'>[];
|
||||
},
|
||||
|
||||
create: (username: string, password: string): number => {
|
||||
const hash = bcrypt.hashSync(password, 10);
|
||||
const result = db.prepare('INSERT INTO users (username, password_hash) VALUES (?, ?)').run(username, hash);
|
||||
return result.lastInsertRowid as number;
|
||||
},
|
||||
|
||||
updatePassword: (id: number, newPassword: string): void => {
|
||||
const hash = bcrypt.hashSync(newPassword, 10);
|
||||
db.prepare('UPDATE users SET password_hash = ? WHERE id = ?').run(hash, id);
|
||||
},
|
||||
|
||||
delete: (id: number): void => {
|
||||
db.prepare('DELETE FROM users WHERE id = ?').run(id);
|
||||
},
|
||||
|
||||
verifyPassword: (user: User, password: string): boolean => {
|
||||
return bcrypt.compareSync(password, user.password_hash);
|
||||
},
|
||||
|
||||
count: (): number => {
|
||||
const result = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
|
||||
return result.count;
|
||||
},
|
||||
};
|
||||
|
||||
// Questionnaire operations
|
||||
export const questionnaireOps = {
|
||||
create: (uuid: string, slug: string, title: string, description: 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);
|
||||
return result.lastInsertRowid as number;
|
||||
},
|
||||
|
||||
findBySlug: (slug: string): Questionnaire | undefined => {
|
||||
return db.prepare('SELECT * FROM questionnaires WHERE slug = ?').get(slug) as Questionnaire | undefined;
|
||||
},
|
||||
|
||||
findByUuid: (uuid: string): Questionnaire | undefined => {
|
||||
return db.prepare('SELECT * FROM questionnaires WHERE uuid = ?').get(uuid) as Questionnaire | undefined;
|
||||
},
|
||||
|
||||
findById: (id: number): Questionnaire | undefined => {
|
||||
return db.prepare('SELECT * FROM questionnaires WHERE id = ?').get(id) as Questionnaire | undefined;
|
||||
},
|
||||
|
||||
getAll: (): Questionnaire[] => {
|
||||
return db.prepare(`
|
||||
SELECT q.*, u.username as creator_name,
|
||||
(SELECT COUNT(*) FROM activities WHERE questionnaire_id = q.id) as activity_count
|
||||
FROM questionnaires q
|
||||
LEFT JOIN users u ON q.created_by = u.id
|
||||
ORDER BY q.created_at DESC
|
||||
`).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);
|
||||
},
|
||||
|
||||
delete: (id: number): void => {
|
||||
db.prepare('DELETE FROM questionnaires WHERE id = ?').run(id);
|
||||
},
|
||||
|
||||
isSlugAvailable: (slug: string, excludeId?: number): boolean => {
|
||||
if (excludeId) {
|
||||
const result = db.prepare('SELECT id FROM questionnaires WHERE slug = ? AND id != ?').get(slug, excludeId);
|
||||
return !result;
|
||||
}
|
||||
const result = db.prepare('SELECT id FROM questionnaires WHERE slug = ?').get(slug);
|
||||
return !result;
|
||||
},
|
||||
};
|
||||
|
||||
// Participant operations
|
||||
export const participantOps = {
|
||||
create: (questionnaireId: number, name: string, token: string): number => {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO participants (questionnaire_id, name, token) VALUES (?, ?, ?)'
|
||||
).run(questionnaireId, name, token);
|
||||
return result.lastInsertRowid as number;
|
||||
},
|
||||
|
||||
findByToken: (token: string): Participant | undefined => {
|
||||
return db.prepare('SELECT * FROM participants WHERE token = ?').get(token) as Participant | undefined;
|
||||
},
|
||||
|
||||
findById: (id: number): Participant | undefined => {
|
||||
return db.prepare('SELECT * FROM participants WHERE id = ?').get(id) as Participant | undefined;
|
||||
},
|
||||
|
||||
getByQuestionnaire: (questionnaireId: number): Participant[] => {
|
||||
return db.prepare('SELECT * FROM participants WHERE questionnaire_id = ? ORDER BY created_at DESC').all(questionnaireId) as Participant[];
|
||||
},
|
||||
|
||||
delete: (id: number): void => {
|
||||
db.prepare('DELETE FROM participants WHERE id = ?').run(id);
|
||||
},
|
||||
|
||||
isTokenAvailable: (token: string): boolean => {
|
||||
const result = db.prepare('SELECT id FROM participants WHERE token = ?').get(token);
|
||||
return !result;
|
||||
},
|
||||
};
|
||||
|
||||
// Activity operations
|
||||
export const activityOps = {
|
||||
create: (questionnaireId: number, name: string, addedBy: string, description: string | null = null): number => {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO activities (questionnaire_id, name, added_by, description) VALUES (?, ?, ?, ?)'
|
||||
).run(questionnaireId, name, addedBy, description);
|
||||
return result.lastInsertRowid as number;
|
||||
},
|
||||
|
||||
findById: (id: number): Activity | undefined => {
|
||||
return db.prepare('SELECT * FROM activities WHERE id = ?').get(id) as Activity | undefined;
|
||||
},
|
||||
|
||||
update: (id: number, name: string, description: string | null): void => {
|
||||
db.prepare('UPDATE activities SET name = ?, description = ? WHERE id = ?').run(name, description, id);
|
||||
},
|
||||
|
||||
getByQuestionnaire: (questionnaireId: number): Activity[] => {
|
||||
const activities = db.prepare(`
|
||||
SELECT a.*,
|
||||
COALESCE(SUM(CASE WHEN v.vote_type = 1 THEN 1 ELSE 0 END), 0) as upvotes,
|
||||
COALESCE(SUM(CASE WHEN v.vote_type = -1 THEN 1 ELSE 0 END), 0) as downvotes,
|
||||
COALESCE(SUM(v.vote_type), 0) as net_votes
|
||||
FROM activities a
|
||||
LEFT JOIN votes v ON a.id = v.activity_id
|
||||
WHERE a.questionnaire_id = ?
|
||||
GROUP BY a.id
|
||||
ORDER BY net_votes DESC, a.created_at ASC
|
||||
`).all(questionnaireId) as Activity[];
|
||||
|
||||
// Add comment counts
|
||||
activities.forEach(activity => {
|
||||
activity.comment_count = commentOps.countByActivity(activity.id);
|
||||
});
|
||||
|
||||
return activities;
|
||||
},
|
||||
|
||||
delete: (id: number): void => {
|
||||
db.prepare('DELETE FROM activities WHERE id = ?').run(id);
|
||||
},
|
||||
};
|
||||
|
||||
// Vote operations
|
||||
export const voteOps = {
|
||||
upsert: (activityId: number, voterName: string, voteType: number): { action: string; voteType: number } => {
|
||||
const existing = db.prepare(
|
||||
'SELECT id, vote_type FROM votes WHERE activity_id = ? AND voter_name = ?'
|
||||
).get(activityId, voterName) as { id: number; vote_type: number } | undefined;
|
||||
|
||||
if (existing) {
|
||||
if (existing.vote_type === voteType) {
|
||||
db.prepare('DELETE FROM votes WHERE id = ?').run(existing.id);
|
||||
return { action: 'removed', voteType: 0 };
|
||||
} else {
|
||||
db.prepare('UPDATE votes SET vote_type = ? WHERE id = ?').run(voteType, existing.id);
|
||||
return { action: 'changed', voteType };
|
||||
}
|
||||
} else {
|
||||
db.prepare(
|
||||
'INSERT INTO votes (activity_id, voter_name, vote_type) VALUES (?, ?, ?)'
|
||||
).run(activityId, voterName, voteType);
|
||||
return { action: 'added', voteType };
|
||||
}
|
||||
},
|
||||
|
||||
getByQuestionnaireAndVoter: (questionnaireId: number, voterName: string): { activity_id: number; vote_type: number }[] => {
|
||||
return db.prepare(`
|
||||
SELECT v.activity_id, v.vote_type
|
||||
FROM votes v
|
||||
JOIN activities a ON v.activity_id = a.id
|
||||
WHERE a.questionnaire_id = ? AND v.voter_name = ?
|
||||
`).all(questionnaireId, voterName) as { activity_id: number; vote_type: number }[];
|
||||
},
|
||||
};
|
||||
|
||||
// Comment operations
|
||||
export const commentOps = {
|
||||
create: (activityId: number, authorName: string, content: string, parentId: number | null = null): number => {
|
||||
const result = db.prepare(
|
||||
'INSERT INTO comments (activity_id, author_name, content, parent_id) VALUES (?, ?, ?, ?)'
|
||||
).run(activityId, authorName, content, parentId);
|
||||
return result.lastInsertRowid as number;
|
||||
},
|
||||
|
||||
getByActivity: (activityId: number): Comment[] => {
|
||||
return db.prepare(`
|
||||
SELECT * FROM comments
|
||||
WHERE activity_id = ?
|
||||
ORDER BY created_at ASC
|
||||
`).all(activityId) as Comment[];
|
||||
},
|
||||
|
||||
getById: (id: number): Comment | undefined => {
|
||||
return db.prepare('SELECT * FROM comments WHERE id = ?').get(id) as Comment | undefined;
|
||||
},
|
||||
|
||||
countByActivity: (activityId: number): number => {
|
||||
const result = db.prepare('SELECT COUNT(*) as count FROM comments WHERE activity_id = ?').get(activityId) as { count: number };
|
||||
return result.count;
|
||||
},
|
||||
|
||||
delete: (id: number): void => {
|
||||
db.prepare('DELETE FROM comments WHERE id = ?').run(id);
|
||||
},
|
||||
};
|
||||
|
||||
75
server/index.ts
Normal file
75
server/index.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import express from 'express';
|
||||
import session from 'express-session';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import cors from 'cors';
|
||||
import path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
import { initializeDatabase } from './database.js';
|
||||
import authRoutes from './routes/auth.js';
|
||||
import adminRoutes from './routes/admin.js';
|
||||
import questionnaireRoutes from './routes/questionnaire.js';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
|
||||
// Initialize database
|
||||
initializeDatabase();
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 4000;
|
||||
const isProduction = process.env.NODE_ENV === 'production';
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(cookieParser());
|
||||
|
||||
// CORS for development
|
||||
if (!isProduction) {
|
||||
app.use(cors({
|
||||
origin: 'http://localhost:5173',
|
||||
credentials: true,
|
||||
}));
|
||||
}
|
||||
|
||||
// Session configuration
|
||||
declare module 'express-session' {
|
||||
interface SessionData {
|
||||
user?: { id: number; username: string };
|
||||
returnTo?: string;
|
||||
}
|
||||
}
|
||||
|
||||
app.use(session({
|
||||
secret: process.env.SESSION_SECRET || 'dev-secret-change-in-production',
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: isProduction && process.env.HTTPS === 'true',
|
||||
httpOnly: true,
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
sameSite: isProduction ? 'strict' : 'lax',
|
||||
},
|
||||
}));
|
||||
|
||||
// API Routes
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/admin', adminRoutes);
|
||||
app.use('/api/q', questionnaireRoutes);
|
||||
|
||||
// Serve static files in production
|
||||
if (isProduction) {
|
||||
const clientPath = path.join(__dirname, '../client');
|
||||
app.use(express.static(clientPath));
|
||||
|
||||
// SPA fallback
|
||||
app.get('*', (req, res) => {
|
||||
res.sendFile(path.join(clientPath, 'index.html'));
|
||||
});
|
||||
}
|
||||
|
||||
app.listen(PORT, () => {
|
||||
console.log(`Server running on http://localhost:${PORT}`);
|
||||
});
|
||||
|
||||
10
server/middleware/auth.ts
Normal file
10
server/middleware/auth.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction): void {
|
||||
if (req.session && req.session.user) {
|
||||
next();
|
||||
} else {
|
||||
res.status(401).json({ error: 'Authenticatie vereist' });
|
||||
}
|
||||
}
|
||||
|
||||
283
server/routes/admin.ts
Normal file
283
server/routes/admin.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
import crypto from 'crypto';
|
||||
import { userOps, questionnaireOps, activityOps, participantOps } from '../database.js';
|
||||
import { requireAuth } from '../middleware/auth.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Apply auth middleware to all admin routes
|
||||
router.use(requireAuth);
|
||||
|
||||
// Get all questionnaires
|
||||
router.get('/questionnaires', (req: Request, res: Response) => {
|
||||
const questionnaires = questionnaireOps.getAll();
|
||||
res.json(questionnaires);
|
||||
});
|
||||
|
||||
// Validate slug format
|
||||
function isValidSlug(slug: string): boolean {
|
||||
return /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug);
|
||||
}
|
||||
|
||||
// Create questionnaire
|
||||
router.post('/questionnaires', (req: Request, res: Response) => {
|
||||
const { title, description, slug, isPrivate } = req.body;
|
||||
|
||||
if (!title?.trim()) {
|
||||
res.status(400).json({ error: 'Titel is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slug?.trim()) {
|
||||
res.status(400).json({ error: 'Slug is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanSlug = slug.trim().toLowerCase();
|
||||
|
||||
if (!isValidSlug(cleanSlug)) {
|
||||
res.status(400).json({ error: 'Slug mag alleen kleine letters, cijfers en koppeltekens bevatten' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanSlug.length < 3 || cleanSlug.length > 50) {
|
||||
res.status(400).json({ error: 'Slug moet tussen 3 en 50 tekens zijn' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!questionnaireOps.isSlugAvailable(cleanSlug)) {
|
||||
res.status(400).json({ error: 'Deze slug is al in gebruik' });
|
||||
return;
|
||||
}
|
||||
|
||||
const uuid = uuidv4();
|
||||
const id = questionnaireOps.create(uuid, cleanSlug, title.trim(), description?.trim() || null, !!isPrivate, req.session.user!.id);
|
||||
const questionnaire = questionnaireOps.findById(id);
|
||||
|
||||
res.json({ success: true, questionnaire });
|
||||
});
|
||||
|
||||
// Get questionnaire by ID
|
||||
router.get('/questionnaires/:id', (req: Request, res: Response) => {
|
||||
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activities = activityOps.getByQuestionnaire(questionnaire.id);
|
||||
res.json({ questionnaire, activities });
|
||||
});
|
||||
|
||||
// Update questionnaire
|
||||
router.put('/questionnaires/:id', (req: Request, res: Response) => {
|
||||
const { title, description, slug, isPrivate } = req.body;
|
||||
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!title?.trim()) {
|
||||
res.status(400).json({ error: 'Titel is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!slug?.trim()) {
|
||||
res.status(400).json({ error: 'Slug is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
const cleanSlug = slug.trim().toLowerCase();
|
||||
|
||||
if (!isValidSlug(cleanSlug)) {
|
||||
res.status(400).json({ error: 'Slug mag alleen kleine letters, cijfers en koppeltekens bevatten' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (cleanSlug.length < 3 || cleanSlug.length > 50) {
|
||||
res.status(400).json({ error: 'Slug moet tussen 3 en 50 tekens zijn' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!questionnaireOps.isSlugAvailable(cleanSlug, questionnaire.id)) {
|
||||
res.status(400).json({ error: 'Deze slug is al in gebruik' });
|
||||
return;
|
||||
}
|
||||
|
||||
questionnaireOps.update(questionnaire.id, cleanSlug, title.trim(), description?.trim() || null, !!isPrivate);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Check slug availability
|
||||
router.get('/questionnaires/check-slug/:slug', (req: Request, res: Response) => {
|
||||
const { slug } = req.params;
|
||||
const { excludeId } = req.query;
|
||||
|
||||
const cleanSlug = slug.trim().toLowerCase();
|
||||
const available = questionnaireOps.isSlugAvailable(cleanSlug, excludeId ? parseInt(excludeId as string) : undefined);
|
||||
|
||||
res.json({ available });
|
||||
});
|
||||
|
||||
// Get participants for a questionnaire
|
||||
router.get('/questionnaires/:id/participants', (req: Request, res: Response) => {
|
||||
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const participants = participantOps.getByQuestionnaire(questionnaire.id);
|
||||
res.json(participants);
|
||||
});
|
||||
|
||||
// Generate unique token
|
||||
function generateToken(): string {
|
||||
return crypto.randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
// Add participant to questionnaire
|
||||
router.post('/questionnaires/:id/participants', (req: Request, res: Response) => {
|
||||
const { name } = req.body;
|
||||
const questionnaire = questionnaireOps.findById(parseInt(req.params.id));
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name?.trim()) {
|
||||
res.status(400).json({ error: 'Naam is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Generate unique token
|
||||
let token = generateToken();
|
||||
while (!participantOps.isTokenAvailable(token)) {
|
||||
token = generateToken();
|
||||
}
|
||||
|
||||
const id = participantOps.create(questionnaire.id, name.trim(), token);
|
||||
const participant = participantOps.findById(id);
|
||||
|
||||
res.json({ success: true, participant });
|
||||
});
|
||||
|
||||
// Delete participant
|
||||
router.delete('/participants/:id', (req: Request, res: Response) => {
|
||||
participantOps.delete(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Delete questionnaire
|
||||
router.delete('/questionnaires/:id', (req: Request, res: Response) => {
|
||||
questionnaireOps.delete(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Update activity
|
||||
router.put('/activities/:id', (req: Request, res: Response) => {
|
||||
const { name, description } = req.body;
|
||||
const activity = activityOps.findById(parseInt(req.params.id));
|
||||
|
||||
if (!activity) {
|
||||
res.status(404).json({ error: 'Activiteit niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name?.trim()) {
|
||||
res.status(400).json({ error: 'Naam activiteit is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
activityOps.update(activity.id, name.trim(), description?.trim() || null);
|
||||
const updatedActivity = activityOps.findById(activity.id);
|
||||
res.json({ success: true, activity: updatedActivity });
|
||||
});
|
||||
|
||||
// Delete activity
|
||||
router.delete('/activities/:id', (req: Request, res: Response) => {
|
||||
activityOps.delete(parseInt(req.params.id));
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Get all users
|
||||
router.get('/users', (req: Request, res: Response) => {
|
||||
const users = userOps.getAll();
|
||||
res.json(users);
|
||||
});
|
||||
|
||||
// Create user
|
||||
router.post('/users', (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Gebruikersnaam en wachtwoord zijn verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
res.status(400).json({ error: 'Wachtwoord moet minimaal 6 tekens zijn' });
|
||||
return;
|
||||
}
|
||||
|
||||
const existingUser = userOps.findByUsername(username);
|
||||
if (existingUser) {
|
||||
res.status(400).json({ error: 'Gebruikersnaam bestaat al' });
|
||||
return;
|
||||
}
|
||||
|
||||
const id = userOps.create(username, password);
|
||||
const user = userOps.findById(id);
|
||||
res.json({ success: true, user });
|
||||
});
|
||||
|
||||
// Delete user
|
||||
router.delete('/users/:id', (req: Request, res: Response) => {
|
||||
const userId = parseInt(req.params.id);
|
||||
|
||||
if (userId === req.session.user!.id) {
|
||||
res.status(400).json({ error: 'Je kunt jezelf niet verwijderen' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (userOps.count() <= 1) {
|
||||
res.status(400).json({ error: 'Je kunt de laatste beheerder niet verwijderen' });
|
||||
return;
|
||||
}
|
||||
|
||||
userOps.delete(userId);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Change password
|
||||
router.post('/change-password', (req: Request, res: Response) => {
|
||||
const { currentPassword, newPassword } = req.body;
|
||||
|
||||
const user = userOps.findByUsername(req.session.user!.username);
|
||||
if (!user) {
|
||||
res.status(404).json({ error: 'Gebruiker niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!userOps.verifyPassword(user, currentPassword)) {
|
||||
res.status(400).json({ error: 'Huidig wachtwoord is onjuist' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (newPassword.length < 6) {
|
||||
res.status(400).json({ error: 'Wachtwoord moet minimaal 6 tekens zijn' });
|
||||
return;
|
||||
}
|
||||
|
||||
userOps.updatePassword(req.session.user!.id, newPassword);
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
50
server/routes/auth.ts
Normal file
50
server/routes/auth.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { userOps } from '../database.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Check auth status
|
||||
router.get('/status', (req: Request, res: Response) => {
|
||||
if (req.session.user) {
|
||||
res.json({ authenticated: true, user: req.session.user });
|
||||
} else {
|
||||
res.json({ authenticated: false });
|
||||
}
|
||||
});
|
||||
|
||||
// Login
|
||||
router.post('/login', (req: Request, res: Response) => {
|
||||
const { username, password } = req.body;
|
||||
|
||||
if (!username || !password) {
|
||||
res.status(400).json({ error: 'Gebruikersnaam en wachtwoord zijn verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
const user = userOps.findByUsername(username);
|
||||
|
||||
if (!user || !userOps.verifyPassword(user, password)) {
|
||||
res.status(401).json({ error: 'Ongeldige gebruikersnaam of wachtwoord' });
|
||||
return;
|
||||
}
|
||||
|
||||
req.session.user = {
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
};
|
||||
|
||||
res.json({ success: true, user: req.session.user });
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req: Request, res: Response) => {
|
||||
req.session.destroy((err) => {
|
||||
if (err) {
|
||||
console.error('Session destroy error:', err);
|
||||
}
|
||||
res.json({ success: true });
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
366
server/routes/questionnaire.ts
Normal file
366
server/routes/questionnaire.ts
Normal file
@@ -0,0 +1,366 @@
|
||||
import { Router, Request, Response } from 'express';
|
||||
import { questionnaireOps, activityOps, voteOps, commentOps, participantOps, Comment, Questionnaire } from '../database.js';
|
||||
|
||||
// Helper to check if user can participate (write access)
|
||||
function canParticipate(questionnaire: Questionnaire, req: Request): { canWrite: boolean; participantName: string | null } {
|
||||
// Check for participant token first
|
||||
const token = req.cookies.participantToken;
|
||||
if (token) {
|
||||
const participant = participantOps.findByToken(token);
|
||||
if (participant && participant.questionnaire_id === questionnaire.id) {
|
||||
return { canWrite: true, participantName: participant.name };
|
||||
}
|
||||
}
|
||||
|
||||
// For public questionnaires, use visitor name
|
||||
if (!questionnaire.is_private) {
|
||||
const visitorName = req.cookies.visitorName;
|
||||
return { canWrite: !!visitorName, participantName: visitorName || null };
|
||||
}
|
||||
|
||||
// Private questionnaire without valid token
|
||||
return { canWrite: false, participantName: null };
|
||||
}
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Access questionnaire via participant token
|
||||
router.get('/token/:token', (req: Request, res: Response) => {
|
||||
const participant = participantOps.findByToken(req.params.token);
|
||||
|
||||
if (!participant) {
|
||||
res.status(404).json({ error: 'Ongeldige toegangslink' });
|
||||
return;
|
||||
}
|
||||
|
||||
const questionnaire = questionnaireOps.findById(participant.questionnaire_id);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Set participant token cookie
|
||||
res.cookie('participantToken', participant.token, {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
|
||||
const activities = activityOps.getByQuestionnaire(questionnaire.id);
|
||||
|
||||
let userVotes: Record<number, number> = {};
|
||||
const votes = voteOps.getByQuestionnaireAndVoter(questionnaire.id, participant.name);
|
||||
votes.forEach(v => {
|
||||
userVotes[v.activity_id] = v.vote_type;
|
||||
});
|
||||
|
||||
res.json({
|
||||
questionnaire,
|
||||
activities,
|
||||
visitorName: participant.name,
|
||||
userVotes,
|
||||
isParticipant: true,
|
||||
canWrite: true,
|
||||
});
|
||||
});
|
||||
|
||||
// Get questionnaire by slug (public - may be read-only for private questionnaires)
|
||||
router.get('/:slug', (req: Request, res: Response) => {
|
||||
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activities = activityOps.getByQuestionnaire(questionnaire.id);
|
||||
const { canWrite, participantName } = canParticipate(questionnaire, req);
|
||||
|
||||
let userVotes: Record<number, number> = {};
|
||||
if (participantName) {
|
||||
const votes = voteOps.getByQuestionnaireAndVoter(questionnaire.id, participantName);
|
||||
votes.forEach(v => {
|
||||
userVotes[v.activity_id] = v.vote_type;
|
||||
});
|
||||
}
|
||||
|
||||
res.json({
|
||||
questionnaire,
|
||||
activities,
|
||||
visitorName: participantName || '',
|
||||
userVotes,
|
||||
isPrivate: !!questionnaire.is_private,
|
||||
canWrite,
|
||||
});
|
||||
});
|
||||
|
||||
// Set visitor name (only for public questionnaires)
|
||||
router.post('/:slug/set-name', (req: Request, res: Response) => {
|
||||
const { name } = req.body;
|
||||
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (questionnaire.is_private) {
|
||||
res.status(403).json({ error: 'Voor deze vragenlijst is een uitnodigingslink nodig' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name?.trim()) {
|
||||
res.status(400).json({ error: 'Naam is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
res.cookie('visitorName', name.trim(), {
|
||||
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
|
||||
httpOnly: true,
|
||||
sameSite: 'lax',
|
||||
});
|
||||
|
||||
res.json({ success: true, name: name.trim() });
|
||||
});
|
||||
|
||||
// Clear visitor name
|
||||
router.post('/:slug/clear-name', (req: Request, res: Response) => {
|
||||
res.clearCookie('visitorName');
|
||||
res.clearCookie('participantToken');
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Add activity
|
||||
router.post('/:slug/activities', (req: Request, res: Response) => {
|
||||
const { name, description } = req.body;
|
||||
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { canWrite, participantName } = canParticipate(questionnaire, req);
|
||||
|
||||
if (!canWrite || !participantName) {
|
||||
if (questionnaire.is_private) {
|
||||
res.status(403).json({ error: 'Je hebt een uitnodigingslink nodig om deel te nemen' });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Voer eerst je naam in' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name?.trim()) {
|
||||
res.status(400).json({ error: 'Naam activiteit is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activityDescription = description?.trim() || null;
|
||||
const activityId = activityOps.create(questionnaire.id, name.trim(), participantName, activityDescription);
|
||||
const activities = activityOps.getByQuestionnaire(questionnaire.id);
|
||||
const activity = activities.find(a => a.id === activityId);
|
||||
|
||||
res.json({ success: true, activity });
|
||||
});
|
||||
|
||||
// Update activity (only by the user who added it)
|
||||
router.put('/:slug/activities/:activityId', (req: Request, res: Response) => {
|
||||
const { name, description } = req.body;
|
||||
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { canWrite, participantName } = canParticipate(questionnaire, req);
|
||||
|
||||
if (!canWrite || !participantName) {
|
||||
if (questionnaire.is_private) {
|
||||
res.status(403).json({ error: 'Je hebt een uitnodigingslink nodig om te bewerken' });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Voer eerst je naam in' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = activityOps.findById(parseInt(req.params.activityId));
|
||||
if (!activity || activity.questionnaire_id !== questionnaire.id) {
|
||||
res.status(404).json({ error: 'Activiteit 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' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!name?.trim()) {
|
||||
res.status(400).json({ error: 'Naam activiteit is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
activityOps.update(activity.id, name.trim(), description?.trim() || null);
|
||||
const activities = activityOps.getByQuestionnaire(questionnaire.id);
|
||||
const updatedActivity = activities.find(a => a.id === activity.id);
|
||||
|
||||
res.json({ success: true, activity: updatedActivity });
|
||||
});
|
||||
|
||||
// Vote on activity
|
||||
router.post('/:slug/activities/:activityId/vote', (req: Request, res: Response) => {
|
||||
const { voteType } = req.body;
|
||||
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { canWrite, participantName } = canParticipate(questionnaire, req);
|
||||
|
||||
if (!canWrite || !participantName) {
|
||||
if (questionnaire.is_private) {
|
||||
res.status(403).json({ error: 'Je hebt een uitnodigingslink nodig om te stemmen' });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Voer eerst je naam in' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = activityOps.findById(parseInt(req.params.activityId));
|
||||
if (!activity || activity.questionnaire_id !== questionnaire.id) {
|
||||
res.status(404).json({ error: 'Activiteit niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (voteType !== 1 && voteType !== -1) {
|
||||
res.status(400).json({ error: 'Ongeldig stemtype' });
|
||||
return;
|
||||
}
|
||||
|
||||
const result = voteOps.upsert(activity.id, participantName, voteType);
|
||||
const activities = activityOps.getByQuestionnaire(questionnaire.id);
|
||||
const updatedActivity = activities.find(a => a.id === activity.id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
action: result.action,
|
||||
currentVote: result.voteType,
|
||||
activity: updatedActivity,
|
||||
});
|
||||
});
|
||||
|
||||
// Get activities
|
||||
router.get('/:slug/activities', (req: Request, res: Response) => {
|
||||
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activities = activityOps.getByQuestionnaire(questionnaire.id);
|
||||
const visitorName = req.cookies.visitorName || '';
|
||||
|
||||
let userVotes: Record<number, number> = {};
|
||||
if (visitorName) {
|
||||
const votes = voteOps.getByQuestionnaireAndVoter(questionnaire.id, visitorName);
|
||||
votes.forEach(v => {
|
||||
userVotes[v.activity_id] = v.vote_type;
|
||||
});
|
||||
}
|
||||
|
||||
res.json({ activities, userVotes });
|
||||
});
|
||||
|
||||
// Get comments for an activity
|
||||
router.get('/:slug/activities/:activityId/comments', (req: Request, res: Response) => {
|
||||
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = activityOps.findById(parseInt(req.params.activityId));
|
||||
if (!activity || activity.questionnaire_id !== questionnaire.id) {
|
||||
res.status(404).json({ error: 'Activiteit niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const comments = commentOps.getByActivity(activity.id);
|
||||
|
||||
// Build nested comment tree
|
||||
const commentMap: Record<number, Comment> = {};
|
||||
const rootComments: Comment[] = [];
|
||||
|
||||
comments.forEach(comment => {
|
||||
comment.replies = [];
|
||||
commentMap[comment.id] = comment;
|
||||
});
|
||||
|
||||
comments.forEach(comment => {
|
||||
if (comment.parent_id) {
|
||||
if (commentMap[comment.parent_id]) {
|
||||
commentMap[comment.parent_id].replies!.push(comment);
|
||||
}
|
||||
} else {
|
||||
rootComments.push(comment);
|
||||
}
|
||||
});
|
||||
|
||||
res.json({ comments: rootComments, activity });
|
||||
});
|
||||
|
||||
// Add a comment
|
||||
router.post('/:slug/activities/:activityId/comments', (req: Request, res: Response) => {
|
||||
const { content, parentId } = req.body;
|
||||
const questionnaire = questionnaireOps.findBySlug(req.params.slug);
|
||||
|
||||
if (!questionnaire) {
|
||||
res.status(404).json({ error: 'Vragenlijst niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
const { canWrite, participantName } = canParticipate(questionnaire, req);
|
||||
|
||||
if (!canWrite || !participantName) {
|
||||
if (questionnaire.is_private) {
|
||||
res.status(403).json({ error: 'Je hebt een uitnodigingslink nodig om te reageren' });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Voer eerst je naam in' });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const activity = activityOps.findById(parseInt(req.params.activityId));
|
||||
if (!activity || activity.questionnaire_id !== questionnaire.id) {
|
||||
res.status(404).json({ error: 'Activiteit niet gevonden' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!content?.trim()) {
|
||||
res.status(400).json({ error: 'Reactie inhoud is verplicht' });
|
||||
return;
|
||||
}
|
||||
|
||||
if (parentId) {
|
||||
const parentComment = commentOps.getById(parentId);
|
||||
if (!parentComment || parentComment.activity_id !== activity.id) {
|
||||
res.status(400).json({ error: 'Ongeldige reactie om op te reageren' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const commentId = commentOps.create(activity.id, participantName, content.trim(), parentId || null);
|
||||
const comment = commentOps.getById(commentId);
|
||||
|
||||
res.json({ success: true, comment: { ...comment, replies: [] } });
|
||||
});
|
||||
|
||||
export default router;
|
||||
|
||||
Reference in New Issue
Block a user