File

apps/recallassess/recallassess-api/src/api/shared/email/services/unsubscribe-token.service.ts

Index

Properties
Methods

Constructor

constructor(prisma: BNestPrismaService)
Parameters :
Name Type Optional
prisma BNestPrismaService No

Methods

Async generateToken
generateToken(email: string, userType: "participant" | "participant_admin" | "super_admin", userId: number)

Generate secure unsubscribe token

Parameters :
Name Type Optional
email string No
userType "participant" | "participant_admin" | "super_admin" No
userId number No
Returns : Promise<string>
Async getOrGenerateToken
getOrGenerateToken(participantId: number)

Get or generate token for participant Reuses existing valid token if available, otherwise generates new one

Parameters :
Name Type Optional
participantId number No
Returns : Promise<string>
Async getOrGenerateTokenForUser
getOrGenerateTokenForUser(userId: number)

Get or generate token for super admin (User)

Parameters :
Name Type Optional
userId number No
Returns : Promise<string>
Async markTokenAsUsed
markTokenAsUsed(token: string)

Mark token as used

Parameters :
Name Type Optional
token string No
Returns : Promise<void>
Async validateToken
validateToken(token: string)

Validate and get token data

Parameters :
Name Type Optional
token string No
Returns : Promise<literal type>

Properties

Private Readonly logger
Type : unknown
Default value : new Logger(UnsubscribeTokenService.name)
import { Injectable, Logger, BadRequestException } from "@nestjs/common";
import { BNestPrismaService } from "@bish-nest/core/services";
import * as crypto from "crypto";

@Injectable()
export class UnsubscribeTokenService {
  private readonly logger = new Logger(UnsubscribeTokenService.name);

  constructor(private readonly prisma: BNestPrismaService) {}

  /**
   * Generate secure unsubscribe token
   */
  async generateToken(
    email: string,
    userType: "participant" | "participant_admin" | "super_admin",
    userId: number,
  ): Promise<string> {
    // Generate secure token (32 bytes = 64 hex characters)
    const token = crypto.randomBytes(32).toString("hex");

    // Expires in 90 days
    const expiresAt = new Date();
    expiresAt.setDate(expiresAt.getDate() + 90);

    await this.prisma.client.emailUnsubscribeToken.create({
      data: {
        token,
        email: email.toLowerCase(),
        user_type: userType,
        user_id: userId,
        expires_at: expiresAt,
      },
    });

    this.logger.debug(`Generated unsubscribe token for ${email} (${userType})`);

    return token;
  }

  /**
   * Validate and get token data
   */
  async validateToken(token: string): Promise<{
    email: string;
    user_type: string;
    user_id: number;
  }> {
    if (!token) {
      throw new BadRequestException("Token is required");
    }

    const tokenRecord = await this.prisma.client.emailUnsubscribeToken.findUnique({
      where: { token },
    });

    if (!tokenRecord) {
      throw new BadRequestException("Invalid unsubscribe token");
    }

    if (tokenRecord.used) {
      throw new BadRequestException("This unsubscribe link has already been used");
    }

    if (new Date() > tokenRecord.expires_at) {
      throw new BadRequestException("Unsubscribe link has expired");
    }

    return {
      email: tokenRecord.email,
      user_type: tokenRecord.user_type,
      user_id: tokenRecord.user_id,
    };
  }

  /**
   * Mark token as used
   */
  async markTokenAsUsed(token: string): Promise<void> {
    await this.prisma.client.emailUnsubscribeToken.update({
      where: { token },
      data: {
        used: true,
        used_at: new Date(),
      },
    });

    this.logger.debug(`Marked unsubscribe token as used: ${token.substring(0, 8)}...`);
  }

  /**
   * Get or generate token for participant
   * Reuses existing valid token if available, otherwise generates new one
   */
  async getOrGenerateToken(participantId: number): Promise<string> {
    const participant = await this.prisma.client.participant.findUnique({
      where: { id: participantId },
      select: {
        id: true,
        email: true,
        role: true,
      },
    });

    if (!participant) {
      throw new BadRequestException(`Participant not found: ${participantId}`);
    }

    // Check for existing valid token
    const existingToken = await this.prisma.client.emailUnsubscribeToken.findFirst({
      where: {
        email: participant.email.toLowerCase(),
        user_type: participant.role === "PARTICIPANT_ADMIN" ? "participant_admin" : "participant",
        user_id: participantId,
        used: false,
        expires_at: { gt: new Date() },
      },
      orderBy: { created_at: "desc" },
    });

    if (existingToken) {
      return existingToken.token;
    }

    // Generate new token
    return this.generateToken(
      participant.email,
      participant.role === "PARTICIPANT_ADMIN" ? "participant_admin" : "participant",
      participantId,
    );
  }

  /**
   * Get or generate token for super admin (User)
   */
  async getOrGenerateTokenForUser(userId: number): Promise<string> {
    const user = await this.prisma.client.user.findUnique({
      where: { id: userId },
      select: {
        id: true,
        email: true,
      },
    });

    if (!user) {
      throw new BadRequestException(`User not found: ${userId}`);
    }

    // Check for existing valid token
    const existingToken = await this.prisma.client.emailUnsubscribeToken.findFirst({
      where: {
        email: user.email.toLowerCase(),
        user_type: "super_admin",
        user_id: userId,
        used: false,
        expires_at: { gt: new Date() },
      },
      orderBy: { created_at: "desc" },
    });

    if (existingToken) {
      return existingToken.token;
    }

    // Generate new token
    return this.generateToken(user.email, "super_admin", userId);
  }
}

results matching ""

    No results matching ""