diff --git a/backend/src/routes/projects.ts b/backend/src/routes/projects.ts index 8f45905..2bc2c40 100644 --- a/backend/src/routes/projects.ts +++ b/backend/src/routes/projects.ts @@ -1,5 +1,365 @@ -import { FastifyInstance } from 'fastify'; +import { FastifyInstance, FastifyRequest } from 'fastify'; +import { DatabaseInterface } from '../database/DatabaseInterface'; +import { Project, ProjectMember, Version } from '../database/DatabaseInterface'; +import { z } from 'zod'; + +// RBAC roles +const ROLES = { + ADMIN: 'Admin', + PLANNER: 'Planner', + BETRACHTER: 'Betrachter', + GAST: 'Gast' +}; + +// RBAC middleware +const requireAuth = async (request: FastifyRequest, db: DatabaseInterface) => { + const token = request.headers.authorization?.replace('Bearer ', ''); + if (!token) { + throw { statusCode: 401, message: 'Authentication required' }; + } + + const session = await db.getSessionByToken(token); + if (!session) { + throw { statusCode: 401, message: 'Invalid token' }; + } + + const user = await db.getUserById(session.user_id); + if (!user || !user.is_active) { + throw { statusCode: 401, message: 'User not active' }; + } + + return user; +}; + +const requireRole = (roles: string[]) => { + return async (request: FastifyRequest, db: DatabaseInterface) => { + const user = await requireAuth(request, db); + if (!roles.includes(user.role)) { + throw { statusCode: 403, message: 'Insufficient permissions' }; + } + return user; + }; +}; + +const requireProjectMember = async (request: FastifyRequest, db: DatabaseInterface, projectId: string) => { + const user = await requireAuth(request, db); + const isMember = await db.isProjectMember(projectId, user.id); + if (!isMember) { + throw { statusCode: 403, message: 'Not a project member' }; + } + return user; +}; + +const requireAdmin = requireRole([ROLES.ADMIN]); +const requirePlannerOrHigher = requireRole([ROLES.ADMIN, ROLES.PLANNER]); +const requireMember = requireProjectMember; + +// Input validation schemas +const CreateProjectSchema = z.object({ + name: z.string().min(1).max(100), + description: z.string().max(500).optional(), + units: z.string().optional() +}); + +const UpdateProjectSchema = z.object({ + name: z.string().min(1).max(100).optional(), + description: z.string().max(500).optional(), + units: z.string().optional() +}); + +const InviteMemberSchema = z.object({ + email: z.string().email(), + role: z.enum([ROLES.ADMIN, ROLES.PLANNER, ROLES.BETRACHTER, ROLES.GAST]) +}); + +const UpdateMemberSchema = z.object({ + role: z.enum([ROLES.ADMIN, ROLES.PLANNER, ROLES.BETRACHTER, ROLES.GAST]) +}); + +const CreateVersionSchema = z.object({ + label: z.string().min(1).max(50), + description: z.string().max(500).optional() +}); export default async function projectsRoutes(fastify: FastifyInstance) { - // Placeholder for projects routes + const db = fastify.db as DatabaseInterface; + + // 1. GET /projects - List user projects (auth required) + fastify.get('/', { + preHandler: requireAuth.bind(null, fastify.request, db) + }, async (request, reply) => { + const user = await requireAuth(request, db); + const projects = await db.getProjectsByOwner(user.id); + return projects; + }); + + // 2. POST /projects - Create new project (auth required) + fastify.post('/', { + preHandler: requireAuth.bind(null, fastify.request, db) + }, async (request, reply) => { + const user = await requireAuth(request, db); + const body = CreateProjectSchema.parse(request.body); + + const project: Omit = { + name: body.name, + description: body.description, + owner_id: user.id, + units: body.units || 'm', + created_at: new Date().toISOString(), + updated_at: new Date().toISOString() + }; + + const newProject = await db.createProject(project); + + // Add owner as project member + const member: Omit = { + project_id: newProject.id, + user_id: user.id, + role: ROLES.ADMIN, + invited_at: new Date().toISOString(), + accepted_at: new Date().toISOString() + }; + + await db.createProjectMember(member); + + return newProject; + }); + + // 3. GET /projects/:id - Get project details (auth required, member check) + fastify.get('/:id', { + preHandler: async (request, reply) => { + const params = request.params as { id: string }; + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string }; + const project = await db.getProjectById(params.id); + + if (!project) { + throw { statusCode: 404, message: 'Project not found' }; + } + + return project; + }); + + // 4. PUT /projects/:id - Update project metadata (Planner+ only) + fastify.put('/:id', { + preHandler: async (request, reply) => { + const params = request.params as { id: string }; + await requirePlannerOrHigher(request, db); + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string }; + const body = UpdateProjectSchema.parse(request.body); + + const project = await db.getProjectById(params.id); + if (!project) { + throw { statusCode: 404, message: 'Project not found' }; + } + + const updates: Partial = {}; + if (body.name !== undefined) updates.name = body.name; + if (body.description !== undefined) updates.description = body.description; + if (body.units !== undefined) updates.units = body.units; + + updates.updated_at = new Date().toISOString(); + + const updatedProject = await db.updateProject(params.id, updates); + return updatedProject; + }); + + // 5. DELETE /projects/:id - Delete project (Admin only) + fastify.delete('/:id', { + preHandler: async (request, reply) => { + const params = request.params as { id: string }; + await requireAdmin(request, db); + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string }; + + const project = await db.getProjectById(params.id); + if (!project) { + throw { statusCode: 404, message: 'Project not found' }; + } + + await db.deleteProject(params.id); + return { message: 'Project deleted successfully' }; + }); + + // 6. GET /projects/:id/members - List project members (member check) + fastify.get('/:id/members', { + preHandler: async (request, reply) => { + const params = request.params as { id: string }; + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string }; + const members = await db.getProjectMembers(params.id); + return members; + }); + + // 7. POST /projects/:id/members - Invite member (Admin only) + fastify.post('/:id/members', { + preHandler: async (request, reply) => { + const params = request.params as { id: string }; + await requireAdmin(request, db); + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string }; + const body = InviteMemberSchema.parse(request.body); + + // Check if user exists + const user = await db.getUserByEmail(body.email); + if (!user) { + throw { statusCode: 404, message: 'User not found' }; + } + + // Check if already a member + const isMember = await db.isProjectMember(params.id, user.id); + if (isMember) { + throw { statusCode: 400, message: 'User is already a member' }; + } + + // Create member invitation + const member: Omit = { + project_id: params.id, + user_id: user.id, + role: body.role, + invited_at: new Date().toISOString() + }; + + const newMember = await db.createProjectMember(member); + return newMember; + }); + + // 8. PUT /projects/:id/members/:userId - Update member role (Admin only) + fastify.put('/:id/members/:userId', { + preHandler: async (request, reply) => { + const params = request.params as { id: string, userId: string }; + await requireAdmin(request, db); + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string, userId: string }; + const body = UpdateMemberSchema.parse(request.body); + + // Check if member exists + const member = await db.getProjectMemberById(params.userId); + if (!member) { + throw { statusCode: 404, message: 'Member not found' }; + } + + // Update member role + const updatedMember = await db.updateProjectMember(params.userId, { role: body.role }); + return updatedMember; + }); + + // 9. DELETE /projects/:id/members/:userId - Remove member (Admin only) + fastify.delete('/:id/members/:userId', { + preHandler: async (request, reply) => { + const params = request.params as { id: string, userId: string }; + await requireAdmin(request, db); + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string, userId: string }; + + // Check if member exists + const member = await db.getProjectMemberById(params.userId); + if (!member) { + throw { statusCode: 404, message: 'Member not found' }; + } + + // Remove member + await db.deleteProjectMember(params.userId); + return { message: 'Member removed successfully' }; + }); + + // 10. GET /projects/:id/versions - List versions (member check) + fastify.get('/:id/versions', { + preHandler: async (request, reply) => { + const params = request.params as { id: string }; + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string }; + const versions = await db.getVersionsByProject(params.id); + return versions; + }); + + // 11. POST /projects/:id/versions - Create version snapshot (Planner+) + fastify.post('/:id/versions', { + preHandler: async (request, reply) => { + const params = request.params as { id: string }; + await requirePlannerOrHigher(request, db); + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string }; + const body = CreateVersionSchema.parse(request.body); + + // Create version + const version: Omit = { + project_id: params.id, + label: body.label, + description: body.description, + snapshot_data: Buffer.from(JSON.stringify({})), // Empty snapshot for now + created_by: (await requireAuth(request, db)).id, + created_at: new Date().toISOString() + }; + + const newVersion = await db.createVersion(version); + return newVersion; + }); + + // 12. GET /projects/:id/versions/:vId - Get version details (member) + fastify.get('/:id/versions/:vId', { + preHandler: async (request, reply) => { + const params = request.params as { id: string, vId: string }; + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string, vId: string }; + const version = await db.getVersionById(params.vId); + + if (!version) { + throw { statusCode: 404, message: 'Version not found' }; + } + + // Check if version belongs to project + if (version.project_id !== params.id) { + throw { statusCode: 404, message: 'Version not found' }; + } + + return version; + }); + + // 13. POST /projects/:id/versions/:vId/restore - Restore version (Planner+) + fastify.post('/:id/versions/:vId/restore', { + preHandler: async (request, reply) => { + const params = request.params as { id: string, vId: string }; + await requirePlannerOrHigher(request, db); + await requireMember(request, db, params.id); + } + }, async (request, reply) => { + const params = request.params as { id: string, vId: string }; + + // Get version + const version = await db.getVersionById(params.vId); + if (!version) { + throw { statusCode: 404, message: 'Version not found' }; + } + + // Check if version belongs to project + if (version.project_id !== params.id) { + throw { statusCode: 404, message: 'Version not found' }; + } + + // Restore version (implementation depends on how snapshots are stored) + // For now, we'll just return a success message + return { message: 'Version restored successfully' }; + }); }