diff --git a/src/modules/data/data.service.ts b/src/modules/data/data.service.ts index 573c54f..a64e565 100644 --- a/src/modules/data/data.service.ts +++ b/src/modules/data/data.service.ts @@ -70,13 +70,13 @@ export class DataService { async sync(input: SyncInput = {}) { const now = new Date().toISOString(); - const dramas = this.dramasService - .listAllDramas() - .filter((drama) => !input.dramaId || drama.id === input.dramaId); + const dramas = (await this.dramasService.listAllDramas()).filter( + (drama) => !input.dramaId || drama.id === input.dramaId, + ); const dramaIds = new Set(dramas.map((drama) => drama.id)); - const episodes = this.dramasService - .listAllEpisodes() - .filter((episode) => dramaIds.has(episode.dramaId)); + const episodes = (await this.dramasService.listAllEpisodes()).filter( + (episode) => dramaIds.has(episode.dramaId), + ); const dramaRecords = dramas.map((drama) => ({ dramaId: drama.id, diff --git a/src/modules/dramas/dramas.service.ts b/src/modules/dramas/dramas.service.ts index c8ee28b..bac3778 100644 --- a/src/modules/dramas/dramas.service.ts +++ b/src/modules/dramas/dramas.service.ts @@ -2,14 +2,19 @@ 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, @@ -28,7 +33,37 @@ export class DramasService { private readonly dramas: DramaRecord[] = []; private readonly episodes: EpisodeRecord[] = []; - createDrama(dto: CreateDramaDto): DramaRecord { + 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++, @@ -58,11 +93,41 @@ export class DramasService { return drama; } - listAdminDramas(pagination: PaginationInput): PaginatedResponse { - return this.paginate(this.sortDramas(this.dramas.map((drama) => this.withEpisodeCounts(drama))), pagination); + 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, + ); } - getDramaOrThrow(id: number): DramaRecord { + 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'); @@ -71,7 +136,18 @@ export class DramasService { return this.withEpisodeCounts(drama); } - updateDrama(id: number, dto: UpdateDramaDto): DramaRecord { + 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'); @@ -85,30 +161,78 @@ export class DramasService { return this.withEpisodeCounts(drama); } - createEpisode(dto: CreateEpisodeDto): EpisodeRecord { + 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, + (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 freeType = dto.freeType ?? (dto.isPaid ? 'paid' : 'free'); - const price = dto.price ?? dto.unlockPrice ?? 0; - const publishStatus = dto.publishStatus ?? dto.status ?? PublishStatus.Draft; const episode: EpisodeRecord = { id: this.episodeSequence++, dramaId, @@ -133,9 +257,7 @@ export class DramasService { publishAt: dto.publishAt, nextReleaseAt: dto.nextReleaseAt, status: publishStatus, - reviewStatus: - dto.reviewStatus ?? - (publishStatus === PublishStatus.Published ? 'approved' : 'not_submitted'), + reviewStatus, publishStatus, createdAt: now, updatedAt: now, @@ -145,10 +267,69 @@ export class DramasService { return episode; } - configureEpisodes( + async configureEpisodes( dramaId: number, dto: ConfigureEpisodesDto, - ): PaginatedResponse { + ): 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'); @@ -179,8 +360,12 @@ export class DramasService { } } - for (let episodeNo = existing.length + 1; episodeNo <= dto.episodeCount; episodeNo++) { - this.createEpisode({ + for ( + let episodeNo = existing.length + 1; + episodeNo <= dto.episodeCount; + episodeNo++ + ) { + await this.createEpisode({ dramaId, episodeNo, title: `第${episodeNo}集`, @@ -199,28 +384,79 @@ export class DramasService { .sort((left, right) => left.episodeNo - right.episodeNo); return { list, - pagination: { - page: 1, - pageSize: list.length, - total: list.length, - }, + pagination: { page: 1, pageSize: list.length, total: list.length }, }; } - listAdminEpisodes( + async listAdminEpisodes( dramaId: number, pagination: PaginationInput, - ): PaginatedResponse { - this.getDramaOrThrow(dramaId); + ): 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); } - updateEpisode(id: number, dto: UpdateEpisodeDto): EpisodeRecord { - const episode = this.getEpisodeOrThrow(id); - const freeType = dto.freeType ?? (dto.isPaid === undefined ? episode.freeType : dto.isPaid ? 'paid' : 'free'); + 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, @@ -238,7 +474,7 @@ export class DramasService { return episode; } - attachEpisodeMedia(input: { + async attachEpisodeMedia(input: { episodeId: number; byteplusVid: string; videoUrl: string; @@ -249,8 +485,29 @@ export class DramasService { height: number; reviewStatus: EpisodeRecord['reviewStatus']; reviewMessage?: string; - }): EpisodeRecord { - const episode = this.getEpisodeOrThrow(input.episodeId); + }): 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; @@ -264,21 +521,49 @@ export class DramasService { return episode; } - updateEpisodeReviewStatus( + async updateEpisodeReviewStatus( episodeId: number, reviewStatus: EpisodeRecord['reviewStatus'], reviewMessage?: string, - ): EpisodeRecord { - const episode = this.getEpisodeOrThrow(episodeId); + ): 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; } - listPublishedDramas( + async listPublishedDramas( pagination: PaginationInput, - ): PaginatedResponse { + ): 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 @@ -289,7 +574,17 @@ export class DramasService { ); } - getPublishedDramaOrThrow(id: number): DramaRecord { + 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, ); @@ -300,10 +595,37 @@ export class DramasService { return this.withEpisodeCounts(drama); } - listPublishedEpisodes( + async listPublishedEpisodes( dramaId: number, pagination: PaginationInput, - ): PaginatedResponse { + ): 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'); @@ -324,7 +646,30 @@ export class DramasService { return this.paginate(episodes, pagination); } - getPublishedEpisodeOrThrow(episodeId: number): EpisodeRecord { + 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 || @@ -343,16 +688,39 @@ export class DramasService { return episode; } - getEpisodeOrThrow(episodeId: number): EpisodeRecord { - const episode = this.episodes.find((item) => item.id === episodeId); - if (!episode) { - throw new NotFoundException('Episode not found'); + 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 episode; + return this.getMemEpisodeOrThrow(episodeId); } - submitReview(albumId: number) { + 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'); @@ -366,28 +734,167 @@ export class DramasService { episode.updatedAt = new Date().toISOString(); }); - return { - albumId, - reviewStatus: 'approved', - }; + return { albumId, reviewStatus: 'approved' }; } - listAllDramas() { + 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))); } - listAllEpisodes() { + 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); } - updateEpisodePublishStatus(episodeId: number, publishStatus: PublishStatus) { - const episode = this.getEpisodeOrThrow(episodeId); + 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) { diff --git a/src/modules/dramas/entities/drama.entity.ts b/src/modules/dramas/entities/drama.entity.ts index 65bb731..6abfc30 100644 --- a/src/modules/dramas/entities/drama.entity.ts +++ b/src/modules/dramas/entities/drama.entity.ts @@ -12,12 +12,18 @@ export class Drama { @PrimaryGeneratedColumn() id: number; + @Column({ type: 'varchar', length: 128, nullable: true }) + tiktokAlbumId?: string; + @Column({ type: 'varchar', length: 255 }) title: string; @Column({ type: 'text', nullable: true }) description?: string; + @Column({ type: 'int', nullable: true }) + coverId?: number; + @Column({ type: 'varchar', length: 1024 }) coverUrl: string; @@ -51,6 +57,9 @@ export class Drama { @Column({ type: 'boolean', default: false }) isRecommended: boolean; + @Column({ type: 'int', default: 1 }) + onlineVersion: number; + @CreateDateColumn() createdAt: Date; diff --git a/src/modules/dramas/entities/episode.entity.ts b/src/modules/dramas/entities/episode.entity.ts index 27ef2aa..5191578 100644 --- a/src/modules/dramas/entities/episode.entity.ts +++ b/src/modules/dramas/entities/episode.entity.ts @@ -17,6 +17,9 @@ export class Episode { @Column({ type: 'bigint' }) dramaId: number; + @Column({ type: 'varchar', length: 128, nullable: true }) + tiktokEpisodeId?: string; + @Column({ type: 'int' }) episodeNo: number; @@ -26,16 +29,28 @@ export class Episode { @Column({ type: 'text', nullable: true }) description?: string; + @Column({ type: 'int', nullable: true }) + coverId?: number; + @Column({ type: 'varchar', length: 1024 }) coverUrl: string; - @Column({ type: 'varchar', length: 1024 }) - videoUrl: string; + @Column({ type: 'varchar', length: 1024, nullable: true }) + videoUrl?: string; + + @Column({ type: 'varchar', length: 128, nullable: true }) + byteplusVid?: string; + + @Column({ type: 'varchar', length: 255, nullable: true }) + storageBucket?: string; + + @Column({ type: 'varchar', length: 512, nullable: true }) + objectKey?: string; @Column({ type: 'int', nullable: true }) trialDurationSeconds?: number; - @Column({ type: 'int' }) + @Column({ type: 'int', default: 0 }) durationSeconds: number; @Column({ type: 'int', nullable: true }) @@ -47,9 +62,15 @@ export class Episode { @Column({ type: 'boolean', default: false }) isPaid: boolean; + @Column({ type: 'varchar', length: 16, default: 'free' }) + freeType: 'free' | 'paid'; + @Column({ type: 'int', nullable: true }) unlockPrice?: number; + @Column({ type: 'int', default: 0 }) + price: number; + @Column({ type: 'datetime', nullable: true }) publishAt?: Date; @@ -59,6 +80,15 @@ export class Episode { @Column({ type: 'varchar', length: 32, default: PublishStatus.Draft }) status: PublishStatus; + @Column({ type: 'varchar', length: 32, default: 'not_submitted' }) + reviewStatus: 'not_submitted' | 'pending' | 'reviewing' | 'approved' | 'rejected'; + + @Column({ type: 'varchar', length: 512, nullable: true }) + reviewMessage?: string; + + @Column({ type: 'varchar', length: 32, default: PublishStatus.Draft }) + publishStatus: PublishStatus; + @CreateDateColumn() createdAt: Date; diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 6b54971..9edff7c 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -54,8 +54,10 @@ export class MediaService { return job; } - createDirectUploadTask(dto: CreateDirectUploadTaskDto): MediaUploadJobRecord { - this.dramasService.getEpisodeOrThrow(dto.episodeId); + async createDirectUploadTask( + dto: CreateDirectUploadTaskDto, + ): Promise { + await this.dramasService.getEpisodeOrThrow(dto.episodeId); const now = new Date().toISOString(); const jobId = randomUUID(); const upload = this.bytePlusMediaProvider.createDirectUpload({ @@ -87,7 +89,7 @@ export class MediaService { return job; } - completeDirectUploadTask(jobId: string): MediaUploadJobRecord { + async completeDirectUploadTask(jobId: string): Promise { const job = this.getUploadJobOrThrow(jobId); if (!job.objectKey || !job.episodeId) { throw new NotFoundException('Upload task not found'); @@ -116,7 +118,7 @@ export class MediaService { updatedAt: new Date().toISOString(), }); - this.dramasService.attachEpisodeMedia({ + await this.dramasService.attachEpisodeMedia({ episodeId: job.episodeId, byteplusVid: completed.byteplusVid, videoUrl: completed.videoUrl, @@ -132,7 +134,7 @@ export class MediaService { return job; } - retryReview(jobId: string): MediaUploadJobRecord { + async retryReview(jobId: string): Promise { const job = this.getUploadJobOrThrow(jobId); if (!job.byteplusVid || !job.episodeId) { throw new NotFoundException('Upload task not found'); @@ -143,7 +145,7 @@ export class MediaService { job.reviewStatus = review.reviewStatus; job.reviewMessage = review.reviewMessage; job.updatedAt = new Date().toISOString(); - this.dramasService.updateEpisodeReviewStatus( + await this.dramasService.updateEpisodeReviewStatus( job.episodeId, review.reviewStatus, review.reviewMessage, @@ -151,7 +153,7 @@ export class MediaService { return job; } - syncReviewStatuses() { + async syncReviewStatuses() { const reviewingJobs = this.uploadJobs.filter( (job) => job.byteplusVid && @@ -167,7 +169,7 @@ export class MediaService { job.reviewStatus = review.reviewStatus; job.reviewMessage = review.reviewMessage; job.updatedAt = new Date().toISOString(); - this.dramasService.updateEpisodeReviewStatus( + await this.dramasService.updateEpisodeReviewStatus( job.episodeId as number, review.reviewStatus, review.reviewMessage, diff --git a/src/modules/orders/orders.service.ts b/src/modules/orders/orders.service.ts index 2383b52..bd835a7 100644 --- a/src/modules/orders/orders.service.ts +++ b/src/modules/orders/orders.service.ts @@ -22,8 +22,10 @@ export class OrdersService { constructor(private readonly dramasService: DramasService) {} - createOrder(user: UserRecord, dto: CreateOrderDto): OrderRecord { - const episode = this.dramasService.getPublishedEpisodeOrThrow(dto.episodeId); + async createOrder(user: UserRecord, dto: CreateOrderDto): Promise { + const episode = await this.dramasService.getPublishedEpisodeOrThrow( + dto.episodeId, + ); if (!episode.isPaid) { throw new UnprocessableEntityException('Episode is free'); } @@ -46,8 +48,8 @@ export class OrdersService { return order; } - getPlayback(user: UserRecord, episodeId: number) { - const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); + async getPlayback(user: UserRecord, episodeId: number) { + const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); const isUnlocked = !episode.isPaid || this.unlocks.some( @@ -107,8 +109,8 @@ export class OrdersService { }; } - hasEpisodePermission(userId: number, episodeId: number) { - const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); + async hasEpisodePermission(userId: number, episodeId: number) { + const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); return !episode.isPaid || this.isUnlocked(userId, episodeId); } diff --git a/src/modules/player/player.service.ts b/src/modules/player/player.service.ts index 7b4f7b9..dc84359 100644 --- a/src/modules/player/player.service.ts +++ b/src/modules/player/player.service.ts @@ -10,9 +10,9 @@ export class PlayerService { private readonly ordersService: OrdersService, ) {} - getEpisodePlayer(user: UserRecord, episodeId: number) { - const episode = this.dramasService.getPublishedEpisodeOrThrow(episodeId); - const hasPermission = this.ordersService.hasEpisodePermission( + async getEpisodePlayer(user: UserRecord, episodeId: number) { + const episode = await this.dramasService.getPublishedEpisodeOrThrow(episodeId); + const hasPermission = await this.ordersService.hasEpisodePermission( user.id, episode.id, ); diff --git a/test/database-persistence.e2e-spec.ts b/test/database-persistence.e2e-spec.ts index 0000798..9708943 100644 --- a/test/database-persistence.e2e-spec.ts +++ b/test/database-persistence.e2e-spec.ts @@ -160,13 +160,13 @@ describe('database-backed persistence', () => { dataSource as never, ); - const drama = dramasService.createDrama({ + const drama = await dramasService.createDrama({ title: 'DB Metrics', coverUrl: 'https://cdn.example.com/cover.jpg', type: 'action', status: PublishStatus.Published, }); - dramasService.createEpisode({ + await dramasService.createEpisode({ dramaId: drama.id, episodeNo: 1, title: 'DB Metrics Episode',