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,13 @@
import { IsEmail, IsString } from 'class-validator';
export class CreateUserDTO {
@IsString()
legalName: string;
@IsString()
@IsEmail()
email: string;
@IsString()
cognitoId: string;
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { CreateUserDTO } from './create-user.dto';
import { UpdateFilmmakerDTO } from 'src/filmmakers/dto/request/update-filmmaker.dto';
import { IsOptional, IsString, ValidateNested } from 'class-validator';
import { Type } from 'class-transformer';
export class UpdateUserDTO extends PartialType(CreateUserDTO) {
@IsString()
@IsOptional()
profilePictureUrl?: string;
@ValidateNested()
@ApiProperty({ type: () => UpdateFilmmakerDTO })
@Type(() => UpdateFilmmakerDTO)
filmmaker?: UpdateFilmmakerDTO;
}

View File

@@ -0,0 +1,42 @@
import { FilmmakerDTO } from 'src/filmmakers/dto/response/filmmaker.dto';
import { SubscriptionDTO } from 'src/subscriptions/dto/response/subscription.dto';
import { User } from 'src/users/entities/user.entity';
export class UserDTO {
id: string;
email: string;
cognitoId: string;
filmmaker?: FilmmakerDTO;
subscriptions?: SubscriptionDTO[];
legalName: string;
lastName: string;
profilePictureUrl: string;
constructor(user: User) {
this.id = user.id;
this.email = user.email;
this.cognitoId = user.cognitoId;
if (user.filmmaker) {
this.filmmaker = new FilmmakerDTO(user.filmmaker);
}
if (user.subscriptions && user.subscriptions.length > 0) {
user.subscriptions.sort(
(a, b) => b.periodEnd.getTime() - a.periodEnd.getTime(),
);
const activeSubscriptions = user.subscriptions.filter(
(subscription) =>
subscription.periodEnd > new Date() &&
(subscription.status === 'succeeded' ||
subscription.status === 'cancelled'),
);
this.subscriptions = activeSubscriptions.map(
(activeSubscription) => new SubscriptionDTO(activeSubscription),
);
}
this.legalName = user.legalName;
if (user.profilePictureUrl) {
this.profilePictureUrl =
process.env.S3_PUBLIC_BUCKET_URL + user.profilePictureUrl;
}
}
}

View File

@@ -0,0 +1,68 @@
import { DiscountRedemption } from 'src/discount-redemption/entities/discount-redemption.entity';
import { Filmmaker } from 'src/filmmakers/entities/filmmaker.entity';
import { LibraryItem } from 'src/library/entities/library-item.entity';
import { Payout } from 'src/payout/entities/payout.entity';
import { Rent } from 'src/rents/entities/rent.entity';
import { SeasonRent } from 'src/season/entities/season-rents.entity';
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
OneToMany,
OneToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('users')
export class User {
@PrimaryColumn()
id: string;
@Column()
email: string;
@Column({ unique: true, nullable: true })
cognitoId: string;
@Column({ unique: true, nullable: true })
nostrPubkey?: string;
@Column()
legalName: string;
@Column({ nullable: true })
profilePictureUrl: string;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt?: Date;
@OneToOne(() => Filmmaker, (filmmaker) => filmmaker.user)
filmmaker?: Filmmaker;
@OneToMany(() => Subscription, (sub) => sub.user)
subscriptions: Subscription[];
@OneToMany(() => Rent, (rent) => rent.user)
rents?: Rent[];
@OneToMany(() => SeasonRent, (seasonRent) => seasonRent.user)
seasonRents: SeasonRent[];
@OneToMany(() => Payout, (payout) => payout.user)
payouts?: Payout[];
@OneToMany(() => DiscountRedemption, (redemption) => redemption.user)
redemptions: DiscountRedemption[];
@OneToMany(() => LibraryItem, (item) => item.user)
libraryItems: LibraryItem[];
}

View File

@@ -0,0 +1,18 @@
import { CanActivate, ExecutionContext, Injectable } from '@nestjs/common';
import { Observable } from 'rxjs';
@Injectable()
export class UserGuard implements CanActivate {
constructor() {}
canActivate(
context: ExecutionContext,
): boolean | Promise<boolean> | Observable<boolean> {
const request = context.switchToHttp().getRequest();
const userId = request.params.id;
const user = request.user;
return userId === user.id;
}
}

View File

@@ -0,0 +1,31 @@
import {
Controller,
Body,
Patch,
Param,
UseGuards,
Delete,
} from '@nestjs/common';
import { UsersService } from './users.service';
import { UpdateUserDTO } from './dto/request/update-user.dto';
import { UserGuard } from './guards/user.guard';
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
import { UserDTO } from './dto/response/user.dto';
import { AdminAuthGuard } from 'src/auth/guards/admin.guard';
@Controller('users')
export class UsersController {
constructor(private readonly usersService: UsersService) {}
@Patch(':id')
@UseGuards(HybridAuthGuard, UserGuard)
async update(@Param('id') id: string, @Body() updateUserDTO: UpdateUserDTO) {
return new UserDTO(await this.usersService.update(id, updateUserDTO));
}
@Delete(':id')
@UseGuards(AdminAuthGuard)
async delete(@Param('id') id: string) {
return await this.usersService.delete(id);
}
}

View File

@@ -0,0 +1,26 @@
import { Global, Module, forwardRef } from '@nestjs/common';
import { UsersService } from './users.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { User } from './entities/user.entity';
import { Filmmaker } from 'src/filmmakers/entities/filmmaker.entity';
import { UsersController } from './users.controller';
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
import { UploadModule } from 'src/upload/upload.module';
import { MailModule } from 'src/mail/mail.module';
import { AuthModule } from 'src/auth/auth.module';
import { DiscountRedemption } from 'src/discount-redemption/entities/discount-redemption.entity';
@Global()
@Module({
imports: [
TypeOrmModule.forFeature([User, Filmmaker, DiscountRedemption]),
forwardRef(() => FilmmakersModule),
UploadModule,
MailModule,
forwardRef(() => AuthModule),
],
providers: [UsersService],
exports: [UsersService],
controllers: [UsersController],
})
export class UsersModule {}

View File

@@ -0,0 +1,202 @@
import { Inject, Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { CreateUserDTO } from './dto/request/create-user.dto';
import { User } from './entities/user.entity';
import { randomUUID } from 'node:crypto';
import { UpdateUserDTO } from './dto/request/update-user.dto';
import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
import { UploadService } from 'src/upload/upload.service';
import { MailService } from 'src/mail/mail.service';
import { AuthService } from 'src/auth/auth.service';
export interface ICreateUser {
userDto: CreateUserDTO;
templateId?: string;
data?: Record<string, unknown>;
userType?: 'audience' | 'filmmaker';
}
@Injectable()
export class UsersService {
constructor(
@InjectRepository(User)
private readonly userRepository: Repository<User>,
@Inject(FilmmakersService)
private readonly filmmakersService: FilmmakersService,
@Inject(UploadService)
private readonly uploadService: UploadService,
@Inject(MailService)
private readonly mailService: MailService,
@Inject(AuthService)
private readonly authService: AuthService,
) {}
async createUser({
userDto: dto,
templateId,
data,
userType,
}: ICreateUser): Promise<User> {
const user = this.userRepository.create({
id: randomUUID(),
legalName: dto.legalName,
email: dto.email,
cognitoId: dto.cognitoId,
});
const defaultTemplateId =
userType === 'filmmaker'
? 'd-39ebc41a80de4318b4e59ca0d398d70d'
: 'd-ef5b24f8c80d454b8d6d230f41832cfe';
this.mailService.sendMail({
templateId: templateId ?? defaultTemplateId,
to: user.email,
data,
});
return await this.userRepository.save(user);
}
async findUsersById(id: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { id },
relations: ['filmmaker', 'subscriptions'],
relationLoadStrategy: 'query',
});
return user;
}
async findUserByEmail(email: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { email },
relations: ['filmmaker', 'subscriptions'],
// load only the most recent subscription
relationLoadStrategy: 'query',
});
return user;
}
async findUserByCognitoId(cognitoId: string): Promise<User> {
const user = await this.userRepository.findOne({
where: { cognitoId },
relations: ['filmmaker', 'subscriptions'],
// load only the most recent subscription
relationLoadStrategy: 'query',
});
return user;
}
async findUserByNostrPubkey(nostrPubkey: string): Promise<User | null> {
return await this.userRepository.findOne({
where: { nostrPubkey },
relations: ['filmmaker', 'subscriptions'],
relationLoadStrategy: 'query',
});
}
/**
* Auto-create a user + filmmaker record for a Nostr-only login.
* Every Nostr user is treated as a potential creator (filmmaker).
* Uses a generated placeholder email so the NOT-NULL constraint is satisfied.
*/
async createNostrUser(nostrPubkey: string): Promise<User> {
const shortKey = nostrPubkey.slice(0, 12);
const userId = randomUUID();
const user = this.userRepository.create({
id: userId,
nostrPubkey,
email: `${shortKey}@nostr.local`,
legalName: `Nostr ${shortKey}`,
});
const savedUser = await this.userRepository.save(user);
// Auto-create a filmmaker profile so the user can access Backstage
try {
await this.filmmakersService.create({
userId,
professionalName: `Nostr ${shortKey}`,
});
} catch (error) {
Logger.warn(
`Could not auto-create filmmaker for ${shortKey}: ${error.message}`,
'UsersService',
);
}
// Re-fetch with relations so the filmmaker is included
return (
(await this.findUserByNostrPubkey(nostrPubkey)) ?? savedUser
);
}
/**
* Ensure a user has a filmmaker profile — creates one if missing.
* Returns the refreshed user with the filmmaker relation loaded.
*/
async ensureFilmmaker(user: User): Promise<User> {
if (user.filmmaker) return user;
const shortKey =
user.nostrPubkey?.slice(0, 12) ?? user.email.split('@')[0];
await this.filmmakersService.create({
userId: user.id,
professionalName: `Nostr ${shortKey}`,
});
return (await this.findUserByNostrPubkey(user.nostrPubkey)) ?? user;
}
async linkNostrPubkey(userId: string, nostrPubkey: string): Promise<User> {
const existingUserWithKey = await this.userRepository.findOneBy({
nostrPubkey,
});
if (existingUserWithKey && existingUserWithKey.id !== userId) {
throw new Error('Nostr pubkey already linked to another user');
}
const user = await this.findUsersById(userId);
user.nostrPubkey = nostrPubkey;
return await this.userRepository.save(user);
}
async unlinkNostrPubkey(userId: string): Promise<User> {
const user = await this.findUsersById(userId);
user.nostrPubkey = undefined;
return await this.userRepository.save(user);
}
async update(id: string, dto: UpdateUserDTO): Promise<User> {
const user = await this.findUsersById(id);
user.legalName = dto.legalName;
user.email = dto.email;
user.profilePictureUrl = dto.profilePictureUrl;
if (dto.profilePictureUrl) {
this.uploadService.pruneFolder(
user.profilePictureUrl,
process.env.S3_PUBLIC_BUCKET_NAME,
);
}
if (dto.filmmaker && user.filmmaker) {
await this.filmmakersService.update(user.filmmaker.id, dto.filmmaker);
}
return await this.userRepository.save(user);
}
async delete(id: string) {
const user = await this.findUsersById(id);
Logger.log(`Deleting user ${user.email}`);
if (user.filmmaker) {
await this.filmmakersService.delete(user.filmmaker.id);
}
await this.authService.deleteUserFromCognito(user.email);
if (user.profilePictureUrl) {
this.uploadService.deleteObject(
user.profilePictureUrl,
process.env.S3_PUBLIC_BUCKET_NAME,
);
}
return await this.userRepository.softDelete({ id });
}
}