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:
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class CreateSeasonRentDto {
|
||||
@IsString()
|
||||
id: string; // Season id
|
||||
}
|
||||
25
backend/src/season/dto/request/create-season.dto.entity.ts
Normal file
25
backend/src/season/dto/request/create-season.dto.entity.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
40
backend/src/season/dto/response/season-rent.dto.ts
Normal file
40
backend/src/season/dto/response/season-rent.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
29
backend/src/season/dto/response/season.dto.ts
Normal file
29
backend/src/season/dto/response/season.dto.ts
Normal 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)) || [];
|
||||
}
|
||||
}
|
||||
49
backend/src/season/entities/season-rents.entity.ts
Normal file
49
backend/src/season/entities/season-rents.entity.ts
Normal 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;
|
||||
}
|
||||
69
backend/src/season/entities/season.entity.ts
Normal file
69
backend/src/season/entities/season.entity.ts
Normal 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'];
|
||||
100
backend/src/season/season-rents.controller.ts
Normal file
100
backend/src/season/season-rents.controller.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
309
backend/src/season/season-rents.service.ts
Normal file
309
backend/src/season/season-rents.service.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
38
backend/src/season/season.controller.ts
Normal file
38
backend/src/season/season.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
36
backend/src/season/season.module.ts
Normal file
36
backend/src/season/season.module.ts
Normal 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 {}
|
||||
285
backend/src/season/season.service.ts
Normal file
285
backend/src/season/season.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user