Initial commit: Activiteiten Inventaris applicatie

This commit is contained in:
2026-01-06 01:23:45 +01:00
commit 6d26aea0cf
38 changed files with 9818 additions and 0 deletions

438
server/database.ts Normal file
View 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
View 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
View 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
View 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
View 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;

View 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;