107 lines
3.8 KiB
TypeScript
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 };
|
|
},
|
|
};
|