File

apps/recallassess/recallassess-api/src/api/shared/email/controllers/email-preferences.controller.ts

Prefix

api/admin/email/preferences

Description

Super-admin / recallassess-admin PWA endpoints under the same base path as CLEmailPreferencesController. Duplicate routes (unsubscribe/resubscribe/status list) live only in the client controller to avoid Nest route conflicts.

Index

Methods

Methods

Async bulkResubscribe
bulkResubscribe(dto: literal type)
Decorators :
@Post('bulk-resubscribe')
@HttpCode(HttpStatus.OK)
Parameters :
Name Type Optional
dto literal type No
Returns : unknown
Async bulkUnsubscribe
bulkUnsubscribe(dto: literal type)
Decorators :
@Post('bulk-unsubscribe')
@HttpCode(HttpStatus.OK)
Parameters :
Name Type Optional
dto literal type No
Returns : unknown
Async getCompanyEmailHistory
getCompanyEmailHistory(companyId: number, limit?: number)
Decorators :
@Get('company/:companyId/history')
@HttpCode(HttpStatus.OK)
Parameters :
Name Type Optional
companyId number No
limit number Yes
Returns : unknown
Async getEmailHistory
getEmailHistory(participantId: number, limit?: number)
Decorators :
@Get('participant/:participantId/history')
@HttpCode(HttpStatus.OK)
Parameters :
Name Type Optional
participantId number No
limit number Yes
Returns : unknown
Async getEmailStatus
getEmailStatus(participantId: number)
Decorators :
@Get('participant/:participantId/status')
@HttpCode(HttpStatus.OK)
Parameters :
Name Type Optional
participantId number No
Returns : unknown
Async getParticipantPreferences
getParticipantPreferences(participantId: number)
Decorators :
@Get('participant/:participantId')
@HttpCode(HttpStatus.OK)
Parameters :
Name Type Optional
participantId number No
Returns : unknown
Async updateParticipantPreferences
updateParticipantPreferences(participantId: number, preferences: Partial<unknown>)
Decorators :
@Put('participant/:participantId')
@HttpCode(HttpStatus.OK)
Parameters :
Name Type Optional
participantId number No
preferences Partial<unknown> No
Returns : unknown
import { BNestEmailSenderService, requireEnv } from "@bish-nest/core";

import { BNestPrismaService } from "@bish-nest/core/services";
import {
  Body,
  Controller,
  Get,
  HttpCode,
  HttpStatus,
  Logger,
  Param,
  ParseIntPipe,
  Post,
  Put,
  Query,
} from "@nestjs/common";
import { EmailPreferencesService } from "../services/email-preferences.service";

/**
 * Super-admin / recallassess-admin PWA endpoints under the same base path as
 * {@link CLEmailPreferencesController}. Duplicate routes (unsubscribe/resubscribe/status list)
 * live only in the client controller to avoid Nest route conflicts.
 */
@Controller("api/admin/email/preferences")
export class EmailPreferencesController {
  private readonly logger = new Logger(EmailPreferencesController.name);

  constructor(
    private readonly emailPreferencesService: EmailPreferencesService,
    private readonly prisma: BNestPrismaService,
    private readonly emailSender: BNestEmailSenderService,
  ) {}

  @Get("participant/:participantId")
  @HttpCode(HttpStatus.OK)
  async getParticipantPreferences(@Param("participantId", ParseIntPipe) participantId: number) {
    return this.emailPreferencesService.getParticipantPreferences(participantId);
  }

  @Put("participant/:participantId")
  @HttpCode(HttpStatus.OK)
  async updateParticipantPreferences(
    @Param("participantId", ParseIntPipe) participantId: number,
    @Body() preferences: Partial<import("../services/email-preferences.service").EmailPreferences>,
  ) {
    return this.emailPreferencesService.updateParticipantPreferences(participantId, preferences);
  }

  @Get("participant/:participantId/status")
  @HttpCode(HttpStatus.OK)
  async getEmailStatus(@Param("participantId", ParseIntPipe) participantId: number) {
    const isUnsubscribed = await this.emailPreferencesService.isUnsubscribed(participantId);
    const preferences = await this.emailPreferencesService.getParticipantPreferences(participantId);

    // Get email preference details
    const prefs = await (this.prisma.client as any).emailPreference.findUnique({
      where: { participant_id: participantId },
    });

    return {
      isUnsubscribed,
      allRemindersEnabled: prefs?.all_reminders_enabled ?? true,
      unsubscribedAt: prefs?.unsubscribed_at ?? null,
      unsubscribedReason: prefs?.unsubscribed_reason ?? null,
      resubscribedAt: prefs?.resubscribed_at ?? null,
      resubscribeCount: prefs?.resubscribe_count ?? 0,
      preferences,
    };
  }

  @Get("participant/:participantId/history")
  @HttpCode(HttpStatus.OK)
  async getEmailHistory(
    @Param("participantId", ParseIntPipe) participantId: number,
    @Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
  ) {
    const participant = await this.prisma.client.participant.findUnique({
      where: { id: participantId },
      select: { email: true },
    });
    if (!participant) {
      return [];
    }

    const take = limit || 10;
    const emailTrimmed = participant.email?.trim();

    // Many rows only set recipient_email or learning_group_participant_id, not participant_id.
    const emails = await this.prisma.client.emailLog.findMany({
      where: {
        OR: [
          { participant_id: participantId },
          {
            learningGroupParticipant: {
              participant_id: participantId,
            },
          },
          ...(emailTrimmed
            ? [
                {
                  recipient_email: {
                    equals: emailTrimmed,
                    mode: "insensitive" as const,
                  },
                },
              ]
            : []),
        ],
      },
      orderBy: {
        created_at: "desc",
      },
      take,
      select: {
        id: true,
        subject: true,
        status: true,
        sent_date: true,
        created_at: true,
        error_message: true,
        bounce_reason: true,
        complaint_reason: true,
        emailTemplate: {
          select: {
            template_key: true,
            subject: true,
            description: true,
          },
        },
      },
    });

    return emails;
  }

  @Get("company/:companyId/history")
  @HttpCode(HttpStatus.OK)
  async getCompanyEmailHistory(
    @Param("companyId", ParseIntPipe) companyId: number,
    @Query("limit", new ParseIntPipe({ optional: true })) limit?: number,
  ) {
    const emails = await this.prisma.client.emailLog.findMany({
      where: {
        company_id: companyId,
      },
      orderBy: {
        created_at: "desc",
      },
      take: limit || 10,
      select: {
        id: true,
        recipient_email: true,
        subject: true,
        status: true,
        sent_date: true,
        created_at: true,
        error_message: true,
        bounce_reason: true,
        complaint_reason: true,
        emailTemplate: {
          select: {
            template_key: true,
            subject: true,
            description: true,
          },
        },
      },
    });

    return emails;
  }

  @Post("bulk-unsubscribe")
  @HttpCode(HttpStatus.OK)
  async bulkUnsubscribe(@Body() dto: { participantIds: number[]; reason?: string; adminNotes?: string }) {
    const results = {
      total: dto.participantIds.length,
      successful: 0,
      failed: 0,
      errors: [] as Array<{ participantId: number; error: string }>,
    };

    const frontendUrl = requireEnv("FRONTEND_URL");
    const managePreferencesUrl = `${frontendUrl}/portal/settings/notifications`;
    const resubscribeUrl = `${frontendUrl}/portal/settings/notifications`;

    for (const participantId of dto.participantIds) {
      try {
        // Get participant info
        const participant = await this.prisma.client.participant.findUnique({
          where: { id: participantId },
          select: {
            email: true,
            first_name: true,
            last_name: true,
            company_id: true,
          },
        });

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

        // Perform unsubscribe
        await this.emailPreferencesService.unsubscribeParticipant(participantId, dto.reason);
        results.successful++;

        // Send confirmation email to participant (fire-and-forget)
        void this.emailSender
          .sendTemplatedEmail({
            to: participant.email,
            templateKey: "subscription.unsubscribe.confirmation",
            variables: {
              "user.name":
                participant.first_name && participant.last_name
                  ? `${participant.first_name} ${participant.last_name}`
                  : participant.email,
              "system.resubscribeUrl": resubscribeUrl,
              "system.managePreferencesUrl": managePreferencesUrl,
            },
            metadata: {
              participant_id: participantId,
              company_id: participant.company_id,
              unsubscribe_reason: dto.reason || "admin-panel-bulk",
              initiated_by: "super_admin",
            },
          })
          .catch((error) => {
            this.logger.error(`Failed to send unsubscribe confirmation email to ${participant.email}:`, error);
            // Don't fail the unsubscribe if confirmation email fails
          });
      } catch (error: any) {
        results.failed++;
        results.errors.push({
          participantId,
          error: error.message || "Unknown error",
        });
      }
    }

    return results;
  }

  @Post("bulk-resubscribe")
  @HttpCode(HttpStatus.OK)
  async bulkResubscribe(@Body() dto: { participantIds: number[] }) {
    const results = {
      total: dto.participantIds.length,
      successful: 0,
      failed: 0,
      errors: [] as Array<{ participantId: number; error: string }>,
    };

    const frontendUrlR = requireEnv("FRONTEND_URL");
    const managePreferencesUrl = `${frontendUrlR}/portal/settings/notifications`;

    for (const participantId of dto.participantIds) {
      try {
        // Get participant info
        const participant = await this.prisma.client.participant.findUnique({
          where: { id: participantId },
          select: {
            email: true,
            first_name: true,
            last_name: true,
            company_id: true,
          },
        });

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

        // Perform resubscribe
        await this.emailPreferencesService.resubscribeParticipant(participantId, "admin_panel_bulk");
        results.successful++;

        // Send welcome back email to participant (fire-and-forget)
        void this.emailSender
          .sendTemplatedEmail({
            to: participant.email,
            templateKey: "subscription.resubscribe.welcome",
            variables: {
              "user.name":
                participant.first_name && participant.last_name
                  ? `${participant.first_name} ${participant.last_name}`
                  : participant.email,
              "system.managePreferencesUrl": managePreferencesUrl,
            },
            metadata: {
              participant_id: participantId,
              company_id: participant.company_id,
              initiated_by: "super_admin",
            },
          })
          .catch((error) => {
            this.logger.error(`Failed to send resubscribe welcome email to ${participant.email}:`, error);
            // Don't fail the resubscribe if welcome email fails
          });
      } catch (error: any) {
        results.failed++;
        results.errors.push({
          participantId,
          error: error.message || "Unknown error",
        });
      }
    }

    return results;
  }
}

results matching ""

    No results matching ""