import crypto from 'crypto'; import jwt from 'jsonwebtoken'; import bcrypt from 'bcryptjs'; import { UserCreatedVia, UserRole, UserStatus } from '@prisma/client'; import { prisma } from '../../config/database'; import { env } from '../../config/env'; import { authService } from '../auth/auth.service'; import { AppError } from '../../middleware/error-handler'; import type { RedeemInviteInput } from './volunteer-invite.schemas'; interface InviteTokenPayload { type: 'volunteer_invite'; adminUserId: string; cutId?: string; shiftId?: string; } export const volunteerInviteService = { /** * Generate a signed invite token (JWT, 30 min expiry). * Contains the inviting admin's ID and optional cut/shift context. */ generateInviteToken(adminUserId: string, cutId?: string, shiftId?: string): string { const payload: InviteTokenPayload = { type: 'volunteer_invite', adminUserId, ...(cutId && { cutId }), ...(shiftId && { shiftId }), }; return jwt.sign(payload, env.JWT_ACCESS_SECRET, { expiresIn: '30m' }); }, /** * Redeem an invite token: verify it, create (or reactivate) a TEMP user, * and return a JWT token pair for immediate login. */ async redeemInvite(input: RedeemInviteInput) { // 1. Verify and decode the invite token let payload: InviteTokenPayload; try { const decoded = jwt.verify(input.token, env.JWT_ACCESS_SECRET); payload = decoded as InviteTokenPayload; } catch { throw new AppError(400, 'Invalid or expired invite link', 'INVALID_INVITE_TOKEN'); } // 2. Validate token type to prevent JWT confusion attacks if (payload.type !== 'volunteer_invite') { throw new AppError(400, 'Invalid invite token', 'INVALID_TOKEN_TYPE'); } const email = input.email.toLowerCase().trim(); const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000); // +24h // 3. Check for existing user const existingUser = await prisma.user.findUnique({ where: { email } }); if (existingUser) { // Active non-TEMP user — just generate new token pair (re-login) if (existingUser.status === UserStatus.ACTIVE && existingUser.role !== UserRole.TEMP) { const tokens = await authService.generateTokenPair(existingUser); return { tokens, user: existingUser, cutId: payload.cutId, shiftId: payload.shiftId }; } // Expired or inactive TEMP user — reactivate with extended expiry if (existingUser.role === UserRole.TEMP) { const reactivated = await prisma.user.update({ where: { id: existingUser.id }, data: { status: UserStatus.ACTIVE, expiresAt, name: input.name || existingUser.name, phone: input.phone || existingUser.phone, }, }); const tokens = await authService.generateTokenPair(reactivated); return { tokens, user: reactivated, cutId: payload.cutId, shiftId: payload.shiftId }; } // Suspended/inactive non-TEMP user — block throw new AppError(403, 'Account is not active. Please contact an administrator.', 'ACCOUNT_INACTIVE'); } // 4. Create new TEMP user with random password (never shown to user) const randomPassword = crypto.randomBytes(16).toString('hex'); const hashedPassword = await bcrypt.hash(randomPassword, 10); const newUser = await prisma.user.create({ data: { email, password: hashedPassword, name: input.name || null, phone: input.phone || null, role: UserRole.TEMP, roles: JSON.stringify([UserRole.TEMP]), status: UserStatus.ACTIVE, createdVia: UserCreatedVia.QUICK_JOIN_INVITE, expiresAt, }, }); const tokens = await authService.generateTokenPair(newUser); return { tokens, user: newUser, cutId: payload.cutId, shiftId: payload.shiftId }; }, };