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

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;