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:
@@ -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
96
backend/.env.example
Normal 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
11
backend/.github/dependabot.yml
vendored
Normal 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'
|
||||
57
backend/.github/workflows/production-bff.yml
vendored
Normal file
57
backend/.github/workflows/production-bff.yml
vendored
Normal 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
|
||||
57
backend/.github/workflows/production.yml
vendored
Normal file
57
backend/.github/workflows/production.yml
vendored
Normal 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
101
backend/.github/workflows/staging.yml
vendored
Normal 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
45
backend/.gitignore
vendored
Normal 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
|
||||
1
backend/.husky/commit-msg
Normal file
1
backend/.husky/commit-msg
Normal file
@@ -0,0 +1 @@
|
||||
export COMMIT_MSG=$(cat $1) && echo "$COMMIT_MSG" | npx --no -- commitlint
|
||||
1
backend/.husky/pre-commit
Normal file
1
backend/.husky/pre-commit
Normal file
@@ -0,0 +1 @@
|
||||
npx lint-staged
|
||||
3
backend/.lintstagedrc
Normal file
3
backend/.lintstagedrc
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"*": "npm run lint:staged --"
|
||||
}
|
||||
1
backend/.nvmrc
Normal file
1
backend/.nvmrc
Normal file
@@ -0,0 +1 @@
|
||||
lts/iron
|
||||
5
backend/.prettierrc
Normal file
5
backend/.prettierrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"singleQuote": true,
|
||||
"trailingComma": "all",
|
||||
"endOfLine": "auto"
|
||||
}
|
||||
29
backend/.vscode/launch.json
vendored
Normal file
29
backend/.vscode/launch.json
vendored
Normal 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
9
backend/.vscode/settings.json
vendored
Normal 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
25
backend/Dockerfile
Normal 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
25
backend/Dockerfile.ffmpeg
Normal 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
273
backend/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||

|
||||
|
||||
# What is indeehub-api?
|
||||
|
||||
`indeehub-api` is the IndeeHub API Backend repository.\
|
||||
What stories will you tell?
|
||||
|
||||
# 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)
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
|
||||
# **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
|
||||
```
|
||||
|
||||
|
||||
|
||||
# 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
|
||||
|
||||
[](https://sonarqube.indeehub.studio/dashboard?id=IndeeHub_indeehub-api_53ec3bbb-4a99-40e6-9de4-ae1acf6b125d)
|
||||
29
backend/commitlint.config.js
Normal file
29
backend/commitlint.config.js
Normal 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'];
|
||||
},
|
||||
},
|
||||
};
|
||||
21
backend/docker/docker-compose.yml
Normal file
21
backend/docker/docker-compose.yml
Normal 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
|
||||
89
backend/docs/nostr-auth/README.md
Normal file
89
backend/docs/nostr-auth/README.md
Normal 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 caller’s 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 doesn’t 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.
|
||||
10
backend/docs/nostr-auth/security-checklist.md
Normal file
10
backend/docs/nostr-auth/security-checklist.md
Normal 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
92
backend/eslint.config.mjs
Normal 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
8
backend/nest-cli.json
Normal 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
22300
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
165
backend/package.json
Normal file
165
backend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
13
backend/sonar-project.properties
Normal file
13
backend/sonar-project.properties
Normal 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
|
||||
52
backend/src/admin/admin.controller.ts
Normal file
52
backend/src/admin/admin.controller.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
15
backend/src/admin/admin.module.ts
Normal file
15
backend/src/admin/admin.module.ts
Normal 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 {}
|
||||
137
backend/src/admin/admin.service.ts
Normal file
137
backend/src/admin/admin.service.ts
Normal 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
11
backend/src/admin/dto/admin-bulk-register.dto.ts
Normal file
11
backend/src/admin/dto/admin-bulk-register.dto.ts
Normal 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[];
|
||||
}
|
||||
15
backend/src/admin/dto/admin-register.dto.ts
Normal file
15
backend/src/admin/dto/admin-register.dto.ts
Normal 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;
|
||||
}
|
||||
12
backend/src/admin/dto/update-user-type.dto.ts
Normal file
12
backend/src/admin/dto/update-user-type.dto.ts
Normal 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;
|
||||
}
|
||||
12
backend/src/app.controller.ts
Normal file
12
backend/src/app.controller.ts
Normal 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
111
backend/src/app.module.ts
Normal 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 {}
|
||||
9
backend/src/app.service.ts
Normal file
9
backend/src/app.service.ts
Normal 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.`;
|
||||
}
|
||||
}
|
||||
38
backend/src/auth/__tests__/nostr-session.service.spec.ts
Normal file
38
backend/src/auth/__tests__/nostr-session.service.spec.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
9
backend/src/auth/auth.config.ts
Normal file
9
backend/src/auth/auth.config.ts
Normal 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}`;
|
||||
}
|
||||
47
backend/src/auth/auth.controller.spec.ts
Normal file
47
backend/src/auth/auth.controller.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
161
backend/src/auth/auth.controller.ts
Normal file
161
backend/src/auth/auth.controller.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
46
backend/src/auth/auth.module.ts
Normal file
46
backend/src/auth/auth.module.ts
Normal 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 {}
|
||||
71
backend/src/auth/auth.service.ts
Normal file
71
backend/src/auth/auth.service.ts
Normal 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 {};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsString } from 'class-validator';
|
||||
|
||||
export class RefreshNostrSessionDto {
|
||||
@IsString()
|
||||
refreshToken: string;
|
||||
}
|
||||
11
backend/src/auth/dto/request/register-otp.dto.ts
Normal file
11
backend/src/auth/dto/request/register-otp.dto.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { IsEmail, IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class RegisterOneTimePasswordDTO {
|
||||
@IsNotEmpty()
|
||||
@IsString()
|
||||
password: string;
|
||||
|
||||
@IsNotEmpty()
|
||||
@IsEmail()
|
||||
email: string;
|
||||
}
|
||||
29
backend/src/auth/dto/request/register.dto.ts
Normal file
29
backend/src/auth/dto/request/register.dto.ts
Normal 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';
|
||||
}
|
||||
6
backend/src/auth/dto/request/request-user.interface.ts
Normal file
6
backend/src/auth/dto/request/request-user.interface.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { Request } from 'express';
|
||||
import { User } from 'src/users/entities/user.entity';
|
||||
|
||||
export interface RequestUser extends Request {
|
||||
user: User;
|
||||
}
|
||||
15
backend/src/auth/dto/request/validate-session.dto.ts
Normal file
15
backend/src/auth/dto/request/validate-session.dto.ts
Normal 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';
|
||||
}
|
||||
38
backend/src/auth/guards/admin.guard.ts
Normal file
38
backend/src/auth/guards/admin.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
93
backend/src/auth/guards/hybrid-auth.guard.ts
Normal file
93
backend/src/auth/guards/hybrid-auth.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
backend/src/auth/guards/jwt.guard.ts
Normal file
27
backend/src/auth/guards/jwt.guard.ts
Normal 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.',
|
||||
);
|
||||
}
|
||||
}
|
||||
49
backend/src/auth/guards/nostr-session-jwt.guard.ts
Normal file
49
backend/src/auth/guards/nostr-session-jwt.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
38
backend/src/auth/guards/token.guard.ts
Normal file
38
backend/src/auth/guards/token.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
31
backend/src/auth/jwt.strategy.ts
Normal file
31
backend/src/auth/jwt.strategy.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
123
backend/src/auth/nostr-session.service.ts
Normal file
123
backend/src/auth/nostr-session.service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
8
backend/src/auth/user.decorator.ts
Normal file
8
backend/src/auth/user.decorator.ts
Normal 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;
|
||||
},
|
||||
);
|
||||
17
backend/src/award-issuers/award-issuers.controller.ts
Normal file
17
backend/src/award-issuers/award-issuers.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
12
backend/src/award-issuers/award-issuers.module.ts
Normal file
12
backend/src/award-issuers/award-issuers.module.ts
Normal 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 {}
|
||||
16
backend/src/award-issuers/award-issuers.service.ts
Normal file
16
backend/src/award-issuers/award-issuers.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
11
backend/src/award-issuers/dto/response/award-issuer.dto.ts
Normal file
11
backend/src/award-issuers/dto/response/award-issuer.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
27
backend/src/award-issuers/entities/award-issuer.entity.ts
Normal file
27
backend/src/award-issuers/entities/award-issuer.entity.ts
Normal 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[];
|
||||
}
|
||||
2
backend/src/common/constants.ts
Normal file
2
backend/src/common/constants.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const NANOID_LENGTH = 16;
|
||||
export const PRICE_PER_SECOND = 0.000_208_3;
|
||||
43
backend/src/common/decorators/throttle.decorator.ts
Normal file
43
backend/src/common/decorators/throttle.decorator.ts
Normal 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;
|
||||
}
|
||||
8
backend/src/common/enums/filter-date-range.enum.ts
Normal file
8
backend/src/common/enums/filter-date-range.enum.ts
Normal 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];
|
||||
40
backend/src/common/filter/error-exception.filter.ts
Normal file
40
backend/src/common/filter/error-exception.filter.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
9
backend/src/common/guards/user-throttle.guard.ts
Normal file
9
backend/src/common/guards/user-throttle.guard.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
73
backend/src/common/helper.ts
Normal file
73
backend/src/common/helper.ts
Normal 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() };
|
||||
};
|
||||
100
backend/src/contents/contents.controller.ts
Normal file
100
backend/src/contents/contents.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
47
backend/src/contents/contents.module.ts
Normal file
47
backend/src/contents/contents.module.ts
Normal 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 {}
|
||||
961
backend/src/contents/contents.service.ts
Normal file
961
backend/src/contents/contents.service.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
223
backend/src/contents/dto/request/add-content.dto.ts
Normal file
223
backend/src/contents/dto/request/add-content.dto.ts
Normal 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;
|
||||
}
|
||||
18
backend/src/contents/dto/request/list-contents.dto.ts
Normal file
18
backend/src/contents/dto/request/list-contents.dto.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
10
backend/src/contents/dto/request/update-content.dto.ts
Normal file
10
backend/src/contents/dto/request/update-content.dto.ts
Normal 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;
|
||||
}
|
||||
82
backend/src/contents/dto/response/base-content.dto.ts
Normal file
82
backend/src/contents/dto/response/base-content.dto.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
16
backend/src/contents/dto/response/caption.dto.ts
Normal file
16
backend/src/contents/dto/response/caption.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
30
backend/src/contents/dto/response/cast.dto.ts
Normal file
30
backend/src/contents/dto/response/cast.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
51
backend/src/contents/dto/response/content.dto.ts
Normal file
51
backend/src/contents/dto/response/content.dto.ts
Normal 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))
|
||||
: [];
|
||||
}
|
||||
}
|
||||
30
backend/src/contents/dto/response/crew.dto.ts
Normal file
30
backend/src/contents/dto/response/crew.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
11
backend/src/contents/dto/response/invite.dto.ts
Normal file
11
backend/src/contents/dto/response/invite.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
21
backend/src/contents/dto/response/rss-shareholder.dto.ts
Normal file
21
backend/src/contents/dto/response/rss-shareholder.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
14
backend/src/contents/dto/response/shareholder.dto.ts
Normal file
14
backend/src/contents/dto/response/shareholder.dto.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
21
backend/src/contents/dto/response/stream-content.dto.ts
Normal file
21
backend/src/contents/dto/response/stream-content.dto.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
36
backend/src/contents/entities/caption.entity.ts
Normal file
36
backend/src/contents/entities/caption.entity.ts
Normal 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;
|
||||
}
|
||||
49
backend/src/contents/entities/cast.entity.ts
Normal file
49
backend/src/contents/entities/cast.entity.ts
Normal 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;
|
||||
}
|
||||
32
backend/src/contents/entities/content-key.entity.ts
Normal file
32
backend/src/contents/entities/content-key.entity.ts
Normal 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;
|
||||
}
|
||||
164
backend/src/contents/entities/content.entity.ts
Normal file
164
backend/src/contents/entities/content.entity.ts
Normal 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',
|
||||
];
|
||||
53
backend/src/contents/entities/crew.entity.ts
Normal file
53
backend/src/contents/entities/crew.entity.ts
Normal 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;
|
||||
}
|
||||
59
backend/src/contents/entities/rss-shareholder.entity.ts
Normal file
59
backend/src/contents/entities/rss-shareholder.entity.ts
Normal 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;
|
||||
}
|
||||
74
backend/src/contents/entities/shareholder.entity.ts
Normal file
74
backend/src/contents/entities/shareholder.entity.ts
Normal 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[];
|
||||
}
|
||||
2
backend/src/contents/enums/content-status.enum.ts
Normal file
2
backend/src/contents/enums/content-status.enum.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export const contentStatuses = ['processing', 'failed', 'completed'] as const;
|
||||
export type ContentStatus = (typeof contentStatuses)[number];
|
||||
188
backend/src/contents/enums/language.enum.ts
Normal file
188
backend/src/contents/enums/language.enum.ts
Normal 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];
|
||||
46
backend/src/contents/interceptor/contents.interceptor.ts
Normal file
46
backend/src/contents/interceptor/contents.interceptor.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
67
backend/src/contents/key.controller.ts
Normal file
67
backend/src/contents/key.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
10
backend/src/contents/types/transcode.ts
Normal file
10
backend/src/contents/types/transcode.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
export type Transcode = {
|
||||
inputBucket: string;
|
||||
outputBucket: string;
|
||||
inputKey: string;
|
||||
outputKey: string;
|
||||
correlationId: string;
|
||||
callbackUrl: string;
|
||||
drmContentId?: string;
|
||||
drmMediaId?: string;
|
||||
};
|
||||
51
backend/src/database/database.module.ts
Normal file
51
backend/src/database/database.module.ts
Normal 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 {}
|
||||
15
backend/src/database/migrations/1694527530562-users_table.ts
Normal file
15
backend/src/database/migrations/1694527530562-users_table.ts
Normal 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"`);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()`,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"`);
|
||||
}
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user