apps/recallassess/recallassess-api/src/api/client/course/course.service.ts
Methods |
|
constructor(prisma: BNestPrismaService, mediaUtilService: BNestMediaUtilService)
|
|||||||||
|
Parameters :
|
| Private Async enrichCourseData | ||||||||||||
enrichCourseData(course: CourseWithModules, index: number)
|
||||||||||||
|
Enrich course data with additional fields for frontend
Parameters :
Returns :
Promise<CLCourseDto>
Enriched course DTO |
| Async getPublishedCourseById | ||||||||
getPublishedCourseById(id: number)
|
||||||||
|
Get a single published course by ID
Parameters :
Returns :
Promise<CLCourseDto | null>
Enriched course data |
| Async getPublishedCourses |
getPublishedCourses()
|
|
Get all published courses with enriched data for client consumption
Returns :
Promise<CLCourseDto[]>
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,
};
}
}