Implement backend API and database services in Docker setup

- Added a new `api` service for the NestJS backend, including health checks and dependencies on PostgreSQL, Redis, and MinIO.
- Introduced PostgreSQL and Redis services with health checks and configurations for data persistence.
- Added MinIO for S3-compatible object storage and a one-shot service to initialize required buckets.
- Updated the Nginx configuration to proxy requests to the new backend API and MinIO storage.
- Enhanced the Dockerfile to support the new API environment variables and configurations.
- Updated the `package.json` and `package-lock.json` to include new dependencies for QR code generation and other utilities.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-12 20:14:39 +00:00
parent f19fd6feef
commit cdd24a5def
478 changed files with 55355 additions and 529 deletions

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class CreateSeasonRentDto {
@IsString()
id: string; // Season id
}

View File

@@ -0,0 +1,25 @@
import { IsString, IsNumber, IsOptional, IsBoolean } from 'class-validator';
export class CreateSeasonDto {
@IsString()
projectId: string;
@IsNumber()
seasonNumber: number;
@IsOptional()
@IsNumber()
rentalPrice?: number;
@IsOptional()
@IsString()
title?: string;
@IsOptional()
@IsString()
description?: string;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}

View File

@@ -0,0 +1,8 @@
import { IsOptional, IsUUID } from 'class-validator';
import { CreateSeasonDto } from './create-season.dto.entity';
export class UpdateSeasonDto extends CreateSeasonDto {
@IsUUID()
@IsOptional()
id?: string;
}

View File

@@ -0,0 +1,40 @@
import { ProjectDTO } from 'src/projects/dto/response/project.dto';
import { SeasonRent } from 'src/season/entities/season-rents.entity';
import { SeasonDTO } from './season.dto';
import { ContentDTO } from 'src/contents/dto/response/content.dto';
export class SeasonRentDTO {
id: string;
status: 'active' | 'expired';
usdAmount: number;
createdAt: Date;
expiresAt: Date;
title: string;
project: ProjectDTO;
season: SeasonDTO;
contents: ContentDTO[];
constructor(seasonRent: SeasonRent) {
this.id = seasonRent.id;
const expiresAt = new Date(seasonRent.createdAt);
expiresAt.setDate(expiresAt.getDate() + 2);
this.status =
seasonRent.status == 'paid' && new Date() < expiresAt
? 'active'
: 'expired';
this.usdAmount = seasonRent.usdAmount;
this.project = seasonRent?.season?.project
? new ProjectDTO(seasonRent.season.project)
: undefined;
this.title =
seasonRent.season?.title || seasonRent.season?.project?.title || '';
this.createdAt = seasonRent.createdAt;
this.expiresAt = expiresAt;
this.contents =
seasonRent.season?.contents?.map((content) => new ContentDTO(content)) ||
[];
this.season = seasonRent?.season
? new SeasonDTO(seasonRent.season)
: undefined;
}
}

View File

@@ -0,0 +1,29 @@
import { ContentDTO } from 'src/contents/dto/response/content.dto';
import { Season } from 'src/season/entities/season.entity';
export class SeasonDTO {
id: string;
projectId: string;
seasonNumber: number;
rentalPrice: number;
title?: string;
description?: string;
isActive: boolean;
createdAt: Date;
updatedAt: Date;
contents?: ContentDTO[];
constructor(season: Season) {
this.id = season.id;
this.projectId = season.projectId;
this.seasonNumber = season.seasonNumber;
this.rentalPrice = season.rentalPrice;
this.title = season.title;
this.description = season.description;
this.isActive = season.isActive;
this.createdAt = season.createdAt;
this.updatedAt = season.updatedAt;
this.contents =
season?.contents?.map((content) => new ContentDTO(content)) || [];
}
}

View File

@@ -0,0 +1,49 @@
import { ColumnNumericTransformer } from 'src/database/transformers/column-numeric-transformer';
import { Season } from './season.entity';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
Index,
PrimaryColumn,
} from 'typeorm';
import { User } from 'src/users/entities/user.entity';
@Entity('season_rents')
@Index(['seasonId', 'userId'], { unique: false })
export class SeasonRent {
@PrimaryColumn()
id: string;
@Column()
seasonId: string;
@Column()
userId: string;
@Column('decimal', {
precision: 5,
scale: 2,
transformer: new ColumnNumericTransformer(),
})
usdAmount: number;
@Column()
status: 'pending' | 'paid' | 'cancelled';
@Column({ nullable: true })
providerId: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@ManyToOne(() => Season, (season) => season.seasonRents)
@JoinColumn({ name: 'season_id' })
season: Season;
@ManyToOne(() => User, (user) => user.seasonRents)
@JoinColumn({ name: 'user_id' })
user: User;
}

View File

@@ -0,0 +1,69 @@
import { Project } from 'src/projects/entities/project.entity';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
Index,
UpdateDateColumn,
OneToMany,
PrimaryColumn,
} from 'typeorm';
import { SeasonRent } from './season-rents.entity';
import { Content } from 'src/contents/entities/content.entity';
import { Discount } from 'src/discounts/entities/discount.entity';
@Entity('seasons')
@Index(['projectId', 'seasonNumber'], { unique: true }) // Ensure unique season per project
export class Season {
@PrimaryColumn()
id: string;
@Column({ name: 'project_id' })
projectId: string;
@Column()
seasonNumber: number;
@Column('decimal', {
precision: 10,
scale: 2,
default: 0,
transformer: {
to: (value) => value,
from: (value) => Number.parseFloat(value),
},
})
rentalPrice: number;
@Column({ nullable: true })
title: string;
@Column({ nullable: true })
description: string;
@Column({ default: true })
isActive: boolean;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => Project, (project) => project.seasons)
@JoinColumn({ name: 'project_id' })
project: Project;
@OneToMany(() => SeasonRent, (seasonRent) => seasonRent.season)
seasonRents: SeasonRent[];
@OneToMany(() => Content, (content) => content.seriesSeason)
contents: Content[];
@OneToMany(() => Discount, (discount) => discount.season)
discounts: Discount[];
}
export const fullRelations = ['project', 'seasonRents', 'contents'];

View File

@@ -0,0 +1,100 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
import { User } from 'src/auth/user.decorator';
import { RequestUser } from 'src/auth/dto/request/request-user.interface';
import { SeasonRentsService } from './season-rents.service';
import { SeasonRentDTO } from './dto/response/season-rent.dto';
import { ForSeasonRentalGuard } from 'src/rents/guards/for-season-rental.guard';
import { SeasonRentRequestDTO } from 'src/rents/dto/request/season-rent.dto';
import { ListRentsDTO } from 'src/rents/dto/request/list-films.dto';
import { CouponCodeDto } from 'src/discounts/dto/request/coupon-code.dto';
@Controller('rents/seasons')
export class SeasonRentsController {
constructor(private readonly seasonRentsService: SeasonRentsService) {}
@Get()
@UseGuards(HybridAuthGuard)
async find(@User() user: RequestUser['user'], @Query() query: ListRentsDTO) {
const rents = await this.seasonRentsService.findByUserId(user.id, query);
return rents.map((rent) => new SeasonRentDTO(rent));
}
@Get('/count')
@UseGuards(HybridAuthGuard)
async findCount(
@User() user: RequestUser['user'],
@Query() query: ListRentsDTO,
) {
const count = await this.seasonRentsService.getCountByUserId(
user.id,
query,
);
return { count };
}
@Get('/project/:projectId/season/:seasonNumber')
@UseGuards(HybridAuthGuard)
async findFilm(
@Param('projectId') projectId: string,
@Param('seasonNumber') seasonNumber: number,
@User() user: RequestUser['user'],
) {
const season = await this.seasonRentsService.findSeasonRentsByProjectId(
user.id,
projectId,
seasonNumber,
);
return new SeasonRentDTO(season);
}
@Get('/content/:id/exists')
@UseGuards(HybridAuthGuard)
async filmRentExists(
@Param('id') contentId: string,
@User() { id }: RequestUser['user'],
) {
try {
const exists = await this.seasonRentsService.seasonRentByUserExists(
id,
contentId,
);
return { exists };
} catch {
return { exists: false };
}
}
@Post('lightning')
@UseGuards(HybridAuthGuard, ForSeasonRentalGuard)
async seasonLightningRent(
@Body() { id }: SeasonRentRequestDTO,
@User() user: RequestUser['user'],
@Body() { couponCode }: CouponCodeDto,
) {
const quote = await this.seasonRentsService.createLightningInvoice(
id,
user.email,
user.id,
couponCode,
);
return quote;
}
@Patch('/lightning/:id/quote')
@UseGuards(HybridAuthGuard)
async createQuote(@Param('id') invoiceId: string) {
const quote = await this.seasonRentsService.createLightningQuote(invoiceId);
return quote;
}
}

View File

@@ -0,0 +1,309 @@
import {
BadRequestException,
Inject,
Injectable,
NotFoundException,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { IsNull, Repository } from 'typeorm';
import { SeasonRent } from './entities/season-rents.entity';
import { SeasonService } from './season.service';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { randomUUID } from 'node:crypto';
import { fullRelations, Season } from './entities/season.entity';
import { UsersService } from 'src/users/users.service';
import { REVENUE_PERCENTAGE_TO_PAY } from 'src/rents/config/constants';
import { Shareholder } from 'src/contents/entities/shareholder.entity';
import { RentsGateway } from 'src/events/rents.gateway';
import Invoice from 'src/payment/providers/dto/strike/invoice';
import { ListRentsDTO } from 'src/rents/dto/request/list-films.dto';
import { Discount } from 'src/discounts/entities/discount.entity';
import { DiscountRedemptionService } from 'src/discount-redemption/discount-redemption.service';
import { isValidDiscountedPrice } from 'src/discounts/utils/is-valid-discount';
import { calculateDiscountedPrice } from 'src/discounts/utils/calculate-discount-price';
@Injectable()
export class SeasonRentsService {
constructor(
@InjectRepository(SeasonRent)
private seasonRentRepository: Repository<SeasonRent>,
@InjectRepository(Discount)
private discountRepository: Repository<Discount>,
@InjectRepository(Shareholder)
private shareholdersRepository: Repository<Shareholder>,
@Inject(SeasonService)
private seasonService: SeasonService,
@Inject(BTCPayService)
private readonly btcpayService: BTCPayService,
@Inject(UsersService)
private readonly usersService: UsersService,
@Inject(DiscountRedemptionService)
private readonly discountRedemptionService: DiscountRedemptionService,
@Inject(RentsGateway)
private readonly eventsGateway: RentsGateway,
) {}
async findByUserId(userId: string, query: ListRentsDTO) {
const rentsQuery = await this.getRentsQuery(userId, query);
rentsQuery.limit(query.limit);
rentsQuery.offset(query.offset);
return rentsQuery.getMany();
}
async getRentsQuery(userId: string, query: ListRentsDTO) {
const rentsQuery =
this.seasonRentRepository.createQueryBuilder('seasonRent');
rentsQuery.withDeleted();
rentsQuery.leftJoinAndSelect('seasonRent.season', 'season');
rentsQuery.leftJoinAndSelect('season.project', 'project');
rentsQuery.leftJoinAndSelect('season.contents', 'contents');
rentsQuery.where('seasonRent.userId = :userId', { userId });
rentsQuery.andWhere('seasonRent.status = :status', { status: 'paid' });
rentsQuery.andWhere('project.deleted_at IS NULL');
if (query.status === 'active') {
rentsQuery.andWhere('seasonRent.createdAt > :date', {
date: this.getExpiringDate(),
});
} else if (query.status === 'expired') {
rentsQuery.andWhere('seasonRent.createdAt < :date', {
date: this.getExpiringDate(),
});
}
if (query?.projectId) {
rentsQuery.andWhere('project.id = :projectId', {
projectId: query.projectId,
});
}
rentsQuery.orderBy('seasonRent.createdAt', 'DESC');
return rentsQuery;
}
async getCountByUserId(userId: string, query: ListRentsDTO) {
const rentsQuery = await this.getRentsQuery(userId, query);
return rentsQuery.getCount();
}
async findSeasonRentsByProjectId(
userId: string,
projectId: string,
seasonNumber: number,
) {
const queryBuilder = this.seasonRentRepository
.createQueryBuilder('seasonRent')
.leftJoin('seasonRent.season', 'season')
.where('seasonRent.userId = :userId', { userId })
.andWhere('season.projectId = :projectId', { projectId })
.andWhere('seasonRent.status = :status', { status: 'paid' })
.andWhere('season.seasonNumber = :seasonNumber', { seasonNumber })
.orderBy('seasonRent.createdAt', 'DESC');
const seasonRentByProjectId = await queryBuilder.getOne();
if (!seasonRentByProjectId) {
throw new NotFoundException();
}
return seasonRentByProjectId;
}
async seasonRentByUserExists(userId: string, contentId: string) {
const count = await this.seasonRentRepository
.createQueryBuilder('seasonRent')
.innerJoin('seasonRent.season', 'season')
.innerJoin('season.contents', 'content')
.where('content.id = :contentId', { contentId })
.andWhere('seasonRent.userId = :userId', { userId })
.andWhere('seasonRent.status = :status', { status: 'paid' })
.andWhere('seasonRent.createdAt >= :fromDate', {
fromDate: this.getExpiringDate(),
})
.getCount();
return count > 0;
}
async createLightningInvoice(
seasonId: string,
userEmail: string,
userId: string,
couponCode?: string,
) {
const season = await this.seasonService.findOne(seasonId);
const {
finalPrice,
discount,
rent: freeRental,
} = await this.processDiscount(userId, season, couponCode, userEmail);
if (discount?.type === 'free') {
return {
...freeRental,
};
}
const invoice = await this.btcpayService.issueInvoice(
finalPrice,
'Invoice for order',
randomUUID(),
);
const rent = await this.seasonRentRepository.save({
id: randomUUID(),
seasonId,
userId,
usdAmount: finalPrice,
status: 'pending',
providerId: invoice.invoiceId,
});
const quote = await this.createLightningQuote(invoice.invoiceId);
return {
...rent,
sourceAmount: quote.sourceAmount,
conversionRate: quote.conversionRate,
expiration: quote.expiration,
lnInvoice: quote.lnInvoice,
};
}
async createLightningQuote(invoiceId: string) {
return this.btcpayService.issueQuote(invoiceId);
}
async lightningPaid(invoiceId: string, invoice?: Invoice) {
if (!invoice) {
invoice = await this.btcpayService.getInvoice(invoiceId);
}
const rent = await this.seasonRentRepository.findOne({
where: { providerId: invoiceId },
});
const season = await this.seasonService.findOne(
rent.seasonId,
fullRelations,
);
if (!rent) {
throw new BadRequestException('Rent not found');
}
if (invoice.state === 'PAID') {
rent.status = 'paid';
await this.seasonRentRepository.save(rent);
await this.payShareholders(season, rent.usdAmount);
this.eventsGateway.server.emit(`${rent.id}`, {
invoiceId,
rentId: rent.id,
});
} else {
throw new BadRequestException('Invoice not paid');
}
}
async payShareholders(season: Season, amount: number) {
const total = amount * REVENUE_PERCENTAGE_TO_PAY;
const contentCount = season.contents.length;
const contentRevenue = total / contentCount;
for (const content of season.contents) {
await this.shareholdersRepository.update(
{
contentId: content.id,
deletedAt: IsNull(),
},
{
rentPendingRevenue: () =>
`rent_pending_revenue + (cast(share as decimal) / 100.00 * ${contentRevenue})`,
},
);
}
}
private getExpiringDate() {
return new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
}
async processDiscount(
userId: string,
season: Season,
couponCode?: string,
email?: string,
) {
let finalPrice = season.rentalPrice;
let rent: SeasonRent | undefined;
if (couponCode) {
const discount = await this.discountRepository.findOne({
where: {
couponCode,
seasonId: season.id,
deletedAt: IsNull(),
},
});
const discountedPrice = calculateDiscountedPrice(
discount.type,
discount.value,
season.rentalPrice,
);
if (!isValidDiscountedPrice(discountedPrice, discount.type)) {
throw new UnprocessableEntityException('Invalid discount');
}
finalPrice = discountedPrice;
const canRedeem = await this.discountRedemptionService.canRedeem(
discount.createdById,
discount.type,
{ seasonId: season.id },
);
if (!canRedeem) {
let errorMessage =
'Maximum free redemptions reached for this filmmaker';
if (discount?.email !== email) {
errorMessage = 'Invalid discount';
}
this.eventsGateway.server.emit('error', {
errorMessage,
});
throw new UnprocessableEntityException(errorMessage);
}
if (discount.type === 'free') {
rent = await this.seasonRentRepository.save({
id: randomUUID(),
seasonId: season.id,
userId,
usdAmount: finalPrice,
status: 'paid',
providerId: undefined,
});
this.eventsGateway.server.emit('free', {
invoiceId: rent.id,
rentId: rent.id,
});
}
await this.discountRedemptionService.createRedemption({
userId,
seasonId: season.id,
usdAmount: finalPrice,
couponCode,
discount: {
id: discount.id,
type: discount.type,
createdById: discount.createdById,
},
});
return {
finalPrice,
discount,
rent,
};
} else {
return {
finalPrice,
discount: undefined,
rent: undefined,
};
}
}
}

View File

@@ -0,0 +1,38 @@
import { Controller, Get, Param, Query, UseGuards } from '@nestjs/common';
import { SeasonService } from './season.service';
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
import { User } from 'src/auth/user.decorator';
import { RequestUser } from 'src/auth/dto/request/request-user.interface';
import { ListRentsDTO } from 'src/rents/dto/request/list-films.dto';
import { SeasonRentDTO } from './dto/response/season-rent.dto';
import { SeasonDTO } from './dto/response/season.dto';
@Controller('seasons')
export class SeasonController {
constructor(private readonly seasonService: SeasonService) {}
@Get()
@UseGuards(HybridAuthGuard)
async findByUser(
@User() user: RequestUser['user'],
@Query() query: ListRentsDTO,
) {
const rents = await this.seasonService.findSeasonRentsByUserId(
user.id,
query,
);
return rents.map((rent) => new SeasonRentDTO(rent));
}
@Get(':id')
async findById(@Param('id') id: string) {
const season = await this.seasonService.findOne(id, ['contents']);
return new SeasonDTO(season);
}
@Get('project/:id')
async findByProjectId(@Param('id') id: string) {
const seasons = await this.seasonService.findByProjectId(id);
return seasons.map((season) => new SeasonDTO(season));
}
}

View File

@@ -0,0 +1,36 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { SeasonService } from './season.service';
import { Season } from './entities/season.entity';
import { SeasonRent } from './entities/season-rents.entity';
import { SeasonController } from './season.controller';
import { SeasonRentsService } from './season-rents.service';
import { SeasonRentsController } from './season-rents.controller';
import { Shareholder } from 'src/contents/entities/shareholder.entity';
import { RentsGateway } from 'src/events/rents.gateway';
import { EventsModule } from 'src/events/events.module';
import { Discount } from 'src/discounts/entities/discount.entity';
import { DiscountRedemptionService } from 'src/discount-redemption/discount-redemption.service';
import { DiscountRedemption } from 'src/discount-redemption/entities/discount-redemption.entity';
@Module({
controllers: [SeasonController, SeasonRentsController],
imports: [
TypeOrmModule.forFeature([
Shareholder,
Season,
SeasonRent,
Discount,
DiscountRedemption,
]),
EventsModule,
],
providers: [
SeasonService,
SeasonRentsService,
DiscountRedemptionService,
RentsGateway,
],
exports: [SeasonService, SeasonRentsService],
})
export class SeasonModule {}

View File

@@ -0,0 +1,285 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Season } from './entities/season.entity';
import { In, Repository } from 'typeorm';
import { Content } from 'src/contents/entities/content.entity';
import { SeasonRent } from './entities/season-rents.entity';
import { ListRentsDTO } from 'src/rents/dto/request/list-films.dto';
import { randomUUID } from 'node:crypto';
import { UpdateSeasonDto } from './dto/request/update-season.dto.entity';
@Injectable()
export class SeasonService {
constructor(
@InjectRepository(Season)
private seasonRepository: Repository<Season>,
@InjectRepository(SeasonRent)
private seasonRentRepository: Repository<SeasonRent>,
) {}
findOne(id: string, relations?: string[]) {
return this.seasonRepository.findOne({
where: { id },
relations,
});
}
async findSeasonsByProjectAndNumbers(
projectId: string,
seasonNumbers: number[],
) {
const existingSeasons = await this.seasonRepository.find({
where: {
projectId,
seasonNumber: In(seasonNumbers),
},
});
return existingSeasons;
}
async findSeasonRentsByUserId(userId: string, query: ListRentsDTO) {
const rentsQuery = await this.getRentsQuery(userId, query);
rentsQuery.limit(query.limit);
rentsQuery.offset(query.offset);
return rentsQuery.getMany();
}
async findByProjectId(projectId: string) {
return this.seasonRepository.find({
where: { projectId },
});
}
async getOrCreateSeasonsUpsert(
projectId: string,
seasonsToUpdate: UpdateSeasonDto[],
): Promise<Season[]> {
const seasonNumbers = seasonsToUpdate.map((s) => s.seasonNumber);
if (seasonNumbers.length === 0) {
return [];
}
const uniqueSeasonNumbers = [...new Set(seasonNumbers)].sort(
(a, b) => a - b,
);
const existingSeasons = await this.findSeasonsByProjectAndNumbers(
projectId,
uniqueSeasonNumbers,
);
// Create a map for quick lookup of existing season IDs
const existingSeasonsMap = new Map(
existingSeasons.map((season) => [season.seasonNumber, season.id]),
);
const existingSeasonNumbers = new Set(
existingSeasons.map((s) => s.seasonNumber),
);
const newSeasonNumbers = new Set(
uniqueSeasonNumbers.filter(
(number_) => !existingSeasonNumbers.has(number_),
),
);
// Create upsert data
const seasonsData = uniqueSeasonNumbers.map((seasonNumber) => ({
id: newSeasonNumbers.has(seasonNumber)
? randomUUID()
: existingSeasonsMap.get(seasonNumber),
projectId,
seasonNumber,
rentalPrice:
seasonsToUpdate.find(
(seasonToUpdate) => seasonToUpdate.seasonNumber === seasonNumber,
)?.rentalPrice || 0,
title: `Season ${seasonNumber}`,
}));
// Upsert all seasons at once
await this.seasonRepository.upsert(seasonsData, {
conflictPaths: ['projectId', 'seasonNumber'], // Composite unique constraint
skipUpdateIfNoValuesChanged: true,
});
// Return all seasons for the project
return this.seasonRepository.find({
where: {
projectId,
seasonNumber: In(uniqueSeasonNumbers),
},
});
}
async removeByProjectId(projectId: string, seasonNumbers?: number[]) {
if (seasonNumbers && seasonNumbers.length === 0) {
return await this.seasonRepository.delete({
projectId,
seasonNumber: In(seasonNumbers),
});
}
await this.seasonRepository.delete({
projectId,
});
}
async bulkUpdateSeasons(
updates: {
projectId: string;
seasonNumber: number;
rentalPrice?: number;
title?: string;
description?: string;
isActive?: boolean;
}[],
): Promise<void> {
await this.seasonRepository.manager.transaction(async (manager) => {
for (const update of updates) {
const {
projectId,
seasonNumber,
rentalPrice,
title,
description,
isActive,
} = update;
await manager
.getRepository(Season)
.createQueryBuilder()
.update(Season)
.set({
rentalPrice,
title,
description,
isActive,
})
.where('project_id = :projectId AND season_number = :seasonNumber', {
projectId,
seasonNumber,
})
.execute();
}
});
}
/**
* Based on a comparison of the current seasons and the new contents,
* determines which seasons already exist, which have been removed,
* and which should be added.
*
* @param projectId - The ID of the project to which the seasons belong.
* @param newContents - List of content items, each with a `season` property (season number).
* @param currentSeasons - Currently registered seasons associated with the project.
* @returns An object with three arrays:
* - `addedSeasons`: seasons found in the new contents but not in the current list.
* - `existingSeasons`: seasons present in both current and new lists.
* - `removedSeasons`: seasons that are no longer present in the new list.
*/
analizeSeasonDiff(
projectId: string,
newContents: Pick<Content, 'season'>[],
currentSeasons: Pick<Season, 'projectId' | 'seasonNumber'>[],
) {
const newSeasons = newContents.map((newContent) => ({
projectId,
seasonNumber: newContent.season,
}));
const { existing, removed, added } = this.compareSeasons(
currentSeasons,
newSeasons,
);
return {
added,
existing,
removed,
};
}
/**
* Determines which seasons already exist, which have been removed,
* and which should be added. It does not verify if the season has been updated.
*
* @param projectId - The ID of the project to which the seasons belong.
* @param newContents - List of content items, each with a `season` property (season number).
* @param currentSeasons - Currently registered seasons associated with the project.
* @returns An object with three arrays:
* - `addedSeasons`: seasons found in the new contents but not in the current list.
* - `existingSeasons`: seasons present in both current and new lists.
* - `removedSeasons`: seasons that are no longer present in the new list.
*/
compareSeasons(
currentSeasons: Pick<Season, 'projectId' | 'seasonNumber'>[],
newSeasons: Pick<Season, 'projectId' | 'seasonNumber'>[],
) {
const createKey = (
season: Pick<Season, 'projectId' | 'seasonNumber'>,
): string => `${season.projectId}-${season.seasonNumber}`;
const currentSeasonsMap = new Map(
currentSeasons.map((season) => [createKey(season), season]),
);
const newSeasonsMap = new Map(
newSeasons.map((season) => [createKey(season), season]),
);
const existing: Pick<Season, 'projectId' | 'seasonNumber'>[] = [];
const removed: Pick<Season, 'projectId' | 'seasonNumber'>[] = [];
const added: Pick<Season, 'projectId' | 'seasonNumber'>[] = [];
const allKeys = new Set([
...currentSeasonsMap.keys(),
...newSeasonsMap.keys(),
]);
for (const key of allKeys) {
const currentSeason = currentSeasonsMap.get(key);
const newSeason = newSeasonsMap.get(key);
if (currentSeason && newSeason) {
existing.push(currentSeason);
} else if (currentSeason && !newSeason) {
removed.push(currentSeason);
} else if (!currentSeason && newSeason) {
added.push(newSeason);
}
}
return {
existing,
removed,
added,
};
}
async getRentsQuery(userId: string, query: ListRentsDTO) {
const rentsQuery =
this.seasonRentRepository.createQueryBuilder('season_rent');
rentsQuery.withDeleted();
rentsQuery.leftJoinAndSelect('season_rent.content', 'content');
rentsQuery.leftJoinAndSelect('content.project', 'project');
rentsQuery.where('season_rent.userId = :userId', { userId });
rentsQuery.andWhere('season_rent.status = :status', { status: 'paid' });
rentsQuery.andWhere('project.deleted_at IS NULL');
if (query.status === 'active') {
rentsQuery.andWhere('season_rent.createdAt > :date', {
date: this.getExpiringDate(),
});
} else if (query.status === 'expired') {
rentsQuery.andWhere('season_rent.createdAt < :date', {
date: this.getExpiringDate(),
});
}
rentsQuery.orderBy('season_rent.createdAt', 'DESC');
return rentsQuery;
}
private getExpiringDate() {
return new Date(Date.now() - 2 * 24 * 60 * 60 * 1000);
}
}