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,8 @@
export const DiscountTypes = {
PERCENTAGE: 'percentage',
FIXED: 'fixed',
FREE: 'free',
} as const;
export type DiscountType = (typeof DiscountTypes)[keyof typeof DiscountTypes];
export const DiscountTypeCodes = Object.values(DiscountTypes);
export const DiscountTypeCodesSet = new Set(DiscountTypeCodes);

View File

@@ -0,0 +1,88 @@
import {
Controller,
Get,
Post,
Body,
Patch,
Param,
Delete,
UseGuards,
} from '@nestjs/common';
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
import { DiscountsService } from './discounts.service';
import { CreateDiscountDto } from './dto/create-discount.dto';
import { UpdateDiscountDto } from './dto/update-discount.dto';
import { User } from 'src/auth/user.decorator';
import { RequestUser } from 'src/auth/dto/request/request-user.interface';
@Controller('discounts')
export class DiscountsController {
constructor(private readonly discountsService: DiscountsService) {}
@Post()
@UseGuards(HybridAuthGuard)
create(
@Body() createDiscountDto: CreateDiscountDto,
@User() user: RequestUser['user'],
) {
return this.discountsService.create(createDiscountDto, user);
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.discountsService.findOne(id);
}
@Patch(':id')
@UseGuards(HybridAuthGuard)
update(
@Param('id') id: string,
@Body() updateDiscountDto: UpdateDiscountDto,
@User() user: RequestUser['user'],
) {
return this.discountsService.update(id, updateDiscountDto, user);
}
@Delete(':id')
@UseGuards(HybridAuthGuard)
remove(@Param('id') id: string, @User() user: RequestUser['user']) {
return this.discountsService.remove(id, user);
}
@Get('/project/:projectId')
@UseGuards(HybridAuthGuard)
findByProjectId(
@Param('projectId') projectId: string,
@User() user: RequestUser['user'],
) {
return this.discountsService.findByProjectIdAndUser(projectId, user.id);
}
@Get('/content/:contentId/:couponCode')
@UseGuards(HybridAuthGuard)
findByContentId(
@Param('contentId') contentId: string,
@Param('couponCode') couponCode: string,
@User() user: RequestUser['user'],
) {
return this.discountsService.findByDiscountCode({
couponCode: couponCode.toUpperCase(),
contentId,
user,
});
}
@Get('/season/:seasonId/:couponCode')
@UseGuards(HybridAuthGuard)
findBySeasonId(
@Param('seasonId') seasonId: string,
@Param('couponCode') couponCode: string,
@User() user: RequestUser['user'],
) {
return this.discountsService.findByDiscountCode({
couponCode: couponCode.toUpperCase(),
seasonId,
user,
});
}
}

View File

@@ -0,0 +1,16 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { DiscountsService } from './discounts.service';
import { DiscountsController } from './discounts.controller';
import { Discount } from './entities/discount.entity';
import { Season } from 'src/season/entities/season.entity';
import { Content } from 'src/contents/entities/content.entity';
import { Permission } from 'src/projects/entities/permission.entity';
@Module({
imports: [TypeOrmModule.forFeature([Discount, Season, Content, Permission])],
controllers: [DiscountsController],
providers: [DiscountsService],
exports: [DiscountsService],
})
export class DiscountsModule {}

View File

@@ -0,0 +1,266 @@
import {
Injectable,
NotFoundException,
UnauthorizedException,
UnprocessableEntityException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Discount } from './entities/discount.entity';
import { CreateDiscountDto } from './dto/create-discount.dto';
import { UpdateDiscountDto } from './dto/update-discount.dto';
import { randomUUID } from 'node:crypto';
import { Season } from 'src/season/entities/season.entity';
import { Content } from 'src/contents/entities/content.entity';
import { User } from 'src/users/entities/user.entity';
import { Permission } from 'src/projects/entities/permission.entity';
import { calculateDiscountedPrice } from './utils/calculate-discount-price';
import { isValidDiscountedPrice } from './utils/is-valid-discount';
export interface FindByDiscountCodeProps {
couponCode: string;
contentId?: string;
seasonId?: string;
user: User;
}
@Injectable()
export class DiscountsService {
constructor(
@InjectRepository(Discount)
private readonly discountRepository: Repository<Discount>,
@InjectRepository(Season)
private readonly seasonRepository: Repository<Season>,
@InjectRepository(Content)
private readonly contentRepository: Repository<Content>,
@InjectRepository(Permission)
private readonly permissionRepository: Repository<Permission>,
) {}
async create(
createDiscountDto: CreateDiscountDto,
user: User,
): Promise<Discount> {
const userId = user.id;
const validatedDiscountValue = await this.applyDiscount(
createDiscountDto,
user,
);
const discount = this.discountRepository.create({
id: randomUUID(),
couponCode: createDiscountDto.couponCode,
value: validatedDiscountValue || createDiscountDto.discountValue,
type: createDiscountDto.discountType,
content: { id: createDiscountDto.contentId },
season: { id: createDiscountDto.seasonId },
maxUses:
createDiscountDto.discountType === 'free'
? 1
: createDiscountDto?.maxUses,
email: createDiscountDto?.email,
createdBy: { id: userId },
});
return this.discountRepository.save(discount);
}
async findAll(): Promise<Discount[]> {
return this.discountRepository.find();
}
async findOne(id: string): Promise<Discount> {
const discount = await this.discountRepository.findOne({ where: { id } });
if (!discount) throw new NotFoundException('Discount not found');
return discount;
}
async findByDiscountCode({
couponCode,
contentId,
seasonId,
user,
}: FindByDiscountCodeProps): Promise<Discount> {
const discount = await this.discountRepository.findOne({
where: { couponCode, contentId, seasonId },
});
if (discount?.type === 'free' && discount.email !== user.email) {
throw new NotFoundException();
}
return discount;
}
async update(
id: string,
updateDiscountDto: UpdateDiscountDto,
user: User,
): Promise<Discount> {
const userId = user.id;
const validatedDiscountValue = await this.applyDiscount(
updateDiscountDto,
user,
id,
);
await this.discountRepository.update(
{ id, createdById: userId },
{
couponCode: updateDiscountDto.couponCode,
value: validatedDiscountValue || updateDiscountDto.discountValue,
type: updateDiscountDto.discountType,
contentId: updateDiscountDto.contentId,
seasonId: updateDiscountDto.seasonId,
maxUses:
updateDiscountDto.discountType === 'free'
? 1
: updateDiscountDto?.maxUses,
// eslint-disable-next-line unicorn/no-null
email: updateDiscountDto?.email ?? null,
},
);
return await this.findOne(id);
}
async remove(id: string, user: User) {
const deleted = await this.discountRepository.delete({
createdById: user.id,
id,
});
if (!deleted.affected) {
throw new NotFoundException();
}
return { message: 'Discount deleted successfully' };
}
async findByProjectIdAndUser(
projectId: string,
userId: string,
): Promise<Discount[]> {
return this.discountRepository
.createQueryBuilder('discount')
.leftJoinAndSelect('discount.content', 'content')
.leftJoinAndSelect('discount.season', 'season')
.where(
'(content.projectId = :projectId OR season.projectId = :projectId)',
{
projectId,
},
)
.andWhere('discount.createdBy = :userId', { userId })
.getMany();
}
async applyDiscount(
discountDto: CreateDiscountDto | UpdateDiscountDto,
user: User,
discountId?: string,
) {
let content: Content;
let season: Season;
let validatedDiscountValue: number;
const userId = user.id;
const filmmakerId = user.filmmaker?.id;
let targetProjectId: string;
const exists = await this.discountRepository.exists({
where: {
contentId: discountDto?.contentId,
seasonId: discountDto?.seasonId,
couponCode: discountDto?.couponCode,
},
});
if (exists && !discountId) {
throw new UnprocessableEntityException('Discount code already exists');
}
if (discountDto?.contentId) {
content = await this.contentRepository.findOne({
where: { id: discountDto.contentId },
});
if (!content) {
throw new NotFoundException('Content not found');
}
if (!content?.rentalPrice) {
throw new UnprocessableEntityException(
'Content is not available for rental',
);
}
targetProjectId = content.projectId;
}
if (discountDto?.seasonId) {
season = await this.seasonRepository.findOne({
where: { id: discountDto.seasonId },
});
if (!season) {
throw new NotFoundException('Season not found');
}
if (!season?.rentalPrice) {
throw new UnprocessableEntityException(
'Season is not available for rental',
);
}
targetProjectId = season.projectId;
}
const ownerPermission = await this.permissionRepository.findOne({
where: {
filmmakerId: filmmakerId,
projectId: targetProjectId,
role: 'owner',
},
});
if (!ownerPermission) {
throw new UnauthorizedException(
'User is not authorized to create discounts for this project',
);
}
if (discountDto.discountType === 'free') {
const freeDiscountCount = await this.discountRepository.count({
where: {
type: 'free',
createdById: userId,
seasonId: discountDto?.seasonId,
contentId: discountDto?.contentId,
},
});
if (freeDiscountCount >= 5) {
throw new UnprocessableEntityException(
'Maximum number of free discounts reached',
);
}
if (!discountDto?.email) {
throw new UnprocessableEntityException(
'Email is required for free discounts',
);
}
validatedDiscountValue = content
? content.rentalPrice
: season.rentalPrice;
} else {
const discountedPrice = calculateDiscountedPrice(
discountDto.discountType,
discountDto.discountValue,
content ? content.rentalPrice : season.rentalPrice,
);
if (!isValidDiscountedPrice(discountedPrice, discountDto.discountType)) {
throw new UnprocessableEntityException('Invalid discount');
}
}
return validatedDiscountValue;
}
}

View File

@@ -0,0 +1,59 @@
import {
IsString,
IsNumber,
IsOptional,
IsEnum,
IsDateString,
MaxLength,
Min,
IsEmail,
} from 'class-validator';
import {
DiscountType,
DiscountTypes,
} from '../constants/discount-type.constant';
import { Transform } from 'class-transformer';
import * as dayjs from 'dayjs';
export class CreateDiscountDto {
@IsString()
@MaxLength(50)
@Transform(({ value }) => value?.toUpperCase())
couponCode: string;
@IsNumber()
@Min(0)
discountValue: number;
@IsEnum(DiscountTypes)
@IsOptional()
discountType?: DiscountType = DiscountTypes.PERCENTAGE;
@IsOptional()
@IsString()
contentId: string;
@IsOptional()
@IsString()
seasonId: string;
@IsOptional()
@IsDateString()
@Transform(({ value }) => value ?? dayjs().toISOString())
startDate?: string;
@IsOptional()
@IsDateString()
@Transform(({ value }) => value ?? dayjs().add(1, 'month').toISOString())
expirationDate?: string;
@IsOptional()
@IsNumber()
@Min(1)
maxUses?: number;
@IsOptional()
@IsEmail()
@Transform(({ value }) => value?.toLowerCase())
email?: string | null;
}

View File

@@ -0,0 +1,10 @@
import { IsOptional, IsString, MaxLength } from 'class-validator';
import { Transform } from 'class-transformer';
export class CouponCodeDto {
@IsOptional()
@IsString()
@MaxLength(50)
@Transform(({ value }) => value?.toUpperCase())
couponCode: string;
}

View File

@@ -0,0 +1,6 @@
import { PartialType, OmitType } from '@nestjs/mapped-types';
import { CreateDiscountDto } from './create-discount.dto';
export class UpdateDiscountDto extends PartialType(
OmitType(CreateDiscountDto, [] as const),
) {}

View File

@@ -0,0 +1,103 @@
import { Content } from 'src/contents/entities/content.entity';
import { DiscountRedemption } from 'src/discount-redemption/entities/discount-redemption.entity';
import {
Entity,
Column,
ManyToOne,
OneToMany,
CreateDateColumn,
UpdateDateColumn,
JoinColumn,
DeleteDateColumn,
PrimaryColumn,
} from 'typeorm';
import {
DiscountType,
DiscountTypes,
} from '../constants/discount-type.constant';
import { User } from 'src/users/entities/user.entity';
import { Season } from 'src/season/entities/season.entity';
@Entity('discounts')
export class Discount {
@PrimaryColumn()
id: string;
@ManyToOne(() => Content, (content) => content.discounts, {
nullable: true,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'content_id' })
content: Content | null;
@Column({ type: 'varchar', nullable: true })
contentId: string | null;
@ManyToOne(() => Season, (season) => season.discounts, {
nullable: true,
onDelete: 'CASCADE',
})
@JoinColumn({ name: 'season_id' })
season: Season | null;
@Column({ type: 'varchar', nullable: true })
seasonId: string | null;
@Column({ type: 'varchar', length: 50, nullable: false })
couponCode: string;
@Column({
type: 'decimal',
precision: 5,
scale: 2,
nullable: false,
transformer: {
to: (value) => value,
from: (value) => Number.parseFloat(value),
},
})
value: number;
@Column({
type: 'varchar',
length: 20,
default: DiscountTypes.PERCENTAGE,
})
type: DiscountType;
// Start date (null = no start date)
@Column({ type: 'timestamp', nullable: true })
startDate: Date | null;
// End date (null = no expiration date)
@Column({ type: 'timestamp', nullable: true })
expirationDate: Date | null;
// Total usage limit (null = unlimited)
@Column({ type: 'int', nullable: true })
maxUses: number | null;
@Column({ type: 'varchar', length: 255, nullable: true })
email: string | null;
@OneToMany(() => DiscountRedemption, (redemption) => redemption.discount)
redemptions: DiscountRedemption[];
@ManyToOne(() => User, { nullable: false })
@JoinColumn({ name: 'created_by_id' })
createdBy: User;
@Column({ type: 'varchar' })
createdById: string;
@CreateDateColumn()
createdAt: Date;
@UpdateDateColumn()
updatedAt: Date;
// Soft delete
@DeleteDateColumn()
deletedAt: Date | null;
}

View File

@@ -0,0 +1,16 @@
import { Discount } from '../entities/discount.entity';
export const calculateDiscountedPrice = (
discountType: Omit<Discount['type'], 'free'>,
discountAmount: number,
rentalPrice: number,
) => {
let discountedPrice = 0;
if (discountType === 'fixed') {
discountedPrice = rentalPrice - discountAmount;
} else if (discountType === 'percentage') {
discountedPrice = rentalPrice * (1 - discountAmount / 100);
}
return discountedPrice;
};

View File

@@ -0,0 +1,9 @@
import { Discount } from '../entities/discount.entity';
export const isValidDiscountedPrice = (
discountedPrice: number,
discountType: Discount['type'],
) => {
if (discountType === 'free') return true;
return discountedPrice >= 0.5;
};