341 lines
10 KiB
TypeScript
341 lines
10 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import crypto from 'crypto';
|
|
import multer, { FileFilterCallback } from 'multer';
|
|
import path from 'path';
|
|
import fs from 'fs';
|
|
import { fileURLToPath } from 'url';
|
|
import { userOps, questionnaireOps, activityOps, participantOps } from '../database.js';
|
|
import { requireAuth } from '../middleware/auth.js';
|
|
|
|
const __filename = fileURLToPath(import.meta.url);
|
|
const __dirname = path.dirname(__filename);
|
|
|
|
// Configure multer for image uploads
|
|
const uploadsDir = process.env.NODE_ENV === 'production'
|
|
? '/app/data/uploads'
|
|
: path.join(__dirname, '..', '..', 'data', 'uploads');
|
|
|
|
// Ensure uploads directory exists
|
|
if (!fs.existsSync(uploadsDir)) {
|
|
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
}
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: (_req: Request, _file: Express.Multer.File, cb: (error: Error | null, destination: string) => void) => {
|
|
cb(null, uploadsDir);
|
|
},
|
|
filename: (_req: Request, file: Express.Multer.File, cb: (error: Error | null, filename: string) => void) => {
|
|
const uniqueSuffix = Date.now() + '-' + Math.round(Math.random() * 1E9);
|
|
const ext = path.extname(file.originalname);
|
|
cb(null, 'og-' + uniqueSuffix + ext);
|
|
}
|
|
});
|
|
|
|
const upload = multer({
|
|
storage,
|
|
limits: { fileSize: 5 * 1024 * 1024 }, // 5MB max
|
|
fileFilter: (_req: Request, file: Express.Multer.File, cb: FileFilterCallback) => {
|
|
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
|
if (allowedTypes.includes(file.mimetype)) {
|
|
cb(null, true);
|
|
} else {
|
|
cb(new Error('Alleen JPEG, PNG, GIF en WebP afbeeldingen zijn toegestaan'));
|
|
}
|
|
}
|
|
});
|
|
|
|
const router = Router();
|
|
|
|
// Apply auth middleware to all admin routes
|
|
router.use(requireAuth);
|
|
|
|
// Upload image
|
|
router.post('/upload', upload.single('image'), (req: Request, res: Response) => {
|
|
const file = req.file as Express.Multer.File | undefined;
|
|
if (!file) {
|
|
res.status(400).json({ error: 'Geen bestand geüpload' });
|
|
return;
|
|
}
|
|
|
|
// Return the URL to access the uploaded file
|
|
const imageUrl = `/uploads/${file.filename}`;
|
|
res.json({ success: true, url: imageUrl });
|
|
});
|
|
|
|
// 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, ogImage, 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, ogImage?.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, ogImage, 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, ogImage?.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, phone } = 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;
|
|
}
|
|
|
|
// Clean phone number (remove spaces, keep + and digits)
|
|
const cleanPhone = phone?.trim() ? phone.trim().replace(/[^\d+]/g, '') : null;
|
|
|
|
// Generate unique token
|
|
let token = generateToken();
|
|
while (!participantOps.isTokenAvailable(token)) {
|
|
token = generateToken();
|
|
}
|
|
|
|
const id = participantOps.create(questionnaire.id, name.trim(), cleanPhone, 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;
|
|
|