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

@@ -16,14 +16,17 @@ COPY . .
# via the /relay nginx proxy at runtime (instead of hardcoding localhost)
ENV VITE_NOSTR_RELAYS=""
# Enable mock data mode — no backend API server in this deployment,
# so auth and content use built-in mock/local data instead of timing out
# Enable mock data mode as default — set to false to use the backend API
ENV VITE_USE_MOCK_DATA=true
# Content origin must match the seeder's ORIGIN so that relay queries find
# the seeded data, regardless of how users access the app in their browser
ENV VITE_CONTENT_ORIGIN=http://localhost:7777
# IndeeHub self-hosted backend API (via nginx /api proxy)
ENV VITE_INDEEHUB_API_URL=/api
ENV VITE_INDEEHUB_CDN_URL=/storage
# Build the application
RUN npm run build

96
backend/.env.example Normal file
View File

@@ -0,0 +1,96 @@
ENVIRONMENT=local # local | development | production
# App - Local
PORT=4000
DOMAIN=localhost:4000
FRONTEND_URL=http://localhost:3000
# DB - API
DATABASE_HOST=localhost
DATABASE_PORT=5432
DATABASE_USER=postgres
DATABASE_PASSWORD=local
DATABASE_NAME=indeehub
# DB - EVENTS/ANALYTICS (POSTHOG)
DATABASE_POSTHOG_HOST=localhost
DATABASE_POSTHOG_PORT=5434
DATABASE_POSTHOG_USER=postgres
DATABASE_POSTHOG_PASSWORD=local
DATABASE_POSTHOG_NAME=staging
# Trascoding Queue - Local
QUEUE_HOST=localhost
QUEUE_PORT=6379
QUEUE_PASSWORD=
# BTCPay Server - Bitcoin/Lightning Payments
BTCPAY_URL=https://btcpay.yourdomain.com
BTCPAY_STORE_ID=
BTCPAY_API_KEY=
BTCPAY_WEBHOOK_SECRET=
# User Pool - AWS Cognito
COGNITO_USER_POOL_ID=
COGNITO_CLIENT_ID=
# Sendgrid - Email Service
SENDGRID_API_KEY=
SENDGRID_SENDER=
SENDGRID_WAITLIST=
# AWS KEYS
AWS_ACCESS_KEY=
AWS_SECRET_KEY=
AWS_REGION=
# AWS S3
S3_PUBLIC_BUCKET_URL=
S3_PUBLIC_BUCKET_NAME=
# AWS CloudFront
CLOUDFRONT_PRIVATE_KEY=
CLOUDFRONT_KEY_PAIR_ID=
# Note: Stripe has been removed. All payments are now via BTCPay Server (Lightning).
# PAY WITH FLASH - ENTHUSIAST SUBSCRIPTION
FLASH_JWT_SECRET_ENTHUSIAST=
# PAY WITH FLASH - FILM BUFF SUBSCRIPTION
FLASH_JWT_SECRET_FILM_BUFF=
# PAY WITH FLASH - CINEPHILE SUBSCRIPTION
FLASH_JWT_SECRET_CINEPHILE=
# PAY WITH FLASH - RSS ADDON SUBSCRIPTION
FLASH_JWT_SECRET_RSS_ADDON=
# PAY WITH FLASH - VERIFICATION ADDON SUBSCRIPTION
FLASH_JWT_SECRET_VERIFICATION_ADDON=
# Transcoding API
TRANSCODING_API_KEY=
TRANSCODING_API_URL=http://localhost:4001
# Podping - RSS Update Notifications
PODPING_URL=https://podping.cloud/
PODPING_KEY=
PODPING_USER_AGENT=
# Retool API KEY - Admin Dashboard
ADMIN_API_KEY=
# PostHog - Analytics
POSTHOG_API_KEY=
# Sentry - Error Tracking
SENTRY_ENVIRONMENT=
# BUYDRM
PRIVATE_AUTH_CERTIFICATE_KEY_ID =
DRM_SECRET_NAME =
# Project Review
DASHBOARD_REVIEW_URL = https://oneseventech.retool.com/apps/5e86de84-7cdb-11ef-998f-47951c6124a1/Testing%20IndeeHub/film-details?id=
PROJECT_REVIEW_RECIPIENT_EMAIL = <your email>

11
backend/.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,11 @@
# To get started with Dependabot version updates, you'll need to specify which
# package ecosystems to update and where the package manifests are located.
# Please see the documentation for all configuration options:
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
version: 2
updates:
- package-ecosystem: 'npm' # See documentation for possible values
directory: '/' # Location of package manifests
schedule:
interval: 'weekly'

View File

@@ -0,0 +1,57 @@
name: Deploy to Production BFF
on:
push:
branches: [production-bff]
jobs:
build:
name: Build Image
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_production_bff
IMAGE_TAG: latest
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
- name: Tag image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_production_bff
IMAGE_TAG: latest
run: |
CURRENT_SHA=${GITHUB_SHA::8}
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:${CURRENT_SHA}
- name: Push image to Amazon ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_production_bff
IMAGE_TAG: latest
run: |
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- name: Restart ECS Cluster
env:
CLUSTER_NAME: indeehub-cluster-production-bff
SERVICE_NAME: indeehub-api-service-production-bff
run: |
aws ecs update-service --cluster $CLUSTER_NAME --service $SERVICE_NAME --force-new-deployment

View File

@@ -0,0 +1,57 @@
name: Deploy to Production
on:
push:
branches: [production]
jobs:
build:
name: Build Image
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_production
IMAGE_TAG: latest
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
- name: Tag image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_production
IMAGE_TAG: latest
run: |
CURRENT_SHA=${GITHUB_SHA::8}
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:${CURRENT_SHA}
- name: Push image to Amazon ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_production
IMAGE_TAG: latest
run: |
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- name: Restart ECS Cluster
env:
CLUSTER_NAME: indeehub-cluster-production
SERVICE_NAME: indeehub-api-service-production
run: |
aws ecs update-service --cluster $CLUSTER_NAME --service $SERVICE_NAME --force-new-deployment

101
backend/.github/workflows/staging.yml vendored Normal file
View File

@@ -0,0 +1,101 @@
name: Deploy to Staging
on:
push:
branches: [staging]
jobs:
build:
name: Build Image
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v1
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-west-1
- name: Login to Amazon ECR
id: login-ecr
uses: aws-actions/amazon-ecr-login@v1
- name: Build image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_staging
IMAGE_TAG: latest
run: |
docker build -t $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG .
- name: Tag image
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_staging
IMAGE_TAG: latest
run: |
CURRENT_SHA=${GITHUB_SHA::8}
docker tag $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG $ECR_REGISTRY/$ECR_REPOSITORY:${CURRENT_SHA}
- name: Push image to Amazon ECR
env:
ECR_REGISTRY: ${{ steps.login-ecr.outputs.registry }}
ECR_REPOSITORY: indeehub_ecr_repo_staging
IMAGE_TAG: latest
run: |
docker push $ECR_REGISTRY/$ECR_REPOSITORY:$IMAGE_TAG
- name: Restart ECS Cluster
env:
CLUSTER_NAME: indeehub-cluster-staging
SERVICE_NAME: indeehub-api-service-staging
run: |
aws ecs update-service --cluster $CLUSTER_NAME --service $SERVICE_NAME --force-new-deployment
sentry:
name: Deploy to Sentry
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Install Node.js
uses: actions/setup-node@v1
with:
node-version: '18'
- name: Build and Install Dependencies
run: npm install && npm run build
- name: Install Sentry CLI
run: npm install -g @sentry/cli
- name: Inject and Upload Sentry Source Maps
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: indeehub
SENTRY_PROJECT: indeehub-api
run: |
sentry-cli sourcemaps inject --org $SENTRY_ORG --project $SENTRY_PROJECT ./dist
sentry-cli sourcemaps upload --org $SENTRY_ORG --project $SENTRY_PROJECT ./dist
- name: Set up Sentry Release
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: indeehub
SENTRY_PROJECT: indeehub-api
run: |
sentry-cli releases new "$GITHUB_SHA"
sentry-cli releases set-commits --auto "$GITHUB_SHA" --ignore-missing
- name: Notify Sentry Deployment
env:
SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }}
SENTRY_ORG: indeehub
SENTRY_PROJECT: indeehub-api
run: |
sentry-cli releases deploys "$GITHUB_SHA" new -e staging

45
backend/.gitignore vendored Normal file
View File

@@ -0,0 +1,45 @@
# compiled output
/dist
/node_modules
# Logs
logs
*.log
npm-debug.log*
pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# OS
.DS_Store
# Tests
/coverage
/.nyc_output
# IDEs and editors
/.idea
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# IDE - VSCode
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
*.env
.terraform/
*.tfvars
.eslintcache
# Sentry Config File
.sentryclirc

View File

@@ -0,0 +1 @@
export COMMIT_MSG=$(cat $1) && echo "$COMMIT_MSG" | npx --no -- commitlint

View File

@@ -0,0 +1 @@
npx lint-staged

3
backend/.lintstagedrc Normal file
View File

@@ -0,0 +1,3 @@
{
"*": "npm run lint:staged --"
}

1
backend/.nvmrc Normal file
View File

@@ -0,0 +1 @@
lts/iron

5
backend/.prettierrc Normal file
View File

@@ -0,0 +1,5 @@
{
"singleQuote": true,
"trailingComma": "all",
"endOfLine": "auto"
}

29
backend/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,29 @@
{
"version": "0.2.0",
"configurations": [
{
"type": "node",
"request": "launch",
"name": "Debug Nest Framework",
"args": ["${workspaceFolder}/src/main.ts"],
"runtimeArgs": [
"--nolazy",
"-r",
"ts-node/register",
"-r",
"tsconfig-paths/register",
"--watch",
"--",
"--inspect"
],
"restart": true,
"envFile": "${workspaceFolder}/.env",
"cwd": "${workspaceRoot}",
"protocol": "inspector",
"internalConsoleOptions": "openOnSessionStart",
"sourceMaps": true,
"console": "internalConsole",
"outputCapture": "std"
}
]
}

9
backend/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,9 @@
{
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "explicit"
},
"sonarlint.connectedMode.project": {
"connectionId": "sonarqube-indeehub",
"projectKey": "IndeeHub_indeehub-api_53ec3bbb-4a99-40e6-9de4-ae1acf6b125d"
}
}

25
backend/Dockerfile Normal file
View File

@@ -0,0 +1,25 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files first for better caching
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
# Copy source and build
COPY . .
RUN npm run build
RUN npm prune --production
# ── Production image ──────────────────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/package-lock.json ./package-lock.json
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 4000
# Run TypeORM migrations on startup, then start the API
CMD ["sh", "-c", "npx typeorm migration:run -d dist/database/ormconfig.js 2>/dev/null; export NODE_OPTIONS='--max-old-space-size=1024' && npm run start:prod"]

25
backend/Dockerfile.ffmpeg Normal file
View File

@@ -0,0 +1,25 @@
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files for the worker
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build
RUN npm prune --production
# ── Production image with FFmpeg ──────────────────────────────
FROM node:20-alpine AS production
WORKDIR /app
# Install FFmpeg
RUN apk add --no-cache ffmpeg
COPY --from=builder /app/package.json ./package.json
COPY --from=builder /app/package-lock.json ./package-lock.json
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
# The FFmpeg worker runs as a standalone BullMQ consumer
CMD ["node", "dist/ffmpeg-worker/worker.js"]

273
backend/README.md Normal file
View File

@@ -0,0 +1,273 @@
![](/.swm/images/IH-banner.png)
# What is indeehub-api?
`indeehub-api` is the IndeeHub API Backend repository.\
What stories will you tell?&nbsp;
# Table of Contents
- [Quickstart Guides](#quickstart-guides)
- [MacOS Quickstart](#macos-quickstart)
- [Linux (Unix) Quickstart](#linux-unix-quickstart)
- [Windows Quickstart](#windows-quickstart)
- [Overview](#overview)
- [Requirements](#requirements)
- [Devtool dependencies](#devtool-dependencies)
- [Installed packages](#installed-packages)
- [Running the API](#running-the-api)
- [Recommended VSCode Extensions](#recommended-vscode-extensions)
- [Running DB Migrations](#running-db-migrations)
- [Running Stripe Webhooks locally](#running-stripe-webhooks-locally)
- [SonarQube](#sonarqube)
# Quickstart Guides
## MacOS Quickstart
### Install NVM
```zsh
brew update
brew install nvm
mkdir ~/.nvm
echo "export NVM_DIR=~/.nvm\nsource \$(brew --prefix nvm)/nvm.sh" >> .zshrc
source ~/.zshrc
```
### Install dependencies
```zsh
nvm install # this will install the node version set in .nvmrc (lts/hydrogen)
npm i
cp .env.example .env # Add the environment variables
```
## Linux (Unix) Quickstart
### Install NVM
```bash
curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.7/install.sh | bash
export NVM_DIR="$([ -z "${XDG_CONFIG_HOME-}" ] && printf %s "${HOME}/.nvm" || printf %s "${XDG_CONFIG_HOME}/nvm")"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" # This loads nvm
```
### Install dependencies
```bash
nvm install # this will install the node version set in .nvmrc (lts/hydrogen)
npm i
cp .env.example .env # And add the environment variables
```
## Windows Quickstart
To avoid EOL and other Windows-related issues, we recommend installing WSL 2 and running the repository on a Linux distro of your choice.
Follow this guide to install WSL: [https://learn.microsoft.com/en-us/windows/wsl/install](https://github.com/IndeeHub/indeehub-frontend/tree/main#macos-quickstart)&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
## Overview
- **TypeScript**: A typed superset of JavaScript designed with large-scale applications in mind.
- **ESLint**: Static code analysis to help find problems within a codebase.
- **Prettier**: An opinionated code formatted.
- **Nest.js**: A progressive Node.js framework for building efficient, reliable, and scalable server-side applications.
- **Swagger**: A tool that helps design, build, document, and consume RESTful Web services.
- **TypeORM**: An ORM that can run in NodeJS and can be used with TypeScript.
- **Cypress**: End-to-end testing framework for web applications.
- **Commitizen**: Conventional commit messages CLI.
### **Requirements**
- **NodeJS 18+**
- **npm (or equivalent)**
Notes:
- We recommend the use of Commitizen to use conventional commit messages and linting before commits.
### **Devtool dependencies**
- **Cypress**: End-to-end testing framework for web applications.
### Installed Packages
- @aws-sdk/client-s3
- @nestjs/common
- @nestjs/config
- @nestjs/core
- @nestjs/passport
- @nestjs/platform-express
- @nestjs/schedule
- @nestjs/swagger
- @nestjs/typeorm
- @sendgrid/mail
- @smithy/hash-node
- @smithy/protocol-http
- @zbd/node
- amazon-cognito-identity-js
- aws-jwt-verify
- axios
- class-transformer
- class-validator
- jwks-rsa
- moment
- passport
- passport-jwt
- pg
- reflect-metadata
- rxjs
- stripe
- typeorm
- typeorm-naming-strategies
**DevDependencies**
- @nestjs/cli
- @nestjs/schematics
- @nestjs/testing
- @types/express
- @types/jest
- @types/node
- @types/supertest
- @typescript-eslint/eslint-plugin
- @typescript-eslint/parser
- eslint
- eslint-config-prettier
- eslint-plugin-prettier
- jest
- prettier
- source-map-support
- supertest
- ts-jest
- ts-loader
- ts-node
- tsconfig-paths
- typescript
&nbsp;
# **Running the API**
```bash
npm run start:dev
# Or if you want to use the debug tool
npm run start:debug
```
# Running DB Migrations
```bash
npm run build
npm run typeorm:generate-migration --name=add-your-migration-name # will generate it based on the differences between the entities and the DB Schema
npm run typeorm:create-migration --name=add-your-migration-name # will create a blank migration
npm run build # after you finish the migrations
npm run typeorm:run-migrations # will apply the migrations to the current DB
```
&nbsp;
# Running Stripe Webhooks locally
## Installing Stripe CLI
### MacOS
```zsh
brew install stripe/stripe-cli/stripe
```
### Windows
```bash
scoop bucket add stripe https://github.com/stripe/scoop-stripe-cli.git
scoop install stripe
```
### Linux
```bash
curl -s https://packages.stripe.dev/api/security/keypair/stripe-cli-gpg/public | gpg --dearmor | sudo tee /usr/share/keyrings/stripe.gpg
echo "deb [signed-by=/usr/share/keyrings/stripe.gpg] https://packages.stripe.dev/stripe-cli-debian-local stable main" | sudo tee -a /etc/apt/sources.list.d/stripe.list
sudo apt update
sudo apt install stripe
```
## Log in to CLI
```bash
stripe login
Your pairing code is: enjoy-enough-outwit-win
This pairing code verifies your authentication with Stripe.
Press Enter to open the browser or visit https://dashboard.stripe.com/stripecli/confirm_auth?t=THQdJfL3x12udFkNorJL8OF1iFlN8Az1
```
## Start webhook listener
```
stripe listen --forward-to localhost:4242/webhook
```
It will output your endpoint secret, and add it to the .env file.
# SonarQube
[![Quality gate](https://sonarqube.indeehub.studio/api/project_badges/quality_gate?project=IndeeHub_indeehub-api_53ec3bbb-4a99-40e6-9de4-ae1acf6b125d&token=sqb_b1e2bd689935a9954f40e4b584689c226f741f3e)](https://sonarqube.indeehub.studio/dashboard?id=IndeeHub_indeehub-api_53ec3bbb-4a99-40e6-9de4-ae1acf6b125d)

View File

@@ -0,0 +1,29 @@
// from https://github.com/arvinxx/gitmoji-commit-workflow/tree/master/packages/gitmoji-regex
const isGitmoji = (commit) =>
commit.match(
/^(?::\w*:|(?:\ud83c[\udf00-\udfff])|(?:\ud83d[\udc00-\ude4f\ude80-\udeff])|[\u2600-\u2B55]|♻️|🥅|🧱|🤡|🏷️|🚧|♻️|✏️|⚡️|🔥|⬆️|⏪|🦺|🗃️)\s(?<type>\w*)(?:\((?<scope>.*)\))?!?:\s(?<subject>(?:(?!#).)*(?:(?!\s).))(?:\s\(?(?<ticket>#\d*)\)?)?$/gm,
);
const isConventional = (commit) =>
commit.match(
/^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert|merge)(\(.+\))?:\s/,
);
module.exports = {
extends: ['@commitlint/config-conventional', 'gitmoji'],
rules: {
'subject-empty': () => {
const commitMsg = process.env.COMMIT_MSG || '';
return isGitmoji(commitMsg) ? [0, 'always'] : [2, 'never'];
},
'type-empty': () => {
const commitMsg = process.env.COMMIT_MSG || '';
return isGitmoji(commitMsg) ? [0, 'always'] : [2, 'never'];
},
'start-with-gitmoji': () => {
const commitMsg = process.env.COMMIT_MSG || '';
return isGitmoji(commitMsg) && !isConventional(commitMsg)
? [2, 'never']
: [0, 'always'];
},
},
};

View File

@@ -0,0 +1,21 @@
version: '3.8'
services:
api:
container_name: 'indeehub-api'
image: 'indeehub-api'
environment:
DATABASE_HOST: XXXX.cbjb1ugj4p4e.us-east-1.rds.amazonaws.com
DATABASE_NAME: XXXX
DATABASE_USER: XXXX
DATABASE_PASSWORD: XXXX
DATABASE_PORT: 5432
build:
context: ../
dockerfile: Dockerfile
entrypoint: ['npm', 'run', 'start:prod']
ports:
- 3000:3000
volumes:
- ./app:/usr/src/app
- /usr/src/app/node_modules

View File

@@ -0,0 +1,89 @@
# Nostr HTTP Auth (NIP-98)
IndeeHub exposes an HTTP authentication path that accepts requests signed by Nostr keys (NIP-98). Clients sign each request body + method + URL and send it via the `Authorization: Nostr <base64Event>` header. The server verifies the event, enforces a tight replay window, and makes the callers pubkey available on the Express request.
## Request format
- Header: `Authorization: Nostr <base64Event>`.
- Event requirements: `kind` 27235, `created_at` within ±120s of server time, and the following tags:
- `["method", "<HTTP_METHOD>"]`
- `["u", "<FULL_URL>"]` (include scheme, host, path, and query; exclude fragment)
- `["payload", "<sha256-hex of raw body>"]` (omit or leave empty only when the request has no body)
## Client examples
### Browser (NIP-07)
```ts
async function fetchWithNostr(url: string, method: string, body?: unknown) {
const payload = body ? JSON.stringify(body) : '';
const tags = [
['method', method],
['u', url],
['payload', payload ? sha256(payload) : ''],
];
const unsigned = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags,
content: '',
};
const event = await window.nostr.signEvent(unsigned);
const authorization = `Nostr ${btoa(JSON.stringify(event))}`;
return fetch(url, {
method,
headers: {
'content-type': 'application/json',
authorization,
},
body: payload || undefined,
});
}
```
### Node (nostr-tools)
```ts
import { finalizeEvent, getPublicKey, generateSecretKey } from 'nostr-tools';
import crypto from 'node:crypto';
const secret = generateSecretKey();
const pubkey = getPublicKey(secret);
const payload = JSON.stringify({ ping: 'pong' });
const unsigned = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
tags: [
['u', 'https://api.indeehub.studio/nostr-auth/echo'],
['method', 'POST'],
['payload', crypto.createHash('sha256').update(payload).digest('hex')],
],
content: '',
};
const event = finalizeEvent(unsigned, secret);
const authorization = `Nostr ${Buffer.from(JSON.stringify(event)).toString('base64')}`;
```
## Server usage
- Apply the guard: `@UseGuards(NostrAuthGuard)` to require Nostr signatures.
- Hybrid mode: `@UseGuards(HybridAuthGuard)` accepts either Nostr (NIP-98) or the existing JWT guard.
- Access the caller: `req.nostrPubkey` and `req.nostrEvent` are populated on successful verification.
- Session bridge: `POST /auth/nostr/session` (guarded by `NostrAuthGuard`) exchanges a signed HTTP request for a 15-minute JWT (`sub = pubkey`) plus refresh token. `POST /auth/nostr/refresh` exchanges a refresh token for a new pair. Use `@UseGuards(NostrSessionJwtGuard)` or `HybridAuthGuard` to accept these JWTs.
- Link an existing user: `POST /auth/nostr/link` with both `JwtAuthGuard` (current user) and `NostrAuthGuard` (NIP-98 header) to attach a pubkey to the user record. `POST /auth/nostr/unlink` removes it. When a pubkey is linked, Nostr session JWTs also include `uid` so downstream code can attach `req.user`. If you need both JWT + Nostr on the same call, send the Nostr signature in `nostr-authorization` (or `x-nostr-authorization`) header so it doesnt conflict with `Authorization: Bearer ...`.
Example:
```ts
@Post('protected')
@UseGuards(NostrAuthGuard)
handle(@Req() req: Request) {
return { pubkey: req.nostrPubkey };
}
```
## Troubleshooting
- Clock skew: ensure client and server clocks are within ±2 minutes.
- URL canonicalization: sign the exact scheme/host/path/query received by the server (no fragments).
- Payload hash: hash the raw request body bytes; any whitespace or re-serialization changes will fail verification.
- Headers: set the `Host` and (if behind proxies) `x-forwarded-proto` to the values used when signing the URL.
- Stale signatures: rotate signatures per request; reuse will fail once outside the replay window.

View File

@@ -0,0 +1,10 @@
# Nostr Auth Security Checklist
- Replay window: enforce ±120s `created_at` tolerance; reject reused or stale events.
- HTTPS only: require TLS termination before the API; never accept plain HTTP in production.
- Canonical URL: sign/verify the exact scheme + host + path + query; strip fragments.
- Payload hashing: hash raw bytes; reject if the `payload` tag hash differs from the received body.
- Rate limiting: apply IP/pubkey-based throttling to mitigate brute force or flood attempts.
- Logging hygiene: avoid persisting raw payloads/signatures; redact PII and secrets from logs.
- Dependency pinning: lock `nostr-tools` and crypto dependencies; track CVEs and update promptly.
- Test coverage: maintain ≥90% coverage for the Nostr auth service/guard and add E2E cases for tamper/replay.

92
backend/eslint.config.mjs Normal file
View File

@@ -0,0 +1,92 @@
import typescriptEslint from '@typescript-eslint/eslint-plugin';
import unicorn from 'eslint-plugin-unicorn';
import importPlugin from 'eslint-plugin-import';
import tsParser from '@typescript-eslint/parser';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import js from '@eslint/js';
import { FlatCompat } from '@eslint/eslintrc';
import eslintPluginPrettier from 'eslint-plugin-prettier';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const compat = new FlatCompat({
baseDirectory: __dirname,
recommendedConfig: js.configs.recommended,
allConfig: js.configs.all,
});
export default [
{
ignores: [
'**/node_modules',
'**/dist',
'commitlint.config.js',
'eslint.config.mjs',
],
},
...compat.extends(
'eslint:recommended',
'plugin:@typescript-eslint/recommended',
'plugin:unicorn/recommended',
'plugin:import/recommended',
'plugin:import/typescript',
'plugin:prettier/recommended',
),
{
plugins: {
'@typescript-eslint': typescriptEslint,
unicorn,
importPlugin,
eslintPluginPrettier,
},
settings: {
"import/resolver": {
// You will also need to install and configure the TypeScript resolver
// See also https://github.com/import-js/eslint-import-resolver-typescript#configuration
"typescript": true,
"node": true,
},
},
languageOptions: {
parser: tsParser,
ecmaVersion: 'latest',
sourceType: 'module',
parserOptions: {
project: './tsconfig.json',
},
},
rules: {
'semi': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'@typescript-eslint/no-unused-vars': [
'warn',
{
argsIgnorePattern: '^_',
},
],
'@typescript-eslint/explicit-module-boundary-types': 'off',
'unicorn/filename-case': [
'error',
{
case: 'kebabCase',
},
],
'unicorn/prevent-abbreviations': [
'error',
{
replacements: {
props: false,
},
},
],
indent: 'off',
},
},
];

8
backend/nest-cli.json Normal file
View File

@@ -0,0 +1,8 @@
{
"$schema": "https://json.schemastore.org/nest-cli",
"collection": "@nestjs/schematics",
"sourceRoot": "src",
"compilerOptions": {
"deleteOutDir": true
}
}

22300
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

165
backend/package.json Normal file
View File

@@ -0,0 +1,165 @@
{
"name": "indeehub-api",
"version": "0.0.1",
"private": true,
"description": "",
"license": "UNLICENSED",
"author": "",
"scripts": {
"build": "nest build",
"commit": "npx git-cz",
"commit:FORCE": "npx git-cz -n",
"lint": "npx eslint . --cache",
"lint:fix": "npx eslint . --cache --fix",
"lint:staged": "npx eslint --cache --fix --max-warnings 0 --no-warn-ignored",
"prepare": "husky",
"start": "nest start",
"start:debug": "nest start --debug --watch",
"start:dev": "nest start --watch",
"start:prod": "node dist/main",
"test": "jest",
"test:cov": "jest --coverage",
"test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --runInBand",
"test:e2e": "jest --config ./test/jest-e2e.json",
"test:watch": "jest --watch",
"typeorm": "ts-node ./node_modules/typeorm/cli",
"typeorm:create-migration": "npm run typeorm -- migration:create src/database/migrations/$npm_config_name",
"typeorm:generate-migration": "npm run typeorm -- -d src/database/ormconfig.ts migration:generate src/database/migrations/$npm_config_name",
"typeorm:revert-migration": "npm run typeorm -- -d src/database/ormconfig.ts migration:revert",
"typeorm:run-migrations": "npm run typeorm migration:run -- -d src/database/ormconfig.ts"
},
"config": {
"commitizen": {
"path": "@commitlint/cz-commitlint"
}
},
"jest": {
"collectCoverage": true,
"collectCoverageFrom": [
"nostr-auth/nostr-auth.service.ts",
"nostr-auth/nostr-auth.guard.ts",
"auth/nostr-session.service.ts",
"!**/*.spec.ts",
"!**/node_modules/**",
"!**/dist/**"
],
"coverageDirectory": "../coverage",
"coveragePathIgnorePatterns": [
"/node_modules/",
"/dist/"
],
"coverageReporters": [
"json",
"lcov",
"text",
"clover"
],
"moduleFileExtensions": [
"js",
"json",
"ts"
],
"moduleNameMapper": {
"^src/(.*)$": "<rootDir>/$1"
},
"rootDir": "src",
"testEnvironment": "node",
"testRegex": ".*\\.spec\\.ts$",
"transform": {
"^.+\\.ts$": "ts-jest"
}
},
"dependencies": {
"@aws-sdk/client-auto-scaling": "^3.917.0",
"@aws-sdk/client-cognito-identity-provider": "^3.966.0",
"@aws-sdk/client-ecs": "^3.699.0",
"@aws-sdk/client-s3": "^3.917.0",
"@aws-sdk/client-secrets-manager": "^3.950.0",
"@aws-sdk/cloudfront-signer": "^3.966.0",
"@aws-sdk/s3-request-presigner": "^3.654.0",
"@aws-sdk/url-parser": "^3.374.0",
"@aws-sdk/util-format-url": "^3.734.0",
"@logtail/node": "^0.5.0",
"@nestjs/bullmq": "^11.0.4",
"@nestjs/cache-manager": "^3.0.0",
"@nestjs/common": "^11.0.11",
"@nestjs/config": "^4.0.2",
"@nestjs/core": "^11.1.9",
"@nestjs/passport": "^11.0.5",
"@nestjs/platform-express": "^11.1.6",
"@nestjs/platform-socket.io": "^11.0.10",
"@nestjs/schedule": "^5.0.1",
"@nestjs/swagger": "^11.2.3",
"@nestjs/throttler": "^6.5.0",
"@nestjs/typeorm": "^11.0.0",
"@nestjs/websockets": "^11.0.11",
"@sendgrid/mail": "^8.1.4",
"@sentry/cli": "^2.38.2",
"@sentry/nestjs": "^9.0.1",
"@sentry/profiling-node": "^9.0.1",
"@smithy/hash-node": "^4.2.5",
"@smithy/protocol-http": "^5.0.1",
"@types/jsonwebtoken": "^9.0.10",
"@zbd/node": "^0.6.4",
"amazon-cognito-identity-js": "^6.3.12",
"aws-jwt-verify": "^5.1.1",
"axios": "^1.8.3",
"bullmq": "^5.14.0",
"cache-manager": "^6.4.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
"dayjs": "^1.11.13",
"fast-xml-parser": "^5.2.0",
"jsonwebtoken": "^9.0.3",
"jwks-rsa": "^3.0.1",
"moment": "^2.30.1",
"nodemailer": "^6.9.0",
"nostr-tools": "^2.19.3",
"passport": "^0.7.0",
"passport-jwt": "^4.0.1",
"pg": "^8.16.3",
"reflect-metadata": "^0.2.2",
"rss": "^1.2.2",
"rxjs": "^7.8.1",
"socket.io": "^4.8.1",
"stripe": "^17.4.0",
"typeorm": "^0.3.27",
"typeorm-naming-strategies": "^4.1.0"
},
"devDependencies": {
"@commitlint/cli": "^19.6.1",
"@commitlint/config-conventional": "^19.5.0",
"@commitlint/cz-commitlint": "^20.1.0",
"@eslint/eslintrc": "^3.3.0",
"@eslint/js": "^9.39.2",
"@nestjs/cli": "^11.0.14",
"@nestjs/schematics": "^11.0.1",
"@nestjs/testing": "^11.0.10",
"@types/express": "^5.0.0",
"@types/jest": "^29.5.14",
"@types/node": "^25.0.6",
"@types/rss": "^0.0.32",
"@types/socket.io": "^3.0.2",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^8.46.1",
"@typescript-eslint/parser": "^8.24.1",
"commitizen": "^4.3.0",
"commitlint-config-gitmoji": "^2.3.1",
"eslint-config-prettier": "^10.0.1",
"eslint-import-resolver-typescript": "^3.6.3",
"eslint-plugin-import": "^2.31.0",
"eslint-plugin-prettier": "^5.5.4",
"eslint-plugin-unicorn": "^56.0.0",
"husky": "^9.1.6",
"jest": "^29.5.0",
"lint-staged": "^16.2.4",
"prettier": "^3.3.3",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
"ts-loader": "^9.5.1",
"ts-node": "^10.9.2",
"tsconfig-paths": "^4.2.0",
"typescript": "^5.9.3"
}
}

View File

@@ -0,0 +1,13 @@
sonar.projectKey=IndeeHub_indeehub-api
sonar.organization=indeehub
# This is the name and version displayed in the SonarCloud UI.
#sonar.projectName=indeehub-api
#sonar.projectVersion=1.0
# Path is relative to the sonar-project.properties file. Replace "\" by "/" on Windows.
#sonar.sources=.
# Encoding of the source code. Default is default system encoding
#sonar.sourceEncoding=UTF-8

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`,
);
}
}

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