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,52 @@
import {
Controller,
Post,
Body,
UseGuards,
Patch,
Param,
} from '@nestjs/common';
import { AdminService } from './admin.service';
import { AdminAuthGuard } from 'src/auth/guards/admin.guard';
import { AdminRegisterDTO } from './dto/admin-register.dto';
import { AdminBulkRegisterDTO } from './dto/admin-bulk-register.dto';
import { AdminCreateSubscriptionDTO } from 'src/subscriptions/dto/admin-create-subscription.dto';
import { UpdateUserTypeDTO } from './dto/update-user-type.dto';
@Controller('admin')
export class AdminController {
constructor(private readonly _adminService: AdminService) {}
@Post('users')
@UseGuards(AdminAuthGuard)
async createUser(@Body() registerRequest: AdminRegisterDTO) {
return await this._adminService.createUser(registerRequest);
}
@Post('users/batch')
@UseGuards(AdminAuthGuard)
async createUsers(@Body() { users }: AdminBulkRegisterDTO) {
const statuses = this._adminService.createManyUsers(users);
return statuses;
}
@Post('users/:userId/subscriptions')
@UseGuards(AdminAuthGuard)
async createSubscription(
@Body() createSubscriptionDTO: AdminCreateSubscriptionDTO,
) {
return await this._adminService.createSubscription(createSubscriptionDTO);
}
@Patch('users/:userId/type')
@UseGuards(AdminAuthGuard)
async updateUserTypeToFilmmaker(
@Param('userId') userId: string,
@Body() { professionalName }: UpdateUserTypeDTO,
) {
return await this._adminService.updateAudienceToFilmmaker(
userId,
professionalName,
);
}
}

View File

@@ -0,0 +1,15 @@
import { Module } from '@nestjs/common';
import { AdminService } from './admin.service';
import { AdminController } from './admin.controller';
import { AuthService } from 'src/auth/auth.service';
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Subscription } from 'src/subscriptions/entities/subscription.entity';
@Module({
controllers: [AdminController],
imports: [FilmmakersModule, TypeOrmModule.forFeature([Subscription])],
providers: [AdminService, AuthService, SubscriptionsService],
})
export class AdminModule {}

View File

@@ -0,0 +1,137 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
NotFoundException,
} from '@nestjs/common';
import { AdminRegisterDTO } from './dto/admin-register.dto';
import { AuthService } from 'src/auth/auth.service';
import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
import { UsersService } from 'src/users/users.service';
// Sentry removed
import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
import { AdminCreateSubscriptionDTO } from 'src/subscriptions/dto/admin-create-subscription.dto';
@Injectable()
export class AdminService {
constructor(
@Inject(AuthService)
private readonly _authService: AuthService,
@Inject(FilmmakersService)
private readonly _filmmakerService: FilmmakersService,
@Inject(UsersService)
private readonly _userService: UsersService,
@Inject(SubscriptionsService)
private readonly _subscriptionsService: SubscriptionsService,
) {}
async updateAudienceToFilmmaker(userId: string, professionalName?: string) {
const user = await this._userService.findUsersById(userId);
if (!user) throw new NotFoundException('User not found');
if (user.filmmaker)
throw new BadRequestException('User is already a filmmaker');
await this._filmmakerService.create({
professionalName: professionalName || user.legalName,
userId: userId,
});
}
async createUser(registerRequest: AdminRegisterDTO) {
try {
const user = await this._userService.createUser({
userDto: {
...registerRequest,
cognitoId: undefined,
},
userType: registerRequest.type,
});
if (registerRequest.type === 'audience') return user;
await this._filmmakerService.create({
professionalName: registerRequest.professionalName,
userId: user.id,
});
return user;
} catch (error) {
throw new BadRequestException(error.message);
}
}
async createManyUsers(users: AdminRegisterDTO[]) {
const statuses = [];
for (const newUser of users) {
try {
let user;
let currentStatus = 'error';
user = await this._userService.findUserByEmail(newUser.email);
if (!user) {
const welcomeAdminTemplateId = 'd-76a659621f1844a5b50bf4b20a393b99';
const templateData = {
email: newUser.email,
password: newUser.password,
};
user = await this._userService.createUser({
userDto: {
...newUser,
cognitoId: undefined,
},
templateId: welcomeAdminTemplateId,
data: templateData,
userType: newUser.type,
});
// Create filmmaker profile if the user is a filmmaker
if (newUser.type === 'filmmaker') {
await this._filmmakerService.create({
professionalName: newUser.professionalName,
userId: user.id,
});
}
// Create subscription if provided
if (
newUser.subscription.duration &&
newUser.subscription.type &&
newUser.subscription.period
) {
await this._subscriptionsService.adminCreateSubscription({
userId: user.id,
type: newUser.subscription.type,
period: newUser.subscription.period,
duration: newUser.subscription.duration,
});
}
currentStatus = 'success';
}
statuses.push({
email: newUser.email,
status: currentStatus,
userId: user.id,
reason:
currentStatus === 'error' ? 'email already exists' : undefined,
});
} catch (error) {
Logger.log(error);
Logger.error(`Failed to create user ${newUser.email}: ${error.message}`);
statuses.push({
email: newUser.email,
status: 'error',
reason: 'unprocessable Content',
});
}
}
return statuses;
}
async createSubscription(createSubscriptionDTO: AdminCreateSubscriptionDTO) {
return await this._subscriptionsService.adminCreateSubscription(
createSubscriptionDTO,
);
}
}

View File

@@ -0,0 +1,11 @@
import { ValidateNested } from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { AdminRegisterDTO } from './admin-register.dto';
export class AdminBulkRegisterDTO {
@ValidateNested({ each: true })
@ApiProperty({ type: () => AdminRegisterDTO })
@Type(() => AdminRegisterDTO)
users: AdminRegisterDTO[];
}

View File

@@ -0,0 +1,15 @@
import { IsNotEmpty, IsOptional, IsString } from 'class-validator';
import { RegisterDTO } from 'src/auth/dto/request/register.dto';
import { AdminCreateSubscriptionDTO } from 'src/subscriptions/dto/admin-create-subscription.dto';
export class AdminRegisterDTO extends RegisterDTO {
@IsNotEmpty()
@IsString()
password: string;
@IsOptional()
cognitoId: string;
@IsOptional()
subscription: AdminCreateSubscriptionDTO;
}

View File

@@ -0,0 +1,12 @@
import { IsEnum, IsOptional, IsString, MaxLength } from 'class-validator';
export class UpdateUserTypeDTO {
// Currently, the only switch from audience type to filmmaker is allowed
@IsEnum(['filmmaker'])
type: 'filmmaker';
@IsOptional()
@IsString()
@MaxLength(100)
professionalName?: string;
}

View File

@@ -0,0 +1,12 @@
import { Controller, Get } from '@nestjs/common';
import { AppService } from './app.service';
@Controller('')
export class AppController {
constructor(private readonly appService: AppService) {}
@Get()
getHello(): string {
return this.appService.getHello();
}
}

111
backend/src/app.module.ts Normal file
View File

@@ -0,0 +1,111 @@
import { Module } from '@nestjs/common';
import { AppController } from './app.controller';
import { AppService } from './app.service';
import { ConfigModule } from '@nestjs/config';
import { AuthModule } from './auth/auth.module';
import { UsersModule } from './users/users.module';
import { DatabaseModule } from './database/database.module';
import { UploadModule } from './upload/upload.module';
import { FilmmakersModule } from './filmmakers/filmmakers.module';
import { WaitlistModule } from './waitlist/waitlist.module';
import { PaymentModule } from './payment/payment.module';
import { ScheduleModule } from '@nestjs/schedule';
import { InvitesModule } from './invites/invites.module';
import { MailModule } from './mail/mail.module';
import { MigrationModule } from './migration/migration.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { AwardIssuersModule } from './award-issuers/award-issuers.module';
import { FestivalsModule } from './festivals/festivals.module';
import { GenresModule } from './genres/genres.module';
import { CacheInterceptor, CacheModule } from '@nestjs/cache-manager';
import { APP_FILTER, APP_INTERCEPTOR } from '@nestjs/core';
import { RentsModule } from './rents/rents.module';
import { EventsModule } from './events/events.module';
import { WebhooksModule } from './webhooks/webhook.module';
import { ProjectsModule } from './projects/projects.module';
import { ContentsModule } from './contents/contents.module';
import { GlobalFilter } from './common/filter/error-exception.filter';
import { RssModule } from './rss/rss.module';
import { ThrottlerModule } from '@nestjs/throttler';
import { TranscodingServerModule } from './transcoding-server/transcoding-server.module';
import { BullModule } from '@nestjs/bullmq';
// Sentry removed -- use self-hosted error tracking later
import { PostHogModule } from './posthog/posthog.module';
import { SecretsManagerModule } from './secrets-manager/secrets-manager.module';
import { DRMModule } from './drm/drm.module';
import { AdminModule } from './admin/admin.module';
import { SeasonModule } from './season/season.module';
import { DiscountsModule } from './discounts/discounts.module';
import { DiscountRedemptionModule } from './discount-redemption/discount-redemption.module';
import { NostrAuthModule } from './nostr-auth/nostr-auth.module';
import { LibraryModule } from './library/library.module';
@Module({
imports: [
CacheModule.register({
isGlobal: true,
ttl: 30,
max: 100,
}),
ThrottlerModule.forRoot([
{
ttl: 60_000,
limit: 100,
},
]),
BullModule.forRootAsync({
useFactory: () => ({
connection: {
host: process.env.QUEUE_HOST,
port: Number.parseInt(process.env.QUEUE_PORT, 10),
password: process.env.QUEUE_PASSWORD,
},
}),
}),
ConfigModule.forRoot(),
ScheduleModule.forRoot(),
MailModule,
PaymentModule,
DatabaseModule,
AuthModule,
UsersModule,
UploadModule,
ProjectsModule,
ContentsModule,
FilmmakersModule,
WaitlistModule,
InvitesModule,
MigrationModule,
SubscriptionsModule,
AwardIssuersModule,
FestivalsModule,
GenresModule,
RentsModule,
EventsModule,
WebhooksModule,
RssModule,
TranscodingServerModule,
PostHogModule,
SecretsManagerModule,
DRMModule,
AdminModule,
SeasonModule,
DiscountsModule,
DiscountRedemptionModule,
NostrAuthModule,
LibraryModule,
],
controllers: [AppController],
providers: [
AppService,
{
provide: APP_INTERCEPTOR,
useClass: CacheInterceptor,
},
{
provide: APP_FILTER,
useClass: GlobalFilter,
},
],
})
export class AppModule {}

View File

@@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
// return how long the app has been running
return `App has been running for ${process.uptime()} seconds.`;
}
}

View File

@@ -0,0 +1,38 @@
import { NostrSessionService } from '../nostr-session.service';
import { UsersService } from 'src/users/users.service';
describe('NostrSessionService', () => {
const originalSecret = process.env.NOSTR_JWT_SECRET;
const originalRefresh = process.env.NOSTR_JWT_REFRESH_SECRET;
const usersService = {
findUserByNostrPubkey: jest.fn(),
} as unknown as UsersService;
beforeAll(() => {
process.env.NOSTR_JWT_SECRET = 'test-secret';
process.env.NOSTR_JWT_REFRESH_SECRET = 'test-refresh-secret';
process.env.NOSTR_JWT_TTL = '60';
process.env.NOSTR_JWT_REFRESH_TTL = '120';
});
afterAll(() => {
process.env.NOSTR_JWT_SECRET = originalSecret;
process.env.NOSTR_JWT_REFRESH_SECRET = originalRefresh;
});
it('issues and verifies session tokens', async () => {
const service = new NostrSessionService(usersService);
const { accessToken, refreshToken, expiresIn } =
await service.issueTokens('pubkey');
expect(expiresIn).toBe(60);
const accessPayload = service.verifyAccessToken(accessToken);
expect(accessPayload.sub).toBe('pubkey');
expect(accessPayload.typ).toBe('nostr-session');
const refreshPayload = service.verifyRefreshToken(refreshToken);
expect(refreshPayload.sub).toBe('pubkey');
expect(refreshPayload.typ).toBe('nostr-refresh');
});
});

View File

@@ -0,0 +1,9 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AuthConfig {
public userPoolId: string = process.env.COGNITO_USER_POOL_ID;
public clientId: string = process.env.COGNITO_CLIENT_ID;
public region: string = process.env.AWS_REGION;
public authority = `https://cognito-idp.${process.env.AWS_REGION}.amazonaws.com/${process.env.COGNITO_USER_POOL_ID}`;
}

View File

@@ -0,0 +1,47 @@
import { Test, TestingModule } from '@nestjs/testing';
import { AuthController } from './auth.controller';
import { AuthService } from './auth.service';
import { MailService } from '../mail/mail.service';
import { UsersService } from '../users/users.service';
import { FilmmakersService } from '../filmmakers/filmmakers.service';
import { NostrSessionService } from './nostr-session.service';
// Mock MailService
const mockMailService = {
sendEmail: jest.fn(),
};
const mockAuthService = {};
const mockUsersService = {};
const mockFilmmakersService = {};
const mockNostrSessionService = {};
describe('AuthController', () => {
let controller: AuthController;
process.env.COGNITO_USER_POOL_ID = 'dummy_user_pool_id';
process.env.COGNITO_CLIENT_ID = 'dummy_client_id';
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [AuthController],
providers: [
{ provide: AuthService, useValue: mockAuthService },
{ provide: MailService, useValue: mockMailService },
{ provide: UsersService, useValue: mockUsersService },
{ provide: FilmmakersService, useValue: mockFilmmakersService },
{ provide: NostrSessionService, useValue: mockNostrSessionService },
],
}).compile();
controller = module.get<AuthController>(AuthController);
});
it('should be defined', () => {
expect(controller).toBeDefined();
});
});

View File

@@ -0,0 +1,161 @@
import {
BadRequestException,
Body,
Controller,
Get,
Post,
UseGuards,
Req,
UnauthorizedException,
} from '@nestjs/common';
import { AuthService } from './auth.service';
import { JwtAuthGuard } from './guards/jwt.guard';
import { User } from './user.decorator';
import { UsersService } from 'src/users/users.service';
import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
import { RequestUser } from './dto/request/request-user.interface';
import { UserDTO } from 'src/users/dto/response/user.dto';
import { RegisterDTO } from './dto/request/register.dto';
import { ValidateSessionDTO } from './dto/request/validate-session.dto';
import { RegisterOneTimePasswordDTO } from './dto/request/register-otp.dto';
import { Request } from 'express';
import { NostrAuthGuard } from 'src/nostr-auth/nostr-auth.guard';
import { NostrSessionService } from './nostr-session.service';
import { RefreshNostrSessionDto } from './dto/request/refresh-nostr-session.dto';
import { NostrSessionJwtGuard } from './guards/nostr-session-jwt.guard';
import { HybridAuthGuard } from './guards/hybrid-auth.guard';
@Controller('auth')
export class AuthController {
constructor(
private readonly authService: AuthService,
private readonly userService: UsersService,
private readonly filmmakerService: FilmmakersService,
private readonly nostrSessionService: NostrSessionService,
) {}
@Post('register')
async register(@Body() registerDTO: RegisterDTO) {
try {
const user = await this.userService.createUser({
userDto: registerDTO,
userType: registerDTO.type,
});
if (registerDTO.type === 'audience') return user;
await this.filmmakerService.create({
professionalName: registerDTO.professionalName,
userId: user.id,
});
return user;
} catch (error) {
throw new BadRequestException(error.message);
}
}
// Register user for OTP flow if not exists, only for audience
@Post('otp/init')
async initOTP(@Body() registerDTO: RegisterOneTimePasswordDTO) {
try {
let user = await this.userService.findUserByEmail(registerDTO.email);
if (user) return { email: user.email };
// Register user
const cognitoUser = await this.authService.registerUser(registerDTO);
// Create user in the database
user = await this.userService.createUser({
userDto: {
cognitoId: cognitoUser.UserSub,
email: registerDTO.email,
legalName: registerDTO.email, // Audience not required legal name
},
userType: 'audience',
});
return { email: user.email };
} catch (error) {
throw new BadRequestException(error.message);
}
}
@Get('me')
@UseGuards(HybridAuthGuard)
async getMe(@Req() request: Request) {
const userFromGuard = (request as RequestUser).user;
if (userFromGuard) {
return new UserDTO(userFromGuard);
}
if (request.nostrPubkey) {
const user = await this.userService.findUserByNostrPubkey(
request.nostrPubkey,
);
if (user) {
return new UserDTO(user);
}
}
throw new UnauthorizedException();
}
@Post('validate-session')
@UseGuards(JwtAuthGuard)
async validateSession(
@Body() validateSessionDTO: ValidateSessionDTO,
@User() user: RequestUser['user'],
) {
return this.authService.validateSession(validateSessionDTO, user.email);
}
@Post('nostr/session')
@UseGuards(NostrAuthGuard)
async createNostrSession(@Req() request: Request) {
if (!request.nostrPubkey) {
throw new UnauthorizedException('Missing Nostr pubkey');
}
return await this.nostrSessionService.issueTokens(request.nostrPubkey);
}
@Post('nostr/refresh')
async refreshNostrSession(@Body() body: RefreshNostrSessionDto) {
const payload = this.nostrSessionService.verifyRefreshToken(
body.refreshToken,
);
return await this.nostrSessionService.issueTokens(payload.sub);
}
@Post('nostr/logout')
@UseGuards(NostrSessionJwtGuard)
logout() {
// Stateless JWT; returning success is equivalent to logout for the client.
return { success: true };
}
@Post('nostr/link')
@UseGuards(JwtAuthGuard, NostrAuthGuard)
async linkNostrPubkey(
@User() user: RequestUser['user'],
@Req() request: Request,
) {
if (!request.nostrPubkey) {
throw new UnauthorizedException('Missing Nostr pubkey');
}
try {
const updatedUser = await this.userService.linkNostrPubkey(
user.id,
request.nostrPubkey,
);
return { nostrPubkey: updatedUser.nostrPubkey };
} catch (error) {
throw new BadRequestException(error.message);
}
}
@Post('nostr/unlink')
@UseGuards(JwtAuthGuard)
async unlinkNostrPubkey(@User() user: RequestUser['user']) {
const updated = await this.userService.unlinkNostrPubkey(user.id);
return { nostrPubkey: updated.nostrPubkey };
}
}

View File

@@ -0,0 +1,46 @@
import { Global, Module, forwardRef } from '@nestjs/common';
import { AuthService } from './auth.service';
import { AuthController } from './auth.controller';
import { PassportModule } from '@nestjs/passport';
import { JwtStrategy } from './jwt.strategy';
import { ConfigModule } from '@nestjs/config';
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
import { SubscriptionsModule } from 'src/subscriptions/subscriptions.module';
import { NostrAuthModule } from 'src/nostr-auth/nostr-auth.module';
import { JwtAuthGuard } from './guards/jwt.guard';
import { TokenAuthGuard } from './guards/token.guard';
import { HybridAuthGuard } from './guards/hybrid-auth.guard';
import { NostrSessionService } from './nostr-session.service';
import { NostrSessionJwtGuard } from './guards/nostr-session-jwt.guard';
import { UsersModule } from 'src/users/users.module';
@Global()
@Module({
imports: [
PassportModule.register({ defaultStrategy: 'jwt' }),
ConfigModule,
FilmmakersModule,
SubscriptionsModule,
NostrAuthModule,
forwardRef(() => UsersModule),
],
providers: [
JwtStrategy,
AuthService,
JwtAuthGuard,
TokenAuthGuard,
HybridAuthGuard,
NostrSessionService,
NostrSessionJwtGuard,
],
controllers: [AuthController],
exports: [
AuthService,
JwtAuthGuard,
TokenAuthGuard,
HybridAuthGuard,
NostrSessionService,
NostrSessionJwtGuard,
],
})
export class AuthModule {}

View File

@@ -0,0 +1,71 @@
import {
BadRequestException,
Inject,
Injectable,
Logger,
} from '@nestjs/common';
import { MailService } from 'src/mail/mail.service';
/**
* Auth service.
* Cognito has been removed. Authentication is now handled via:
* 1. Nostr NIP-98 HTTP Auth (primary method)
* 2. Nostr session JWT tokens (for subsequent requests)
*
* The Cognito methods are stubbed to maintain API compatibility
* while the migration to fully Nostr-based auth is completed.
*/
@Injectable()
export class AuthService {
private readonly logger = new Logger(AuthService.name);
constructor(@Inject(MailService) private readonly mailService: MailService) {
this.logger.log(
'AuthService initialized (Cognito disabled, Nostr-only mode)',
);
}
async registerUser(registerRequest: { email: string; password?: string }) {
this.logger.warn(
'registerUser called -- Cognito is disabled. Use Nostr auth instead.',
);
return {
UserSub: 'nostr-user',
UserConfirmed: true,
};
}
async authenticateUser(user: { email: string; password: string }) {
this.logger.warn(
'authenticateUser called -- Cognito is disabled. Use Nostr auth instead.',
);
throw new BadRequestException(
'Cognito authentication is disabled. Please use Nostr login.',
);
}
async verifyMfaCode(session: string, code: string, email: string) {
throw new BadRequestException('MFA is disabled in Nostr-only mode.');
}
async validateSession(
request: { code: string; session?: string; type?: string },
email: string,
) {
throw new BadRequestException(
'Session validation via Cognito is disabled. Use Nostr auth.',
);
}
async adminConfirmUser(email: string) {
this.logger.warn('adminConfirmUser called -- no-op in Nostr-only mode.');
return { success: true };
}
async deleteUserFromCognito(email: string) {
this.logger.warn(
'deleteUserFromCognito called -- no-op in Nostr-only mode.',
);
return {};
}
}

View File

@@ -0,0 +1,6 @@
import { IsString } from 'class-validator';
export class RefreshNostrSessionDto {
@IsString()
refreshToken: string;
}

View File

@@ -0,0 +1,11 @@
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
export class RegisterOneTimePasswordDTO {
@IsNotEmpty()
@IsString()
password: string;
@IsNotEmpty()
@IsEmail()
email: string;
}

View File

@@ -0,0 +1,29 @@
import {
IsNotEmpty,
IsString,
IsEmail,
IsEnum,
IsOptional,
} from 'class-validator';
export class RegisterDTO {
@IsNotEmpty()
@IsString()
legalName: string;
@IsNotEmpty()
@IsOptional()
@IsString()
professionalName?: string;
@IsNotEmpty()
@IsEmail()
email: string;
@IsString()
cognitoId: string;
@IsNotEmpty()
@IsEnum(['filmmaker', 'audience'])
type: 'filmmaker' | 'audience';
}

View File

@@ -0,0 +1,6 @@
import { Request } from 'express';
import { User } from 'src/users/entities/user.entity';
export interface RequestUser extends Request {
user: User;
}

View File

@@ -0,0 +1,15 @@
import { IsNotEmpty, IsString, IsEnum, IsOptional } from 'class-validator';
export class ValidateSessionDTO {
@IsNotEmpty()
@IsString()
code: string;
@IsString()
@IsOptional()
session?: string;
@IsNotEmpty()
@IsEnum(['password', 'mfa'])
type: 'password' | 'mfa';
}

View File

@@ -0,0 +1,38 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';
@Injectable()
export class AdminAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
try {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
if (
!timingSafeEqual(
Buffer.from(token),
Buffer.from(process.env.ADMIN_API_KEY),
)
) {
throw new UnauthorizedException('Invalid token');
}
return true;
} catch (error) {
Logger.error(`Error validating token: ${error.message}`);
throw new UnauthorizedException();
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,93 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import { NostrAuthGuard } from '../../nostr-auth/nostr-auth.guard';
import { JwtAuthGuard } from './jwt.guard';
import { NostrSessionJwtGuard } from './nostr-session-jwt.guard';
import { AuthGuard } from '@nestjs/passport';
interface GuardResult {
success: boolean;
error?: Error;
}
@Injectable()
export class HybridAuthGuard extends AuthGuard('hybrid') {
constructor(
private readonly nostrAuthGuard: NostrAuthGuard,
private readonly nostrSessionJwtGuard: NostrSessionJwtGuard,
private readonly jwtAuthGuard: JwtAuthGuard,
) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<Request>();
const authorization = this.normalizeAuthorizationHeader(request);
const isNostrRequest = authorization?.toLowerCase().startsWith('nostr ');
let nostrResult: GuardResult | undefined;
if (isNostrRequest) {
nostrResult = await this.tryActivate(this.nostrAuthGuard, context);
if (nostrResult.success) {
return true;
}
}
const nostrJwtResult = await this.tryActivate(
this.nostrSessionJwtGuard,
context,
);
if (nostrJwtResult.success) {
return true;
}
const jwtResult = await this.tryActivate(this.jwtAuthGuard, context);
if (jwtResult.success) {
return true;
}
Logger.log(
`HybridAuthGuard failed: isNostrRequest=${isNostrRequest}, nostrError=${nostrResult?.error?.message}, nostrJwtError=${nostrJwtResult.error?.message}, jwtError=${jwtResult.error?.message}`,
);
if (isNostrRequest && nostrResult?.error) {
throw nostrResult.error;
}
if (jwtResult.error) {
throw jwtResult.error;
}
if (nostrResult?.error) {
throw nostrResult.error;
}
if (nostrJwtResult.error) {
throw nostrJwtResult.error;
}
throw new UnauthorizedException('Authentication failed');
}
private async tryActivate(
guard: CanActivate,
context: ExecutionContext,
): Promise<GuardResult> {
try {
const result = await guard.canActivate(context);
return { success: Boolean(result) };
} catch (error) {
return { success: false, error: error as Error };
}
}
private normalizeAuthorizationHeader(request: Request): string | undefined {
const header = request.headers.authorization;
return Array.isArray(header) ? header[0] : header;
}
}

View File

@@ -0,0 +1,27 @@
import {
ExecutionContext,
Injectable,
Logger,
UnauthorizedException,
} from '@nestjs/common';
import { AuthGuard } from '@nestjs/passport';
/**
* JWT Auth Guard stub.
* Cognito JWT verification has been removed.
* This guard now always rejects -- all auth should go through
* NostrSessionJwtGuard or NostrAuthGuard via the HybridAuthGuard.
*
* Kept for API compatibility with endpoints that still reference JwtAuthGuard.
*/
@Injectable()
export class JwtAuthGuard extends AuthGuard('jwt') {
async canActivate(context: ExecutionContext): Promise<boolean> {
Logger.warn(
'JwtAuthGuard.canActivate called -- Cognito is disabled. Use Nostr auth.',
);
throw new UnauthorizedException(
'Cognito authentication is disabled. Use Nostr login.',
);
}
}

View File

@@ -0,0 +1,49 @@
import {
ExecutionContext,
Injectable,
UnauthorizedException,
} from '@nestjs/common';
import { Request } from 'express';
import { NostrSessionService } from '../nostr-session.service';
import { UsersService } from '../../users/users.service';
import { AuthGuard } from '@nestjs/passport';
@Injectable()
export class NostrSessionJwtGuard extends AuthGuard('nostr-session-jwt') {
constructor(
private readonly sessionService: NostrSessionService,
private readonly usersService: UsersService,
) {
super();
}
async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context
.switchToHttp()
.getRequest<Request & { user?: unknown }>();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
try {
const payload = this.sessionService.verifyAccessToken(token);
request.nostrPubkey = payload.sub;
if (payload.uid) {
const user = await this.usersService.findUsersById(payload.uid);
request.user = user;
}
return true;
} catch (error) {
if (error instanceof UnauthorizedException) {
throw error;
}
throw new UnauthorizedException('Invalid token');
}
}
private extractTokenFromHeader(request: Request): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,38 @@
import {
Injectable,
CanActivate,
ExecutionContext,
UnauthorizedException,
Logger,
} from '@nestjs/common';
import { timingSafeEqual } from 'node:crypto';
@Injectable()
export class TokenAuthGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
try {
const request = context.switchToHttp().getRequest();
const token = this.extractTokenFromHeader(request);
if (!token) {
throw new UnauthorizedException('No token provided');
}
if (
!timingSafeEqual(
Buffer.from(token),
Buffer.from(process.env.TRANSCODING_API_KEY),
)
) {
throw new UnauthorizedException('Invalid token');
}
return true;
} catch (error) {
Logger.error(`Error validating token: ${error.message}`);
throw new UnauthorizedException();
}
}
private extractTokenFromHeader(request: any): string | undefined {
const [type, token] = request.headers.authorization?.split(' ') ?? [];
return type === 'Bearer' ? token : undefined;
}
}

View File

@@ -0,0 +1,31 @@
import { ExtractJwt, Strategy } from 'passport-jwt';
import { PassportStrategy } from '@nestjs/passport';
import { Injectable, Logger } from '@nestjs/common';
/**
* JWT Strategy stub.
* Cognito JWKS verification has been removed.
* This strategy uses a dummy secret and will never actually validate
* because JwtAuthGuard now always rejects.
* All auth goes through NostrSessionJwtGuard or NostrAuthGuard.
*/
@Injectable()
export class JwtStrategy extends PassportStrategy(Strategy) {
constructor() {
super({
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
secretOrKey: process.env.NOSTR_JWT_SECRET || 'dummy-key-cognito-disabled',
issuer: undefined,
algorithms: ['HS256'],
});
Logger.warn(
'JwtStrategy initialized with dummy config (Cognito disabled)',
'AuthModule',
);
}
public async validate(payload: any) {
return payload;
}
}

View File

@@ -0,0 +1,123 @@
import {
Inject,
Injectable,
Logger,
UnauthorizedException,
forwardRef,
} from '@nestjs/common';
import { sign, verify } from 'jsonwebtoken';
import { randomUUID } from 'node:crypto';
import { UsersService } from 'src/users/users.service';
export interface NostrSessionTokens {
accessToken: string;
refreshToken: string;
expiresIn: number;
}
export interface NostrSessionPayload {
sub: string;
typ: 'nostr-session' | 'nostr-refresh';
uid?: string;
}
@Injectable()
export class NostrSessionService {
private readonly logger = new Logger(NostrSessionService.name);
private readonly accessSecret = process.env.NOSTR_JWT_SECRET;
private readonly refreshSecret =
process.env.NOSTR_JWT_REFRESH_SECRET ?? this.accessSecret;
private readonly accessTtlSeconds = Number(
process.env.NOSTR_JWT_TTL ?? 15 * 60,
);
private readonly refreshTtlSeconds = Number(
process.env.NOSTR_JWT_REFRESH_TTL ?? 60 * 60 * 24 * 14,
);
constructor(
@Inject(forwardRef(() => UsersService))
private readonly usersService: UsersService,
) {
if (!this.accessSecret) {
throw new Error('NOSTR_JWT_SECRET is not configured');
}
}
/**
* Issue JWT tokens for a Nostr pubkey.
* Auto-creates a user record on first login so that /auth/me works immediately.
*/
async issueTokens(pubkey: string): Promise<NostrSessionTokens> {
let user = await this.usersService.findUserByNostrPubkey(pubkey);
// Auto-provision a user on first Nostr login
if (!user) {
this.logger.log(`First Nostr login for ${pubkey.slice(0, 12)}… — creating user`);
try {
user = await this.usersService.createNostrUser(pubkey);
} catch (error) {
this.logger.warn(
`Failed to auto-create user for ${pubkey.slice(0, 12)}…: ${error.message}`,
);
}
} else if (!user.filmmaker) {
// Existing user without filmmaker profile — backfill
this.logger.log(`Backfilling filmmaker for ${pubkey.slice(0, 12)}`);
try {
user = await this.usersService.ensureFilmmaker(user);
} catch (error) {
this.logger.warn(`Filmmaker backfill failed: ${error.message}`);
}
}
const payload: NostrSessionPayload = {
sub: pubkey,
typ: 'nostr-session',
uid: user?.id,
};
const refreshPayload: NostrSessionPayload = {
sub: pubkey,
typ: 'nostr-refresh',
uid: user?.id,
};
const accessToken = sign(payload, this.accessSecret, {
expiresIn: this.accessTtlSeconds,
});
const refreshToken = sign(refreshPayload, this.refreshSecret, {
expiresIn: this.refreshTtlSeconds,
});
return {
accessToken,
refreshToken,
expiresIn: this.accessTtlSeconds,
};
}
verifyAccessToken(token: string): NostrSessionPayload {
return this.verifyToken(token, this.accessSecret, 'nostr-session');
}
verifyRefreshToken(token: string): NostrSessionPayload {
return this.verifyToken(token, this.refreshSecret, 'nostr-refresh');
}
private verifyToken(
token: string,
secret: string,
expectedType: NostrSessionPayload['typ'],
): NostrSessionPayload {
try {
const payload = verify(token, secret) as NostrSessionPayload;
if (payload.typ !== expectedType) {
throw new UnauthorizedException('Invalid token type');
}
return payload;
} catch (error) {
if (error instanceof UnauthorizedException) throw error;
throw new UnauthorizedException('Invalid or expired token');
}
}
}

View File

@@ -0,0 +1,8 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
export const User = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const request = context.switchToHttp().getRequest();
return request.user;
},
);

View File

@@ -0,0 +1,17 @@
import { Controller, Get, UseGuards } from '@nestjs/common';
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
import { AwardIssuersService } from './award-issuers.service';
import { AwardIssuerDTO } from './dto/response/award-issuer.dto';
@Controller('award-issuers')
@UseGuards(HybridAuthGuard)
export class AwardIssuersController {
constructor(private readonly issuersService: AwardIssuersService) {}
@Get()
async findAll() {
const issuers = await this.issuersService.findAll();
return issuers.map((issuer) => new AwardIssuerDTO(issuer));
}
}

View File

@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { AwardIssuer } from './entities/award-issuer.entity';
import { AwardIssuersController } from './award-issuers.controller';
import { AwardIssuersService } from './award-issuers.service';
@Module({
imports: [TypeOrmModule.forFeature([AwardIssuer])],
controllers: [AwardIssuersController],
providers: [AwardIssuersService],
})
export class AwardIssuersModule {}

View File

@@ -0,0 +1,16 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { AwardIssuer } from './entities/award-issuer.entity';
@Injectable()
export class AwardIssuersService {
constructor(
@InjectRepository(AwardIssuer)
private issuersRepository: Repository<AwardIssuer>,
) {}
findAll() {
return this.issuersRepository.find();
}
}

View File

@@ -0,0 +1,11 @@
import { AwardIssuer } from 'src/award-issuers/entities/award-issuer.entity';
export class AwardIssuerDTO {
id: string;
name: string;
constructor(awardIssuer: AwardIssuer) {
this.id = awardIssuer.id;
this.name = awardIssuer.name;
}
}

View File

@@ -0,0 +1,27 @@
import { Award } from 'src/projects/entities/award.entity';
import {
Column,
CreateDateColumn,
Entity,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('award_issuers')
export class AwardIssuer {
@PrimaryColumn()
id: string;
@Column()
name: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@OneToMany(() => Award, (award) => award.issuer)
awards: Award[];
}

View File

@@ -0,0 +1,2 @@
export const NANOID_LENGTH = 16;
export const PRICE_PER_SECOND = 0.000_208_3;

View File

@@ -0,0 +1,43 @@
import { createParamDecorator, ExecutionContext } from '@nestjs/common';
/**
* A custom NestJS decorator to extract rate-limiting parameters
* from the response headers.
*
* **Note:** This only works if `Throttle` from `@nestjs/throttler`
* has been imported and used in the controller or application.
*
* @example
* ```typescript
* import { Throttle } from '@nestjs/throttler';
* import { ThrottleParameters } from './throttle-parameters.decorator';
*
* @Throttle(10, 60) // 10 requests per minute
* @Controller('example')
* export class ExampleController {
* @Get()
* exampleEndpoint(@ThrottleParameters() throttleParams) {
* console.log(throttleParams);
* }
* }
* ```
*/
export const ThrottleParameters = createParamDecorator(
(data: unknown, context: ExecutionContext) => {
const response = context.switchToHttp().getResponse();
const headers = response.getHeaders();
const parameters: ThrottleParametersInterface = {
limit: headers['x-ratelimit-limit'],
remaining: headers['x-ratelimit-remaining'],
totalHits:
headers['x-ratelimit-limit'] - headers['x-ratelimit-remaining'],
};
return parameters;
},
);
export interface ThrottleParametersInterface {
limit: number;
remaining: number;
totalHits: number;
}

View File

@@ -0,0 +1,8 @@
export const filterDateRange = [
'last_7_days',
'last_28_days',
'last_60_days',
'last_365_days',
'since_uploaded',
] as const;
export type FilterDateRange = (typeof filterDateRange)[number];

View File

@@ -0,0 +1,40 @@
import {
ExceptionFilter,
Catch,
ArgumentsHost,
HttpException,
HttpStatus,
Logger,
} from '@nestjs/common';
import { Response } from 'express';
@Catch()
export class GlobalFilter implements ExceptionFilter {
catch(exception: unknown, host: ArgumentsHost) {
Logger.error(exception);
const context = host.switchToHttp();
const response = context.getResponse<Response>();
if (exception instanceof HttpException) {
const status = exception.getStatus();
const responsePayload = exception.getResponse();
response.status(status).json({
message: responsePayload['message'],
error: responsePayload['error'],
statusCode: status,
});
} else {
// There is no more error handlers so it should have status code 500
const status = HttpStatus.INTERNAL_SERVER_ERROR;
const statusMessage = HttpStatus[status]
.toLowerCase()
.replaceAll('_', ' ');
const errorMessage =
statusMessage.charAt(0).toUpperCase() + statusMessage.slice(1);
response.status(status).json({
message: errorMessage,
statusCode: status,
});
}
}
}

View File

@@ -0,0 +1,9 @@
import { ThrottlerGuard } from '@nestjs/throttler';
import { Injectable } from '@nestjs/common';
@Injectable()
export class UserThrottlerGuard extends ThrottlerGuard {
protected getTracker(request: Record<string, any>) {
return request.user ? request.user.id : request.ip;
}
}

View File

@@ -0,0 +1,73 @@
import { FilterDateRange } from 'src/payment/enums/filter-date-range.enum';
import { Project } from 'src/projects/entities/project.entity';
export function getFileRoute(fileKey: string): string {
// Remove the name of the file from the key (last /).
// Example: 'folder-1/folder-2/file.mp4' -> 'folder-1/folder-2/'
return fileKey.slice(0, Math.max(0, fileKey.lastIndexOf('/') + 1));
}
export function encodeS3KeyForUrl(fileKey: string): string {
return fileKey
.split('/')
.map((segment) => encodeURIComponent(segment))
.join('/');
}
export function getPublicS3Url(fileKey: string): string {
return `${process.env.S3_PUBLIC_BUCKET_URL}${encodeS3KeyForUrl(fileKey)}`;
}
export function getPrivateS3Url(fileKey: string): string {
return `${process.env.S3_PRIVATE_BUCKET_URL}${encodeS3KeyForUrl(fileKey)}`;
}
export function getTranscodedFileRoute(fileKey: string): string {
const fileRoute = getFileRoute(fileKey);
return `${fileRoute}transcoded/file.m3u8`;
}
export function getTrailerTranscodeOutputKey(fileKey: string): string {
const fileRoute = getFileRoute(fileKey);
return `${fileRoute}trailer/transcoded`;
}
export function getTrailerTranscodedFileRoute(fileKey: string): string {
const outputKey = getTrailerTranscodeOutputKey(fileKey);
return `${outputKey}/file.m3u8`;
}
export function isProjectRSSReady(project: Project) {
return (
project.status === 'published' &&
project.contents.some((content) => content.isRssEnabled)
);
}
export const getDaysCount = (dateRange: FilterDateRange) => {
switch (dateRange) {
case 'last_7_days': {
return 7;
}
case 'last_28_days': {
return 28;
}
case 'last_60_days': {
return 60;
}
case 'last_365_days': {
return 365;
}
case 'since_uploaded':
}
};
export const getFilterDateRange = (dateRange: FilterDateRange) => {
const currentDate = new Date();
const days = getDaysCount(dateRange);
const startDate = days
? new Date(currentDate.setDate(currentDate.getDate() - days))
: new Date(0);
return { startDate, endDate: new Date() };
};

View File

@@ -0,0 +1,100 @@
import {
Controller,
Get,
Param,
UseGuards,
Query,
Patch,
Body,
UseInterceptors,
Post,
} from '@nestjs/common';
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
import { Subscriptions } from 'src/subscriptions/decorators/subscriptions.decorator';
import { SubscriptionsGuard } from 'src/subscriptions/guards/subscription.guard';
import { ContentsService } from './contents.service';
import { ContentDTO } from './dto/response/content.dto';
import { ListContentsDTO } from './dto/request/list-contents.dto';
import { BaseContentDTO } from './dto/response/base-content.dto';
import { UpdateContentDTO } from './dto/request/update-content.dto';
import { PermissionGuard } from 'src/projects/guards/permission.guard';
import { Roles } from 'src/projects/decorators/roles.decorator';
import { ContentsInterceptor } from './interceptor/contents.interceptor';
import { TokenAuthGuard } from 'src/auth/guards/token.guard';
import { StreamContentDTO } from './dto/response/stream-content.dto';
import { TranscodingCompletedDTO } from './dto/request/transcoding-completed.dto';
@Controller('contents')
export class ContentsController {
constructor(private readonly contentsService: ContentsService) {}
@Get(':id')
@UseGuards(HybridAuthGuard)
async find(@Param('id') id: string) {
const content = await this.contentsService.findOne(id);
return new BaseContentDTO(content);
}
@Get('project/:id')
@UseGuards(HybridAuthGuard)
async findAll(@Param('id') id: string, @Query() query: ListContentsDTO) {
const contents = await this.contentsService.findAll(id, query);
return contents.map((project) => new BaseContentDTO(project));
}
@Patch('project/:id')
@UseGuards(HybridAuthGuard, SubscriptionsGuard, PermissionGuard)
@UseInterceptors(ContentsInterceptor)
@Subscriptions(['filmmaker'])
@Roles([
'owner',
'admin',
'editor',
'revenue-manager',
'cast-crew',
'shareholder',
'viewer',
])
async upsert(@Param('id') id: string, @Body() body: UpdateContentDTO) {
const { updatedContent } = await this.contentsService.upsert(id, body);
return new ContentDTO(updatedContent);
}
@Get(':id/stream')
@UseGuards(HybridAuthGuard, SubscriptionsGuard)
@Subscriptions(['enthusiast', 'film-buff', 'cinephile'])
async stream(@Param('id') id: string) {
return new StreamContentDTO(await this.contentsService.stream(id));
}
@Post(':id/transcoding')
@UseGuards(TokenAuthGuard)
async transcoding(
@Param('id') id: string,
@Body() body: TranscodingCompletedDTO,
) {
await this.contentsService.transcodingCompleted(
id,
body.status,
body.metadata,
);
}
@Post(':id/trailer/transcoding')
@UseGuards(TokenAuthGuard)
async trailerTranscoding(
@Param('id') id: string,
@Body() body: TranscodingCompletedDTO,
) {
await this.contentsService.trailerTranscodingCompleted(
id,
body.status,
body.metadata,
);
}
@Post(':id/retranscode')
retranscodeContent(@Param('id') id: string) {
return this.contentsService.retranscodeContent(id);
}
}

View File

@@ -0,0 +1,47 @@
import { Module, forwardRef } from '@nestjs/common';
import { ContentsController } from './contents.controller';
import { KeyController } from './key.controller';
import { TypeOrmModule } from '@nestjs/typeorm';
import { Shareholder } from './entities/shareholder.entity';
import { InvitesModule } from 'src/invites/invites.module';
import { Cast } from './entities/cast.entity';
import { Crew } from './entities/crew.entity';
import { PaymentModule } from 'src/payment/payment.module';
import { ContentsService } from './contents.service';
import { Content } from './entities/content.entity';
import { ContentKey } from './entities/content-key.entity';
import { ProjectsModule } from 'src/projects/projects.module';
import { Caption } from './entities/caption.entity';
import { RssShareholder } from './entities/rss-shareholder.entity';
import { BullModule } from '@nestjs/bullmq';
import { Season } from 'src/season/entities/season.entity';
import { SeasonService } from 'src/season/season.service';
import { SeasonRent } from 'src/season/entities/season-rents.entity';
import { Trailer } from 'src/trailers/entities/trailer.entity';
@Module({
imports: [
TypeOrmModule.forFeature([
Shareholder,
RssShareholder,
Cast,
Crew,
Content,
ContentKey,
Caption,
Season,
SeasonRent,
Trailer,
]),
InvitesModule,
forwardRef(() => PaymentModule),
forwardRef(() => ProjectsModule),
BullModule.registerQueue({
name: 'transcode',
}),
],
controllers: [ContentsController, KeyController],
providers: [ContentsService, SeasonService],
exports: [ContentsService],
})
export class ContentsModule {}

View File

@@ -0,0 +1,961 @@
import { Inject, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { In, Repository } from 'typeorm';
import { randomUUID } from 'node:crypto';
import { Shareholder } from './entities/shareholder.entity';
import { UploadService } from 'src/upload/upload.service';
import { InvitesService } from 'src/invites/invites.service';
import { Cast } from './entities/cast.entity';
import { MailService } from 'src/mail/mail.service';
import { Crew } from './entities/crew.entity';
import { PaymentService } from 'src/payment/services/payment.service';
import { Payment } from 'src/payment/entities/payment.entity';
import { Invite } from 'src/invites/entities/invite.entity';
import {
Content,
defaultRelations,
fullRelations,
} from './entities/content.entity';
import { UpdateContentDTO } from './dto/request/update-content.dto';
import { ListContentsDTO } from './dto/request/list-contents.dto';
import { Caption } from './entities/caption.entity';
import {
getFileRoute,
getTranscodedFileRoute,
getTrailerTranscodeOutputKey,
getTrailerTranscodedFileRoute,
} from 'src/common/helper';
import { ContentStatus } from './enums/content-status.enum';
import { RssShareholder } from './entities/rss-shareholder.entity';
import { InjectQueue } from '@nestjs/bullmq';
import { Transcode } from './types/transcode';
import { Queue } from 'bullmq';
import { Project } from 'src/projects/entities/project.entity';
import { SeasonService } from 'src/season/season.service';
import { UpdateSeasonDto } from 'src/season/dto/request/update-season.dto.entity';
import { Trailer } from 'src/trailers/entities/trailer.entity';
type MappedPayment = {
[key: string]: { usd: number; milisat: number };
};
@Injectable()
export class ContentsService {
constructor(
@InjectRepository(Content)
private contentsRepository: Repository<Content>,
@InjectRepository(Shareholder)
private shareholderRepository: Repository<Shareholder>,
@InjectRepository(RssShareholder)
private rssShareholderRepository: Repository<RssShareholder>,
@InjectRepository(Cast)
private castRepository: Repository<Cast>,
@InjectRepository(Crew)
private crewRepository: Repository<Crew>,
@InjectRepository(Caption)
private captionRepository: Repository<Caption>,
@InjectRepository(Trailer)
private trailerRepository: Repository<Trailer>,
@Inject(UploadService)
private uploadService: UploadService,
@Inject(InvitesService)
private invitesService: InvitesService,
@Inject(MailService)
private mailService: MailService,
@Inject(PaymentService)
private paymentsService: PaymentService,
@InjectQueue('transcode') private transcodeQueue: Queue<Transcode>,
private seasonService: SeasonService,
) {}
findAll(projectId: string, query: ListContentsDTO) {
return this.contentsRepository.find({
where: { projectId, season: query.season },
order: {
order: 'ASC',
},
skip: query.offset,
take: query.limit,
});
}
findOne(id: string, relations: string[] = defaultRelations) {
return this.contentsRepository.findOneOrFail({
where: { id },
relations,
});
}
async upsert(projectId: string, updateContentDTO: UpdateContentDTO) {
let content: Content;
let shouldQueueTrailer = false;
let removeContentTrailerJobs = false;
const trailerProvided: boolean = Object.prototype.hasOwnProperty.call(
updateContentDTO,
'trailer',
);
if (updateContentDTO.id) {
content = await this.findOne(updateContentDTO.id);
const updatePayload: Partial<Content> = {
title: updateContentDTO.title,
synopsis: updateContentDTO.synopsis,
file: updateContentDTO.file,
poster: updateContentDTO.poster,
order: updateContentDTO.order,
rentalPrice: updateContentDTO.rentalPrice,
status: updateContentDTO.file ? 'processing' : content.status,
releaseDate: updateContentDTO?.releaseDate
? new Date(updateContentDTO.releaseDate)
: new Date(),
isRssEnabled: updateContentDTO.isRssEnabled,
};
let updatedTrailer: Trailer | null | undefined;
let trailerToRemove: Trailer | undefined;
if (trailerProvided) {
const newTrailerFile = updateContentDTO.trailer;
const previousTrailerFile = content.trailer?.file;
if (newTrailerFile) {
const trailerChanged = previousTrailerFile !== newTrailerFile;
if (trailerChanged && previousTrailerFile) {
await this.deleteTrailerAssets(previousTrailerFile);
}
const trailerEntity =
content.trailer ?? this.trailerRepository.create();
const resetTranscoding =
trailerChanged || content.trailer === undefined;
trailerEntity.file = newTrailerFile;
if (resetTranscoding) {
trailerEntity.status = 'processing';
// eslint-disable-next-line unicorn/no-null -- Ensure trailer metadata resets after new upload
trailerEntity.metadata = null;
}
updatedTrailer = await this.trailerRepository.save(trailerEntity);
shouldQueueTrailer = resetTranscoding;
} else if (content.trailer) {
if (content.trailer.file) {
await this.deleteTrailerAssets(content.trailer.file);
}
trailerToRemove = content.trailer;
// eslint-disable-next-line unicorn/no-null -- Explicitly clear the trailer relation.
updatedTrailer = null;
removeContentTrailerJobs = true;
} else {
updatedTrailer = undefined;
}
updatePayload.trailer =
updatedTrailer === undefined ? content.trailer : updatedTrailer;
}
await this.contentsRepository.save({
...content,
...updatePayload,
});
if (trailerToRemove) {
await this.trailerRepository.remove(trailerToRemove);
}
} else {
let trailerEntity: Trailer | undefined;
if (updateContentDTO.trailer) {
trailerEntity = await this.trailerRepository.save(
this.trailerRepository.create({
file: updateContentDTO.trailer,
status: 'processing',
// eslint-disable-next-line unicorn/no-null -- Trailer metadata reset on creation
metadata: null,
}),
);
}
content = await this.contentsRepository.save({
id: randomUUID(),
projectId,
title: updateContentDTO.title,
synopsis: updateContentDTO.synopsis,
file: updateContentDTO.file,
trailer: trailerEntity,
poster: updateContentDTO.poster,
season: updateContentDTO.season,
scheduledFor: updateContentDTO.scheduledFor,
rentalPrice: updateContentDTO.rentalPrice,
order: updateContentDTO.order,
status: 'processing',
releaseDate: updateContentDTO?.releaseDate
? new Date(updateContentDTO.releaseDate)
: new Date(),
isRssEnabled: updateContentDTO.isRssEnabled,
});
shouldQueueTrailer = !!trailerEntity;
// For episodic content, create seasons if they don't exist
if (updateContentDTO.season) {
const existingSeasons =
await this.seasonService.findSeasonsByProjectAndNumbers(projectId, [
updateContentDTO.season,
]);
if (existingSeasons.length === 0) {
// Create Season
await this.seasonService.getOrCreateSeasonsUpsert(projectId, [
{ seasonNumber: updateContentDTO.season } as UpdateSeasonDto,
]);
}
}
}
if (
updateContentDTO.file &&
updateContentDTO.file !== content.file &&
content.file &&
content.file !== ''
) {
await this.uploadService.deleteObject(
content.file,
process.env.S3_PRIVATE_BUCKET_NAME,
);
await this.uploadService.deleteObject(
getTranscodedFileRoute(content.file),
process.env.S3_PUBLIC_BUCKET_NAME,
);
}
if (
updateContentDTO.poster &&
updateContentDTO.poster !== content.poster &&
content.poster
) {
await this.uploadService.deleteObject(
content.poster,
process.env.S3_PUBLIC_BUCKET_NAME,
);
}
if (removeContentTrailerJobs) {
await this.removeManyTranscodingJobs([`${content.id}:trailer`]);
}
const newMembers: Array<{ id?: string; email?: string }> = [];
if (
updateContentDTO.shareholders &&
updateContentDTO.shareholders.length > 0
) {
for (const shareholder of content.shareholders || []) {
const shareholderDTO = updateContentDTO.shareholders.find(
(fm) => fm.id === shareholder.filmmakerId,
);
await (shareholderDTO
? this.shareholderRepository.update(
{
id: shareholder.id,
contentId: content.id,
filmmakerId: shareholder.filmmakerId,
},
{
share: shareholderDTO.share,
},
)
: this.shareholderRepository.softDelete({
id: shareholder.id,
filmmakerId: shareholder.filmmakerId,
contentId: content.id,
}));
}
for (const shareholderDTO of updateContentDTO.shareholders) {
const shareholder = (content.shareholders || []).find(
(fm) => fm.filmmakerId === shareholderDTO.id,
);
if (!shareholder) {
await this.shareholderRepository.save({
id: randomUUID(),
filmmakerId: shareholderDTO.id,
share: shareholderDTO.share,
contentId: content.id,
isOwner: false,
});
newMembers.push(shareholderDTO);
}
}
}
if (updateContentDTO.rssShareholders && updateContentDTO.isRssEnabled) {
for (const rssShareholder of content.rssShareholders || []) {
const shareholderDTO = updateContentDTO.rssShareholders.find(
(fm) => fm.id === rssShareholder.id,
);
await (shareholderDTO
? this.rssShareholderRepository.update(
{
id: rssShareholder.id,
contentId: content.id,
},
{
share: shareholderDTO.share,
lightningAddress: shareholderDTO.lightningAddress,
name: shareholderDTO.name,
nodePublicKey: shareholderDTO.nodePublicKey,
key: shareholderDTO.key,
value: shareholderDTO.value,
},
)
: this.rssShareholderRepository.delete({
id: rssShareholder.id,
}));
}
for (const rssShareholderDTO of updateContentDTO.rssShareholders) {
const shareholder = (content.rssShareholders || []).find(
(rs) => rs.id === rssShareholderDTO.id,
);
if (!shareholder) {
await this.rssShareholderRepository.save({
id: randomUUID(),
share: rssShareholderDTO.share,
contentId: content.id,
lightningAddress: rssShareholderDTO.lightningAddress,
name: rssShareholderDTO.name,
nodePublicKey: rssShareholderDTO.nodePublicKey,
key: rssShareholderDTO.key,
value: rssShareholderDTO.value,
});
}
}
}
if (!updateContentDTO.isRssEnabled) {
await this.rssShareholderRepository.delete({
contentId: content.id,
});
}
if (updateContentDTO.cast) {
for (const cast of content.cast || []) {
const castDTO = updateContentDTO.cast.find(
(c) =>
c.character === cast.character &&
((c.email && c.email === cast.email) || c.id === cast.filmmakerId),
);
await (castDTO
? this.castRepository.update(
{
id: cast.id,
contentId: content.id,
},
{
placeholderName: castDTO.placeholderName,
character: castDTO.character,
order: castDTO.order,
},
)
: this.castRepository.delete({
id: cast.id,
contentId: content.id,
}));
}
for (const castDTO of updateContentDTO.cast) {
const cast = (content.cast || []).find(
(c) =>
c.character === castDTO.character &&
(c.filmmakerId === castDTO.id ||
(c.email && c.email === castDTO.email)),
);
if (!cast) {
await this.castRepository.save({
id: randomUUID(),
placeholderName: castDTO.placeholderName,
character: castDTO.character,
email: castDTO.email,
order: castDTO.order,
contentId: content.id,
filmmakerId: castDTO.id,
});
newMembers.push(castDTO);
}
}
}
if (updateContentDTO.crew) {
for (const crew of content.crew || []) {
const crewDTO = updateContentDTO.crew.find(
(c) =>
c.occupation === crew.occupation &&
(c.id === crew.filmmakerId ||
(crew.email && c.email === crew.email)),
);
await (crewDTO
? this.crewRepository.update(
{
id: crew.id,
contentId: content.id,
},
{
placeholderName: crewDTO.placeholderName,
occupation: crewDTO.occupation,
order: crewDTO.order,
},
)
: this.crewRepository.delete({
id: crew.id,
contentId: content.id,
}));
}
for (const crewDTO of updateContentDTO.crew) {
const crew = (content.crew || []).find(
(c) =>
c.occupation === crewDTO.occupation &&
(c.filmmakerId === crewDTO.id ||
(c.email && c.email === crewDTO.email)),
);
if (!crew) {
await this.crewRepository.save({
id: randomUUID(),
occupation: crewDTO.occupation,
placeholderName: crewDTO.placeholderName,
email: crewDTO.email,
order: crewDTO.order,
contentId: content.id,
filmmakerId: crewDTO.id,
});
newMembers.push(crewDTO);
}
}
}
if (updateContentDTO.invites) {
for (const invite of content.invites || []) {
const inviteDTO = updateContentDTO.invites.find(
(inv) => inv.email === invite.email,
);
if (!inviteDTO) {
await this.invitesService.removeInvite(invite.email, content.id);
}
}
const invitesToBeSent: Array<{ email: string; share: number }> = [];
for (const invite of updateContentDTO.invites) {
const existingInvite = content.invites.find(
(inv) => inv.email === invite.email,
);
if (existingInvite) {
await this.invitesService.updateInvite(
existingInvite.email,
content.id,
invite.share,
);
} else {
invitesToBeSent.push({
email: invite.email,
share: invite.share,
});
newMembers.push({ email: invite.email });
}
}
await this.invitesService.sendInvites(invitesToBeSent, content.id);
}
if (updateContentDTO.captions) {
for (const caption of content.captions || []) {
const captionDTO = updateContentDTO.captions.find(
(c) => c.id === caption.id,
);
if (!captionDTO) {
await this.captionRepository.delete({
id: caption.id,
contentId: content.id,
});
await this.uploadService.deleteObject(
caption.url,
process.env.S3_PRIVATE_BUCKET_NAME,
);
}
}
for (const captionDTO of updateContentDTO.captions) {
const caption = (content.captions || []).find(
(d) => d.id === captionDTO.id,
);
if (!caption) {
await this.captionRepository.save({
id: randomUUID(),
language: captionDTO.language,
url: captionDTO.url,
contentId: content.id,
});
}
}
}
const updatedContent = await this.findOne(content.id, [
'project',
...fullRelations,
]);
if (
updateContentDTO.file &&
updatedContent.project?.status === 'published'
) {
await this.sendToTranscodingQueue(updatedContent, updatedContent.project);
}
if (
shouldQueueTrailer &&
updatedContent.project?.status === 'published' &&
updatedContent.trailer?.file
) {
await this.sendTrailerToTranscodingQueue(
updatedContent,
updatedContent.project,
);
}
return { updatedContent, newMembers };
}
async remove(id: string) {
try {
const content = await this.contentsRepository.findOneOrFail({
where: { id },
relations: ['trailer'],
});
if (content.file) {
await this.uploadService.deleteObject(
content.file,
process.env.S3_PRIVATE_BUCKET_NAME,
);
await this.uploadService.deleteObject(
getTranscodedFileRoute(content.file),
process.env.S3_PUBLIC_BUCKET_NAME,
);
}
if (content.poster) {
await this.uploadService.deleteObject(
content.poster,
process.env.S3_PUBLIC_BUCKET_NAME,
);
}
const trailerToRemove = content.trailer;
if (trailerToRemove?.file) {
await this.deleteTrailerAssets(trailerToRemove.file);
}
content.file = undefined;
// eslint-disable-next-line unicorn/no-null -- Remove the FK before deleting the trailer.
content.trailer = null;
await this.contentsRepository.save(content);
if (trailerToRemove) {
await this.trailerRepository.remove(trailerToRemove);
}
const result = await this.contentsRepository.softDelete({ id });
await this.removeManyTranscodingJobs([id, `${id}:trailer`]);
return { success: result.affected > 0 };
} catch (error) {
Logger.error(error);
return { success: false };
}
}
async removeManyTranscodingJobs(contentIds: string[]) {
const existing = await this.transcodeQueue.getJobs(['waiting', 'active']);
const contentsId = new Set(contentIds);
for (const job of existing) {
const data = job.data;
if (contentsId.has(data.correlationId)) {
await job.remove();
}
}
}
async removeAll(projectId: string) {
try {
const contents = await this.contentsRepository.find({
where: { projectId },
relations: ['trailer'],
});
await Promise.all(
contents.map(async (content) => {
if (content.file)
await this.uploadService.deleteObject(
content.file,
process.env.S3_PRIVATE_BUCKET_NAME,
);
if (content.poster)
await this.uploadService.deleteObject(
content.poster,
process.env.S3_PUBLIC_BUCKET_NAME,
);
const trailerToRemove = content.trailer;
if (trailerToRemove?.file) {
await this.deleteTrailerAssets(trailerToRemove.file);
}
if (trailerToRemove) {
// eslint-disable-next-line unicorn/no-null -- Remove the FK before deleting the trailer.
content.trailer = null;
await this.contentsRepository.save(content);
await this.trailerRepository.remove(trailerToRemove);
}
await this.contentsRepository.softDelete({ id: content.id });
}),
);
const jobsToRemove = contents.flatMap((item) => [
item.id,
`${item.id}:trailer`,
]);
await this.removeManyTranscodingJobs(jobsToRemove);
return { success: true };
} catch (error) {
Logger.error(error);
return { success: false };
}
}
async stream(id: string) {
const project = await this.findOne(id, ['project', 'captions', 'trailer']);
return project;
}
async addInvitedFilmmaker(filmmakerId: string, invite: Invite) {
const invited = this.shareholderRepository.create({
id: randomUUID(),
filmmakerId,
contentId: invite.contentId,
isOwner: false,
share: invite.share,
});
await this.shareholderRepository.save(invited);
}
async updateOnRegister(email: string, filmmakerId: string) {
await Promise.all([
this.castRepository.update(
{ email },
{
filmmakerId,
email: undefined,
},
),
this.crewRepository.update(
{ email },
{
filmmakerId,
email: undefined,
},
),
]);
}
async getProjectsRevenueAnalytics(
ids: string[],
filmmakerId: string,
startDate: Date,
) {
const satPricePromise = this.paymentsService.getSatPrice();
const userShareholders = await this.shareholderRepository.find({
where: {
content: { projectId: In(ids) },
filmmakerId,
},
});
const shareholderIds = userShareholders.map((s) => s.id);
const totalShare = userShareholders.reduce((accumulator, shareholder) => {
return accumulator + Number(shareholder.share);
}, 0);
const { usd: userTotalUsd, milisat: userTotalMilisat } =
await this.getShareholdersTotal(shareholderIds, startDate);
const { usd: totalUsd, milisat: totalMilisat } = await this.getTotalRevenue(
ids,
startDate,
);
const satPrice = await satPricePromise;
const userBalance = userShareholders.reduce((accumulator, shareholder) => {
return (
accumulator +
Number(shareholder.rentPendingRevenue) +
Number(shareholder.pendingRevenue)
);
}, 0);
return {
total: {
usd: totalUsd,
milisat: totalMilisat,
},
user: {
averageShare:
Math.round((totalShare / userShareholders.length) * 100) / 100,
usd: userTotalUsd,
milisat: userTotalMilisat,
balance: {
usd: userBalance,
milisat: Number((userBalance / satPrice) * 1000),
},
},
};
}
async getShareholdersTotal(shareholderIds: string[], startDate?: Date) {
const payments = await this.paymentsService.getPaymentsByDate(
shareholderIds,
startDate,
);
const usd = payments.reduce((accumulator, payment) => {
return accumulator + Number(payment.usdAmount);
}, 0);
const milisat = payments.reduce((accumulator, payment) => {
return accumulator + Number(payment.milisatsAmount);
}, 0);
return { payments, usd, milisat };
}
getRevenueByDate(payments: Payment[]): MappedPayment {
const mappedPayments: MappedPayment = {};
for (const payment of payments) {
const date = payment.createdAt.toISOString().split('T')[0];
if (!mappedPayments[date]) {
mappedPayments[date] = { usd: 0, milisat: 0 };
}
mappedPayments[date].usd += Number(payment.usdAmount);
mappedPayments[date].milisat += Number(payment.milisatsAmount);
}
return mappedPayments;
}
async getTotalRevenue(projectId: string[], startDate?: Date) {
const shareholders = await this.shareholderRepository.find({
where: { content: { projectId: In(projectId) } },
});
const payments = await this.paymentsService.getPaymentsByDate(
shareholders.map((s) => s.id),
startDate,
);
const usd = payments.reduce((accumulator, payment) => {
return accumulator + Number(payment.usdAmount);
}, 0);
const milisat = payments.reduce((accumulator, payment) => {
return accumulator + Number(payment.milisatsAmount);
}, 0);
return { usd, milisat };
}
getCastFilterSubquery(filmmakerId: string) {
return this.castRepository
.createQueryBuilder('cast')
.leftJoin('cast.content', 'content')
.select('content.projectId')
.where('cast.filmmakerId = :filmmakerId', {
filmmakerId,
});
}
getCrewFilterSubquery(filmmakerId: string) {
return this.crewRepository
.createQueryBuilder('crew')
.leftJoin('crew.content', 'content')
.select('content.projectId')
.where('crew.filmmakerId = :filmmakerId', {
filmmakerId,
});
}
getShareholderFilterSubquery(filmmakerId: string) {
return this.shareholderRepository
.createQueryBuilder('shareholders')
.leftJoin('shareholders.content', 'content')
.select('content.projectId')
.where('shareholders.filmmakerId = :filmmakerId', {
filmmakerId,
});
}
getCompletedProjectsSubquery() {
return this.contentsRepository
.createQueryBuilder('content')
.select('content.projectId')
.where('content.status = :content_status', {
content_status: 'completed',
})
.groupBy('content.projectId');
}
async sendToTranscodingQueue(content: Content, project: Project) {
await this.removeManyTranscodingJobs([content.id]);
Logger.log(`Sending content ${content.id} to transcoding queue`);
const drmContentId = randomUUID().replaceAll('-', '');
const drmMediaId = randomUUID().replaceAll('-', '');
await this.contentsRepository.update(content.id, {
drmContentId,
drmMediaId,
});
const outputKey =
project.type === 'episodic'
? `${getFileRoute(content.file)}${content.id}/transcoded`
: `${getFileRoute(content.file)}transcoded`;
if (!content.isRssEnabled) {
return await this.transcodeQueue.add('transcode-with-drm', {
inputBucket: process.env.S3_PRIVATE_BUCKET_NAME,
outputBucket: process.env.S3_PUBLIC_BUCKET_NAME,
inputKey: content.file,
outputKey,
correlationId: content.id,
callbackUrl: `${process.env.ENVIRONMENT === 'development' || process.env.ENVIRONMENT === 'local' ? 'http' : 'https'}://${process.env.DOMAIN}/contents/${content.id}/transcoding`,
drmContentId,
drmMediaId,
});
}
await this.transcodeQueue.add('transcode', {
inputBucket: process.env.S3_PRIVATE_BUCKET_NAME,
outputBucket: process.env.S3_PUBLIC_BUCKET_NAME,
inputKey: content.file,
outputKey,
correlationId: content.id,
callbackUrl: `https://${process.env.DOMAIN}/contents/${content.id}/transcoding`,
drmContentId,
drmMediaId,
});
}
async sendTrailerToTranscodingQueue(content: Content, _project: Project) {
if (!content.trailer?.file) return;
await this.removeManyTranscodingJobs([`${content.id}:trailer`]);
Logger.log(`Sending trailer ${content.id} to transcoding queue`);
await this.trailerRepository.update(content.trailer.id, {
status: 'processing',
metadata: undefined,
});
const callbackProtocol =
process.env.ENVIRONMENT === 'development' ||
process.env.ENVIRONMENT === 'local'
? 'http'
: 'https';
await this.transcodeQueue.add('transcode', {
inputBucket: process.env.S3_PRIVATE_BUCKET_NAME,
outputBucket: process.env.S3_PUBLIC_BUCKET_NAME,
inputKey: content.trailer.file,
outputKey: getTrailerTranscodeOutputKey(content.trailer.file),
correlationId: `${content.id}:trailer`,
callbackUrl: `${callbackProtocol}://${process.env.DOMAIN}/contents/${content.id}/trailer/transcoding`,
});
}
async transcodingCompleted(id: string, status: ContentStatus, metadata: any) {
Logger.log(`Transcoding completed for content ${id}, status: ${status}`);
const result = await this.contentsRepository.update(
{ id },
{
status,
metadata,
updatedAt: () => 'updated_at',
},
);
const content = await this.findOne(id, [
'project',
'project.permissions',
'project.permissions.filmmaker',
'project.permissions.filmmaker.user',
]);
const ownerEmail = content.project.permissions.find(
(p) => p.role === 'owner',
).filmmaker.user.email;
const url = `${process.env.FRONTEND_URL}/project/${content.projectId}/details`;
if (status === 'completed') {
this.mailService.sendMail({
to: ownerEmail,
templateId: 'd-ca1940fba8274abd972338a4bc77b08e',
data: {
filmName: content.title,
url,
},
});
}
return result.affected > 0;
}
async trailerTranscodingCompleted(
id: string,
status: ContentStatus,
metadata: any,
) {
Logger.log(
`Trailer transcoding completed for content ${id}, status: ${status}`,
);
const content = await this.contentsRepository.findOne({
where: { id },
relations: ['trailer'],
});
if (!content?.trailer) return false;
await this.trailerRepository.update(content.trailer.id, {
status,
metadata,
});
return true;
}
private async deleteTrailerAssets(trailerFile: string) {
await Promise.all([
this.uploadService.deleteObject(
trailerFile,
process.env.S3_PRIVATE_BUCKET_NAME,
),
this.uploadService.deleteObject(
trailerFile,
process.env.S3_PUBLIC_BUCKET_NAME,
),
this.uploadService.deleteObject(
getTrailerTranscodedFileRoute(trailerFile),
process.env.S3_PRIVATE_BUCKET_NAME,
),
this.uploadService.deleteObject(
getTrailerTranscodedFileRoute(trailerFile),
process.env.S3_PUBLIC_BUCKET_NAME,
),
]);
}
async removeFilmmaker(filmmakerId: string) {
await this.shareholderRepository.delete({
filmmakerId,
});
await this.castRepository.delete({
filmmakerId,
});
await this.crewRepository.delete({
filmmakerId,
});
return { success: true };
}
async retranscodeContent(id: string) {
try {
const content = await this.findOne(id, ['project']);
content.status = 'processing';
await this.contentsRepository.save(content);
await this.sendToTranscodingQueue(content, content.project);
return { success: true };
} catch {
throw new NotFoundException();
}
}
}

View File

@@ -0,0 +1,223 @@
import {
IsBoolean,
IsDate,
IsDateString,
IsEmail,
IsEnum,
IsNumber,
IsOptional,
IsString,
Max,
MaxLength,
Min,
ValidateNested,
} from 'class-validator';
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { Language, languages } from 'src/contents/enums/language.enum';
export class AddContentDTO {
@IsString()
@MaxLength(100)
title?: string;
@IsString()
@MaxLength(350)
@IsOptional()
synopsis?: string;
@IsString()
@IsOptional()
file?: string;
@IsString()
@IsOptional()
trailer?: string;
@IsString()
@IsOptional()
poster?: string;
@IsNumber()
@IsOptional()
season?: number;
@IsNumber()
@IsOptional()
order?: number;
@IsDate()
@IsOptional()
scheduledFor?: Date;
@IsDateString()
@IsOptional()
releaseDate?: Date;
@IsNumber()
@IsOptional()
rentalPrice?: number;
@IsBoolean()
@IsOptional()
isRssEnabled?: boolean;
@ValidateNested()
@ApiProperty({ type: () => AddCaptionDTO })
@Type(() => AddCaptionDTO)
captions: AddCaptionDTO[];
@ValidateNested()
@ApiProperty({ type: () => AddShareholderDTO })
@Type(() => AddShareholderDTO)
shareholders: AddShareholderDTO[];
@ValidateNested()
@ApiProperty({ type: () => AddRssShareholderDTO })
@Type(() => AddRssShareholderDTO)
rssShareholders: AddRssShareholderDTO[];
@ValidateNested()
@ApiProperty({ type: () => AddCastDTO })
@Type(() => AddCastDTO)
@IsOptional()
cast?: AddCastDTO[];
@ValidateNested()
@ApiProperty({ type: () => AddCrewDTO })
@Type(() => AddCrewDTO)
@IsOptional()
crew?: AddCrewDTO[];
@ValidateNested()
@ApiProperty({ type: () => AddShareholderInviteDTO })
@Type(() => AddShareholderInviteDTO)
@IsOptional()
invites?: AddShareholderInviteDTO[];
}
class AddShareholderInviteDTO {
@ApiProperty()
@IsString()
email: string;
@ApiProperty()
@Max(100)
@Min(0)
share: number;
}
class AddRssShareholderDTO {
@ApiProperty()
@IsString()
@IsOptional()
id?: string;
@ApiProperty()
@Max(100)
@Min(0)
share: number;
@ApiProperty()
@IsOptional()
@IsString()
lightningAddress?: string;
@ApiProperty()
@IsOptional()
@IsString()
name?: string;
@ApiProperty()
@IsString()
@IsOptional()
nodePublicKey?: string;
@ApiProperty()
@IsString()
@IsOptional()
key?: string;
@ApiProperty()
@IsString()
@IsOptional()
value?: string;
}
class AddShareholderDTO {
@ApiProperty()
@IsString()
id: string;
@ApiProperty()
@Max(100)
@Min(0)
share: number;
}
class AddCaptionDTO {
@ApiProperty()
@IsString()
@IsOptional()
id?: string;
@ApiProperty()
@IsEnum(languages, {
message: `language must be one of the following: ${languages.join(', ')}`,
})
language: Language;
@ApiProperty()
@IsString()
@IsOptional()
url?: string;
}
class AddCastDTO {
@ApiProperty()
@IsString()
@IsOptional()
id?: string;
@ApiProperty()
@IsString()
@IsOptional()
placeholderName?: string;
@ApiProperty()
@IsString()
character: string;
@ApiProperty()
@IsEmail()
@IsOptional()
email?: string;
@ApiProperty()
@IsNumber()
order: number;
}
class AddCrewDTO {
@ApiProperty()
@IsString()
@IsOptional()
id?: string;
@ApiProperty()
@IsString()
@IsOptional()
placeholderName?: string;
@ApiProperty()
@IsString()
occupation: string;
@ApiProperty()
@IsEmail()
@IsOptional()
email?: string;
@ApiProperty()
@IsNumber()
order: number;
}

View File

@@ -0,0 +1,18 @@
import { ApiProperty } from '@nestjs/swagger';
import { Type } from 'class-transformer';
import { IsOptional } from 'class-validator';
export class ListContentsDTO {
@ApiProperty()
@IsOptional()
@Type(() => Number)
season?: number;
@ApiProperty()
@IsOptional()
limit = 30;
@ApiProperty()
@IsOptional()
offset = 0;
}

View File

@@ -0,0 +1,16 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEnum, IsOptional } from 'class-validator';
import {
ContentStatus,
contentStatuses,
} from 'src/contents/enums/content-status.enum';
export class TranscodingCompletedDTO {
@ApiProperty()
@IsEnum(contentStatuses)
status: ContentStatus;
@ApiProperty()
@IsOptional()
metadata: any;
}

View File

@@ -0,0 +1,10 @@
import { IsOptional, IsUUID } from 'class-validator';
import { ApiProperty, PartialType } from '@nestjs/swagger';
import { AddContentDTO } from './add-content.dto';
export class UpdateContentDTO extends PartialType(AddContentDTO) {
@ApiProperty()
@IsUUID()
@IsOptional()
id?: string;
}

View File

@@ -0,0 +1,82 @@
import {
getPublicS3Url,
getTrailerTranscodedFileRoute,
} from 'src/common/helper';
import { CaptionDTO } from './caption.dto';
import { CastDTO } from './cast.dto';
import { CrewDTO } from './crew.dto';
import { Content } from 'src/contents/entities/content.entity';
import { ContentStatus } from 'src/contents/enums/content-status.enum';
export class BaseContentDTO {
id: string;
title: string;
synopsis?: string;
scheduledFor?: Date;
crew: CrewDTO[];
cast: CastDTO[];
captions: CaptionDTO[];
file?: string;
order: number;
season: number | null;
createdAt: Date;
updatedAt: Date;
projectId: string;
rentalPrice: number;
duration?: number;
isRssEnabled: boolean;
releaseDate: Date;
trailer?: string;
poster?: string;
trailerStatus?: ContentStatus;
constructor(content: Content) {
this.id = content.id;
this.title = content.title;
this.projectId = content.projectId;
this.synopsis = content.synopsis;
this.order = content.order;
this.season = content.season;
this.rentalPrice = content.rentalPrice;
this.isRssEnabled = content.isRssEnabled;
this.releaseDate = content.releaseDate;
if (content.cast) {
this.cast = content.cast.map((cast) => new CastDTO(cast));
this.cast.sort((a, b) => a.order - b.order);
} else this.cast = [];
if (content.crew) {
this.crew = content.crew.map((crew) => new CrewDTO(crew));
this.crew.sort((a, b) => a.order - b.order);
} else this.crew = [];
this.captions = content.captions
? content.captions.map((caption) => new CaptionDTO(caption))
: [];
this.createdAt = content.createdAt;
this.updatedAt = content.updatedAt;
if (content.file) {
this.file = content.file;
}
this.trailerStatus = content.trailer?.status;
if (content.trailer && this.trailerStatus === 'completed') {
this.trailer = getPublicS3Url(
getTrailerTranscodedFileRoute(content.trailer.file),
);
} else if (content.trailer) {
this.trailer = content.trailer.file;
}
if (content.metadata) {
this.duration = content.metadata.format?.duration;
}
if (content.poster) {
this.poster = getPublicS3Url(content.poster);
}
}
}

View File

@@ -0,0 +1,16 @@
import { Caption } from 'src/contents/entities/caption.entity';
import { Language } from 'src/contents/enums/language.enum';
export class CaptionDTO {
id: string;
language: Language;
url: string;
name: string;
constructor(caption: Caption) {
this.id = caption.id;
this.name = caption.url.split('/').pop();
this.language = caption.language;
this.url = process.env.S3_PUBLIC_BUCKET_URL + caption.url;
}
}

View File

@@ -0,0 +1,30 @@
import { FilmmakerDTO } from 'src/filmmakers/dto/response/filmmaker.dto';
import { Cast } from 'src/contents/entities/cast.entity';
export class CastDTO extends FilmmakerDTO {
character: string;
order: number;
email: string;
placeholderName: string;
constructor(cast: Cast) {
super(cast.filmmaker);
this.placeholderName = cast.placeholderName;
this.character = cast.character;
this.order = cast.order;
this.email = cast.email;
}
}
export class PublicCastDTO extends FilmmakerDTO {
placeholderName: string;
character: string;
order: number;
constructor(cast: Cast) {
super(cast.filmmaker);
this.placeholderName = cast.placeholderName;
this.character = cast.character;
this.order = cast.order;
}
}

View File

@@ -0,0 +1,51 @@
import { ShareholderDTO } from './shareholder.dto';
import { InviteShareholderDTO } from './invite.dto';
import { Content } from 'src/contents/entities/content.entity';
import { BaseContentDTO } from './base-content.dto';
import { ContentStatus } from 'src/contents/enums/content-status.enum';
import { RssShareholderDTO } from './rss-shareholder.dto';
import { CaptionDTO } from './caption.dto';
export class ContentDTO extends BaseContentDTO {
invites: InviteShareholderDTO[];
shareholders: ShareholderDTO[];
isRssEnabled: boolean;
rssShareholders: RssShareholderDTO[];
status: ContentStatus;
constructor(content: Content) {
super(content);
this.status = content.status;
this.isRssEnabled = content.isRssEnabled;
if (content.shareholders) {
this.shareholders = content.shareholders.map(
(shareholder) => new ShareholderDTO(shareholder),
);
this.shareholders.sort((a, b) => {
if (a.isOwner && !b.isOwner) {
return -1;
}
if (!a.isOwner && b.isOwner) {
return 1;
}
return b.share - a.share;
});
} else this.shareholders = [];
this.invites = content.invites
? content.invites.map((invite) => new InviteShareholderDTO(invite))
: [];
this.rssShareholders =
content.rssShareholders && content.isRssEnabled
? content.rssShareholders.map(
(rssShareholder) => new RssShareholderDTO(rssShareholder),
)
: [];
this.captions = content?.captions
? content.captions.map((caption) => new CaptionDTO(caption))
: [];
}
}

View File

@@ -0,0 +1,30 @@
import { FilmmakerDTO } from 'src/filmmakers/dto/response/filmmaker.dto';
import { Crew } from 'src/contents/entities/crew.entity';
export class PublicCrewDTO extends FilmmakerDTO {
placeholderName: string;
occupation: string;
order: number;
constructor(cast: Crew) {
super(cast.filmmaker);
this.placeholderName = cast.placeholderName;
this.occupation = cast.occupation;
this.order = cast.order;
}
}
export class CrewDTO extends FilmmakerDTO {
occupation: string;
order: number;
email: string;
placeholderName: string;
constructor(cast: Crew) {
super(cast.filmmaker);
this.placeholderName = cast.placeholderName;
this.occupation = cast.occupation;
this.order = cast.order;
this.email = cast.email;
}
}

View File

@@ -0,0 +1,11 @@
import { Invite } from 'src/invites/entities/invite.entity';
export class InviteShareholderDTO {
email: string;
share: number;
constructor(invite: Invite) {
this.email = invite.email;
this.share = invite.share;
}
}

View File

@@ -0,0 +1,21 @@
import { RssShareholder } from 'src/contents/entities/rss-shareholder.entity';
export class RssShareholderDTO {
id: string;
share: number;
lightningAddress?: string;
name?: string;
nodePublicKey?: string;
key?: string;
value?: string;
constructor(shareholder: RssShareholder) {
this.id = shareholder.id;
this.share = shareholder.share;
this.lightningAddress = shareholder.lightningAddress;
this.name = shareholder.name;
this.nodePublicKey = shareholder.nodePublicKey;
this.key = shareholder.key;
this.value = shareholder.value;
}
}

View File

@@ -0,0 +1,14 @@
import { FilmmakerDTO } from 'src/filmmakers/dto/response/filmmaker.dto';
import { Shareholder } from 'src/contents/entities/shareholder.entity';
export class ShareholderDTO extends FilmmakerDTO {
role: string;
share: number;
isOwner: boolean;
constructor(shareholder: Shareholder) {
super(shareholder.filmmaker);
this.share = shareholder.share;
this.isOwner = shareholder.isOwner;
}
}

View File

@@ -0,0 +1,21 @@
import { Content } from 'src/contents/entities/content.entity';
import { BaseContentDTO } from './base-content.dto';
import { getFileRoute, getPublicS3Url } from 'src/common/helper';
export class StreamContentDTO extends BaseContentDTO {
file: string;
widevine: string;
fairplay: string;
constructor(content: Content) {
super(content);
const outputKey =
content.project.type === 'episodic'
? `${getFileRoute(content.file)}${content.id}/`
: `${getFileRoute(content.file)}`;
const outputUrl = getPublicS3Url(outputKey);
this.file = `${outputUrl}transcoded/file.m3u8`;
this.widevine = `${outputUrl}transcoded/encrypted/video_master.mpd`;
this.fairplay = `${outputUrl}transcoded/encrypted/video_master.m3u8`;
}
}

View File

@@ -0,0 +1,36 @@
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Content } from './content.entity';
import { Language } from 'src/contents/enums/language.enum';
@Entity('captions')
export class Caption {
@PrimaryColumn()
id: string;
@PrimaryColumn()
contentId: string;
@Column()
language: Language;
@Column()
url: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => Content, (content) => content.crew)
@JoinColumn({ name: 'content_id' })
content: Content;
}

View File

@@ -0,0 +1,49 @@
import { Filmmaker } from 'src/filmmakers/entities/filmmaker.entity';
import {
Column,
CreateDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Content } from './content.entity';
@Entity('casts')
export class Cast {
@PrimaryColumn()
id: string;
@Column({ nullable: true })
filmmakerId?: string;
@PrimaryColumn()
contentId: string;
@Column({ nullable: true })
placeholderName: string;
@Column()
character: string;
@Column()
order: number;
@Column({ nullable: true })
email: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@ManyToOne(() => Filmmaker, (filmmaker) => filmmaker.castFilms)
@JoinColumn({ name: 'filmmaker_id' })
filmmaker: Filmmaker;
@ManyToOne(() => Content, (content) => content.cast)
@JoinColumn({ name: 'content_id' })
content: Content;
}

View File

@@ -0,0 +1,32 @@
import {
Column,
CreateDateColumn,
Entity,
PrimaryColumn,
} from 'typeorm';
/**
* AES-128 decryption keys for HLS content protection.
* Each content can have multiple keys (for key rotation).
* The raw 16-byte key is stored encrypted at rest (via DB-level encryption).
*/
@Entity('content_keys')
export class ContentKey {
@PrimaryColumn()
id: string;
@Column()
contentId: string;
@Column({ type: 'bytea' })
keyData: Buffer;
@Column({ type: 'bytea', nullable: true })
iv: Buffer;
@Column({ default: 0 })
rotationIndex: number;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
}

View File

@@ -0,0 +1,164 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Shareholder } from './shareholder.entity';
import { Invite } from 'src/invites/entities/invite.entity';
import { Cast } from './cast.entity';
import { Crew } from './crew.entity';
import { Rent } from 'src/rents/entities/rent.entity';
import { Project } from 'src/projects/entities/project.entity';
import { Caption } from './caption.entity';
import { ContentStatus, contentStatuses } from '../enums/content-status.enum';
import { RssShareholder } from './rss-shareholder.entity';
import { Season } from 'src/season/entities/season.entity';
import { Discount } from 'src/discounts/entities/discount.entity';
import { Trailer } from 'src/trailers/entities/trailer.entity';
@Entity('contents')
export class Content {
@PrimaryColumn()
id: string;
@Column()
projectId: string;
@Column({ nullable: true })
season_id: string;
@Column({ nullable: true })
title: string;
@Column({ nullable: true })
synopsis: string;
@Column({ nullable: true })
file: string;
@Column({ nullable: true })
season?: number;
@Column({ default: 1 })
order: number;
@Column('decimal', {
precision: 10,
scale: 2,
default: 0,
transformer: {
to: (value) => value,
from: (value) => Number.parseFloat(value),
},
})
rentalPrice: number;
@Column({
enum: contentStatuses,
default: 'processing',
})
status: ContentStatus;
@Column('json', { nullable: true })
metadata: any;
@Column('boolean', { default: false })
isRssEnabled: boolean;
@Column({ nullable: true })
drmContentId: string;
@Column({ nullable: true })
drmMediaId: string;
@Column({ type: 'timestamptz', default: () => 'NOW()' })
releaseDate: Date;
@Column({ nullable: true })
trailer_old: string;
@ManyToOne(() => Trailer, (trailer) => trailer.contents, {
nullable: true,
cascade: true,
})
@JoinColumn({ name: 'trailer_id' })
trailer?: Trailer;
@Column({ nullable: true })
poster: string;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date;
@OneToMany(() => Cast, (cast) => cast.content)
cast: Cast[];
@OneToMany(() => Crew, (crew) => crew.content)
crew: Crew[];
@OneToMany(() => Shareholder, (shareholder) => shareholder.content)
shareholders: Shareholder[];
@OneToMany(() => RssShareholder, (rssShareholders) => rssShareholders.content)
rssShareholders?: RssShareholder[];
@OneToMany(() => Invite, (invite) => invite.content)
invites: Invite[];
@OneToMany(() => Rent, (rent) => rent.content)
rents: Rent[];
@OneToMany(() => Caption, (caption) => caption.content)
captions: Caption[];
@ManyToOne(() => Project, (project) => project.contents)
@JoinColumn({ name: 'project_id' })
project: Project;
@ManyToOne(() => Season, (season) => season.contents)
@JoinColumn({ name: 'season_id' })
seriesSeason: Season;
@OneToMany(() => Discount, (discount) => discount.content)
discounts: Discount[];
}
export const defaultRelations = [
'invites',
'shareholders',
'rssShareholders',
'crew',
'cast',
'captions',
'trailer',
];
export const fullRelations = [
'captions',
'invites',
'shareholders',
'shareholders.filmmaker',
'shareholders.filmmaker.user',
'crew',
'crew.filmmaker',
'crew.filmmaker.user',
'cast',
'cast.filmmaker',
'cast.filmmaker.user',
'rssShareholders',
'project',
'seriesSeason',
'trailer',
];

View File

@@ -0,0 +1,53 @@
import { Filmmaker } from 'src/filmmakers/entities/filmmaker.entity';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Content } from './content.entity';
@Entity('crews')
export class Crew {
@PrimaryColumn()
id: string;
@Column({ nullable: true })
filmmakerId?: string;
@PrimaryColumn()
contentId: string;
@Column({ nullable: true })
placeholderName: string;
@Column()
occupation: string;
@Column()
order: number;
@Column({ nullable: true })
email: string;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date;
@ManyToOne(() => Filmmaker, (filmmaker) => filmmaker.crewFilms)
@JoinColumn({ name: 'filmmaker_id' })
filmmaker: Filmmaker;
@ManyToOne(() => Content, (content) => content.crew)
@JoinColumn({ name: 'content_id' })
content: Content;
}

View File

@@ -0,0 +1,59 @@
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
PrimaryColumn,
UpdateDateColumn,
} from 'typeorm';
import { Content } from './content.entity';
@Entity('rss_shareholders')
export class RssShareholder {
@PrimaryColumn()
id: string;
@PrimaryColumn()
contentId: string;
@Column('decimal', {
precision: 10,
scale: 2,
default: 0,
transformer: {
to: (value) => value,
from: (value) => Number.parseFloat(value),
},
})
share: number;
@Column({ nullable: true })
name?: string;
@Column({ nullable: true })
lightningAddress?: string;
@Column({ nullable: true })
nodePublicKey?: string;
@Column({ nullable: true })
key?: string;
@Column({ nullable: true })
value?: string;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date;
@ManyToOne(() => Content, (content) => content.rssShareholders)
@JoinColumn({ name: 'content_id' })
content: Content;
}

View File

@@ -0,0 +1,74 @@
import { Filmmaker } from 'src/filmmakers/entities/filmmaker.entity';
import {
Column,
CreateDateColumn,
DeleteDateColumn,
Entity,
JoinColumn,
ManyToOne,
OneToMany,
PrimaryColumn,
Unique,
UpdateDateColumn,
} from 'typeorm';
import { Payment } from 'src/payment/entities/payment.entity';
import { Content } from './content.entity';
import { ColumnNumericTransformer } from 'src/database/transformers/column-numeric-transformer';
@Entity('shareholders')
export class Shareholder {
@PrimaryColumn()
@Unique('UQ_shareholder_filmmaker_content', ['filmmakerId', 'contentId'])
id: string;
@PrimaryColumn()
filmmakerId: string;
@PrimaryColumn()
contentId: string;
@Column()
share: number;
@Column()
isOwner: boolean;
@Column({
type: 'decimal',
precision: 20,
scale: 10,
default: 0,
transformer: new ColumnNumericTransformer(),
})
pendingRevenue: number;
@Column({
type: 'decimal',
precision: 20,
scale: 10,
default: 0,
transformer: new ColumnNumericTransformer(),
})
rentPendingRevenue: number;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@DeleteDateColumn({ type: 'timestamptz' })
deletedAt: Date;
@ManyToOne(() => Filmmaker, (filmmaker) => filmmaker.shareholderFilms)
@JoinColumn({ name: 'filmmaker_id' })
filmmaker?: Filmmaker;
@ManyToOne(() => Content, (content) => content.shareholders)
@JoinColumn({ name: 'content_id' })
content: Content;
@OneToMany(() => Payment, (payment) => payment.shareholder)
@JoinColumn({ name: 'id' })
payments?: Payment[];
}

View File

@@ -0,0 +1,2 @@
export const contentStatuses = ['processing', 'failed', 'completed'] as const;
export type ContentStatus = (typeof contentStatuses)[number];

View File

@@ -0,0 +1,188 @@
export const languages = [
'aa',
'ab',
'ae',
'af',
'ak',
'am',
'an',
'ar',
'as',
'av',
'ay',
'az',
'ba',
'be',
'bg',
'bh',
'bi',
'bm',
'bn',
'bo',
'br',
'bs',
'ca',
'ce',
'ch',
'co',
'cr',
'cs',
'cu',
'cv',
'cy',
'da',
'de',
'dv',
'dz',
'ee',
'el',
'en',
'eo',
'es',
'et',
'eu',
'fa',
'ff',
'fi',
'fj',
'fo',
'fr',
'fy',
'ga',
'gd',
'gl',
'gn',
'gu',
'gv',
'ha',
'he',
'hi',
'ho',
'hr',
'ht',
'hu',
'hy',
'hz',
'ia',
'id',
'ie',
'ig',
'ii',
'ik',
'io',
'is',
'it',
'iu',
'ja',
'jv',
'ka',
'kg',
'ki',
'kj',
'kk',
'kl',
'km',
'kn',
'ko',
'kr',
'ks',
'ku',
'kv',
'kw',
'ky',
'la',
'lb',
'lg',
'li',
'ln',
'lo',
'lt',
'lu',
'lv',
'mg',
'mh',
'mi',
'mk',
'ml',
'mn',
'mr',
'ms',
'mt',
'my',
'na',
'nb',
'nd',
'ne',
'ng',
'nl',
'nn',
'no',
'nr',
'nv',
'ny',
'oc',
'oj',
'om',
'or',
'os',
'pa',
'pi',
'pl',
'ps',
'pt',
'qu',
'rm',
'rn',
'ro',
'ru',
'rw',
'sa',
'sc',
'sd',
'se',
'sg',
'si',
'sk',
'sl',
'sm',
'sn',
'so',
'sq',
'sr',
'ss',
'st',
'su',
'sv',
'sw',
'ta',
'te',
'tg',
'th',
'ti',
'tk',
'tl',
'tn',
'to',
'tr',
'ts',
'tt',
'tw',
'ty',
'ug',
'uk',
'ur',
'uz',
've',
'vi',
'vo',
'wa',
'wo',
'xh',
'yi',
'yo',
'za',
'zh',
'zu',
] as const;
export type Language = (typeof languages)[number];

View File

@@ -0,0 +1,46 @@
import {
CallHandler,
ExecutionContext,
Injectable,
NestInterceptor,
} from '@nestjs/common';
import { Observable, map } from 'rxjs';
import { UploadService } from 'src/upload/upload.service';
import { ContentDTO } from '../dto/response/content.dto';
@Injectable()
export class ContentsInterceptor implements NestInterceptor {
constructor(private readonly uploadService: UploadService) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
return next
.handle()
.pipe(
map(async (data) =>
Array.isArray(data)
? Promise.all(data.map((project) => this.convertContent(project)))
: this.convertContent(data),
),
);
}
async convertContent(content: ContentDTO) {
const modifiedContent = { ...content };
if (modifiedContent.file) {
modifiedContent.file = await this.uploadService.createPresignedUrl({
key: modifiedContent.file,
expires: 60 * 60 * 24 * 7,
});
}
if (
!modifiedContent.trailer?.startsWith('http') &&
modifiedContent.trailer
) {
modifiedContent.trailer = await this.uploadService.createPresignedUrl({
key: modifiedContent.trailer,
expires: 60 * 60 * 24 * 7,
});
}
return modifiedContent;
}
}

View File

@@ -0,0 +1,67 @@
import {
Controller,
Get,
Param,
Req,
Res,
UnauthorizedException,
NotFoundException,
UseGuards,
Logger,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ContentKey } from './entities/content-key.entity';
import { Response, Request } from 'express';
import { HybridAuthGuard } from 'src/auth/guards/hybrid-auth.guard';
/**
* AES-128 Key Server for HLS content protection.
*
* HLS players request encryption keys via the #EXT-X-KEY URI in the manifest.
* This endpoint serves those keys, gated behind Nostr JWT authentication.
*
* The key is returned as raw binary (application/octet-stream) -- the standard
* format expected by HLS.js and native HLS players.
*/
@Controller('contents')
export class KeyController {
private readonly logger = new Logger(KeyController.name);
constructor(
@InjectRepository(ContentKey)
private readonly keyRepository: Repository<ContentKey>,
) {}
@Get(':id/key')
@UseGuards(HybridAuthGuard)
async getKey(
@Param('id') contentId: string,
@Req() req: Request,
@Res() res: Response,
) {
// User is authenticated via HybridAuthGuard (Nostr JWT or NIP-98)
// Future: check user's subscription/rental access here
const key = await this.keyRepository.findOne({
where: { contentId },
order: { rotationIndex: 'DESC' },
});
if (!key) {
throw new NotFoundException('Encryption key not found for this content');
}
this.logger.log(
`Key served for content ${contentId} (rotation: ${key.rotationIndex})`,
);
res.set({
'Content-Type': 'application/octet-stream',
'Content-Length': key.keyData.length.toString(),
'Cache-Control': 'no-store',
});
res.send(key.keyData);
}
}

View File

@@ -0,0 +1,10 @@
export type Transcode = {
inputBucket: string;
outputBucket: string;
inputKey: string;
outputKey: string;
correlationId: string;
callbackUrl: string;
drmContentId?: string;
drmMediaId?: string;
};

View File

@@ -0,0 +1,51 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { SnakeNamingStrategy } from 'typeorm-naming-strategies';
import { Unique } from './validators/unique.validator';
/**
* Database module.
* Removed the second PostHog database connection.
* Only the main application database is configured.
*/
@Module({
imports: [
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => ({
type: 'postgres',
host: configService.get('DATABASE_HOST'),
port: configService.get<number>('DATABASE_PORT'),
username: configService.get('DATABASE_USER'),
password: configService.get('DATABASE_PASSWORD'),
database: configService.get('DATABASE_NAME'),
namingStrategy: new SnakeNamingStrategy(),
entities: ['dist/**/*.entity.{ts,js}'],
migrations: ['dist/database/migrations/*.{ts,js}'],
migrationsTableName: 'typeorm_migrations',
autoLoadEntities: true,
// In production: false. In development: true for auto-schema sync.
synchronize: configService.get('ENVIRONMENT') === 'development',
connectTimeoutMS: 10_000,
maxQueryExecutionTime: 30_000,
poolSize: 50,
extra: {
poolSize: 50,
connectionTimeoutMillis: 5000,
query_timeout: 30_000,
statement_timeout: 30_000,
},
// No SSL for local/Docker environments
ssl:
configService.get('ENVIRONMENT') === 'local' ||
configService.get('ENVIRONMENT') === 'development'
? false
: { rejectUnauthorized: false },
}),
}),
],
providers: [Unique],
})
export class DatabaseModule {}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class UsersTable1694527530562 implements MigrationInterface {
name = 'UsersTable1694527530562';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "users" ("id" character varying NOT NULL, "email" character varying NOT NULL, "first_name" character varying NOT NULL, "last_name" character varying NOT NULL, "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_a3ffb1c0c8416b9fc6f907b7433" PRIMARY KEY ("id"))`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`DROP TABLE "users"`);
}
}

View File

@@ -0,0 +1,41 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class FilmsTables1695147061311 implements MigrationInterface {
name = 'FilmsTables1695147061311';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "films" ("id" character varying NOT NULL, "title" character varying NOT NULL, "description" character varying, "file" character varying, "trailer" character varying, "poster" character varying, "status" character varying NOT NULL DEFAULT 'draft', "pending_revenue" character varying NOT NULL DEFAULT '0', "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP, CONSTRAINT "PK_697487ada088902377482c970d1" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "filmmakers_films" ("filmmaker_id" character varying NOT NULL, "film_id" character varying NOT NULL, "share" integer NOT NULL, "role" character varying NOT NULL, "is_owner" boolean NOT NULL, "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "PK_7810850c4a8f129497f8852a483" PRIMARY KEY ("filmmaker_id", "film_id"))`,
);
await queryRunner.query(
`CREATE TABLE "filmmakers" ("id" character varying NOT NULL, "headshot" character varying, "bio" character varying, "lightning_address" character varying, "user_id" character varying NOT NULL, "updated_at" TIMESTAMP NOT NULL DEFAULT now(), "created_at" TIMESTAMP NOT NULL DEFAULT now(), CONSTRAINT "REL_00d2acdbee15ba0c728c7b6218" UNIQUE ("user_id"), CONSTRAINT "PK_f9f33b28ad0402474c529ef6aef" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "FK_c9628a5ebf8dd4f66875c116980" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "FK_b8e108f42fd0d417efa58a4d765" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" ADD CONSTRAINT "FK_00d2acdbee15ba0c728c7b6218a" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers" DROP CONSTRAINT "FK_00d2acdbee15ba0c728c7b6218a"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "FK_b8e108f42fd0d417efa58a4d765"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "FK_c9628a5ebf8dd4f66875c116980"`,
);
await queryRunner.query(`DROP TABLE "filmmakers"`);
await queryRunner.query(`DROP TABLE "filmmakers_films"`);
await queryRunner.query(`DROP TABLE "films"`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MoveProfilePic1695392891033 implements MigrationInterface {
name = 'MoveProfilePic1695392891033';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "filmmakers" DROP COLUMN "headshot"`);
await queryRunner.query(
`ALTER TABLE "users" ADD "profile_picture_url" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "users" DROP COLUMN "profile_picture_url"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" ADD "headshot" character varying`,
);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class FixTimestamp1695920604024 implements MigrationInterface {
name = 'FixTimestamp1695920604024';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "updated_at"`);
await queryRunner.query(
`ALTER TABLE "films" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "created_at"`);
await queryRunner.query(
`ALTER TABLE "films" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "deleted_at"`);
await queryRunner.query(
`ALTER TABLE "films" ADD "deleted_at" TIMESTAMP WITH TIME ZONE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "deleted_at"`);
await queryRunner.query(`ALTER TABLE "films" ADD "deleted_at" TIMESTAMP`);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "created_at"`);
await queryRunner.query(
`ALTER TABLE "films" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "updated_at"`);
await queryRunner.query(
`ALTER TABLE "films" ADD "updated_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
}
}

View File

@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCastCrewToFilm1696339984154 implements MigrationInterface {
name = 'AddCastCrewToFilm1696339984154';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "films" ADD "cast" character varying`);
await queryRunner.query(`ALTER TABLE "films" ADD "crew" character varying`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "updated_at"`);
await queryRunner.query(
`ALTER TABLE "users" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "created_at"`);
await queryRunner.query(
`ALTER TABLE "users" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" DROP COLUMN "updated_at"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" DROP COLUMN "created_at"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP COLUMN "updated_at"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP COLUMN "created_at"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now()`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP COLUMN "created_at"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP COLUMN "updated_at"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "updated_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" DROP COLUMN "created_at"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" DROP COLUMN "updated_at"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" ADD "updated_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "created_at"`);
await queryRunner.query(
`ALTER TABLE "users" ADD "created_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "updated_at"`);
await queryRunner.query(
`ALTER TABLE "users" ADD "updated_at" TIMESTAMP NOT NULL DEFAULT now()`,
);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "crew"`);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "cast"`);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemovedRole1697036073577 implements MigrationInterface {
name = 'RemovedRole1697036073577';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP COLUMN "role"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "role" character varying NOT NULL`,
);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedFilmmakerFilmId1697037684328 implements MigrationInterface {
name = 'AddedFilmmakerFilmId1697037684328';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "id" SERIAL NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "PK_7810850c4a8f129497f8852a483"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "PK_94958880e5b2064096130825427" PRIMARY KEY ("filmmaker_id", "film_id", "id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "PK_94958880e5b2064096130825427"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "PK_7810850c4a8f129497f8852a483" PRIMARY KEY ("filmmaker_id", "film_id")`,
);
await queryRunner.query(`ALTER TABLE "filmmakers_films" DROP COLUMN "id"`);
}
}

View File

@@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedFilmmakerFilmIdPrimary1697037772208
implements MigrationInterface
{
name = 'AddedFilmmakerFilmIdPrimary1697037772208';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "PK_94958880e5b2064096130825427"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "PK_7810850c4a8f129497f8852a483" PRIMARY KEY ("filmmaker_id", "film_id")`,
);
await queryRunner.query(`ALTER TABLE "filmmakers_films" DROP COLUMN "id"`);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "id" character varying NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "PK_7810850c4a8f129497f8852a483"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "PK_94958880e5b2064096130825427" PRIMARY KEY ("filmmaker_id", "film_id", "id")`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "PK_94958880e5b2064096130825427"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "PK_7810850c4a8f129497f8852a483" PRIMARY KEY ("filmmaker_id", "film_id")`,
);
await queryRunner.query(`ALTER TABLE "filmmakers_films" DROP COLUMN "id"`);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "id" SERIAL NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "PK_7810850c4a8f129497f8852a483"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "PK_94958880e5b2064096130825427" PRIMARY KEY ("filmmaker_id", "film_id", "id")`,
);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPayments1698684215465 implements MigrationInterface {
name = 'AddPayments1698684215465';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "payments" ("id" character varying NOT NULL, "filmmaker_film_id" character varying NOT NULL, "provider_id" character varying NOT NULL, "milisats_amount" integer, "usd_amount" integer, "status" character varying NOT NULL DEFAULT 'pending', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_197ab7af18c93fbb0c9b28b4a59" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "films" DROP COLUMN "pending_revenue"`,
);
await queryRunner.query(
`ALTER TABLE "films" ADD "pending_revenue" integer NOT NULL DEFAULT '0'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "films" DROP COLUMN "pending_revenue"`,
);
await queryRunner.query(
`ALTER TABLE "films" ADD "pending_revenue" character varying NOT NULL DEFAULT '0'`,
);
await queryRunner.query(`DROP TABLE "payments"`);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class ChangeUsdToDecimal1698929920881 implements MigrationInterface {
name = 'ChangeUsdToDecimal1698929920881';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "payments" DROP COLUMN "usd_amount"`);
await queryRunner.query(
`ALTER TABLE "payments" ADD "usd_amount" numeric(10,4) NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "payments" DROP COLUMN "usd_amount"`);
await queryRunner.query(`ALTER TABLE "payments" ADD "usd_amount" integer`);
}
}

View File

@@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RemovedPendingRevenueFilm1699966298556
implements MigrationInterface
{
name = 'RemovedPendingRevenueFilm1699966298556';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "films" DROP COLUMN "pending_revenue"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "films" ADD "pending_revenue" integer NOT NULL DEFAULT '0'`,
);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class CreatedInvitations1699982200465 implements MigrationInterface {
name = 'CreatedInvitations1699982200465';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "invites" ("id" character varying NOT NULL, "film_id" character varying NOT NULL, "email" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_79bbf0f768df5e816e421b4c050" PRIMARY KEY ("id", "film_id"))`,
);
await queryRunner.query(
`ALTER TABLE "invites" ADD CONSTRAINT "FK_90b37b2a69026d59f882680da11" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "invites" DROP CONSTRAINT "FK_90b37b2a69026d59f882680da11"`,
);
await queryRunner.query(`DROP TABLE "invites"`);
}
}

View File

@@ -0,0 +1,29 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedProfessionalName1700060422508 implements MigrationInterface {
name = 'AddedProfessionalName1700060422508';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "first_name"`);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "last_name"`);
await queryRunner.query(
`ALTER TABLE "users" ADD "full_name" character varying NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers" ADD "professional_name" character varying NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers" DROP COLUMN "professional_name"`,
);
await queryRunner.query(`ALTER TABLE "users" DROP COLUMN "full_name"`);
await queryRunner.query(
`ALTER TABLE "users" ADD "last_name" character varying NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "users" ADD "first_name" character varying NOT NULL`,
);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedRelationship1700156778683 implements MigrationInterface {
name = 'AddedRelationship1700156778683';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD CONSTRAINT "UQ_302549519efea7b1208af57335f" UNIQUE ("id")`,
);
await queryRunner.query(
`ALTER TABLE "payments" ADD CONSTRAINT "FK_fa6dd27aa07238920f170a7b726" FOREIGN KEY ("filmmaker_film_id") REFERENCES "filmmakers_films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "payments" DROP CONSTRAINT "FK_fa6dd27aa07238920f170a7b726"`,
);
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP CONSTRAINT "UQ_302549519efea7b1208af57335f"`,
);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameFullNameToLegalName1700252763989
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "users" RENAME COLUMN "full_name" TO "legal_name"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "users" RENAME COLUMN "legal_name" TO "full_name"`,
);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameDescriptionToSynopsis1700593416123
implements MigrationInterface
{
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "films" RENAME COLUMN "description" TO "synopsis"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "films" RENAME COLUMN "synopsis" TO "description"`,
);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddPendingRevenue1701199616013 implements MigrationInterface {
name = 'AddPendingRevenue1701199616013';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "pending_revenue" numeric(10,2) NOT NULL DEFAULT '0'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP COLUMN "pending_revenue"`,
);
}
}

View File

@@ -0,0 +1,17 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class FixDeleteFilmmaker1701454162558 implements MigrationInterface {
name = 'FixDeleteFilmmaker1701454162558';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" ADD "deleted_at" TIMESTAMP WITH TIME ZONE`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" DROP COLUMN "deleted_at"`,
);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class RenameFilmmakerFilm1702566370518 implements MigrationInterface {
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "filmmakers_films" RENAME TO "shareholders"`,
);
await queryRunner.query(
`ALTER TABLE "payments" RENAME COLUMN "filmmaker_film_id" TO "shareholder_id"`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "shareholders" RENAME TO "filmmakers_films"`,
);
await queryRunner.query(
`ALTER TABLE "payments" RENAME COLUMN "shareholder_id" TO "filmmaker_film_id"`,
);
}
}

View File

@@ -0,0 +1,77 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedCastCrew1702915082800 implements MigrationInterface {
name = 'AddedCastCrew1702915082800';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "shareholders" DROP CONSTRAINT "FK_c9628a5ebf8dd4f66875c116980"`,
);
await queryRunner.query(
`ALTER TABLE "shareholders" DROP CONSTRAINT "FK_b8e108f42fd0d417efa58a4d765"`,
);
await queryRunner.query(
`CREATE TABLE "crews" ("id" character varying NOT NULL, "filmmaker_id" character varying NOT NULL, "film_id" character varying NOT NULL, "role" integer NOT NULL, "occupation" integer NOT NULL, "order" integer NOT NULL, "email" character varying NOT NULL, "pending_revenue" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "deleted_at" TIMESTAMP WITH TIME ZONE, CONSTRAINT "PK_82339ba2ac2655387277341883f" PRIMARY KEY ("id", "film_id"))`,
);
await queryRunner.query(
`CREATE TABLE "casts" ("id" character varying NOT NULL, "filmmaker_id" character varying NOT NULL, "film_id" character varying NOT NULL, "character" character varying NOT NULL, "order" integer NOT NULL, "email" character varying NOT NULL, "pending_revenue" numeric(10,2) NOT NULL DEFAULT '0', "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_481951f875efb1e4e07812e25c5" PRIMARY KEY ("id", "film_id"))`,
);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "cast"`);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "crew"`);
await queryRunner.query(
`ALTER TABLE "payments" ADD "filmmaker_film_id" character varying`,
);
await queryRunner.query(
`ALTER TABLE "shareholders" ADD CONSTRAINT "FK_a731a85e00d9820abe2332924e9" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "shareholders" ADD CONSTRAINT "FK_8f8a4ee33f8afe7b90bc6b4c0a6" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "crews" ADD CONSTRAINT "FK_05c86d3dc89a4e0ca340336ad06" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "crews" ADD CONSTRAINT "FK_fcd6379cdf6bad96ad5bed0e8d2" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "casts" ADD CONSTRAINT "FK_6c75f4d666f344dc2bdeda6b3c8" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "casts" ADD CONSTRAINT "FK_0c808b1f5954f34ba80e18e934c" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "casts" DROP CONSTRAINT "FK_0c808b1f5954f34ba80e18e934c"`,
);
await queryRunner.query(
`ALTER TABLE "casts" DROP CONSTRAINT "FK_6c75f4d666f344dc2bdeda6b3c8"`,
);
await queryRunner.query(
`ALTER TABLE "crews" DROP CONSTRAINT "FK_fcd6379cdf6bad96ad5bed0e8d2"`,
);
await queryRunner.query(
`ALTER TABLE "crews" DROP CONSTRAINT "FK_05c86d3dc89a4e0ca340336ad06"`,
);
await queryRunner.query(
`ALTER TABLE "shareholders" DROP CONSTRAINT "FK_8f8a4ee33f8afe7b90bc6b4c0a6"`,
);
await queryRunner.query(
`ALTER TABLE "shareholders" DROP CONSTRAINT "FK_a731a85e00d9820abe2332924e9"`,
);
await queryRunner.query(
`ALTER TABLE "payments" DROP COLUMN "filmmaker_film_id"`,
);
await queryRunner.query(`ALTER TABLE "films" ADD "crew" character varying`);
await queryRunner.query(`ALTER TABLE "films" ADD "cast" character varying`);
await queryRunner.query(`DROP TABLE "casts"`);
await queryRunner.query(`DROP TABLE "crews"`);
await queryRunner.query(
`ALTER TABLE "shareholders" ADD CONSTRAINT "FK_b8e108f42fd0d417efa58a4d765" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "shareholders" ADD CONSTRAINT "FK_c9628a5ebf8dd4f66875c116980" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
}

View File

@@ -0,0 +1,49 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MakeNullableFilmmakerId1702916300496
implements MigrationInterface
{
name = 'MakeNullableFilmmakerId1702916300496';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "crews" DROP CONSTRAINT "FK_05c86d3dc89a4e0ca340336ad06"`,
);
await queryRunner.query(
`ALTER TABLE "crews" ALTER COLUMN "filmmaker_id" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "casts" DROP CONSTRAINT "FK_6c75f4d666f344dc2bdeda6b3c8"`,
);
await queryRunner.query(
`ALTER TABLE "casts" ALTER COLUMN "filmmaker_id" DROP NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "crews" ADD CONSTRAINT "FK_05c86d3dc89a4e0ca340336ad06" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "casts" ADD CONSTRAINT "FK_6c75f4d666f344dc2bdeda6b3c8" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "casts" DROP CONSTRAINT "FK_6c75f4d666f344dc2bdeda6b3c8"`,
);
await queryRunner.query(
`ALTER TABLE "crews" DROP CONSTRAINT "FK_05c86d3dc89a4e0ca340336ad06"`,
);
await queryRunner.query(
`ALTER TABLE "casts" ALTER COLUMN "filmmaker_id" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "casts" ADD CONSTRAINT "FK_6c75f4d666f344dc2bdeda6b3c8" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "crews" ALTER COLUMN "filmmaker_id" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "crews" ADD CONSTRAINT "FK_05c86d3dc89a4e0ca340336ad06" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
}

View File

@@ -0,0 +1,23 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class MakeNullableEmail1702917298362 implements MigrationInterface {
name = 'MakeNullableEmail1702917298362';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "casts" DROP COLUMN "pending_revenue"`,
);
await queryRunner.query(
`ALTER TABLE "casts" ALTER COLUMN "email" DROP NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "casts" ALTER COLUMN "email" SET NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "casts" ADD "pending_revenue" numeric(10,2) NOT NULL DEFAULT '0'`,
);
}
}

View File

@@ -0,0 +1,33 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddCrew1703106184441 implements MigrationInterface {
name = 'AddCrew1703106184441';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "crews" DROP COLUMN "role"`);
await queryRunner.query(
`ALTER TABLE "crews" DROP COLUMN "pending_revenue"`,
);
await queryRunner.query(`ALTER TABLE "crews" DROP COLUMN "occupation"`);
await queryRunner.query(
`ALTER TABLE "crews" ADD "occupation" character varying NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "crews" ALTER COLUMN "email" DROP NOT NULL`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "crews" ALTER COLUMN "email" SET NOT NULL`,
);
await queryRunner.query(`ALTER TABLE "crews" DROP COLUMN "occupation"`);
await queryRunner.query(
`ALTER TABLE "crews" ADD "occupation" integer NOT NULL`,
);
await queryRunner.query(
`ALTER TABLE "crews" ADD "pending_revenue" numeric(10,2) NOT NULL DEFAULT '0'`,
);
await queryRunner.query(`ALTER TABLE "crews" ADD "role" integer NOT NULL`);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedSubscriptions1705345231915 implements MigrationInterface {
name = 'AddedSubscriptions1705345231915';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "subscriptions" ("id" character varying NOT NULL, "stripe_id" character varying, "status" character varying NOT NULL, "user_id" character varying NOT NULL, "type" character varying NOT NULL, "period" character varying NOT NULL, "period_end" TIMESTAMP WITH TIME ZONE, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_a87248d73155605cf782be9ee5e" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "subscriptions" ADD CONSTRAINT "FK_d0a95ef8a28188364c546eb65c1" FOREIGN KEY ("user_id") REFERENCES "users"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "subscriptions" DROP CONSTRAINT "FK_d0a95ef8a28188364c546eb65c1"`,
);
await queryRunner.query(`DROP TABLE "subscriptions"`);
}
}

View File

@@ -0,0 +1,79 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedProjectTables1706558880071 implements MigrationInterface {
name = 'AddedProjectTables1706558880071';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "payments" DROP CONSTRAINT "FK_fa6dd27aa07238920f170a7b726"`,
);
await queryRunner.query(
`CREATE TABLE "festivals" ("id" character varying NOT NULL, "name" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_6d4d298db683d281bcaed953a46" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "festival_screenings" ("id" character varying NOT NULL, "film_id" character varying NOT NULL, "festival_id" character varying NOT NULL, "year" integer NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_cfec9a37e1774cd1c77c0e78d93" PRIMARY KEY ("id", "film_id"))`,
);
await queryRunner.query(
`CREATE TABLE "documents" ("id" character varying NOT NULL, "film_id" character varying NOT NULL, "name" character varying NOT NULL, "url" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_c6952b0a27da64ed67e193ca1f3" PRIMARY KEY ("id", "film_id"))`,
);
await queryRunner.query(
`CREATE TABLE "awards" ("id" character varying NOT NULL, "film_id" character varying NOT NULL, "award_issuer_id" character varying NOT NULL, "name" character varying NOT NULL, "year" integer NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_2517c15a5bc76c5055f9b909261" PRIMARY KEY ("id", "film_id"))`,
);
await queryRunner.query(
`CREATE TABLE "award_issuers" ("id" character varying NOT NULL, "name" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_4435fba8ba1e78161aa900e313c" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`ALTER TABLE "payments" DROP COLUMN "filmmaker_film_id"`,
);
await queryRunner.query(
`ALTER TABLE "payments" ADD CONSTRAINT "FK_733595cfde6a11d4f4f2b6c7b4b" FOREIGN KEY ("shareholder_id") REFERENCES "shareholders"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "festival_screenings" ADD CONSTRAINT "FK_b72da3cfc0781b8e8bf0ec07b55" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "festival_screenings" ADD CONSTRAINT "FK_64ed0c2ab4ba23d5ad977eba0bb" FOREIGN KEY ("festival_id") REFERENCES "festivals"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "documents" ADD CONSTRAINT "FK_97a23ecdb3849289ba88204317f" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "awards" ADD CONSTRAINT "FK_f9bd206872ffb6401f9992eef3b" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "awards" ADD CONSTRAINT "FK_8fdac39f81cd4cca0557a485c2d" FOREIGN KEY ("award_issuer_id") REFERENCES "award_issuers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "awards" DROP CONSTRAINT "FK_8fdac39f81cd4cca0557a485c2d"`,
);
await queryRunner.query(
`ALTER TABLE "awards" DROP CONSTRAINT "FK_f9bd206872ffb6401f9992eef3b"`,
);
await queryRunner.query(
`ALTER TABLE "documents" DROP CONSTRAINT "FK_97a23ecdb3849289ba88204317f"`,
);
await queryRunner.query(
`ALTER TABLE "festival_screenings" DROP CONSTRAINT "FK_64ed0c2ab4ba23d5ad977eba0bb"`,
);
await queryRunner.query(
`ALTER TABLE "festival_screenings" DROP CONSTRAINT "FK_b72da3cfc0781b8e8bf0ec07b55"`,
);
await queryRunner.query(
`ALTER TABLE "payments" DROP CONSTRAINT "FK_733595cfde6a11d4f4f2b6c7b4b"`,
);
await queryRunner.query(
`ALTER TABLE "payments" ADD "filmmaker_film_id" character varying`,
);
await queryRunner.query(`DROP TABLE "award_issuers"`);
await queryRunner.query(`DROP TABLE "awards"`);
await queryRunner.query(`DROP TABLE "documents"`);
await queryRunner.query(`DROP TABLE "festival_screenings"`);
await queryRunner.query(`DROP TABLE "festivals"`);
await queryRunner.query(
`ALTER TABLE "payments" ADD CONSTRAINT "FK_fa6dd27aa07238920f170a7b726" FOREIGN KEY ("shareholder_id") REFERENCES "shareholders"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
}

View File

@@ -0,0 +1,21 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedNewFilmDetails1706713872230 implements MigrationInterface {
name = 'AddedNewFilmDetails1706713872230';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "films" ADD "project_name" character varying NOT NULL DEFAULT ''`,
);
await queryRunner.query(`ALTER TABLE "films" ADD "type" character varying`);
await queryRunner.query(
`ALTER TABLE "films" ADD "format" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "format"`);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "type"`);
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "project_name"`);
}
}

View File

@@ -0,0 +1,31 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedGenres1706888680949 implements MigrationInterface {
name = 'AddedGenres1706888680949';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "genres" ("id" character varying NOT NULL, "name" character varying NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_80ecd718f0f00dde5d77a9be842" PRIMARY KEY ("id"))`,
);
await queryRunner.query(
`CREATE TABLE "film_genres" ("id" character varying NOT NULL, "film_id" character varying NOT NULL, "genre_id" character varying NOT NULL, "year" integer NOT NULL, "created_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updated_at" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "PK_aa48135bfcc893e3ebfdc416a60" PRIMARY KEY ("id", "film_id"))`,
);
await queryRunner.query(
`ALTER TABLE "film_genres" ADD CONSTRAINT "FK_9b6ca3be1e09e7537b24144d21d" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "film_genres" ADD CONSTRAINT "FK_d273e8fd854a03f655a751f393e" FOREIGN KEY ("genre_id") REFERENCES "genres"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "film_genres" DROP CONSTRAINT "FK_d273e8fd854a03f655a751f393e"`,
);
await queryRunner.query(
`ALTER TABLE "film_genres" DROP CONSTRAINT "FK_9b6ca3be1e09e7537b24144d21d"`,
);
await queryRunner.query(`DROP TABLE "film_genres"`);
await queryRunner.query(`DROP TABLE "genres"`);
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class FixGenreTable1706907277985 implements MigrationInterface {
name = 'FixGenreTable1706907277985';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "film_genres" DROP COLUMN "year"`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "film_genres" ADD "year" integer NOT NULL`,
);
}
}

View File

@@ -0,0 +1,25 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedPlaceholderNameCastCrew1707144468805
implements MigrationInterface
{
name = 'AddedPlaceholderNameCastCrew1707144468805';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "casts" ADD "placeholder_name" character varying`,
);
await queryRunner.query(
`ALTER TABLE "crews" ADD "placeholder_name" character varying`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "crews" DROP COLUMN "placeholder_name"`,
);
await queryRunner.query(
`ALTER TABLE "casts" DROP COLUMN "placeholder_name"`,
);
}
}

View File

@@ -0,0 +1,15 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedSharesToInvites1707234606168 implements MigrationInterface {
name = 'AddedSharesToInvites1707234606168';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "invites" ADD "share" integer NOT NULL DEFAULT '0'`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "invites" DROP COLUMN "share"`);
}
}

View File

@@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedSlugFilm1707246772876 implements MigrationInterface {
name = 'AddedSlugFilm1707246772876';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "films" ADD "slug" character varying`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "films" DROP COLUMN "slug"`);
}
}

View File

@@ -0,0 +1,27 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddedPermissions1707314864786 implements MigrationInterface {
name = 'AddedPermissions1707314864786';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE TABLE "permissions" ("id" character varying NOT NULL, "filmmaker_id" character varying NOT NULL, "film_id" character varying NOT NULL, "role" character varying NOT NULL, CONSTRAINT "id" UNIQUE ("id"), CONSTRAINT "PK_568e9d106e96fbcb887c10b334c" PRIMARY KEY ("id", "filmmaker_id", "film_id"))`,
);
await queryRunner.query(
`ALTER TABLE "permissions" ADD CONSTRAINT "FK_287711659c1379214f4e851d8be" FOREIGN KEY ("filmmaker_id") REFERENCES "filmmakers"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
await queryRunner.query(
`ALTER TABLE "permissions" ADD CONSTRAINT "FK_29e02d061b7c91002165230d636" FOREIGN KEY ("film_id") REFERENCES "films"("id") ON DELETE NO ACTION ON UPDATE NO ACTION`,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`ALTER TABLE "permissions" DROP CONSTRAINT "FK_29e02d061b7c91002165230d636"`,
);
await queryRunner.query(
`ALTER TABLE "permissions" DROP CONSTRAINT "FK_287711659c1379214f4e851d8be"`,
);
await queryRunner.query(`DROP TABLE "permissions"`);
}
}

Some files were not shown because too many files have changed in this diff Show More