import { ConflictException, Injectable, NotFoundException, Optional, UnprocessableEntityException, } from '@nestjs/common'; import { InjectDataSource } from '@nestjs/typeorm'; import { DataSource } from 'typeorm'; import { PaginatedResponse } from '../../common/dto/paginated-response.dto'; import { ConfigureEpisodesDto } from './dto/configure-episodes.dto'; import { CreateDramaDto } from './dto/create-drama.dto'; import { CreateEpisodeDto } from './dto/create-episode.dto'; import { UpdateDramaDto } from './dto/update-drama.dto'; import { UpdateEpisodeDto } from './dto/update-episode.dto'; import { Drama } from './entities/drama.entity'; import { Episode } from './entities/episode.entity'; import { PublishStatus } from './drama-status.enum'; import { DramaRecord, EpisodeRecord, } from './interfaces/drama-records.interface'; interface PaginationInput { page: number; pageSize: number; } @Injectable() export class DramasService { private dramaSequence = 1; private episodeSequence = 1; private readonly dramas: DramaRecord[] = []; private readonly episodes: EpisodeRecord[] = []; constructor( @Optional() @InjectDataSource() readonly dataSource?: DataSource, ) {} async createDrama(dto: CreateDramaDto): Promise { if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Drama); const entity = repository.create({ tiktokAlbumId: dto.tiktokAlbumId, title: dto.title, description: dto.description, coverId: dto.coverId, coverUrl: dto.coverUrl, portraitCoverUrl: dto.portraitCoverUrl, landscapeCoverUrl: dto.landscapeCoverUrl, type: dto.type, tags: dto.tags ?? [], region: dto.region, language: dto.language, year: dto.year, status: dto.status ?? PublishStatus.Draft, sortOrder: dto.sortOrder ?? 0, isRecommended: dto.isRecommended ?? false, onlineVersion: dto.onlineVersion ?? 1, }); const saved = await repository.save(entity); return this.toDramaRecord(saved, 0, 0); } const now = new Date().toISOString(); const drama: DramaRecord = { id: this.dramaSequence++, tiktokAlbumId: dto.tiktokAlbumId, title: dto.title, description: dto.description, coverId: dto.coverId, coverUrl: dto.coverUrl, portraitCoverUrl: dto.portraitCoverUrl, landscapeCoverUrl: dto.landscapeCoverUrl, type: dto.type, tags: dto.tags ?? [], region: dto.region, language: dto.language, year: dto.year, status: dto.status ?? PublishStatus.Draft, sortOrder: dto.sortOrder ?? 0, isRecommended: dto.isRecommended ?? false, onlineVersion: dto.onlineVersion ?? 1, totalEpisodes: 0, publishedEpisodes: 0, createdAt: now, updatedAt: now, }; this.dramas.push(drama); return drama; } async listAdminDramas( pagination: PaginationInput, ): Promise> { if (this.hasDatabase()) { const { page, pageSize, skip, take } = this.resolvePagination(pagination); const [records, total] = await this.dataSource .getRepository(Drama) .findAndCount({ order: { sortOrder: 'DESC', id: 'DESC' }, skip, take, }); const list = await Promise.all( records.map((drama) => this.toDramaRecordWithCounts(drama)), ); return { list, pagination: { page, pageSize, total } }; } return this.paginate( this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))), pagination, ); } async getDramaOrThrow(id: number): Promise { if (this.hasDatabase()) { const drama = await this.dataSource .getRepository(Drama) .findOne({ where: { id } }); if (!drama) { throw new NotFoundException('Drama not found'); } return this.toDramaRecordWithCounts(drama); } const drama = this.dramas.find((item) => item.id === id); if (!drama) { throw new NotFoundException('Drama not found'); } return this.withEpisodeCounts(drama); } async updateDrama(id: number, dto: UpdateDramaDto): Promise { if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Drama); const drama = await repository.findOne({ where: { id } }); if (!drama) { throw new NotFoundException('Drama not found'); } repository.merge(drama, { ...dto, tags: dto.tags ?? drama.tags }); const saved = await repository.save(drama); return this.toDramaRecordWithCounts(saved); } const drama = this.dramas.find((item) => item.id === id); if (!drama) { throw new NotFoundException('Drama not found'); } Object.assign(drama, { ...dto, tags: dto.tags ?? drama.tags, updatedAt: new Date().toISOString(), }); return this.withEpisodeCounts(drama); } async createEpisode(dto: CreateEpisodeDto): Promise { const dramaId = dto.dramaId ?? dto.albumId; const episodeNo = dto.episodeNo ?? dto.seq; if (!dramaId || !episodeNo) { throw new NotFoundException('Drama not found'); } const freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free'); const price = dto.price ?? dto.unlockPrice ?? 0; const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft; const reviewStatus = dto.reviewStatus ?? (publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted'); if (this.hasDatabase()) { const dramaRepository = this.dataSource.getRepository(Drama); const drama = await dramaRepository.findOne({ where: { id: dramaId } }); if (!drama) { throw new NotFoundException('Drama not found'); } const episodeRepository = this.dataSource.getRepository(Episode); const duplicate = await episodeRepository.findOne({ where: { dramaId, episodeNo }, }); if (duplicate) { throw new ConflictException( 'Episode number already exists in this drama', ); } const entity = episodeRepository.create({ dramaId, tiktokEpisodeId: dto.tiktokEpisodeId, episodeNo, title: dto.title, description: dto.description, byteplusVid: dto.byteplusVid, coverId: dto.coverId, coverUrl: dto.coverUrl ?? '', videoUrl: dto.videoUrl, trialDurationSeconds: dto.trialDurationSeconds, durationSeconds: dto.durationSeconds ?? 0, width: dto.width, height: dto.height, isPaid: freeType === 'paid', freeType, unlockPrice: price, price, publishAt: dto.publishAt ? new Date(dto.publishAt) : undefined, nextReleaseAt: dto.nextReleaseAt ? new Date(dto.nextReleaseAt) : undefined, status: publishStatus, reviewStatus, publishStatus, }); const saved = await episodeRepository.save(entity); return this.toEpisodeRecord(saved); } const drama = this.dramas.find((item) => item.id === dramaId); if (!drama) { throw new NotFoundException('Drama not found'); } const duplicate = this.episodes.some( (item) => item.dramaId === dramaId && item.episodeNo === episodeNo, ); if (duplicate) { throw new ConflictException('Episode number already exists in this drama'); } const now = new Date().toISOString(); const episode: EpisodeRecord = { id: this.episodeSequence++, dramaId, albumId: dramaId, tiktokEpisodeId: dto.tiktokEpisodeId, episodeNo, seq: episodeNo, title: dto.title, description: dto.description, byteplusVid: dto.byteplusVid, coverId: dto.coverId, coverUrl: dto.coverUrl ?? '', videoUrl: dto.videoUrl, trialDurationSeconds: dto.trialDurationSeconds, durationSeconds: dto.durationSeconds ?? 0, width: dto.width, height: dto.height, isPaid: freeType === 'paid', freeType, unlockPrice: price, price, publishAt: dto.publishAt, nextReleaseAt: dto.nextReleaseAt, status: publishStatus, reviewStatus, publishStatus, createdAt: now, updatedAt: now, }; this.episodes.push(episode); return episode; } async configureEpisodes( dramaId: number, dto: ConfigureEpisodesDto, ): Promise> { if (this.hasDatabase()) { const dramaRepository = this.dataSource.getRepository(Drama); const drama = await dramaRepository.findOne({ where: { id: dramaId } }); if (!drama) { throw new NotFoundException('Drama not found'); } const episodeRepository = this.dataSource.getRepository(Episode); const existing = await episodeRepository.find({ where: { dramaId }, order: { episodeNo: 'ASC' }, }); if (dto.episodeCount < existing.length) { const removable = existing.slice(dto.episodeCount); const hasLockedEpisode = removable.some( (episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== 'not_submitted', ); if (hasLockedEpisode) { throw new UnprocessableEntityException( 'Only empty draft tail episodes can be removed', ); } await episodeRepository.remove(removable); } for ( let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++ ) { await this.createEpisode({ dramaId, episodeNo, title: `第${episodeNo}集`, coverUrl: drama.coverUrl, durationSeconds: 0, freeType: 'free', price: 0, reviewStatus: 'not_submitted', publishStatus: PublishStatus.Draft, status: PublishStatus.Draft, }); } const list = await episodeRepository.find({ where: { dramaId }, order: { episodeNo: 'ASC' }, }); const records = list.map((episode) => this.toEpisodeRecord(episode)); return { list: records, pagination: { page: 1, pageSize: records.length, total: records.length }, }; } const drama = this.dramas.find((item) => item.id === dramaId); if (!drama) { throw new NotFoundException('Drama not found'); } const existing = this.episodes .filter((episode) => episode.dramaId === dramaId) .sort((left, right) => left.episodeNo - right.episodeNo); if (dto.episodeCount < existing.length) { const removable = existing.slice(dto.episodeCount); const hasLockedEpisode = removable.some( (episode) => episode.byteplusVid || episode.publishStatus !== PublishStatus.Draft || episode.reviewStatus !== 'not_submitted', ); if (hasLockedEpisode) { throw new UnprocessableEntityException( 'Only empty draft tail episodes can be removed', ); } for (const episode of removable) { const index = this.episodes.findIndex((item) => item.id === episode.id); if (index >= 0) { this.episodes.splice(index, 1); } } } for ( let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++ ) { await this.createEpisode({ dramaId, episodeNo, title: `第${episodeNo}集`, coverUrl: drama.coverUrl, durationSeconds: 0, freeType: 'free', price: 0, reviewStatus: 'not_submitted', publishStatus: PublishStatus.Draft, status: PublishStatus.Draft, }); } const list = this.episodes .filter((episode) => episode.dramaId === dramaId) .sort((left, right) => left.episodeNo - right.episodeNo); return { list, pagination: { page: 1, pageSize: list.length, total: list.length }, }; } async listAdminEpisodes( dramaId: number, pagination: PaginationInput, ): Promise> { await this.getDramaOrThrow(dramaId); if (this.hasDatabase()) { const { page, pageSize, skip, take } = this.resolvePagination(pagination); const [records, total] = await this.dataSource .getRepository(Episode) .findAndCount({ where: { dramaId }, order: { episodeNo: 'ASC' }, skip, take, }); return { list: records.map((episode) => this.toEpisodeRecord(episode)), pagination: { page, pageSize, total }, }; } const records = this.episodes .filter((episode) => episode.dramaId === dramaId) .sort((left, right) => left.episodeNo - right.episodeNo); return this.paginate(records, pagination); } async updateEpisode(id: number, dto: UpdateEpisodeDto): Promise { if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Episode); const episode = await repository.findOne({ where: { id } }); if (!episode) { throw new NotFoundException('Episode not found'); } const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? 'paid' : 'free'); repository.merge(episode, { ...dto, dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId, episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo, freeType, isPaid: freeType === 'paid', price: dto.price ?? dto.unlockPrice ?? episode.price, unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice, status: dto.status ?? dto.publishStatus ?? episode.status, publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus, publishAt: dto.publishAt ? new Date(dto.publishAt) : episode.publishAt, nextReleaseAt: dto.nextReleaseAt ? new Date(dto.nextReleaseAt) : episode.nextReleaseAt, }); const saved = await repository.save(episode); return this.toEpisodeRecord(saved); } const episode = await this.getMemEpisodeOrThrow(id); const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? 'paid' : 'free'); Object.assign(episode, { ...dto, dramaId: dto.dramaId ?? dto.albumId ?? episode.dramaId, albumId: dto.albumId ?? dto.dramaId ?? episode.albumId, episodeNo: dto.episodeNo ?? dto.seq ?? episode.episodeNo, seq: dto.seq ?? dto.episodeNo ?? episode.seq, freeType, isPaid: freeType === 'paid', price: dto.price ?? dto.unlockPrice ?? episode.price, unlockPrice: dto.unlockPrice ?? dto.price ?? episode.unlockPrice, status: dto.status ?? dto.publishStatus ?? episode.status, publishStatus: dto.publishStatus ?? dto.status ?? episode.publishStatus, updatedAt: new Date().toISOString(), }); return episode; } async attachEpisodeMedia(input: { episodeId: number; byteplusVid: string; videoUrl: string; storageBucket: string; objectKey: string; durationSeconds: number; width: number; height: number; reviewStatus: EpisodeRecord['reviewStatus']; reviewMessage?: string; }): Promise { if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Episode); const episode = await repository.findOne({ where: { id: input.episodeId }, }); if (!episode) { throw new NotFoundException('Episode not found'); } episode.byteplusVid = input.byteplusVid; episode.videoUrl = input.videoUrl; episode.storageBucket = input.storageBucket; episode.objectKey = input.objectKey; episode.durationSeconds = input.durationSeconds; episode.width = input.width; episode.height = input.height; episode.reviewStatus = input.reviewStatus; episode.reviewMessage = input.reviewMessage; const saved = await repository.save(episode); return this.toEpisodeRecord(saved); } const episode = await this.getMemEpisodeOrThrow(input.episodeId); episode.byteplusVid = input.byteplusVid; episode.videoUrl = input.videoUrl; episode.storageBucket = input.storageBucket; episode.objectKey = input.objectKey; episode.durationSeconds = input.durationSeconds; episode.width = input.width; episode.height = input.height; episode.reviewStatus = input.reviewStatus; episode.reviewMessage = input.reviewMessage; episode.updatedAt = new Date().toISOString(); return episode; } async updateEpisodeReviewStatus( episodeId: number, reviewStatus: EpisodeRecord['reviewStatus'], reviewMessage?: string, ): Promise { if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Episode); const episode = await repository.findOne({ where: { id: episodeId } }); if (!episode) { throw new NotFoundException('Episode not found'); } episode.reviewStatus = reviewStatus; episode.reviewMessage = reviewMessage; const saved = await repository.save(episode); return this.toEpisodeRecord(saved); } const episode = await this.getMemEpisodeOrThrow(episodeId); episode.reviewStatus = reviewStatus; episode.reviewMessage = reviewMessage; episode.updatedAt = new Date().toISOString(); return episode; } async listPublishedDramas( pagination: PaginationInput, ): Promise> { if (this.hasDatabase()) { const { page, pageSize, skip, take } = this.resolvePagination(pagination); const [records, total] = await this.dataSource .getRepository(Drama) .findAndCount({ where: { status: PublishStatus.Published }, order: { sortOrder: 'DESC', id: 'DESC' }, skip, take, }); const list = await Promise.all( records.map((drama) => this.toDramaRecordWithCounts(drama)), ); return { list, pagination: { page, pageSize, total } }; } return this.paginate( this.sortDramas( this.dramas .filter((item) => item.status === PublishStatus.Published) .map((drama) => this.withEpisodeCounts(drama)), ), pagination, ); } async getPublishedDramaOrThrow(id: number): Promise { if (this.hasDatabase()) { const drama = await this.dataSource .getRepository(Drama) .findOne({ where: { id, status: PublishStatus.Published } }); if (!drama) { throw new NotFoundException('Drama not found'); } return this.toDramaRecordWithCounts(drama); } const drama = this.dramas.find( (item) => item.id === id && item.status === PublishStatus.Published, ); if (!drama) { throw new NotFoundException('Drama not found'); } return this.withEpisodeCounts(drama); } async listPublishedEpisodes( dramaId: number, pagination: PaginationInput, ): Promise> { if (this.hasDatabase()) { const drama = await this.dataSource .getRepository(Drama) .findOne({ where: { id: dramaId, status: PublishStatus.Published } }); if (!drama) { throw new NotFoundException('Drama not found'); } const { page, pageSize, skip, take } = this.resolvePagination(pagination); const [records, total] = await this.dataSource .getRepository(Episode) .findAndCount({ where: { dramaId, status: PublishStatus.Published, publishStatus: PublishStatus.Published, reviewStatus: 'approved', }, order: { episodeNo: 'ASC' }, skip, take, }); return { list: records.map((episode) => this.toEpisodeRecord(episode)), pagination: { page, pageSize, total }, }; } const drama = this.dramas.find((item) => item.id === dramaId); if (!drama || drama.status !== PublishStatus.Published) { throw new NotFoundException('Drama not found'); } const episodes = this.episodes .filter( (item) => item.dramaId === dramaId && item.status === PublishStatus.Published, ) .filter( (item) => item.publishStatus === PublishStatus.Published && item.reviewStatus === 'approved', ) .sort((left, right) => left.episodeNo - right.episodeNo); return this.paginate(episodes, pagination); } async getPublishedEpisodeOrThrow(episodeId: number): Promise { if (this.hasDatabase()) { const episode = await this.dataSource.getRepository(Episode).findOne({ where: { id: episodeId, status: PublishStatus.Published, publishStatus: PublishStatus.Published, reviewStatus: 'approved', }, }); if (!episode) { throw new NotFoundException('Episode not found'); } const drama = await this.dataSource .getRepository(Drama) .findOne({ where: { id: episode.dramaId, status: PublishStatus.Published }, }); if (!drama) { throw new NotFoundException('Episode not found'); } return this.toEpisodeRecord(episode); } const episode = this.episodes.find((item) => item.id === episodeId); if ( !episode || episode.status !== PublishStatus.Published || episode.publishStatus !== PublishStatus.Published || episode.reviewStatus !== 'approved' ) { throw new NotFoundException('Episode not found'); } const drama = this.dramas.find((item) => item.id === episode.dramaId); if (!drama || drama.status !== PublishStatus.Published) { throw new NotFoundException('Episode not found'); } return episode; } async getEpisodeOrThrow(episodeId: number): Promise { if (this.hasDatabase()) { const episode = await this.dataSource .getRepository(Episode) .findOne({ where: { id: episodeId } }); if (!episode) { throw new NotFoundException('Episode not found'); } return this.toEpisodeRecord(episode); } return this.getMemEpisodeOrThrow(episodeId); } async submitReview(albumId: number) { if (this.hasDatabase()) { const dramaRepository = this.dataSource.getRepository(Drama); const drama = await dramaRepository.findOne({ where: { id: albumId } }); if (!drama) { throw new NotFoundException('Drama not found'); } const episodeRepository = this.dataSource.getRepository(Episode); const episodes = await episodeRepository.find({ where: { dramaId: albumId }, }); for (const episode of episodes) { episode.reviewStatus = 'approved'; episode.reviewMessage = 'Mock review approved'; } await episodeRepository.save(episodes); return { albumId, reviewStatus: 'approved' }; } const album = this.dramas.find((item) => item.id === albumId); if (!album) { throw new NotFoundException('Drama not found'); } this.episodes .filter((episode) => episode.albumId === albumId) .forEach((episode) => { episode.reviewStatus = 'approved'; episode.reviewMessage = 'Mock review approved'; episode.updatedAt = new Date().toISOString(); }); return { albumId, reviewStatus: 'approved' }; } async listAllDramas(): Promise { if (this.hasDatabase()) { const records = await this.dataSource .getRepository(Drama) .find({ order: { sortOrder: 'DESC', id: 'DESC' } }); return Promise.all(records.map((drama) => this.toDramaRecordWithCounts(drama))); } return this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))); } async listAllEpisodes(): Promise { if (this.hasDatabase()) { const records = await this.dataSource .getRepository(Episode) .find({ order: { id: 'ASC' } }); return records.map((episode) => this.toEpisodeRecord(episode)); } return [...this.episodes].sort((left, right) => left.id - right.id); } async updateEpisodePublishStatus( episodeId: number, publishStatus: PublishStatus, ): Promise { if (this.hasDatabase()) { const repository = this.dataSource.getRepository(Episode); const episode = await repository.findOne({ where: { id: episodeId } }); if (!episode) { throw new NotFoundException('Episode not found'); } episode.publishStatus = publishStatus; episode.status = publishStatus; const saved = await repository.save(episode); return this.toEpisodeRecord(saved); } const episode = await this.getMemEpisodeOrThrow(episodeId); episode.publishStatus = publishStatus; episode.status = publishStatus; episode.updatedAt = new Date().toISOString(); return episode; } private hasDatabase(): this is this & { dataSource: DataSource } { return Boolean(this.dataSource?.isInitialized); } private getMemEpisodeOrThrow(episodeId: number): EpisodeRecord { const episode = this.episodes.find((item) => item.id === episodeId); if (!episode) { throw new NotFoundException('Episode not found'); } return episode; } private async toDramaRecordWithCounts(entity: Drama): Promise { const episodeRepository = this.dataSource!.getRepository(Episode); const dramaId = Number(entity.id); const totalEpisodes = await episodeRepository.count({ where: { dramaId } }); const publishedEpisodes = await episodeRepository.count({ where: { dramaId, publishStatus: PublishStatus.Published, reviewStatus: 'approved', }, }); return this.toDramaRecord(entity, totalEpisodes, publishedEpisodes); } private toDramaRecord( entity: Drama, totalEpisodes: number, publishedEpisodes: number, ): DramaRecord { return { id: Number(entity.id), tiktokAlbumId: entity.tiktokAlbumId, title: entity.title, description: entity.description, coverId: entity.coverId, coverUrl: entity.coverUrl, portraitCoverUrl: entity.portraitCoverUrl, landscapeCoverUrl: entity.landscapeCoverUrl, type: entity.type, tags: entity.tags ?? [], region: entity.region, language: entity.language, year: entity.year, status: entity.status, sortOrder: entity.sortOrder, isRecommended: entity.isRecommended, onlineVersion: entity.onlineVersion, totalEpisodes, publishedEpisodes, createdAt: this.toIso(entity.createdAt), updatedAt: this.toIso(entity.updatedAt), }; } private toEpisodeRecord(entity: Episode): EpisodeRecord { const dramaId = Number(entity.dramaId); return { id: Number(entity.id), dramaId, albumId: dramaId, tiktokEpisodeId: entity.tiktokEpisodeId, episodeNo: entity.episodeNo, seq: entity.episodeNo, title: entity.title, description: entity.description, byteplusVid: entity.byteplusVid, coverId: entity.coverId, coverUrl: entity.coverUrl, videoUrl: entity.videoUrl, storageBucket: entity.storageBucket, objectKey: entity.objectKey, trialDurationSeconds: entity.trialDurationSeconds, durationSeconds: entity.durationSeconds, width: entity.width, height: entity.height, isPaid: entity.isPaid, freeType: entity.freeType, unlockPrice: entity.unlockPrice, price: entity.price, publishAt: this.toIsoOrUndefined(entity.publishAt), nextReleaseAt: this.toIsoOrUndefined(entity.nextReleaseAt), status: entity.status, reviewStatus: entity.reviewStatus, reviewMessage: entity.reviewMessage, publishStatus: entity.publishStatus, createdAt: this.toIso(entity.createdAt), updatedAt: this.toIso(entity.updatedAt), }; } private toIso(value?: Date | string): string { if (!value) { return new Date().toISOString(); } return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); } private toIsoOrUndefined(value?: Date | string): string | undefined { if (!value) { return undefined; } return value instanceof Date ? value.toISOString() : new Date(value).toISOString(); } private resolvePagination(input: PaginationInput) { const page = Math.max(input.page, 1); const pageSize = Math.min(Math.max(input.pageSize, 1), 100); return { page, pageSize, skip: (page - 1) * pageSize, take: pageSize }; } private sortDramas(records: DramaRecord[]) { return [...records].sort((left, right) => { if (right.sortOrder !== left.sortOrder) { return right.sortOrder - left.sortOrder; } return right.id - left.id; }); } private withEpisodeCounts(drama: DramaRecord): DramaRecord { const episodes = this.episodes.filter((episode) => episode.dramaId === drama.id); return { ...drama, totalEpisodes: episodes.length, publishedEpisodes: episodes.filter( (episode) => episode.publishStatus === PublishStatus.Published && episode.reviewStatus === 'approved', ).length, }; } private paginate( records: T[], input: PaginationInput, ): PaginatedResponse { const page = Math.max(input.page, 1); const pageSize = Math.min(Math.max(input.pageSize, 1), 100); const start = (page - 1) * pageSize; return { list: records.slice(start, start + pageSize), pagination: { page, pageSize, total: records.length, }, }; } }