import { randomBytes } from 'crypto'; import nodemailer from 'nodemailer'; import { InvitationRole, InvitationScope, InvitationStatus, Prisma, ProjectMemberRole, WorkspaceMemberRole } from '@prisma/client'; import { db } from '@/lib/db'; import { brandedEmailTemplate, emailButton, emailHeading, emailHighlight, emailRow, escapeHtml, } from '@/lib/email-brand'; import { logError } from '@/lib/logger'; const INVITATION_TTL_DAYS = 7; const MAX_INVITATION_RETRIES = 3; function createSmtpTransport() { const host = process.env.SMTP_HOST; const port = Number(process.env.SMTP_PORT || '587'); const user = process.env.SMTP_USER; const pass = process.env.SMTP_PASSWORD; if (!host || !user || !pass) return null; return nodemailer.createTransport({ host, port, secure: port === 465, auth: { user, pass }, }); } function roleLabel(role: InvitationRole): string { return role === 'ADMIN' ? 'Admin' : 'Commentator'; } function scopeLabel(scope: InvitationScope): string { return scope === 'WORKSPACE' ? 'workspace' : 'project'; } export function buildInvitationUrl(token: string): string { const baseUrl = process.env.NEXTAUTH_URL || 'http://localhost:3000'; const url = new URL('/invitations/accept', baseUrl); url.searchParams.set('token', token); return url.toString(); } export async function sendInvitationEmail(input: { to: string; inviterName: string; role: InvitationRole; scope: InvitationScope; targetName: string; invitationUrl: string; }): Promise { const transporter = createSmtpTransport(); if (!transporter) { console.warn('SMTP not configured — skipping invitation email'); return false; } const fromAddress = process.env.SMTP_FROM || process.env.EMAIL_FROM || 'OpenFrame '; const subject = `[OpenFrame] You were invited to a ${scopeLabel(input.scope)}: ${input.targetName}`; const html = invitationEmailTemplate({ inviterName: input.inviterName, role: roleLabel(input.role), scope: scopeLabel(input.scope), targetName: input.targetName, invitationUrl: input.invitationUrl, }); try { await transporter.sendMail({ from: fromAddress, to: input.to, subject, html, }); return true; } catch (error) { logError('Invitation email send failed:', error); return false; } } function invitationEmailTemplate(input: { inviterName: string; role: string; scope: string; targetName: string; invitationUrl: string; }): string { return brandedEmailTemplate( ` ${emailHeading('✓', `${escapeHtml(input.scope.charAt(0).toUpperCase() + input.scope.slice(1))} Invitation`)} ${emailHighlight('You were invited to join OpenFrame.')} ${emailRow('Invited by', escapeHtml(input.inviterName), true)} ${emailRow('Target', `${escapeHtml(input.targetName)} (${escapeHtml(input.scope)})`, true)} ${emailRow('Role', escapeHtml(input.role))} ${emailRow('Expires', `${INVITATION_TTL_DAYS} days`)}
${emailHighlight('Create an account (or sign in with this email) to accept this invitation.')} ${emailButton('Accept Invitation →', input.invitationUrl)} `, { footerText: `This invitation expires in ${INVITATION_TTL_DAYS} days.`, } ); } export async function createOrRefreshInvitation(params: { email: string; scope: InvitationScope; role: InvitationRole; invitedById: string; workspaceId?: string; projectId?: string; }) { const normalizedEmail = params.email.toLowerCase().trim(); for (let attempt = 1; attempt <= MAX_INVITATION_RETRIES; attempt++) { const now = new Date(); const expiresAt = new Date(now.getTime() + INVITATION_TTL_DAYS * 24 * 60 * 60 * 1000); const token = randomBytes(32).toString('hex'); try { return await db.$transaction(async (tx) => { await tx.invitation.updateMany({ where: { email: normalizedEmail, scope: params.scope, workspaceId: params.workspaceId ?? null, projectId: params.projectId ?? null, status: InvitationStatus.PENDING, expiresAt: { lte: now }, }, data: { status: InvitationStatus.EXPIRED, }, }); const existingPending = await tx.invitation.findFirst({ where: { email: normalizedEmail, scope: params.scope, workspaceId: params.workspaceId ?? null, projectId: params.projectId ?? null, status: InvitationStatus.PENDING, expiresAt: { gt: now }, }, orderBy: { createdAt: 'desc' }, select: { id: true }, }); if (existingPending) { await tx.invitation.updateMany({ where: { email: normalizedEmail, scope: params.scope, workspaceId: params.workspaceId ?? null, projectId: params.projectId ?? null, status: InvitationStatus.PENDING, id: { not: existingPending.id }, }, data: { status: InvitationStatus.CANCELED, }, }); return tx.invitation.update({ where: { id: existingPending.id }, data: { role: params.role, invitedById: params.invitedById, token, expiresAt, }, }); } return tx.invitation.create({ data: { email: normalizedEmail, scope: params.scope, role: params.role, invitedById: params.invitedById, workspaceId: params.workspaceId ?? null, projectId: params.projectId ?? null, token, expiresAt, status: InvitationStatus.PENDING, }, }); }, { isolationLevel: Prisma.TransactionIsolationLevel.Serializable, }); } catch (error) { const isSerializationFailure = error instanceof Prisma.PrismaClientKnownRequestError && error.code === 'P2034'; if (!isSerializationFailure || attempt === MAX_INVITATION_RETRIES) { throw error; } } } throw new Error('Failed to create invitation after retrying'); } export async function getValidInvitationByToken(token: string) { const now = new Date(); return db.invitation.findFirst({ where: { token, status: InvitationStatus.PENDING, expiresAt: { gt: now }, }, }); } async function acceptInvitation(tx: Prisma.TransactionClient, invitationId: string) { await tx.invitation.update({ where: { id: invitationId }, data: { status: InvitationStatus.ACCEPTED, acceptedAt: new Date(), }, }); } async function applyInvitationMembership(tx: Prisma.TransactionClient, invitation: { id: string; role: InvitationRole; scope: InvitationScope; workspaceId: string | null; projectId: string | null; }, userId: string) { if (invitation.scope === InvitationScope.WORKSPACE && invitation.workspaceId) { const workspace = await tx.workspace.findUnique({ where: { id: invitation.workspaceId }, select: { ownerId: true }, }); if (!workspace) return; if (workspace.ownerId !== userId) { await tx.workspaceMember.upsert({ where: { workspaceId_userId: { workspaceId: invitation.workspaceId, userId, }, }, update: { role: invitation.role === InvitationRole.ADMIN ? WorkspaceMemberRole.ADMIN : WorkspaceMemberRole.COMMENTATOR, }, create: { workspaceId: invitation.workspaceId, userId, role: invitation.role === InvitationRole.ADMIN ? WorkspaceMemberRole.ADMIN : WorkspaceMemberRole.COMMENTATOR, }, }); } await acceptInvitation(tx, invitation.id); return; } if (invitation.scope === InvitationScope.PROJECT && invitation.projectId) { const project = await tx.project.findUnique({ where: { id: invitation.projectId }, select: { ownerId: true }, }); if (!project) return; if (project.ownerId !== userId) { await tx.projectMember.upsert({ where: { projectId_userId: { projectId: invitation.projectId, userId, }, }, update: { role: invitation.role === InvitationRole.ADMIN ? ProjectMemberRole.ADMIN : ProjectMemberRole.COMMENTATOR, }, create: { projectId: invitation.projectId, userId, role: invitation.role === InvitationRole.ADMIN ? ProjectMemberRole.ADMIN : ProjectMemberRole.COMMENTATOR, }, }); } await acceptInvitation(tx, invitation.id); } } export async function acceptInvitationTokenForUser(input: { token: string; userId: string; email: string; }): Promise<'accepted' | 'not_found' | 'forbidden' | 'expired'> { const normalizedEmail = input.email.toLowerCase().trim(); const now = new Date(); return db.$transaction(async (tx) => { const invitation = await tx.invitation.findUnique({ where: { token: input.token }, }); if (!invitation) return 'not_found'; if (invitation.email !== normalizedEmail) return 'forbidden'; if (invitation.status !== InvitationStatus.PENDING) return 'not_found'; if (invitation.expiresAt <= now) { await tx.invitation.update({ where: { id: invitation.id }, data: { status: InvitationStatus.EXPIRED }, }); return 'expired'; } await applyInvitationMembership(tx, invitation, input.userId); return 'accepted'; }); } export async function acceptPendingInvitationsForUser(userId: string, email: string) { const normalizedEmail = email.toLowerCase().trim(); const now = new Date(); await db.$transaction(async (tx) => { await tx.invitation.updateMany({ where: { email: normalizedEmail, status: InvitationStatus.PENDING, expiresAt: { lte: now }, }, data: { status: InvitationStatus.EXPIRED, }, }); const pendingInvitations = await tx.invitation.findMany({ where: { email: normalizedEmail, status: InvitationStatus.PENDING, expiresAt: { gt: now }, }, orderBy: { createdAt: 'asc' }, }); for (const invitation of pendingInvitations) { await applyInvitationMembership(tx, invitation, userId); } }); }