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:
13
backend/src/users/dto/request/create-user.dto.ts
Normal file
13
backend/src/users/dto/request/create-user.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { IsEmail, IsString } from 'class-validator';
|
||||
|
||||
export class CreateUserDTO {
|
||||
@IsString()
|
||||
legalName: string;
|
||||
|
||||
@IsString()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
|
||||
@IsString()
|
||||
cognitoId: string;
|
||||
}
|
||||
16
backend/src/users/dto/request/update-user.dto.ts
Normal file
16
backend/src/users/dto/request/update-user.dto.ts
Normal 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;
|
||||
}
|
||||
42
backend/src/users/dto/response/user.dto.ts
Normal file
42
backend/src/users/dto/response/user.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
68
backend/src/users/entities/user.entity.ts
Normal file
68
backend/src/users/entities/user.entity.ts
Normal 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[];
|
||||
}
|
||||
18
backend/src/users/guards/user.guard.ts
Normal file
18
backend/src/users/guards/user.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
backend/src/users/users.controller.ts
Normal file
31
backend/src/users/users.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
26
backend/src/users/users.module.ts
Normal file
26
backend/src/users/users.module.ts
Normal 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 {}
|
||||
202
backend/src/users/users.service.ts
Normal file
202
backend/src/users/users.service.ts
Normal 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 });
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user