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,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);
|
||||
88
backend/src/discounts/discounts.controller.ts
Normal file
88
backend/src/discounts/discounts.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
16
backend/src/discounts/discounts.module.ts
Normal file
16
backend/src/discounts/discounts.module.ts
Normal 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 {}
|
||||
266
backend/src/discounts/discounts.service.ts
Normal file
266
backend/src/discounts/discounts.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
59
backend/src/discounts/dto/create-discount.dto.ts
Normal file
59
backend/src/discounts/dto/create-discount.dto.ts
Normal 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;
|
||||
}
|
||||
10
backend/src/discounts/dto/request/coupon-code.dto.ts
Normal file
10
backend/src/discounts/dto/request/coupon-code.dto.ts
Normal 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;
|
||||
}
|
||||
6
backend/src/discounts/dto/update-discount.dto.ts
Normal file
6
backend/src/discounts/dto/update-discount.dto.ts
Normal 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),
|
||||
) {}
|
||||
103
backend/src/discounts/entities/discount.entity.ts
Normal file
103
backend/src/discounts/entities/discount.entity.ts
Normal 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;
|
||||
}
|
||||
16
backend/src/discounts/utils/calculate-discount-price.ts
Normal file
16
backend/src/discounts/utils/calculate-discount-price.ts
Normal 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;
|
||||
};
|
||||
9
backend/src/discounts/utils/is-valid-discount.ts
Normal file
9
backend/src/discounts/utils/is-valid-discount.ts
Normal 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;
|
||||
};
|
||||
Reference in New Issue
Block a user