File

apps/recallassess/recallassess-api/src/api/client/shared/participant-subscription-course-access.service.ts

Index

Methods

Constructor

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

Methods

Async assertAllowsCompletedStageRead
assertAllowsCompletedStageRead(participantId: number, learningGroupParticipantId: number, stageCompleted: boolean, enrollmentStatus?: string | null)

Stage-result read access when company subscription is inactive: allow if the specific stage is already completed (or enrollment fully completed).

Parameters :
Name Type Optional
participantId number No
learningGroupParticipantId number No
stageCompleted boolean No
enrollmentStatus string | null Yes
Returns : Promise<void>
Async assertAllowsContinuingCourseAccess
assertAllowsContinuingCourseAccess(participantId: number, learningGroupParticipantId: number, enrollmentStatus?: string | null)

Block starting/continuing a course when the company subscription is inactive, unless the enrollment is fully completed (read-only access to results).

Parameters :
Name Type Optional
participantId number No
learningGroupParticipantId number No
enrollmentStatus string | null Yes
Returns : Promise<void>
Async assertAllowsElearningRead
assertAllowsElearningRead(participantId: number, learningGroupParticipantId: number, enrollmentStatus?: string | null)

E-learning read access when subscription is inactive: allow if enrollment is fully completed OR e-learning stage is completed.

Parameters :
Name Type Optional
participantId number No
learningGroupParticipantId number No
enrollmentStatus string | null Yes
Returns : Promise<void>
Async assertAllowsKnowledgeReviewRead
assertAllowsKnowledgeReviewRead(participantId: number, learningGroupParticipantId: number, enrollmentStatus: string, courseId: number, knowledgeReviewId: number)

Knowledge review GET: allow read when subscription inactive if the course is completed, or if the standalone KR was already submitted (review results mid-journey).

Parameters :
Name Type Optional
participantId number No
learningGroupParticipantId number No
enrollmentStatus string No
courseId number No
knowledgeReviewId number No
Returns : Promise<void>
Async assertCompanySubscriptionActive
assertCompanySubscriptionActive(participantId: number)

New allocations / accepting invitations while subscription is inactive.

Parameters :
Name Type Optional
participantId number No
Returns : Promise<void>
Async isCompanyActiveForParticipant
isCompanyActiveForParticipant(participantId: number)

Delegates to isPortalCompanyActive for the participant's company.

Parameters :
Name Type Optional
participantId number No
Returns : Promise<boolean>
Async isPortalCompanyActive
isPortalCompanyActive(companyId: number)

Portal "company is OK for training" — same for FREE_TRIAL and paid plans: no special trial bypass.

  • company.is_subscription_expiry must be false.
  • Company must have a current ACTIVE subscription row with a non-null next_billing_date.
Parameters :
Name Type Optional
companyId number No
Returns : Promise<boolean>
import { BNestPrismaService } from "@bish-nest/core/services";
import { ForbiddenException, Injectable, NotFoundException } from "@nestjs/common";
import { ELearningProgressStatus, ParticipantLearningProgressStatus, SubscriptionStatus } from "@prisma/client";

/**
 * Shown when the participant tries to start/continue training while the company subscription is inactive.
 * Completed enrollments may still view results (read-only).
 */
export const SUBSCRIPTION_EXPIRED_COURSE_ACCESS_MESSAGE =
  "Your company subscription has expired. You can still open completed courses to review the results. To start or continue training, please ask your administrator to renew the subscription";

@Injectable()
export class ParticipantSubscriptionCourseAccessService {
  constructor(private readonly prisma: BNestPrismaService) {}

  /**
   * Portal "company is OK for training" — **same for FREE_TRIAL and paid plans**: no special trial bypass.
   * - `company.is_subscription_expiry` must be false.
   * - Company must have a **current ACTIVE** subscription row with a non-null `next_billing_date`.
   */
  async isPortalCompanyActive(companyId: number): Promise<boolean> {
    const company = await this.prisma.client.company.findUnique({
      where: { id: companyId },
      select: {
        is_subscription_expiry: true,
      },
    });
    if (!company) return false;
    if (company.is_subscription_expiry) return false;

    const hasCurrentActive = await this.prisma.client.subscription.findFirst({
      where: {
        company_id: companyId,
        is_current: true,
        status: SubscriptionStatus.ACTIVE,
        next_billing_date: { not: null },
      },
      select: { id: true },
    });
    return !!hasCurrentActive;
  }

  /** Delegates to {@link isPortalCompanyActive} for the participant's company. */
  async isCompanyActiveForParticipant(participantId: number): Promise<boolean> {
    const participant = await this.prisma.client.participant.findUnique({
      where: { id: participantId },
      select: {
        company_id: true,
      },
    });
    if (!participant?.company_id) return false;
    return this.isPortalCompanyActive(participant.company_id);
  }

  /**
   * Block starting/continuing a course when the company subscription is inactive,
   * unless the enrollment is fully completed (read-only access to results).
   */
  async assertAllowsContinuingCourseAccess(
    participantId: number,
    learningGroupParticipantId: number,
    enrollmentStatus?: string | null,
  ): Promise<void> {
    if (await this.isCompanyActiveForParticipant(participantId)) {
      return;
    }

    let status = enrollmentStatus;
    if (status === undefined || status === null) {
      const row = await this.prisma.client.learningGroupParticipant.findFirst({
        where: { id: learningGroupParticipantId, participant_id: participantId },
        select: { status: true },
      });
      if (!row) {
        throw new NotFoundException("Course enrollment not found or access denied");
      }
      status = row.status;
    }

    if (status === ParticipantLearningProgressStatus.COMPLETED) {
      return;
    }

    throw new ForbiddenException(SUBSCRIPTION_EXPIRED_COURSE_ACCESS_MESSAGE);
  }

  /**
   * Knowledge review GET: allow read when subscription inactive if the course is completed,
   * or if the standalone KR was already submitted (review results mid-journey).
   */
  async assertAllowsKnowledgeReviewRead(
    participantId: number,
    learningGroupParticipantId: number,
    enrollmentStatus: string,
    courseId: number,
    knowledgeReviewId: number,
  ): Promise<void> {
    if (await this.isCompanyActiveForParticipant(participantId)) {
      return;
    }
    if (enrollmentStatus === ParticipantLearningProgressStatus.COMPLETED) {
      return;
    }

    const krDone = await this.prisma.client.knowledgeReviewParticipant.findFirst({
      where: {
        participant_id: participantId,
        knowledge_review_id: knowledgeReviewId,
        course_id: courseId,
        course_module_page_id: null,
      } as any,
    });

    if (krDone) {
      return;
    }

    throw new ForbiddenException(SUBSCRIPTION_EXPIRED_COURSE_ACCESS_MESSAGE);
  }

  /**
   * Stage-result read access when company subscription is inactive:
   * allow if the specific stage is already completed (or enrollment fully completed).
   */
  async assertAllowsCompletedStageRead(
    participantId: number,
    learningGroupParticipantId: number,
    stageCompleted: boolean,
    enrollmentStatus?: string | null,
  ): Promise<void> {
    if (await this.isCompanyActiveForParticipant(participantId)) {
      return;
    }

    if (stageCompleted) {
      return;
    }

    let status = enrollmentStatus;
    if (status === undefined || status === null) {
      const row = await this.prisma.client.learningGroupParticipant.findFirst({
        where: { id: learningGroupParticipantId, participant_id: participantId },
        select: { status: true },
      });
      if (!row) {
        throw new NotFoundException("Course enrollment not found or access denied");
      }
      status = row.status;
    }

    if (status === ParticipantLearningProgressStatus.COMPLETED) {
      return;
    }

    throw new ForbiddenException(SUBSCRIPTION_EXPIRED_COURSE_ACCESS_MESSAGE);
  }

  /**
   * E-learning read access when subscription is inactive:
   * allow if enrollment is fully completed OR e-learning stage is completed.
   */
  async assertAllowsElearningRead(
    participantId: number,
    learningGroupParticipantId: number,
    enrollmentStatus?: string | null,
  ): Promise<void> {
    if (await this.isCompanyActiveForParticipant(participantId)) {
      return;
    }

    const enrollment = await this.prisma.client.learningGroupParticipant.findFirst({
      where: { id: learningGroupParticipantId, participant_id: participantId },
      select: {
        status: true,
        course_id: true,
        learning_group_id: true,
      },
    });
    if (!enrollment) {
      throw new NotFoundException("Course enrollment not found or access denied");
    }

    const status = enrollmentStatus ?? enrollment.status;
    if (status === ParticipantLearningProgressStatus.COMPLETED) {
      return;
    }

    const eLearningParticipant = await this.prisma.client.eLearningParticipant.findUnique({
      where: {
        participant_id_course_id_learning_group_id: {
          participant_id: participantId,
          course_id: enrollment.course_id,
          learning_group_id: enrollment.learning_group_id,
        },
      },
      select: {
        status: true,
        course_modules_completed: true,
        total_course_modules: true,
      },
    });

    const isElearningCompleted =
      eLearningParticipant?.status === ELearningProgressStatus.COMPLETED ||
      ((eLearningParticipant?.course_modules_completed ?? 0) > 0 &&
        (eLearningParticipant?.total_course_modules ?? 0) > 0 &&
        (eLearningParticipant?.course_modules_completed ?? 0) >=
          (eLearningParticipant?.total_course_modules ?? 0));

    if (isElearningCompleted) {
      return;
    }

    throw new ForbiddenException(SUBSCRIPTION_EXPIRED_COURSE_ACCESS_MESSAGE);
  }

  /**
   * New allocations / accepting invitations while subscription is inactive.
   */
  async assertCompanySubscriptionActive(participantId: number): Promise<void> {
    if (!(await this.isCompanyActiveForParticipant(participantId))) {
      throw new ForbiddenException(SUBSCRIPTION_EXPIRED_COURSE_ACCESS_MESSAGE);
    }
  }
}

results matching ""

    No results matching ""