| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550 |
- 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<boolean> {
- 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;
|