File

apps/recallassess/recallassess-api/src/api/client/course/course.service.ts

Index

Methods

Constructor

constructor(prisma: BNestPrismaService, mediaUtilService: BNestMediaUtilService)
Parameters :
Name Type Optional
prisma BNestPrismaService No
mediaUtilService BNestMediaUtilService No

Methods

Private Async enrichCourseData
enrichCourseData(course: CourseWithModules, index: number)

Enrich course data with additional fields for frontend

Parameters :
Name Type Optional Description
course CourseWithModules No

Database course object

index number No

Index for fallback category assignment

Enriched course DTO

Async getPublishedCourseById
getPublishedCourseById(id: number)

Get a single published course by ID

Parameters :
Name Type Optional Description
id number No

Course ID

Enriched course data

Async getPublishedCourses
getPublishedCourses()

Get all published courses with enriched data for client consumption

Array of courses formatted for the landing page

import { optionalEnv } from "@bish-nest/core";
import { BNestMediaUtilService, BNestPrismaService } from "@bish-nest/core/services";
import { Injectable } from "@nestjs/common";
import { Course, CourseModule, Media, MediaName, MediaType } from "@prisma/client";
import { CLCourseDto } from "./dto";

type CourseWithModules = Course & {
  courseModulePages: Array<{
    courseModule: CourseModule | null;
  }>;
  mediaImages: Media[];
  courseDetailPdfDocuments: Media[];
  testimonialsPdfDocuments: Media[];
  _count: {
    courseProgress: number;
  };
};

@Injectable()
export class CLCourseService {
  constructor(
    private prisma: BNestPrismaService,
    private mediaUtilService: BNestMediaUtilService,
  ) {}

  /**
   * Get all published courses with enriched data for client consumption
   * @returns Array of courses formatted for the landing page
   */
  async getPublishedCourses(): Promise<CLCourseDto[]> {
    // Fetch published courses from database with related modules
    const courses = await this.prisma.client.course.findMany({
      where: {
        is_published: true,
      },
      include: {
        courseModulePages: {
          where: {
            courseModule: {
              is_published: true,
            },
          },
          select: {
            courseModule: {
              select: {
                id: true,
                title: true,
                description: true,
                short_description: true,
                is_published: true,
                sort_order: true,
                flag: true,
                created_at: true,
                updated_at: true,
                user_id_created_by: true,
                user_id_updated_by: true,
                course_module_code: true,
                exclude_from_bat: true,
              },
            },
          },
        },
        mediaImages: true,
        courseDetailPdfDocuments: {
          where: {
            media_type: MediaType.DOCUMENT,
            media_name: MediaName.COURSE__DETAIL_PDF,
          },
          orderBy: [{ updated_at: "desc" }, { created_at: "desc" }],
          take: 1,
        },
        testimonialsPdfDocuments: {
          where: {
            media_type: MediaType.DOCUMENT,
            media_name: MediaName.COURSE__TESTIMONIALS_PDF,
          },
          orderBy: [{ updated_at: "desc" }, { created_at: "desc" }],
          take: 1,
        },
        _count: {
          select: {
            courseProgress: true, // Count of participants enrolled
          },
        },
      },
      orderBy: [
        { is_featured: "desc" }, // Featured courses first
        { sort_order: "asc" }, // Then by sort order
        { created_at: "desc" }, // Then by newest
      ],
    });

    // Process S3 URLs for media
    await Promise.all(
      courses.map(async (course) => {
        const courseImages = course.mediaImages?.filter((m) => m.media_name === "COURSE__IMAGE") || [];
        await this.mediaUtilService.addS3UrlPrefixToMediaArray(courseImages);
        course.mediaImages = courseImages;
      }),
    );

    // Transform database courses to client-facing format
    return Promise.all(courses.map((course, index) => this.enrichCourseData(course, index)));
  }

  /**
   * Get a single published course by ID
   * @param id Course ID
   * @returns Enriched course data
   */
  async getPublishedCourseById(id: number): Promise<CLCourseDto | null> {
    const course = await this.prisma.client.course.findFirst({
      where: {
        id,
        is_published: true,
      },
      include: {
        courseModulePages: {
          where: {
            courseModule: {
              is_published: true,
            },
          },
          select: {
            courseModule: {
              select: {
                id: true,
                title: true,
                description: true,
                short_description: true,
                is_published: true,
                sort_order: true,
                flag: true,
                created_at: true,
                updated_at: true,
                user_id_created_by: true,
                user_id_updated_by: true,
                course_module_code: true,
                exclude_from_bat: true,
              },
            },
          },
        },
        mediaImages: true,
        courseDetailPdfDocuments: {
          where: {
            media_type: MediaType.DOCUMENT,
            media_name: MediaName.COURSE__DETAIL_PDF,
          },
          orderBy: [{ updated_at: "desc" }, { created_at: "desc" }],
          take: 1,
        },
        testimonialsPdfDocuments: {
          where: {
            media_type: MediaType.DOCUMENT,
            media_name: MediaName.COURSE__TESTIMONIALS_PDF,
          },
          orderBy: [{ updated_at: "desc" }, { created_at: "desc" }],
          take: 1,
        },
        _count: {
          select: {
            courseProgress: true,
          },
        },
      },
    });

    if (!course) {
      return null;
    }

    // Process S3 URLs for media
    const courseImages = course.mediaImages?.filter((m) => m.media_name === "COURSE__IMAGE") || [];
    await this.mediaUtilService.addS3UrlPrefixToMediaArray(courseImages);
    course.mediaImages = courseImages;

    return this.enrichCourseData(course, 0);
  }

  /**
   * Enrich course data with additional fields for frontend
   * @param course Database course object
   * @param index Index for fallback category assignment
   * @returns Enriched course DTO
   */
  private async enrichCourseData(course: CourseWithModules, index: number): Promise<CLCourseDto> {
    // Extract unique modules from courseModulePages
    const courseModules = course.courseModulePages
      ?.map((page) => page.courseModule)
      .filter((module, idx, self) => module && self.findIndex((m) => m?.id === module.id) === idx)
      .filter((module): module is NonNullable<typeof module> => module !== null) || [];
    const moduleCount = courseModules.length;

    // Use category from DB, fallback to rotation for backward compatibility
    const categories = ["Communication", "Psychology", "Sales", "Negotiation", "Leadership"];
    const category = course.category || categories[index % categories.length];

    // Use level from DB, with color mapping
    const level_color_map = {
      FOUNDATION: "orange",
      INTERMEDIATE: "blue",
      ADVANCED: "green",
    };

    const level = course.level || "FOUNDATION";
    const level_color = level_color_map[level as keyof typeof level_color_map] || "orange";

    // Use database duration if set, otherwise calculate based on module count
    const calculatedDuration = (() => {
      const weeksMin = Math.max(3, Math.floor(moduleCount * 0.4));
      const weeksMax = Math.max(4, Math.ceil(moduleCount * 0.7));
      return `${weeksMin}-${weeksMax} weeks`;
    })();
    const duration = course.duration || calculatedDuration;

    // Get enrolled count
    const enrolled = course._count?.courseProgress || 0;

    // Get course image from S3 media
    const image = course.mediaImages && course.mediaImages.length > 0 ? course.mediaImages[0].media_path : "";

    // PDF links: prefer admin Media-tab uploads (relation rows), else legacy course_*_url columns.
    // Then convert paths / S3 URLs through CloudFront or presigned GET as configured.
    //
    // Note: Do not probe URLs with fetch() from the API. CloudFront/S3 often respond differently
    // to server-side HEAD/GET than to the visitor's browser (signed URLs, WAF, geo), which hid
    // valid PDF links behind "unavailable" UI on the landing page.

    const convertToCdnUrl = async (url?: string | null) => {
      if (!url) return url;

      const bucket = optionalEnv("AWS_S3_MEDIA_BUCKET", "");
      const region = optionalEnv("AWS_REGION", "");
      const s3Domain = bucket && region ? `${bucket}.s3.${region}.amazonaws.com` : undefined;

      // Check if it's an S3 URL (with or without protocol)
      const isS3Url = s3Domain && (url.includes(s3Domain) || url.includes(`s3.${region}.amazonaws.com`));

      // If it's an S3 URL or a path (not starting with http/https), extract the key and convert
      if (isS3Url || !url.startsWith("http")) {
        // Extract the path/key portion - handle both with and without protocol
        let key = url;
        if (url.startsWith("http://") || url.startsWith("https://")) {
          // Remove protocol and domain
          key = url.replace(/^https?:\/\/[^/]+\//, "");
        } else if (isS3Url) {
          // Remove domain if present (e.g., "bucket.s3.region.amazonaws.com/path" -> "path")
          key = url.replace(/^[^/]+\//, "");
        }
        // key is now just the path (e.g., "public/testimonials/file.pdf")
        try {
          return await this.mediaUtilService.getS3Url(key, true);
        } catch (error) {
          // If getS3Url fails (e.g., missing env vars), log and return original URL/path for debugging
          console.error(`[CourseService] Failed to convert URL to CDN: ${url}`, error);
          return url;
        }
      }

      // Already a non-S3 full URL (e.g., CloudFront or custom domain) -> leave as-is
      return url;
    };

    const detailMedia = course.courseDetailPdfDocuments?.[0];
    const testimonialsMedia = course.testimonialsPdfDocuments?.[0];

    const detailPdfPath =
      (await this.mediaUtilService.resolveS3ObjectKeyForMediaPath(
        detailMedia?.media_path ?? course.course_detail_url ?? "",
        {
          isPublic: detailMedia?.is_public ?? true,
          tenant_id: (detailMedia as { tenant_id?: number | null } | undefined)?.tenant_id ?? null,
        },
      )) || undefined;
    const testimonialsPdfPath =
      (await this.mediaUtilService.resolveS3ObjectKeyForMediaPath(
        testimonialsMedia?.media_path ?? course.testimonials_url ?? "",
        {
          isPublic: testimonialsMedia?.is_public ?? true,
          tenant_id: (testimonialsMedia as { tenant_id?: number | null } | undefined)?.tenant_id ?? null,
        },
      )) || undefined;

    const testimonialsUrl = await convertToCdnUrl(testimonialsPdfPath);
    const courseDetailUrl = await convertToCdnUrl(detailPdfPath);

    return {
      id: course.id,
      title: course.title,
      category,
      level,
      level_color,
      description: course.description || "",
      short_description: course.short_description || "",
      modules: moduleCount,
      duration,
      enrolled,
      image,
      image_alt: `${course.title} course illustration`,
      course_code: course.course_code,
      is_published: course.is_published,
      is_featured: course.is_featured,
      course_detail_url: courseDetailUrl,
      testimonials_url: testimonialsUrl,
      course_enrolled_override_text: course.course_enrolled_override_text,
    };
  }
}

results matching ""

    No results matching ""