changemaker.lite/api/src/modules/volunteer-invite/volunteer-invite.service.ts

107 lines
3.8 KiB
TypeScript

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 };
},
};