import { Router, Request, Response } from 'express'; import { randomBytes } from 'crypto'; import { prisma } from '../lib/prisma'; import { authMiddleware, optionalAuth } from '../lib/auth'; import { sendInviteEmail } from '../lib/email'; const router = Router(); const INVITE_EXPIRY_DAYS = 7; const INVITE_EXPIRY_MS = INVITE_EXPIRY_DAYS * 24 * 60 * 60 * 1000; // Frontend base URL used to build full invite links const FRONTEND_URL = process.env.FRONTEND_URL || process.env.NEXT_PUBLIC_API_URL?.replace('/api', '') || 'http://localhost:3000'; function buildInviteUrl(token: string): string { return `${FRONTEND_URL.replace(/\/$/, '')}/invite/${token}`; } function str(v: string | string[] | undefined): string { return Array.isArray(v) ? v[0] ?? '' : (v ?? ''); } // ── Helpers ─────────────────────────────────────────────────────────────────── /** Check if current user can invite to this project (projectId must not be null) */ async function canInvite(projectId: string, userId: string): Promise { const member = await prisma.projectMember.findFirst({ where: { projectId, userId }, }); return !!member && (member.role === 'ADMIN' || member.role === 'EDITOR'); } /** Auto-expire stale invitations */ async function expireOldInvitations() { await prisma.invitation.updateMany({ where: { status: 'PENDING', expiresAt: { lt: new Date() }, }, data: { status: 'EXPIRED' }, }); } // ── GET /api/invitations/:token ─ public verify (no auth needed) ─────────────── router.get('/:token', optionalAuth, async (req: Request, res: Response) => { try { await expireOldInvitations(); const invitation = await prisma.invitation.findUnique({ where: { token: str(req.params.token) }, include: { project: { select: { id: true, name: true } }, }, }); if (!invitation) { res.status(404).json({ error: 'Invitation not found' }); return; } // If user is logged in, check if this is their invitation const isOwnInvitation = req.user?.email === invitation.email; // Workspace invites (projectId=null) have no project membership to check let alreadyMember = false; if (req.user) { if (invitation.projectId === null) { // Workspace invite: user is a member if they already exist as MEMBER/ADMIN const existingUser = await prisma.user.findUnique({ where: { id: req.user.userId } }); alreadyMember = !!(existingUser && existingUser.globalRole !== 'PROJECT_USER'); } else { alreadyMember = !!(await prisma.projectMember.findFirst({ where: { projectId: invitation.projectId!, userId: req.user.userId }, })); } } // Check if the invite email already has an account (so frontend shows sign-in vs register) const inviteeExists = !!(await prisma.user.findUnique({ where: { email: invitation.email }, })); // Determine invite type for UI const isWorkspace = invitation.projectId === null; const type = isWorkspace ? 'WORKSPACE' : 'PROJECT'; // Return full info even for expired/used — frontend shows appropriate UI res.json({ invitation: { id: invitation.id, email: invitation.email, role: invitation.role, projectName: isWorkspace ? null : invitation.project?.name ?? null, projectId: invitation.projectId, expiresAt: invitation.expiresAt, status: invitation.status, isExpired: invitation.status === 'EXPIRED' || invitation.expiresAt < new Date(), isOwnInvitation, alreadyMember: alreadyMember || invitation.status === 'ACCEPTED', isLoggedIn: !!req.user, inviteeExists, type, }, }); } catch (err) { console.error('Verify invitation error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // ── POST /api/invitations/:token/accept ─ public (no auth needed, but user must be logged in) ─ router.post('/:token/accept', authMiddleware, async (req: Request, res: Response) => { try { await expireOldInvitations(); const invitation = await prisma.invitation.findUnique({ where: { token: str(req.params.token) }, }); if (!invitation) { res.status(404).json({ error: 'Invitation not found' }); return; } if (invitation.status !== 'PENDING') { res.status(410).json({ error: `Invitation has been ${invitation.status.toLowerCase()}` }); return; } if (invitation.expiresAt < new Date()) { res.status(410).json({ error: 'Invitation has expired' }); return; } if (invitation.email !== req.user!.email) { res.status(403).json({ error: 'This invitation was sent to a different email address' }); return; } // Workspace invite (projectId=null) — no project membership to create; just accept it if (invitation.projectId === null) { await prisma.invitation.update({ where: { id: invitation.id }, data: { status: 'ACCEPTED' }, }); res.json({ message: 'Invitation accepted', projectId: null }); return; } // Check if already a member const existing = await prisma.projectMember.findFirst({ where: { projectId: invitation.projectId, userId: req.user!.userId }, }); if (existing) { // Mark invitation as accepted anyway await prisma.invitation.update({ where: { id: invitation.id }, data: { status: 'ACCEPTED' }, }); res.json({ message: 'Already a member', projectId: invitation.projectId }); return; } // Create membership + mark invitation accepted (transaction) const [member] = await prisma.$transaction([ prisma.projectMember.create({ data: { userId: req.user!.userId, projectId: invitation.projectId, role: invitation.role, invitedBy: invitation.invitedBy, }, include: { project: { select: { id: true, name: true } }, }, }), prisma.invitation.update({ where: { id: invitation.id }, data: { status: 'ACCEPTED' }, }), ]); res.json({ member }); } catch (err) { console.error('Accept invitation error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // ── Project-scoped invitation routes (require auth + project membership) ─────── // GET /api/projects/:projectId/invitations — list pending invitations router.get('/project/:projectId', authMiddleware, async (req: Request, res: Response) => { try { const projectId = str(req.params.projectId); if (!(await canInvite(projectId, req.user!.userId))) { res.status(403).json({ error: 'Forbidden' }); return; } await expireOldInvitations(); const invitations = await prisma.invitation.findMany({ where: { projectId }, orderBy: { createdAt: 'desc' }, }); res.json({ invitations }); } catch (err) { console.error('List invitations error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/projects/:projectId/invitations — create invitation router.post('/project/:projectId', authMiddleware, async (req: Request, res: Response) => { try { const projectId = str(req.params.projectId); if (!(await canInvite(projectId, req.user!.userId))) { res.status(403).json({ error: 'Forbidden — must be admin or editor' }); return; } const { email, role = 'REVIEWER' } = req.body as { email: string; role?: string }; if (!email) { res.status(400).json({ error: 'Email is required' }); return; } const validRoles = ['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER']; if (!validRoles.includes(role)) { res.status(400).json({ error: 'Invalid role' }); return; } // Check if already a member const existingMember = await prisma.user.findUnique({ where: { email } }); if (existingMember) { const member = await prisma.projectMember.findFirst({ where: { projectId, userId: existingMember.id }, }); if (member) { res.status(409).json({ error: 'User is already a member of this project' }); return; } } // Check if there's already a pending invitation const existingInvite = await prisma.invitation.findFirst({ where: { projectId, email, status: 'PENDING' }, }); if (existingInvite) { res.status(409).json({ error: 'A pending invitation already exists for this email' }); return; } const token = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS); const invitation = await prisma.invitation.create({ data: { email, projectId, role: role as any, token, invitedBy: req.user!.userId, expiresAt, }, }); // Return full invite URL const inviteUrl = buildInviteUrl(token); // Send invite email (skipped for .local domains or if RESEND_API_KEY not set) const project = await prisma.project.findUnique({ where: { id: projectId }, select: { name: true } }); await sendInviteEmail({ to: email, projectName: project?.name, role, expiresDays: INVITE_EXPIRY_DAYS, inviteUrl, type: 'PROJECT', }); res.status(201).json({ invitation, inviteUrl }); } catch (err) { console.error('Create invitation error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // ── Admin: workspace-wide MEMBER invite ────────────────────────────────────────── // POST /api/invitations/workspace — admin: invite a MEMBER to the workspace (no project) // User registers → globalRole = MEMBER, can create their own projects router.post('/workspace', authMiddleware, async (req: Request, res: Response) => { try { if (req.user!.globalRole !== 'ADMIN') { res.status(403).json({ error: 'Admin access required' }); return; } const { email } = req.body as { email: string }; if (!email) { res.status(400).json({ error: 'email is required' }); return; } // If user already exists with MEMBER or ADMIN role, just return existing info const existingUser = await prisma.user.findUnique({ where: { email } }); if (existingUser) { res.status(409).json({ error: `User already exists as ${existingUser.globalRole}. No invitation needed.`, user: { id: existingUser.id, email: existingUser.email, globalRole: existingUser.globalRole } }); return; } // Revoke any existing pending workspace invite for this email await prisma.invitation.updateMany({ where: { email, projectId: null as any, status: 'PENDING' }, data: { status: 'REVOKED' }, }); const token = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS); // projectId = null means workspace invite (creates MEMBER) const invitation = await prisma.invitation.create({ data: { email, projectId: null, // null = workspace invite role: 'REVIEWER', // Role enum used for display; type=WORKSPACE means MEMBER on register token, invitedBy: req.user!.userId, expiresAt, } as any, }); const inviteUrl = buildInviteUrl(token); // Send invite email (skipped for .local domains or if RESEND_API_KEY not set) await sendInviteEmail({ to: email, projectName: null, role: 'MEMBER', expiresDays: INVITE_EXPIRY_DAYS, inviteUrl, type: 'WORKSPACE', }); res.status(201).json({ invitation, inviteUrl }); } catch (err) { console.error('Workspace invite error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // POST /api/invitations — project-scoped invite (PROJECT_USER) // Admin or project member: invite by email to a specific project router.post('/', authMiddleware, async (req: Request, res: Response) => { try { const { email, projectId, role = 'REVIEWER' } = req.body as { email: string; projectId: string; role?: string; }; if (!email || !projectId) { res.status(400).json({ error: 'email and projectId are required' }); return; } const validRoles = ['ADMIN', 'EDITOR', 'REVIEWER', 'VIEWER']; if (!validRoles.includes(role)) { res.status(400).json({ error: 'Invalid role' }); return; } // Check permission: admin OR project ADMIN/EDITOR const isAdmin = req.user!.globalRole === 'ADMIN'; if (!isAdmin && !(await canInvite(projectId, req.user!.userId))) { res.status(403).json({ error: 'Forbidden' }); return; } // Verify project exists const project = await prisma.project.findUnique({ where: { id: projectId } }); if (!project) { res.status(404).json({ error: 'Project not found' }); return; } // Check if already a member const existingMember = await prisma.user.findUnique({ where: { email } }); if (existingMember) { const member = await prisma.projectMember.findFirst({ where: { projectId, userId: existingMember.id }, }); if (member) { res.status(409).json({ error: 'User is already a member of this project' }); return; } } // Revoke any existing pending invite for this email+project await prisma.invitation.updateMany({ where: { projectId, email, status: 'PENDING' }, data: { status: 'REVOKED' }, }); const token = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS); const invitation = await prisma.invitation.create({ data: { email, projectId, role: role as any, token, invitedBy: req.user!.userId, expiresAt, }, }); const inviteUrl = buildInviteUrl(token); // Send invite email (skipped for .local domains or if RESEND_API_KEY not set) await sendInviteEmail({ to: email, projectName: project.name, role, expiresDays: INVITE_EXPIRY_DAYS, inviteUrl, type: 'PROJECT', }); res.status(201).json({ invitation, inviteUrl }); } catch (err) { console.error('Admin invite error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // GET /api/invitations — admin: list all pending invitations (workspace + project) router.get('/', authMiddleware, async (req: Request, res: Response) => { try { if (req.user!.globalRole !== 'ADMIN') { res.status(403).json({ error: 'Admin access required' }); return; } const invitations = await prisma.invitation.findMany({ where: { status: 'PENDING' }, include: { project: { select: { id: true, name: true } }, }, orderBy: { createdAt: 'desc' }, }); // Mark workspace invites (projectId=null) with type='WORKSPACE' const typed = invitations.map(inv => ({ ...inv, type: inv.projectId === null ? 'WORKSPACE' as const : 'PROJECT' as const, })); res.json({ invitations: typed }); } catch (err) { console.error('List invitations error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // DELETE /api/invitations/:id — revoke invitation (admin or project admin/editor) router.delete('/:id', authMiddleware, async (req: Request, res: Response) => { try { const invitation = await prisma.invitation.findUnique({ where: { id: str(req.params.id) }, }); if (!invitation) { res.status(404).json({ error: 'Invitation not found' }); return; } const isAdmin = req.user!.globalRole === 'ADMIN'; // Workspace invites (projectId=null) can only be revoked by ADMIN if (!isAdmin && (invitation.projectId === null || !(await canInvite(invitation.projectId, req.user!.userId)))) { res.status(403).json({ error: 'Forbidden' }); return; } if (invitation.status !== 'PENDING') { res.status(400).json({ error: 'Can only revoke pending invitations' }); return; } await prisma.invitation.update({ where: { id: invitation.id }, data: { status: 'REVOKED' }, }); res.json({ message: 'Invitation revoked' }); } catch (err) { console.error('Revoke invitation error:', err); res.status(500).json({ error: 'Internal server error' }); } }); // Resend invitation — create new token for same email (project admin/editor) router.post('/project/:projectId/resend', authMiddleware, async (req: Request, res: Response) => { try { const projectId = str(req.params.projectId); const isAdmin = req.user!.globalRole === 'ADMIN'; if (!isAdmin && !(await canInvite(projectId, req.user!.userId))) { res.status(403).json({ error: 'Forbidden' }); return; } const { invitationId } = req.body as { invitationId: string }; if (!invitationId) { res.status(400).json({ error: 'invitationId required' }); return; } const existing = await prisma.invitation.findUnique({ where: { id: invitationId } }); if (!existing || existing.projectId !== projectId) { res.status(404).json({ error: 'Invitation not found' }); return; } const token = randomBytes(32).toString('hex'); const expiresAt = new Date(Date.now() + INVITE_EXPIRY_MS); const invitation = await prisma.invitation.update({ where: { id: invitationId }, data: { token, expiresAt, status: 'PENDING' }, }); res.json({ invitation, inviteUrl: buildInviteUrl(token) }); } catch (err) { console.error('Resend invitation error:', err); res.status(500).json({ error: 'Internal server error' }); } }); export default router;