migration to typescript and ESM modules

This commit is contained in:
Dmitri 2026-07-01 15:45:38 +02:00
parent 5fc54b1894
commit df3eb45bbf
354 changed files with 33834 additions and 26093 deletions

View File

@ -1,3 +1,11 @@
**/.DS_Store
.git
docker/data
backend/.env
backend/dist
backend/node_modules backend/node_modules
frontend/.env.local
frontend/.next
frontend/tsconfig.tsbuildinfo
frontend/node_modules frontend/node_modules
frontend/build frontend/build

2
.gitignore vendored
View File

@ -4,10 +4,12 @@ node_modules/
*/node_modules/ */node_modules/
**/node_modules/ **/node_modules/
*/build/ */build/
backend/dist/
frontend/.next/ frontend/.next/
frontend/out/ frontend/out/
frontend/public/sw.js frontend/public/sw.js
frontend/next-env.d.ts frontend/next-env.d.ts
package-lock.json package-lock.json
!backend/package-lock.json
AGENTS.md AGENTS.md
.codex/ .codex/

View File

@ -1,22 +1,21 @@
FROM node:20.15.1-alpine AS builder FROM node:24-alpine AS builder
RUN apk add --no-cache git RUN apk add --no-cache git
WORKDIR /app WORKDIR /app
COPY frontend/package.json frontend/yarn.lock ./ COPY frontend/package*.json ./
RUN yarn install --pure-lockfile RUN npm ci
COPY frontend . COPY frontend .
RUN yarn build RUN npm run build
FROM node:20.15.1-alpine FROM node:24-alpine
# FFmpeg is bundled via npm package ffmpeg-static # FFmpeg is bundled via npm package ffmpeg-static
WORKDIR /app WORKDIR /app
COPY backend/package.json backend/yarn.lock ./ COPY backend/package*.json ./
RUN yarn install --pure-lockfile RUN npm ci
COPY backend . COPY backend .
COPY --from=builder /app/build /app/public COPY --from=builder /app/build /app/public
CMD ["yarn", "start"] CMD ["npm", "run", "start"]

View File

@ -1,22 +1,21 @@
# Base image for Node.js dependencies # Base image for Node.js dependencies
FROM node:20.15.1-alpine AS frontend-deps FROM node:24-alpine AS frontend-deps
RUN apk add --no-cache git RUN apk add --no-cache git
WORKDIR /app/frontend WORKDIR /app/frontend
COPY frontend/package.json frontend/yarn.lock ./ COPY frontend/package*.json ./
RUN yarn install --pure-lockfile RUN npm ci
FROM node:20.15.1-alpine AS backend-deps FROM node:24-alpine AS backend-deps
RUN apk add --no-cache git RUN apk add --no-cache git
WORKDIR /app/backend WORKDIR /app/backend
COPY backend/package.json backend/yarn.lock ./ COPY backend/package*.json ./
RUN yarn install --pure-lockfile RUN npm ci
# Nginx setup and application build # Nginx setup and application build
FROM node:20.15.1-alpine AS build FROM node:24-alpine AS build
RUN apk add --no-cache git nginx curl RUN apk add --no-cache git nginx curl
RUN apk add --no-cache lsof procps RUN apk add --no-cache lsof procps
# FFmpeg is bundled via npm package ffmpeg-static # FFmpeg is bundled via npm package ffmpeg-static
RUN yarn global add concurrently
RUN apk add --no-cache \ RUN apk add --no-cache \
chromium \ chromium \
@ -31,9 +30,6 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
RUN mkdir -p /app/pids RUN mkdir -p /app/pids
# Make sure to add yarn global bin to PATH
ENV PATH /root/.yarn/bin:/root/.config/yarn/global/node_modules/.bin:$PATH
# Copy dependencies # Copy dependencies
WORKDIR /app WORKDIR /app
COPY --from=frontend-deps /app/frontend /app/frontend COPY --from=frontend-deps /app/frontend /app/frontend
@ -63,8 +59,8 @@ ENV FRONT_PORT=3001
ENV BACKEND_PORT=3000 ENV BACKEND_PORT=3000
CMD ["sh", "-c", "\ CMD ["sh", "-c", "\
yarn --cwd /app/frontend dev & echo $! > /app/pids/frontend.pid && \ npm --prefix /app/frontend run dev & echo $! > /app/pids/frontend.pid && \
yarn --cwd /app/backend start & echo $! > /app/pids/backend.pid && \ npm --prefix /app/backend run start & echo $! > /app/pids/backend.pid && \
sleep 10 && nginx -g 'daemon off;' & \ sleep 10 && nginx -g 'daemon off;' & \
NGINX_PID=$! && \ NGINX_PID=$! && \
echo 'Waiting for backend (port 3000) to be available...' && \ echo 'Waiting for backend (port 3000) to be available...' && \

View File

@ -30,9 +30,9 @@ A web application for building and managing interactive virtual tours with drag-
### Prerequisites ### Prerequisites
- Node.js 18+ - Node.js 24 LTS for backend
- PostgreSQL 14+ - PostgreSQL 14+
- Yarn (backend) / npm (frontend) - npm for backend; frontend uses its existing package scripts
### Database Setup (First Time) ### Database Setup (First Time)
@ -46,11 +46,11 @@ PGPASSWORD='postgres' psql -U postgres -c "CREATE DATABASE app_39215 OWNER app_3
```bash ```bash
cd backend cd backend
yarn install npm install
npm run start-dev npm run start-dev
``` ```
Backend runs on **http://localhost:8080** Backend runs on **http://localhost:3000**
### Start Frontend (Terminal 2) ### Start Frontend (Terminal 2)
@ -60,7 +60,7 @@ npm install
npm run dev npm run dev
``` ```
Frontend runs on **http://localhost:3000** Frontend runs on **http://localhost:3001**
### Login ### Login
@ -155,7 +155,7 @@ Pages have an `environment` field (`dev`, `stage`, `production`) that determines
## API Overview ## API Overview
Base URL: `http://localhost:8080/api` Base URL: `http://localhost:3000/api`
| Endpoint | Description | | Endpoint | Description |
|----------|-------------| |----------|-------------|
@ -168,7 +168,7 @@ Base URL: `http://localhost:8080/api`
| `GET /assets` | List assets | | `GET /assets` | List assets |
| `POST /file/presign` | Get S3 presigned URLs for asset download (public) | | `POST /file/presign` | Get S3 presigned URLs for asset download (public) |
Full API documentation: `http://localhost:8080/api-docs` (Swagger) Full API documentation: `http://localhost:3000/api-docs` (Swagger)
## Docker Setup ## Docker Setup
@ -224,7 +224,7 @@ EMAIL_PASS=...
### Frontend (`frontend/.env.local`) ### Frontend (`frontend/.env.local`)
```env ```env
NEXT_PUBLIC_BACK_API=http://localhost:8080/api NEXT_PUBLIC_BACK_API=http://localhost:3000/api
``` ```
## Common Commands ## Common Commands
@ -233,11 +233,13 @@ NEXT_PUBLIC_BACK_API=http://localhost:8080/api
```bash ```bash
cd backend cd backend
yarn start # Start server (migrate + seed + watch) npm run start # Start server (migrate + seed + watch)
yarn db:migrate # Run migrations npm run db:migrate # Run migrations
yarn db:seed # Seed data npm run db:seed # Seed data
yarn db:reset # Drop + create + migrate + seed npm run db:reset # Drop + create + migrate + seed
yarn lint # ESLint npm run lint # ESLint
npm run typecheck # Strict TypeScript check for migrated backend scope
npm run build # Compile migrated TypeScript files
``` ```
### Frontend ### Frontend
@ -255,7 +257,7 @@ npm run format # Prettier
### Connection Refused ### Connection Refused
1. Ensure PostgreSQL is running 1. Ensure PostgreSQL is running
2. Check that port 5432 (db), 8080 (backend), 3000 (frontend) are available 2. Check that port 5432 (db), 3000 (backend), 3001 (frontend) are available
3. Verify database credentials in `.env` 3. Verify database credentials in `.env`
### Database Issues ### Database Issues
@ -263,7 +265,7 @@ npm run format # Prettier
```bash ```bash
# Reset database completely # Reset database completely
cd backend cd backend
yarn db:reset npm run db:reset
``` ```
### Permission Denied ### Permission Denied

7
backend/.dockerignore Normal file
View File

@ -0,0 +1,7 @@
node_modules
dist
tmp
logs
.env
.DS_Store
npm-debug.log*

View File

@ -1,4 +0,0 @@
# Ignore generated and runtime files
node_modules/
tmp/
logs/

View File

@ -1,16 +0,0 @@
module.exports = {
env: {
node: true,
es2021: true
},
extends: [
'eslint:recommended'
],
plugins: [
'import'
],
rules: {
'import/no-unresolved': 'error',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }]
}
};

View File

@ -1,7 +0,0 @@
const path = require('path');
module.exports = {
"config": path.resolve("src", "db", "db.config.js"),
"models-path": path.resolve("src", "db", "models"),
"seeders-path": path.resolve("src", "db", "seeders"),
"migrations-path": path.resolve("src", "db", "migrations")
};

View File

@ -1,25 +1,21 @@
FROM node:20.15.1-alpine FROM node:24-alpine
# Install bash and FFmpeg for video processing (reversed video generation) # Bash is required by docker/wait-for-it.sh when this image is used by docker-compose.
RUN apk update && apk add --no-cache bash ffmpeg # FFmpeg is bundled by ffmpeg-static/ffprobe-static npm packages.
RUN apk add --no-cache bash
# Create app directory # Create app directory
WORKDIR /usr/src/app WORKDIR /usr/src/app
# Install app dependencies # Install app dependencies
# A wildcard is used to ensure both package.json AND package-lock.json are copied
# where available (npm@5+)
COPY package*.json ./ COPY package*.json ./
RUN yarn install RUN npm ci
# If you are building your code for production
# RUN npm ci --only=production
# Bundle app source # Bundle app source
COPY . . COPY . .
EXPOSE 8080 EXPOSE 3000
CMD [ "yarn", "start" ] CMD [ "npm", "run", "start" ]

View File

@ -4,7 +4,7 @@ Node.js/Express REST API server with Sequelize ORM for the Tour Builder Platform
## Tech Stack ## Tech Stack
- **Runtime**: Node.js 18+ - **Runtime**: Node.js 24 LTS
- **Framework**: Express 4.x - **Framework**: Express 4.x
- **Database**: PostgreSQL with Sequelize ORM - **Database**: PostgreSQL with Sequelize ORM
- **Authentication**: Passport.js (JWT, Google OAuth, Microsoft OAuth) - **Authentication**: Passport.js (JWT, Google OAuth, Microsoft OAuth)
@ -14,35 +14,39 @@ Node.js/Express REST API server with Sequelize ORM for the Tour Builder Platform
## Prerequisites ## Prerequisites
- Node.js 18+ - Node.js 24 LTS
- PostgreSQL 14+ - PostgreSQL 14+
- Yarn package manager - npm package manager
## Quick Start ## Quick Start
```bash ```bash
# Install dependencies # Install dependencies
yarn install npm install
# Create database (first time only) # Create database (first time only)
yarn db:create npm run db:create
# Start server (runs migrations, seeds, and watches for changes) # Start server (runs migrations, seeds, and watches for changes)
npm run start-dev npm run start-dev
``` ```
The server runs on **port 8080** by default. The server runs on **port 3000** by default.
## Checks ## Checks
```bash ```bash
npm run lint npm run lint
npm run typecheck
npm run build
npm run test npm run test
npm run test:integration npm run test:integration
npm run check:public-access npm run check:public-access
``` ```
- `npm run test` runs fast unit tests without requiring PostgreSQL. - `npm run test` runs fast unit tests without requiring PostgreSQL.
- `npm run typecheck` checks the migrated TypeScript scope with `strict: true`.
- `npm run build` compiles the migrated TypeScript scope into `dist/`.
- `npm run test:integration` runs rollback-based PostgreSQL integration tests - `npm run test:integration` runs rollback-based PostgreSQL integration tests
when a valid database configuration is available; otherwise the tests skip. when a valid database configuration is available; otherwise the tests skip.
- `npm run check:public-access` audits stale Public role/user permissions and - `npm run check:public-access` audits stale Public role/user permissions and
@ -88,63 +92,65 @@ MS_CLIENT_SECRET=your-client-secret
EMAIL_USER=ses-smtp-user EMAIL_USER=ses-smtp-user
EMAIL_PASS=ses-smtp-password EMAIL_PASS=ses-smtp-password
# OpenAI (optional)
GPT_KEY=your-openai-key
``` ```
## Project Structure ## Project Structure
``` ```
backend/src/ backend/src/
├── index.js # Express app entry point ├── index.ts # Express app entry point
├── config.js # Environment configuration ├── config.ts # Environment configuration
├── helpers.js # Utility functions (wrapAsync) ├── load-env.ts # Central .env bootstrap for app and DB entrypoints
├── helpers.ts # Utility functions (wrapAsync)
├── types/ # Shared TypeScript contracts
├── auth/ # Passport.js authentication strategies ├── auth/ # Passport.js authentication strategies
│ └── auth.js # JWT, Google, Microsoft strategies │ └── auth.ts # JWT, Google, Microsoft strategies
├── db/ ├── db/
│ ├── db.config.js # Database connection config (per environment) │ ├── db-config.ts # Typed database connection config
│ ├── models/ # Sequelize model definitions (16 models) │ ├── umzug.ts # Typed Umzug runner for migrations and seeders
│ ├── api/ # Database access layer (CRUD per model) │ ├── models/ # Sequelize model definitions
│ ├── migrations/ # Database migrations │ ├── api/ # Database access layer
│ └── seeders/ # Seed data (admin users, permissions, roles) │ ├── migrations/ # Applied migration history; do not rewrite
│ └── seeders/ # Typed seed data files
├── routes/ # Express route handlers (22 routes) ├── routes/ # Express route handlers
│ ├── auth.js # Authentication endpoints │ ├── auth.ts # Authentication endpoints
│ ├── projects.js # Project CRUD │ ├── projects.ts # Project CRUD
│ ├── tour_pages.js # Tour page management │ ├── tour_pages.ts # Tour page management
│ ├── assets.js # Asset management │ ├── assets.ts # Asset management
│ ├── file.js # File upload/download, presigned URLs │ ├── file.ts # File upload/download, presigned URLs
│ ├── publish.js # Publishing workflow │ ├── publish.ts # Publishing workflow
│ ├── search.js # Global search │ ├── search.ts # Global search
│ └── ... # Other entity routes │ └── ... # Other entity routes
├── services/ # Business logic layer (21 services) ├── services/ # Business logic layer
│ ├── auth.js # Auth service (JWT, OAuth) │ ├── auth.ts # Auth service (JWT, OAuth)
│ ├── publish.js # Publishing workflow logic │ ├── publish.ts # Publishing workflow logic
│ ├── file.js # File storage abstraction │ ├── file.ts # File storage abstraction
│ ├── search.js # Global search service │ ├── search.ts # Global search service
│ ├── email/ # Email templates and sending │ ├── email/ # Email templates and sending
│ ├── notifications/ # Error classes and i18n messages │ ├── notifications/ # Error classes and i18n messages
│ └── ... # Other entity services │ └── ... # Other entity services
├── middlewares/ ├── middlewares/
│ ├── check-permissions.js # RBAC permission checking │ ├── check-permissions.ts # RBAC permission checking
│ ├── runtime-context.js # Environment detection from headers │ ├── runtime-context.ts # Environment detection from headers
│ ├── runtime-public.js # Public runtime access (no auth) │ ├── runtime-public.ts # Public runtime access (no auth)
│ ├── upload.js # File upload handling (multer) │ ├── upload.ts # File upload handling (multer)
│ └── rateLimiter.js # Rate limiting for API endpoints │ └── rateLimiter.ts # Rate limiting for API endpoints
├── factories/ ├── factories/
│ ├── router.factory.js # Generate CRUD routes │ ├── router.factory.ts # Generate CRUD routes
│ └── service.factory.js # Generate service classes │ └── service.factory.ts # Generate service classes
└── utils/ └── utils/
├── env-validation.js # Environment variable validation (Joi) ├── env-validation.ts # Environment variable validation (Joi)
├── errors.js # Custom error classes ├── errors.ts # Custom error classes
├── logger.js # Pino logger configuration ├── logger.ts # Pino logger configuration
└── index.js # Utils barrel export ├── request-context.ts # Request-scoped currentUser/runtime/log storage
└── index.ts # Utils barrel export
``` ```
## Database Setup ## Database Setup
@ -169,22 +175,24 @@ GRANT ALL PRIVILEGES ON DATABASE app_39215 TO app_39215;
### Available Commands ### Available Commands
```bash ```bash
yarn db:create # Create database npm run db:create # Create database
yarn db:drop # Drop database npm run db:drop # Drop database
yarn db:migrate # Run pending migrations npm run db:migrate # Run pending migrations
yarn db:migrate:undo # Undo last migration npm run db:migrate:undo # Undo last migration
yarn db:migrate:undo:all # Undo all migrations npm run db:migrate:undo:all # Undo all migrations
yarn db:migrate:status # Show migration status npm run db:migrate:status # Show migration status
yarn db:seed # Run all seeders npm run db:seed # Run all seeders
yarn db:seed:undo # Undo all seeders npm run db:seed:undo # Undo all seeders
yarn db:reset # Drop, create, migrate, and seed npm run db:reset # Drop, create, migrate, and seed
yarn start # Migrate, seed, and start with watch npm run start # Migrate, seed, and start with watch
yarn lint # Run ESLint npm run lint # Run ESLint
npm run typecheck # Run strict TypeScript check
npm run build # Compile migrated TypeScript files
``` ```
## API Documentation ## API Documentation
Swagger UI available at: `http://localhost:8080/api-docs` Swagger UI available at: `http://localhost:3000/api-docs`
### Core Endpoints ### Core Endpoints
@ -363,7 +371,7 @@ Separate from server environment, tour pages have a content environment field:
| `stage` | Stage preview | Pre-production review | | `stage` | Stage preview | Pre-production review |
| `production` | Public runtime | Published content | | `production` | Public runtime | Published content |
The `X-Runtime-Environment` header (set by frontend) determines which content environment to query. The `runtime-context.js` middleware resolves this for API requests. The `X-Runtime-Environment` header (set by frontend) determines which content environment to query. The `runtime-context.ts` middleware resolves this for API requests.
## Docker ## Docker

99
backend/eslint.config.js Normal file
View File

@ -0,0 +1,99 @@
import js from '@eslint/js';
import tsParser from '@typescript-eslint/parser';
import tsPlugin from '@typescript-eslint/eslint-plugin';
import importPlugin from 'eslint-plugin-import';
import globals from 'globals';
const typeCheckedConfigs = tsPlugin.configs['flat/recommended-type-checked'];
export default [
{
ignores: ['node_modules/**', 'dist/**', 'tmp/**', 'logs/**'],
},
js.configs.recommended,
{
files: ['**/*.js'],
languageOptions: {
ecmaVersion: 2024,
sourceType: 'module',
globals: globals.node,
},
plugins: {
import: importPlugin,
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.ts'],
},
typescript: {
project: './tsconfig.json',
},
},
},
rules: {
'import/no-unresolved': 'error',
'no-unused-vars': ['error', { argsIgnorePattern: '^_' }],
},
},
...typeCheckedConfigs.map((config) => ({
...config,
files: ['**/*.ts'],
})),
{
files: ['**/*.ts'],
languageOptions: {
parser: tsParser,
parserOptions: {
project: './tsconfig.json',
tsconfigRootDir: import.meta.dirname,
sourceType: 'module',
},
globals: globals.node,
},
plugins: {
'@typescript-eslint': tsPlugin,
import: importPlugin,
},
settings: {
'import/resolver': {
node: {
extensions: ['.js', '.ts'],
},
typescript: {
project: './tsconfig.json',
},
},
},
rules: {
'import/no-unresolved': 'error',
'no-unused-vars': 'off',
'@typescript-eslint/no-unused-vars': [
'error',
{ argsIgnorePattern: '^_', varsIgnorePattern: '^_' },
],
'@typescript-eslint/consistent-type-imports': [
'error',
{ prefer: 'type-imports' },
],
'@typescript-eslint/no-explicit-any': 'error',
'@typescript-eslint/no-non-null-assertion': 'error',
'@typescript-eslint/no-require-imports': 'error',
'@typescript-eslint/no-unsafe-type-assertion': 'error',
'@typescript-eslint/no-unnecessary-type-assertion': 'error',
'no-restricted-syntax': [
'error',
{
selector: 'TSAsExpression',
message:
'Do not use type casts in migrated backend TypeScript; use validation, guards, or typed adapters instead.',
},
{
selector: 'TSTypeAssertion',
message:
'Do not use type casts in migrated backend TypeScript; use validation, guards, or typed adapters instead.',
},
],
},
},
];

10244
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,76 +1,110 @@
{ {
"name": "tourbuilderplatform", "name": "tourbuilderplatform",
"description": "Tour Builder Platform - template backend", "description": "Tour Builder Platform - template backend",
"type": "module",
"scripts": { "scripts": {
"start": "npm run db:migrate && npm run db:seed && npm run watch", "start": "npm run db:migrate && npm run db:seed && npm run watch",
"start-dev": "cross-env NODE_ENV=production LOG_PRETTY=true DOTENV_CONFIG_PATH=.env NODE_OPTIONS=\"-r dotenv/config\" npm run start", "start-dev": "LOG_PRETTY=true npm run start",
"test": "node --test tests/*.test.js", "typecheck": "tsc -p tsconfig.json --noEmit",
"test:integration": "node --test tests/integration/*.test.js", "build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.ts",
"check:public-access": "node scripts/check-public-access-hardening.js", "test": "node --test tests/*.test.ts",
"fix:public-access": "node scripts/check-public-access-hardening.js --fix", "test:integration": "node --test tests/integration/*.test.ts",
"lint": "eslint . --ext .js", "verify": "npm run typecheck && npm run lint && npm run check:esm-boundaries && npm run test",
"db:migrate": "sequelize-cli db:migrate", "check:esm-boundaries": "node scripts/check-esm-boundaries.ts",
"db:migrate:undo": "sequelize-cli db:migrate:undo", "check:public-access": "node scripts/check-public-access-hardening.ts",
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all", "fix:public-access": "node scripts/check-public-access-hardening.ts --fix",
"db:migrate:status": "sequelize-cli db:migrate:status", "lint": "eslint .",
"db:seed": "sequelize-cli db:seed:all", "db:migrate": "node src/db/umzug.ts migrate:up",
"db:seed:undo": "sequelize-cli db:seed:undo:all", "db:migrate:undo": "node src/db/umzug.ts migrate:down",
"db:drop": "sequelize-cli db:drop", "db:migrate:undo:all": "node src/db/umzug.ts migrate:down:all",
"db:create": "sequelize-cli db:create", "db:migrate:status": "node src/db/umzug.ts migrate:status",
"db:seed": "node src/db/umzug.ts seed:up",
"db:seed:undo": "node src/db/umzug.ts seed:down:all",
"db:drop": "node src/db/umzug.ts db:drop",
"db:create": "node src/db/umzug.ts db:create",
"db:reset": "npm run db:drop && npm run db:create && npm run db:migrate && npm run db:seed", "db:reset": "npm run db:drop && npm run db:create && npm run db:migrate && npm run db:seed",
"watch": "node watcher.js" "watch": "node watcher.ts"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.1011.0", "@aws-sdk/client-s3": "^3.1011.0",
"@aws-sdk/s3-request-presigner": "^3.1016.0", "@aws-sdk/s3-request-presigner": "^3.1016.0",
"@google-cloud/storage": "^7.0.0", "@google-cloud/storage": "^7.0.0",
"axios": "^1.13.0", "@smithy/node-http-handler": "^4.9.1",
"@smithy/types": "^4.15.0",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
"body-parser": "^2.3.0",
"chokidar": "^4.0.3", "chokidar": "^4.0.3",
"cors": "^2.8.6", "cors": "^2.8.6",
"csv-parser": "^3.2.0", "csv-parser": "^3.2.0",
"dotenv": "^16.4.0", "dotenv": "^16.4.0",
"express": "4.18.2", "express": "^4.22.2",
"express-validator": "^7.0.0",
"ffmpeg-static": "^5.2.0", "ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0", "ffprobe-static": "^3.1.0",
"fluent-ffmpeg": "^2.1.3", "fluent-ffmpeg": "^2.1.3",
"formidable": "1.2.2",
"helmet": "^8.0.0", "helmet": "^8.0.0",
"joi": "^17.13.0", "joi": "^17.13.0",
"json2csv": "^5.0.7", "json2csv": "^5.0.7",
"jsonwebtoken": "^9.0.0", "jsonwebtoken": "^9.0.0",
"lodash": "^4.17.23",
"moment": "2.30.1",
"multer": "^2.0.0", "multer": "^2.0.0",
"mysql2": "2.2.5", "nodemailer": "^9.0.3",
"nodemailer": "6.9.9",
"passport": "^0.7.0", "passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0", "passport-google-oauth2": "^0.2.0",
"passport-jwt": "^4.0.1", "passport-jwt": "^4.0.1",
"passport-microsoft": "^2.0.0", "passport-microsoft": "^2.0.0",
"pg": "^8.20.0", "pg": "^8.20.0",
"pg-hstore": "2.3.4",
"pino": "^9.0.0", "pino": "^9.0.0",
"pino-pretty": "^11.0.0", "pino-pretty": "^11.0.0",
"sequelize": "^6.37.0", "sequelize": "^6.37.0",
"sequelize-json-schema": "^2.1.1",
"sqlite": "4.0.15",
"swagger-jsdoc": "^6.2.8", "swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0", "swagger-ui-express": "^5.0.0",
"tedious": "^18.6.0" "uuid": "^14.0.1",
"validator": "^13.15.35"
}, },
"engines": { "engines": {
"node": ">=18" "node": ">=24 <25"
},
"overrides": {
"gaxios": {
"uuid": "^11.1.1"
},
"sequelize": {
"uuid": "^11.1.1"
},
"teeny-request": {
"uuid": "^11.1.1"
}
}, },
"private": true, "private": true,
"devDependencies": { "devDependencies": {
"cross-env": "^7.0.3", "@eslint/js": "^8.57.1",
"@types/bcrypt": "^6.0.0",
"@types/body-parser": "^1.19.6",
"@types/cors": "^2.8.19",
"@types/express": "^4.17.25",
"@types/express-serve-static-core": "^4.19.8",
"@types/ffprobe-static": "^2.0.3",
"@types/fluent-ffmpeg": "^2.1.28",
"@types/json2csv": "^5.0.7",
"@types/jsonwebtoken": "^9.0.10",
"@types/multer": "^2.1.0",
"@types/node": "^24.13.2",
"@types/nodemailer": "^8.0.1",
"@types/passport": "^1.0.17",
"@types/passport-google-oauth2": "^0.1.10",
"@types/passport-jwt": "^4.0.1",
"@types/passport-microsoft": "^2.1.1",
"@types/swagger-jsdoc": "^6.0.4",
"@types/swagger-ui-express": "^4.1.8",
"@types/validator": "^13.15.10",
"@typescript-eslint/eslint-plugin": "^8.62.1",
"@typescript-eslint/parser": "^8.62.1",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"eslint-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.29.1", "eslint-plugin-import": "^2.29.1",
"mocha": "^10.0.0", "globals": "^15.15.0",
"node-mocks-http": "^1.17.0", "node-mocks-http": "^1.17.0",
"nodemon": "^3.0.0", "nodemon": "^3.0.0",
"sequelize-cli": "^6.6.5" "typescript": "^6.0.3",
"umzug": "^3.8.3"
} }
} }

View File

@ -0,0 +1,90 @@
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
interface BoundaryViolation {
file: string;
reason: string;
}
const sourceRoots = ['src', 'scripts', 'tests'];
const commonJsPattern = new RegExp(
String.raw`\b(${['require\\s*\\(', 'module\\.exports', 'exports\\.'].join('|')})`,
);
async function collectFiles(dir: string): Promise<string[]> {
const entries = await readdir(dir, { withFileTypes: true });
const nestedFiles = await Promise.all(
entries.map(async (entry) => {
const fullPath = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (entry.name === 'node_modules' || entry.name === 'dist') return [];
return collectFiles(fullPath);
}
return [fullPath];
}),
);
return nestedFiles.flat();
}
function toProjectPath(filePath: string): string {
return path.relative(process.cwd(), filePath).split(path.sep).join('/');
}
function isHistoricalMigration(projectPath: string): boolean {
return projectPath.startsWith('src/db/migrations/') && projectPath.endsWith('.js');
}
function checkJsBoundary(projectPath: string): BoundaryViolation | null {
if (isHistoricalMigration(projectPath)) return null;
return {
file: projectPath,
reason: 'Unexpected JavaScript source. Use TypeScript ESM source.',
};
}
function checkTsBoundary(projectPath: string, source: string): BoundaryViolation | null {
if (!commonJsPattern.test(source)) return null;
return {
file: projectPath,
reason:
'Unexpected CommonJS syntax in TypeScript source. Use ESM import/export.',
};
}
async function checkFile(filePath: string): Promise<BoundaryViolation | null> {
const projectPath = toProjectPath(filePath);
const ext = path.extname(filePath);
if (ext !== '.js' && ext !== '.ts') return null;
const source = await readFile(filePath, 'utf8');
if (ext === '.js') {
return checkJsBoundary(projectPath);
}
return checkTsBoundary(projectPath, source);
}
async function main(): Promise<void> {
const files = (
await Promise.all(sourceRoots.map((root) => collectFiles(path.join(process.cwd(), root))))
).flat();
const checks = await Promise.all(files.map((file) => checkFile(file)));
const violations = checks.filter((violation) => violation !== null);
if (violations.length > 0) {
console.error('ESM boundary check failed:');
for (const violation of violations) {
console.error(`- ${violation.file}: ${violation.reason}`);
}
process.exitCode = 1;
return;
}
console.log('ESM boundary check passed.');
}
void main();

View File

@ -1,12 +1,18 @@
#!/usr/bin/env node #!/usr/bin/env node
const db = require('../src/db/models'); import db from '../src/db/models/index.ts';
const AccessPolicyAuditService = require('../src/services/access-policy-audit'); import AccessPolicyAuditService from '../src/services/access-policy-audit.ts';
import type {
AccessPolicyAuditReport,
PublicAccessHardeningSummary,
} from '../src/types/index.ts';
const shouldFix = process.argv.includes('--fix'); const shouldFix = process.argv.includes('--fix');
const EXIT_TIMEOUT_MS = 1500; const EXIT_TIMEOUT_MS = 1500;
function summarizeReport(report) { function summarizeReport(
report: AccessPolicyAuditReport,
): PublicAccessHardeningSummary {
return { return {
publicRolePermissions: report.publicRolePermissions.length, publicRolePermissions: report.publicRolePermissions.length,
publicUsersWithCustomPermissions: publicUsersWithCustomPermissions:
@ -16,45 +22,41 @@ function summarizeReport(report) {
}; };
} }
async function main() { function logJson(value: unknown): void {
console.log(JSON.stringify(value, null, 2));
}
function logError(error: unknown): void {
console.error(error);
}
async function main(): Promise<void> {
if (shouldFix) { if (shouldFix) {
const result = await db.sequelize.transaction((transaction) => const result = await db.sequelize.transaction((transaction) =>
AccessPolicyAuditService.cleanupViolations({ transaction }), AccessPolicyAuditService.cleanupViolations({ transaction }),
); );
console.log( logJson({
JSON.stringify( fixed: true,
{ summary: {
fixed: true, removedPublicRolePermissions: result.removedPublicRolePermissions,
summary: { clearedPublicUserCustomPermissions:
removedPublicRolePermissions: result.removedPublicRolePermissions, result.clearedPublicUserCustomPermissions,
clearedPublicUserCustomPermissions: removedNonPublicProductionPresentationGrants:
result.clearedPublicUserCustomPermissions, result.removedNonPublicProductionPresentationGrants,
removedNonPublicProductionPresentationGrants: },
result.removedNonPublicProductionPresentationGrants, });
},
},
null,
2,
),
);
return; return;
} }
const report = await AccessPolicyAuditService.findViolations(); const report = await AccessPolicyAuditService.findViolations();
const hasViolations = AccessPolicyAuditService.hasViolations(report); const hasViolations = AccessPolicyAuditService.hasViolations(report);
console.log( logJson({
JSON.stringify( ok: !hasViolations,
{ summary: summarizeReport(report),
ok: !hasViolations, report,
summary: summarizeReport(report), });
report,
},
null,
2,
),
);
if (hasViolations) { if (hasViolations) {
process.exitCode = 1; process.exitCode = 1;
@ -63,7 +65,7 @@ async function main() {
main() main()
.catch((error) => { .catch((error) => {
console.error(error); logError(error);
process.exitCode = 1; process.exitCode = 1;
}) })
.finally(async () => { .finally(async () => {

View File

@ -0,0 +1,45 @@
import { copyFile, mkdir, readdir } from 'node:fs/promises';
import path from 'node:path';
interface RuntimeAssetCopy {
source: string;
target: string;
}
const runtimeAssetCopies: readonly RuntimeAssetCopy[] = [
{
source: 'src/db/migrations/package.json',
target: 'dist/src/db/migrations/package.json',
},
];
async function copyRuntimeAsset(copy: RuntimeAssetCopy): Promise<void> {
await mkdir(path.dirname(copy.target), { recursive: true });
await copyFile(copy.source, copy.target);
}
async function copyMigrationFiles(): Promise<void> {
const sourceDirectory = 'src/db/migrations';
const targetDirectory = 'dist/src/db/migrations';
const entries = await readdir(sourceDirectory, { withFileTypes: true });
await mkdir(targetDirectory, { recursive: true });
await Promise.all(
entries
.filter((entry) => entry.isFile() && entry.name.endsWith('.js'))
.map((entry) =>
copyFile(
path.join(sourceDirectory, entry.name),
path.join(targetDirectory, entry.name),
),
),
);
}
async function main(): Promise<void> {
await Promise.all(runtimeAssetCopies.map((copy) => copyRuntimeAsset(copy)));
await copyMigrationFiles();
}
void main();

View File

@ -1,512 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const { URL } = require('url');
let CONFIG_CACHE = null;
class LocalAIApi {
static createResponse(params, options) {
return createResponse(params, options);
}
static request(pathValue, payload, options) {
return request(pathValue, payload, options);
}
static fetchStatus(aiRequestId, options) {
return fetchStatus(aiRequestId, options);
}
static awaitResponse(aiRequestId, options) {
return awaitResponse(aiRequestId, options);
}
static extractText(response) {
return extractText(response);
}
static decodeJsonFromResponse(response) {
return decodeJsonFromResponse(response);
}
}
async function createResponse(params, options = {}) {
const payload = { ...(params || {}) };
if (!Array.isArray(payload.input) || payload.input.length === 0) {
return {
success: false,
error: 'input_missing',
message: 'Parameter "input" is required and must be a non-empty array.',
};
}
const cfg = config();
if (!payload.model) {
payload.model = cfg.defaultModel;
}
const initial = await request(options.path, payload, options);
if (!initial.success) {
return initial;
}
const data = initial.data;
if (data && typeof data === 'object' && data.ai_request_id) {
const pollTimeout = Number(options.poll_timeout ?? 300);
const pollInterval = Number(options.poll_interval ?? 5);
return await awaitResponse(data.ai_request_id, {
interval: pollInterval,
timeout: pollTimeout,
headers: options.headers,
timeout_per_call: options.timeout,
verify_tls: options.verify_tls,
});
}
return initial;
}
async function request(pathValue, payload = {}, options = {}) {
const cfg = config();
const resolvedPath = pathValue || options.path || cfg.responsesPath;
if (!resolvedPath) {
return {
success: false,
error: 'project_id_missing',
message: 'PROJECT_ID is not defined; cannot resolve AI proxy endpoint.',
};
}
if (!cfg.projectUuid) {
return {
success: false,
error: 'project_uuid_missing',
message: 'PROJECT_UUID is not defined; aborting AI request.',
};
}
const bodyPayload = { ...(payload || {}) };
if (!bodyPayload.project_uuid) {
bodyPayload.project_uuid = cfg.projectUuid;
}
const url = buildUrl(resolvedPath, cfg.baseUrl);
const timeout = resolveTimeout(options.timeout, cfg.timeout);
const verifyTls = resolveVerifyTls(options.verify_tls, cfg.verifyTls);
const headers = {
Accept: 'application/json',
'Content-Type': 'application/json',
[cfg.projectHeader]: cfg.projectUuid,
};
if (Array.isArray(options.headers)) {
for (const header of options.headers) {
if (typeof header === 'string' && header.includes(':')) {
const [name, value] = header.split(':', 2);
headers[name.trim()] = value.trim();
}
}
}
const body = JSON.stringify(bodyPayload);
return sendRequest(url, 'POST', body, headers, timeout, verifyTls);
}
async function fetchStatus(aiRequestId, options = {}) {
const cfg = config();
if (!cfg.projectUuid) {
return {
success: false,
error: 'project_uuid_missing',
message: 'PROJECT_UUID is not defined; aborting status check.',
};
}
const statusPath = resolveStatusPath(aiRequestId, cfg);
const url = buildUrl(statusPath, cfg.baseUrl);
const timeout = resolveTimeout(options.timeout, cfg.timeout);
const verifyTls = resolveVerifyTls(options.verify_tls, cfg.verifyTls);
const headers = {
Accept: 'application/json',
[cfg.projectHeader]: cfg.projectUuid,
};
if (Array.isArray(options.headers)) {
for (const header of options.headers) {
if (typeof header === 'string' && header.includes(':')) {
const [name, value] = header.split(':', 2);
headers[name.trim()] = value.trim();
}
}
}
return sendRequest(url, 'GET', null, headers, timeout, verifyTls);
}
async function awaitResponse(aiRequestId, options = {}) {
const timeout = Number(options.timeout ?? 300);
const interval = Math.max(Number(options.interval ?? 5), 1);
const deadline = Date.now() + Math.max(timeout, interval) * 1000;
let isPending = true;
while (isPending) {
const statusResp = await fetchStatus(aiRequestId, {
headers: options.headers,
timeout: options.timeout_per_call,
verify_tls: options.verify_tls,
});
if (statusResp.success) {
const data = statusResp.data || {};
if (data && typeof data === 'object') {
if (data.status === 'success') {
isPending = false;
return {
success: true,
status: 200,
data: data.response || data,
};
}
if (data.status === 'failed') {
isPending = false;
return {
success: false,
status: 500,
error: String(data.error || 'AI request failed'),
data,
};
}
}
} else {
return statusResp;
}
if (Date.now() >= deadline) {
return {
success: false,
error: 'timeout',
message: 'Timed out waiting for AI response.',
};
}
await sleep(interval * 1000);
}
}
function extractText(response) {
const payload =
response && typeof response === 'object' ? response.data || response : null;
if (!payload || typeof payload !== 'object') {
return '';
}
if (Array.isArray(payload.output)) {
let combined = '';
for (const item of payload.output) {
if (!item || !Array.isArray(item.content)) {
continue;
}
for (const block of item.content) {
if (
block &&
typeof block === 'object' &&
block.type === 'output_text' &&
typeof block.text === 'string' &&
block.text.length > 0
) {
combined += block.text;
}
}
}
if (combined) {
return combined;
}
}
if (
payload.choices &&
payload.choices[0] &&
payload.choices[0].message &&
typeof payload.choices[0].message.content === 'string'
) {
return payload.choices[0].message.content;
}
return '';
}
function decodeJsonFromResponse(response) {
const text = extractText(response);
if (!text) {
throw new Error('No text found in AI response.');
}
const parsed = parseJson(text);
if (parsed.ok && parsed.value && typeof parsed.value === 'object') {
return parsed.value;
}
const stripped = stripJsonFence(text);
if (stripped !== text) {
const parsedStripped = parseJson(stripped);
if (
parsedStripped.ok &&
parsedStripped.value &&
typeof parsedStripped.value === 'object'
) {
return parsedStripped.value;
}
throw new Error(
`JSON parse failed after stripping fences: ${parsedStripped.error}`,
);
}
throw new Error(`JSON parse failed: ${parsed.error}`);
}
function config() {
if (CONFIG_CACHE) {
return CONFIG_CACHE;
}
ensureEnvLoaded();
const baseUrl = process.env.AI_PROXY_BASE_URL || 'https://flatlogic.com';
const projectId = process.env.PROJECT_ID || null;
let responsesPath = process.env.AI_RESPONSES_PATH || null;
if (!responsesPath && projectId) {
responsesPath = `/projects/${projectId}/ai-request`;
}
const timeout = resolveTimeout(process.env.AI_TIMEOUT, 30);
const verifyTls = resolveVerifyTls(process.env.AI_VERIFY_TLS, true);
CONFIG_CACHE = {
baseUrl,
responsesPath,
projectId,
projectUuid: process.env.PROJECT_UUID || null,
projectHeader: process.env.AI_PROJECT_HEADER || 'project-uuid',
defaultModel: process.env.AI_DEFAULT_MODEL || 'gpt-5-mini',
timeout,
verifyTls,
};
return CONFIG_CACHE;
}
function buildUrl(pathValue, baseUrl) {
const trimmed = String(pathValue || '').trim();
if (trimmed === '') {
return baseUrl;
}
if (trimmed.startsWith('http://') || trimmed.startsWith('https://')) {
return trimmed;
}
if (trimmed.startsWith('/')) {
return `${baseUrl}${trimmed}`;
}
return `${baseUrl}/${trimmed}`;
}
function resolveStatusPath(aiRequestId, cfg) {
const basePath = (cfg.responsesPath || '').replace(/\/+$/, '');
if (!basePath) {
return `/ai-request/${encodeURIComponent(String(aiRequestId))}/status`;
}
const normalized = basePath.endsWith('/ai-request')
? basePath
: `${basePath}/ai-request`;
return `${normalized}/${encodeURIComponent(String(aiRequestId))}/status`;
}
function sendRequest(
urlString,
method,
body,
headers,
timeoutSeconds,
verifyTls,
) {
return new Promise((resolve) => {
let targetUrl;
try {
targetUrl = new URL(urlString);
} catch (err) {
resolve({
success: false,
error: 'invalid_url',
message: err.message,
});
return;
}
const isHttps = targetUrl.protocol === 'https:';
const requestFn = isHttps ? https.request : http.request;
const options = {
protocol: targetUrl.protocol,
hostname: targetUrl.hostname,
port: targetUrl.port || (isHttps ? 443 : 80),
path: `${targetUrl.pathname}${targetUrl.search}`,
method: method.toUpperCase(),
headers,
timeout: Math.max(Number(timeoutSeconds || 30), 1) * 1000,
};
if (isHttps) {
options.rejectUnauthorized = Boolean(verifyTls);
}
const req = requestFn(options, (res) => {
let responseBody = '';
res.setEncoding('utf8');
res.on('data', (chunk) => {
responseBody += chunk;
});
res.on('end', () => {
const status = res.statusCode || 0;
const parsed = parseJson(responseBody);
const payload = parsed.ok ? parsed.value : responseBody;
if (status >= 200 && status < 300) {
const result = {
success: true,
status,
data: payload,
};
if (!parsed.ok) {
result.json_error = parsed.error;
}
resolve(result);
return;
}
const errorMessage =
parsed.ok && payload && typeof payload === 'object'
? String(
payload.error || payload.message || 'AI proxy request failed',
)
: String(responseBody || 'AI proxy request failed');
resolve({
success: false,
status,
error: errorMessage,
response: payload,
json_error: parsed.ok ? undefined : parsed.error,
});
});
});
req.on('timeout', () => {
req.destroy(new Error('request_timeout'));
});
req.on('error', (err) => {
resolve({
success: false,
error: 'request_failed',
message: err.message,
});
});
if (body) {
req.write(body);
}
req.end();
});
}
function parseJson(value) {
if (typeof value !== 'string' || value.trim() === '') {
return { ok: false, error: 'empty_response' };
}
try {
return { ok: true, value: JSON.parse(value) };
} catch (err) {
return { ok: false, error: err.message };
}
}
function stripJsonFence(text) {
const trimmed = text.trim();
if (trimmed.startsWith('```json')) {
return trimmed
.replace(/^```json/, '')
.replace(/```$/, '')
.trim();
}
if (trimmed.startsWith('```')) {
return trimmed.replace(/^```/, '').replace(/```$/, '').trim();
}
return text;
}
function resolveTimeout(value, fallback) {
const parsed = Number.parseInt(String(value ?? fallback), 10);
return Number.isNaN(parsed) ? Number(fallback) : parsed;
}
function resolveVerifyTls(value, fallback) {
if (value === undefined || value === null) {
return Boolean(fallback);
}
return String(value).toLowerCase() !== 'false' && String(value) !== '0';
}
function ensureEnvLoaded() {
if (process.env.PROJECT_UUID && process.env.PROJECT_ID) {
return;
}
const envPath = path.resolve(__dirname, '../../../../.env');
if (!fs.existsSync(envPath)) {
return;
}
let content;
try {
content = fs.readFileSync(envPath, 'utf8');
} catch (err) {
throw new Error(`Failed to read executor .env: ${err.message}`);
}
for (const line of content.split(/\r?\n/)) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#') || !trimmed.includes('=')) {
continue;
}
const [rawKey, ...rest] = trimmed.split('=');
const key = rawKey.trim();
if (!key) {
continue;
}
const value = rest
.join('=')
.trim()
.replace(/^['"]|['"]$/g, '');
if (!process.env[key]) {
process.env[key] = value;
}
}
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
module.exports = {
LocalAIApi,
createResponse,
request,
fetchStatus,
awaitResponse,
extractText,
decodeJsonFromResponse,
};

View File

@ -1,80 +0,0 @@
const config = require('../config');
const providers = config.providers;
const helpers = require('../helpers');
const db = require('../db/models');
const passport = require('passport');
const JWTstrategy = require('passport-jwt').Strategy;
const ExtractJWT = require('passport-jwt').ExtractJwt;
const GoogleStrategy = require('passport-google-oauth2').Strategy;
const MicrosoftStrategy = require('passport-microsoft').Strategy;
const UsersDBApi = require('../db/api/users');
passport.use(
new JWTstrategy(
{
passReqToCallback: true,
secretOrKey: config.secret_key,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
},
async (req, token, done) => {
try {
// Use lightweight auth query - only loads essential fields + permissions
const user = await UsersDBApi.findByForAuth({
email: token.user.email,
});
if (user && user.disabled) {
return done(new Error(`User '${user.email}' is disabled`));
}
req.currentUser = user;
return done(null, user);
} catch (error) {
done(error);
}
},
),
);
passport.use(
new GoogleStrategy(
{
clientID: config.google.clientId,
clientSecret: config.google.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/google/callback',
passReqToCallback: true,
},
function (request, accessToken, refreshToken, profile, done) {
socialStrategy(profile.email, profile, providers.GOOGLE, done);
},
),
);
passport.use(
new MicrosoftStrategy(
{
clientID: config.microsoft.clientId,
clientSecret: config.microsoft.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
passReqToCallback: true,
},
function (request, accessToken, refreshToken, profile, done) {
const email = profile._json.mail || profile._json.userPrincipalName;
socialStrategy(email, profile, providers.MICROSOFT, done);
},
),
);
function socialStrategy(email, profile, provider, done) {
db.users.findOrCreate({ where: { email, provider } }).then(([user]) => {
const body = {
id: user.id,
email: user.email,
name: profile.displayName,
};
const token = helpers.jwtSign({ user: body });
return done(null, { token });
});
}

228
backend/src/auth/auth.ts Normal file
View File

@ -0,0 +1,228 @@
import type { Request } from 'express';
import passport from 'passport';
import { Strategy as JwtStrategy, ExtractJwt } from 'passport-jwt';
import type { VerifiedCallback } from 'passport-jwt';
import { Strategy as GoogleStrategy } from 'passport-google-oauth2';
import { Strategy as MicrosoftStrategy } from 'passport-microsoft';
import type { VerifyCallback as OAuthVerifyCallback } from 'passport-google-oauth2';
import config from '../config.ts';
import db from '../db/models/index.ts';
import UsersDBApi from '../db/api/users.ts';
import { jwtSign } from '../helpers.ts';
import { setCurrentUser, setSocialAuthToken } from '../utils/request-context.ts';
import type {
AuthTokenPayload,
CurrentUser,
SocialAuthUserRecord,
UserRecord,
} from '../types/index.ts';
interface JwtPayload {
user?: {
email?: string;
};
}
interface SocialProfile {
email?: string;
displayName?: string;
_json?: {
mail?: string;
userPrincipalName?: string;
};
}
type SocialDone = OAuthVerifyCallback;
function isJwtPayload(value: unknown): value is JwtPayload {
return (
value !== null &&
typeof value === 'object' &&
'user' in value &&
value.user !== null &&
typeof value.user === 'object'
);
}
function isSocialProfile(value: unknown): value is SocialProfile {
return value !== null && typeof value === 'object';
}
function getJwtEmail(token: unknown): string | undefined {
if (!isJwtPayload(token)) return undefined;
return typeof token.user?.email === 'string' ? token.user.email : undefined;
}
function getGoogleEmail(profile: unknown): string | undefined {
if (!isSocialProfile(profile)) return undefined;
return typeof profile.email === 'string' ? profile.email : undefined;
}
function getMicrosoftEmail(profile: unknown): string | undefined {
if (!isSocialProfile(profile)) return undefined;
const json = profile._json;
return json?.mail || json?.userPrincipalName;
}
function getDisplayName(profile: SocialProfile): string | undefined {
return typeof profile.displayName === 'string'
? profile.displayName
: undefined;
}
function toCurrentUser(user: UserRecord): CurrentUser {
const currentUser: CurrentUser = {
id: user.id,
};
if (user.email !== null) {
currentUser.email = user.email;
}
if (user.firstName !== null && user.firstName !== undefined) {
currentUser.firstName = user.firstName;
}
if (user.lastName !== null && user.lastName !== undefined) {
currentUser.lastName = user.lastName;
}
if (user.app_role !== undefined) {
currentUser.app_role = user.app_role;
}
if (user.custom_permissions !== undefined) {
currentUser.custom_permissions = user.custom_permissions;
}
if (user.app_role_permissions !== undefined) {
currentUser.app_role_permissions = user.app_role_permissions;
}
return currentUser;
}
async function socialStrategy(
req: Request,
email: string | undefined,
profile: unknown,
provider: string,
done: SocialDone,
): Promise<void> {
if (!email || !isSocialProfile(profile)) {
done(new Error('Social profile email is missing'));
return;
}
try {
const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate({
where: { email, provider },
});
const body: AuthTokenPayload['user'] = {
id: user.id,
email: user.email,
};
const token = jwtSign({
user: {
...body,
name: getDisplayName(profile),
},
});
setSocialAuthToken(req, token);
done(null, {});
} catch (error) {
done(error);
}
}
async function verifyJwt(
req: Request,
token: unknown,
done: VerifiedCallback,
): Promise<void> {
try {
const email = getJwtEmail(token);
const user: UserRecord | null = email
? await UsersDBApi.findByForAuth({ email })
: null;
if (user && user.disabled) {
done(new Error(`User '${user.email}' is disabled`));
return;
}
if (user) {
setCurrentUser(req, toCurrentUser(user));
}
done(null, user);
} catch (error) {
done(error);
}
}
passport.use(
new JwtStrategy(
{
passReqToCallback: true,
secretOrKey: config.secret_key,
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
},
(
req: Request,
token: unknown,
done: VerifiedCallback,
) => {
void verifyJwt(req, token, done);
},
),
);
passport.use(
new GoogleStrategy(
{
clientID: config.google.clientId,
clientSecret: config.google.clientSecret,
callbackURL: `${config.apiUrl}/auth/signin/google/callback`,
passReqToCallback: true,
},
(
req: Request,
_accessToken: string,
_refreshToken: string,
profile: unknown,
done: SocialDone,
) => {
void socialStrategy(
req,
getGoogleEmail(profile),
profile,
config.providers.GOOGLE,
done,
);
},
),
);
passport.use(
new MicrosoftStrategy(
{
clientID: config.microsoft.clientId,
clientSecret: config.microsoft.clientSecret,
callbackURL: `${config.apiUrl}/auth/signin/microsoft/callback`,
passReqToCallback: true,
},
(
req: Request,
_accessToken: string,
_refreshToken: string,
profile: unknown,
done: SocialDone,
) => {
void socialStrategy(
req,
getMicrosoftEmail(profile),
profile,
config.providers.MICROSOFT,
done,
);
},
),
);

View File

@ -0,0 +1,56 @@
import type { RequestHandler } from 'express';
import passport from 'passport';
import type { AuthenticateOptions } from 'passport';
import type { CurrentUser } from '../types/index.ts';
type JwtAuthenticationUser = CurrentUser | false | null | undefined;
type JwtAuthenticationCallback = (
error: unknown,
user: JwtAuthenticationUser,
) => void | Promise<void>;
function isRequestHandler(value: unknown): value is RequestHandler {
return typeof value === 'function';
}
function authenticateJwt(): RequestHandler {
const middleware: unknown = passport.authenticate('jwt', { session: false });
if (!isRequestHandler(middleware)) {
throw new Error('Passport JWT authentication middleware is unavailable.');
}
return middleware;
}
function authenticateJwtWithCallback(
callback: JwtAuthenticationCallback,
): RequestHandler {
const middleware: unknown = passport.authenticate(
'jwt',
{ session: false },
callback,
);
if (!isRequestHandler(middleware)) {
throw new Error('Passport JWT authentication middleware is unavailable.');
}
return middleware;
}
function authenticatePassport(
strategy: string,
options: AuthenticateOptions,
): RequestHandler {
const middleware: unknown = passport.authenticate(strategy, options);
if (!isRequestHandler(middleware)) {
throw new Error(`Passport ${strategy} authentication middleware is unavailable.`);
}
return middleware;
}
export { authenticateJwt, authenticateJwtWithCallback, authenticatePassport };

View File

@ -1,102 +0,0 @@
const os = require('os');
const path = require('path');
require('dotenv').config({ path: path.resolve(__dirname, '../.env') });
const { validateEnv } = require('./utils/env-validation');
validateEnv();
const config = {
gcloud: {
bucket: 'fldemo-files',
hash: 'afeefb9d49f5b7977577876b99532ac7',
},
s3: {
bucket: process.env.AWS_S3_BUCKET || '',
region: process.env.AWS_S3_REGION || 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7',
// Timeout configuration (in milliseconds)
connectionTimeout:
parseInt(process.env.AWS_S3_CONNECTION_TIMEOUT, 10) || 5000,
requestTimeout: parseInt(process.env.AWS_S3_REQUEST_TIMEOUT, 10) || 30000,
// Retry configuration
maxAttempts: parseInt(process.env.AWS_S3_MAX_ATTEMPTS, 10) || 3,
// Connection pool configuration
maxSockets: parseInt(process.env.AWS_S3_MAX_SOCKETS, 10) || 50,
keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false',
// Presigned URL expiry (in seconds)
presignExpirySeconds:
parseInt(process.env.AWS_S3_PRESIGN_EXPIRY, 10) || 3600,
},
bcrypt: {
saltRounds: 12,
},
admin_pass: process.env.ADMIN_PASS || '88dbeaf8',
user_pass: process.env.USER_PASS || 'c3baadeda5c6',
admin_email: process.env.ADMIN_EMAIL || 'admin@flatlogic.com',
providers: {
LOCAL: 'local',
GOOGLE: 'google',
MICROSOFT: 'microsoft',
},
secret_key: process.env.SECRET_KEY || '88dbeaf8-e906-405e-9e41-c3baadeda5c6',
remote: '',
port: process.env.NODE_ENV === 'production' ? '' : '8080',
hostUI: process.env.NODE_ENV === 'production' ? '' : 'http://localhost',
portUI: process.env.NODE_ENV === 'production' ? '' : '3000',
portUIProd: process.env.NODE_ENV === 'production' ? '' : ':3000',
swaggerUI: process.env.NODE_ENV === 'production' ? '' : 'http://localhost',
swaggerPort: process.env.NODE_ENV === 'production' ? '' : ':8080',
google: {
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
},
microsoft: {
clientId: process.env.MS_CLIENT_ID || '',
clientSecret: process.env.MS_CLIENT_SECRET || '',
},
uploadDir: os.tmpdir(),
// Local cache for S3 proxy downloads (improves performance for repeated requests)
s3CacheDir: process.env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'),
s3CacheEnabled: process.env.S3_CACHE_ENABLED !== 'false', // Enabled by default
s3CacheMaxAge: parseInt(process.env.S3_CACHE_MAX_AGE, 10) || 86400, // 24 hours
email: {
from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: process.env.EMAIL_USER || '',
pass: process.env.EMAIL_PASS,
},
tls: {
rejectUnauthorized: process.env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
},
},
roles: {
admin: 'Administrator',
user: 'Analytics Viewer',
},
project_uuid: '88dbeaf8-e906-405e-9e41-c3baadeda5c6',
flHost:
process.env.NODE_ENV === 'production' ||
process.env.NODE_ENV === 'dev_stage'
? 'https://flatlogic.com/projects'
: 'http://localhost:3000/projects',
gpt_key: process.env.GPT_KEY || '',
};
config.host =
process.env.NODE_ENV === 'production' ? config.remote : 'http://localhost';
config.apiUrl = `${config.host}${config.port ? `:${config.port}` : ``}/api`;
config.swaggerUrl = `${config.swaggerUI}${config.swaggerPort}`;
config.uiUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}/#`;
config.backUrl = `${config.hostUI}${config.portUI ? `:${config.portUI}` : ``}`;
module.exports = config;

96
backend/src/config.ts Normal file
View File

@ -0,0 +1,96 @@
import os from 'node:os';
import path from 'node:path';
import './load-env.ts';
import { validateEnv } from './utils/env-validation.ts';
import type { BackendConfig } from './types/index.ts';
validateEnv();
function envNumber(name: string, fallback: number): number {
const parsed = Number.parseInt(process.env[name] || '', 10);
return Number.isFinite(parsed) ? parsed : fallback;
}
const isProduction = process.env.NODE_ENV === 'production';
const remote = '';
const port = isProduction ? '' : '8080';
const hostUI = isProduction ? '' : 'http://localhost';
const portUI = isProduction ? '' : '3000';
const swaggerUI = isProduction ? '' : 'http://localhost';
const swaggerPort = isProduction ? '' : ':8080';
const host = isProduction ? remote : 'http://localhost';
const config: BackendConfig = {
gcloud: {
bucket: 'fldemo-files',
hash: 'afeefb9d49f5b7977577876b99532ac7',
},
s3: {
bucket: process.env.AWS_S3_BUCKET || '',
region: process.env.AWS_S3_REGION || 'us-east-1',
accessKeyId: process.env.AWS_ACCESS_KEY_ID || '',
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY || '',
prefix: process.env.AWS_S3_PREFIX || 'afeefb9d49f5b7977577876b99532ac7',
connectionTimeout: envNumber('AWS_S3_CONNECTION_TIMEOUT', 5000),
requestTimeout: envNumber('AWS_S3_REQUEST_TIMEOUT', 30000),
maxAttempts: envNumber('AWS_S3_MAX_ATTEMPTS', 3),
maxSockets: envNumber('AWS_S3_MAX_SOCKETS', 50),
keepAlive: process.env.AWS_S3_KEEP_ALIVE !== 'false',
presignExpirySeconds: envNumber('AWS_S3_PRESIGN_EXPIRY', 3600),
},
bcrypt: {
saltRounds: 12,
},
admin_pass: process.env.ADMIN_PASS || '88dbeaf8',
user_pass: process.env.USER_PASS || 'c3baadeda5c6',
admin_email: process.env.ADMIN_EMAIL || 'admin@flatlogic.com',
providers: {
LOCAL: 'local',
GOOGLE: 'google',
MICROSOFT: 'microsoft',
},
secret_key: process.env.SECRET_KEY || '88dbeaf8-e906-405e-9e41-c3baadeda5c6',
remote,
port,
host,
hostUI,
portUI,
portUIProd: isProduction ? '' : ':3000',
swaggerUI,
swaggerPort,
google: {
clientId: process.env.GOOGLE_CLIENT_ID || '',
clientSecret: process.env.GOOGLE_CLIENT_SECRET || '',
},
microsoft: {
clientId: process.env.MS_CLIENT_ID || '',
clientSecret: process.env.MS_CLIENT_SECRET || '',
},
uploadDir: os.tmpdir(),
s3CacheDir: process.env.S3_CACHE_DIR || path.join(os.tmpdir(), 's3-cache'),
s3CacheEnabled: process.env.S3_CACHE_ENABLED !== 'false',
s3CacheMaxAge: envNumber('S3_CACHE_MAX_AGE', 86400),
email: {
from: 'Tour Builder Platform <app@flatlogic.app>',
host: 'email-smtp.us-east-1.amazonaws.com',
port: 587,
auth: {
user: process.env.EMAIL_USER || '',
pass: process.env.EMAIL_PASS || '',
},
tls: {
rejectUnauthorized: process.env.EMAIL_TLS_REJECT_UNAUTHORIZED !== 'false',
},
},
roles: {
admin: 'Administrator',
user: 'Analytics Viewer',
},
apiUrl: `${host}${port ? `:${port}` : ''}/api`,
swaggerUrl: `${swaggerUI}${swaggerPort}`,
uiUrl: `${hostUI}${portUI ? `:${portUI}` : ''}/#`,
backUrl: `${hostUI}${portUI ? `:${portUI}` : ''}`,
};
export default config;

View File

@ -1,95 +0,0 @@
/**
* @typedef {Object} ServiceCreateOptions
* @property {Object} data
* @property {Object} [currentUser]
* @property {Object} [transaction]
* @property {Object} [runtimeContext]
* @property {boolean} [sendInvitationEmails]
* @property {string} [host]
*/
/**
* @typedef {Object} EntityIdOptions
* @property {string} id
* @property {Object} [currentUser]
* @property {Object} [transaction]
* @property {Object} [runtimeContext]
*/
/**
* @typedef {Object} DeleteByIdsOptions
* @property {string[]} ids
* @property {Object} [currentUser]
* @property {Object} [transaction]
* @property {Object} [runtimeContext]
*/
/**
* @typedef {Object} AutocompleteOptions
* @property {string} [query]
* @property {number} [limit]
* @property {number} [offset]
*/
/**
* @typedef {Object} UpdateOptions
* @property {string} id
* @property {Object} data
* @property {Object} [currentUser]
* @property {Object} [transaction]
* @property {Object} [runtimeContext]
*/
function assertOptionsObject(options, contractName, methodName) {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError(
`${contractName}.${methodName} expects an options object`,
);
}
}
function assertCreateOptions(options, contractName) {
assertOptionsObject(options, contractName, 'create');
if (options.data === undefined) {
throw new TypeError(`${contractName}.create requires { data }`);
}
}
function assertIdOptions(options, contractName, methodName) {
assertOptionsObject(options, contractName, methodName);
if (!options.id) {
throw new TypeError(`${contractName}.${methodName} requires { id }`);
}
}
function assertDeleteByIdsOptions(options, contractName) {
assertOptionsObject(options, contractName, 'deleteByIds');
if (!Array.isArray(options.ids)) {
throw new TypeError(`${contractName}.deleteByIds requires { ids }`);
}
}
function assertAutocompleteOptions(options, contractName) {
assertOptionsObject(options, contractName, 'findAllAutocomplete');
}
function assertUpdateOptions(options, contractName) {
assertOptionsObject(options, contractName, 'update');
if (!options.id || options.data === undefined) {
throw new TypeError(
`${contractName}.update requires { id, data } in the options object`,
);
}
}
module.exports = {
assertAutocompleteOptions,
assertCreateOptions,
assertDeleteByIdsOptions,
assertIdOptions,
assertUpdateOptions,
};

View File

@ -0,0 +1,87 @@
import type {
AutocompleteOptions,
CreateOptions,
DeleteByIdsOptions,
EntityIdOptions,
UpdateOptions,
} from '../types/index.ts';
interface ContractOptions {
data?: unknown;
id?: unknown;
ids?: unknown;
}
function assertOptionsObject(
options: unknown,
contractName: string,
methodName: string,
): asserts options is ContractOptions {
if (!options || typeof options !== 'object' || Array.isArray(options)) {
throw new TypeError(
`${contractName}.${methodName} expects an options object`,
);
}
}
function assertCreateOptions(
options: unknown,
contractName: string,
): asserts options is CreateOptions<unknown> {
assertOptionsObject(options, contractName, 'create');
if (options.data === undefined) {
throw new TypeError(`${contractName}.create requires { data }`);
}
}
function assertIdOptions(
options: unknown,
contractName: string,
methodName: string,
): asserts options is EntityIdOptions {
assertOptionsObject(options, contractName, methodName);
if (!options.id) {
throw new TypeError(`${contractName}.${methodName} requires { id }`);
}
}
function assertDeleteByIdsOptions(
options: unknown,
contractName: string,
): asserts options is DeleteByIdsOptions {
assertOptionsObject(options, contractName, 'deleteByIds');
if (!Array.isArray(options.ids)) {
throw new TypeError(`${contractName}.deleteByIds requires { ids }`);
}
}
function assertAutocompleteOptions(
options: unknown,
contractName: string,
): asserts options is AutocompleteOptions {
assertOptionsObject(options, contractName, 'findAllAutocomplete');
}
function assertUpdateOptions(
options: unknown,
contractName: string,
): asserts options is UpdateOptions<unknown> {
assertOptionsObject(options, contractName, 'update');
if (!options.id || options.data === undefined) {
throw new TypeError(
`${contractName}.update requires { id, data } in the options object`,
);
}
}
export {
assertAutocompleteOptions,
assertCreateOptions,
assertDeleteByIdsOptions,
assertIdOptions,
assertUpdateOptions,
};

View File

@ -1,28 +1,34 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
AccessLogAssociationConfig,
AccessLogData,
AccessLogFieldMapping,
AccessLogRelationFilterConfig,
} from '../../types/index.ts';
class Access_logsDBApi extends GenericDBApi { class Access_logsDBApi extends GenericDBApi {
static get MODEL() { static override get MODEL(): unknown {
return db.access_logs; return db.access_logs;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'access_logs'; return 'access_logs';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return ['path', 'ip_address', 'user_agent']; return ['path', 'ip_address', 'user_agent'];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return ['accessed_at']; return ['accessed_at'];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return ['environment']; return ['environment'];
} }
static get CSV_FIELDS() { static override get CSV_FIELDS(): string[] {
return [ return [
'id', 'id',
'environment', 'environment',
@ -34,29 +40,29 @@ class Access_logsDBApi extends GenericDBApi {
]; ];
} }
static get AUTOCOMPLETE_FIELD() { static override get AUTOCOMPLETE_FIELD(): string {
return 'path'; return 'path';
} }
static get ASSOCIATIONS() { static override get ASSOCIATIONS(): AccessLogAssociationConfig[] {
return [ return [
{ field: 'project', setter: 'setProject', isArray: false }, { field: 'project', setter: 'setProject', isArray: false },
{ field: 'user', setter: 'setUser', isArray: false }, { field: 'user', setter: 'setUser', isArray: false },
]; ];
} }
static get FIND_BY_INCLUDES() { static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'project' }, { association: 'user' }]; return [{ association: 'project' }, { association: 'user' }];
} }
static get FIND_ALL_INCLUDES() { static override get FIND_ALL_INCLUDES(): unknown[] {
return [ return [
{ model: db.projects, as: 'project', required: false }, { model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false }, { model: db.users, as: 'user', required: false },
]; ];
} }
static get RELATION_FILTERS() { static override get RELATION_FILTERS(): AccessLogRelationFilterConfig[] {
return [ return [
{ {
filterKey: 'project', filterKey: 'project',
@ -73,7 +79,7 @@ class Access_logsDBApi extends GenericDBApi {
]; ];
} }
static getFieldMapping(data) { static override getFieldMapping(data: AccessLogData): AccessLogFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
environment: data.environment || null, environment: data.environment || null,
@ -85,4 +91,4 @@ class Access_logsDBApi extends GenericDBApi {
} }
} }
module.exports = Access_logsDBApi; export default Access_logsDBApi;

View File

@ -1,28 +1,34 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
AssetVariantAssociationConfig,
AssetVariantData,
AssetVariantFieldMapping,
AssetVariantRelationFilterConfig,
} from '../../types/index.ts';
class Asset_variantsDBApi extends GenericDBApi { class Asset_variantsDBApi extends GenericDBApi {
static get MODEL() { static override get MODEL(): unknown {
return db.asset_variants; return db.asset_variants;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'asset_variants'; return 'asset_variants';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return ['cdn_url']; return ['cdn_url'];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return ['width_px', 'height_px', 'size_mb']; return ['width_px', 'height_px', 'size_mb'];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return ['variant_type']; return ['variant_type'];
} }
static get CSV_FIELDS() { static override get CSV_FIELDS(): string[] {
return [ return [
'id', 'id',
'variant_type', 'variant_type',
@ -34,19 +40,19 @@ class Asset_variantsDBApi extends GenericDBApi {
]; ];
} }
static get AUTOCOMPLETE_FIELD() { static override get AUTOCOMPLETE_FIELD(): string {
return 'variant_type'; return 'variant_type';
} }
static get ASSOCIATIONS() { static override get ASSOCIATIONS(): AssetVariantAssociationConfig[] {
return [{ field: 'asset', setter: 'setAsset', isArray: false }]; return [{ field: 'asset', setter: 'setAsset', isArray: false }];
} }
static get FIND_BY_INCLUDES() { static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'asset' }]; return [{ association: 'asset' }];
} }
static get FIND_ALL_INCLUDES() { static override get FIND_ALL_INCLUDES(): unknown[] {
return [ return [
{ {
model: db.assets, model: db.assets,
@ -56,7 +62,7 @@ class Asset_variantsDBApi extends GenericDBApi {
]; ];
} }
static get RELATION_FILTERS() { static override get RELATION_FILTERS(): AssetVariantRelationFilterConfig[] {
return [ return [
{ {
filterKey: 'asset', filterKey: 'asset',
@ -67,7 +73,7 @@ class Asset_variantsDBApi extends GenericDBApi {
]; ];
} }
static getFieldMapping(data) { static override getFieldMapping(data: AssetVariantData): AssetVariantFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
assetId: data.assetId || null, assetId: data.assetId || null,
@ -81,4 +87,4 @@ class Asset_variantsDBApi extends GenericDBApi {
} }
} }
module.exports = Asset_variantsDBApi; export default Asset_variantsDBApi;

View File

@ -1,16 +1,26 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
AssetData,
AssetFieldMapping,
AssetUsageType,
AssetsDbApi,
DbAssociationConfig,
DbRelationFilterConfig,
} from '../../types/index.ts';
class AssetsDBApi extends GenericDBApi { class AssetsDBApi extends GenericDBApi {
static get MODEL() { declare static findBy: AssetsDbApi['findBy'];
static override get MODEL(): unknown {
return db.assets; return db.assets;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'assets'; return 'assets';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return [ return [
'name', 'name',
'cdn_url', 'cdn_url',
@ -21,19 +31,19 @@ class AssetsDBApi extends GenericDBApi {
]; ];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return ['size_mb', 'width_px', 'height_px', 'duration_sec', 'frame_rate']; return ['size_mb', 'width_px', 'height_px', 'duration_sec', 'frame_rate'];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return ['asset_type', 'type', 'is_public']; return ['asset_type', 'type', 'is_public'];
} }
static get UUID_FIELDS() { static override get UUID_FIELDS(): string[] {
return ['projectId']; return ['projectId'];
} }
static get CSV_FIELDS() { static override get CSV_FIELDS(): string[] {
return [ return [
'id', 'id',
'name', 'name',
@ -47,26 +57,26 @@ class AssetsDBApi extends GenericDBApi {
]; ];
} }
static get AUTOCOMPLETE_FIELD() { static override get AUTOCOMPLETE_FIELD(): string {
return 'name'; return 'name';
} }
static get ASSOCIATIONS() { static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }]; return [{ field: 'project', setter: 'setProject', isArray: false }];
} }
static get FIND_BY_INCLUDES() { static override get FIND_BY_INCLUDES(): unknown[] {
return [ return [
{ association: 'asset_variants_asset' }, { association: 'asset_variants_asset' },
{ association: 'project' }, { association: 'project' },
]; ];
} }
static get FIND_ALL_INCLUDES() { static override get FIND_ALL_INCLUDES(): unknown[] {
return [{ model: db.projects, as: 'project', required: false }]; return [{ model: db.projects, as: 'project', required: false }];
} }
static get RELATION_FILTERS() { static override get RELATION_FILTERS(): DbRelationFilterConfig[] {
return [ return [
{ {
filterKey: 'project', filterKey: 'project',
@ -77,12 +87,12 @@ class AssetsDBApi extends GenericDBApi {
]; ];
} }
static getFieldMapping(data) { static override getFieldMapping(data: AssetData): AssetFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
name: data.name || null, name: data.name || null,
asset_type: data.asset_type || null, asset_type: data.asset_type || null,
type: data.type || 'general', type: data.type || defaultAssetUsageType,
cdn_url: data.cdn_url || null, cdn_url: data.cdn_url || null,
storage_key: data.storage_key || null, storage_key: data.storage_key || null,
mime_type: data.mime_type || null, mime_type: data.mime_type || null,
@ -100,4 +110,6 @@ class AssetsDBApi extends GenericDBApi {
} }
} }
module.exports = AssetsDBApi; const defaultAssetUsageType: AssetUsageType = 'general';
export default AssetsDBApi;

View File

@ -1,517 +0,0 @@
const db = require('../models');
const Utils = require('../utils');
const { parse } = require('json2csv');
const { logger } = require('../../utils/logger');
const {
assertAutocompleteOptions,
assertCreateOptions,
assertDeleteByIdsOptions,
assertIdOptions,
assertUpdateOptions,
} = require('../../contracts/entity-options');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class GenericDBApi {
static get MODEL() {
throw new Error('MODEL must be defined in subclass');
}
static get TABLE_NAME() {
return this.MODEL.getTableName();
}
static get SEARCHABLE_FIELDS() {
return [];
}
static get RANGE_FIELDS() {
return [];
}
static get ENUM_FIELDS() {
return [];
}
/**
* UUID fields that require validation before querying.
* These are typically foreign key fields like 'projectId'.
* Invalid UUIDs will return empty results instead of causing DB errors.
* Override in subclass to specify fields.
* Example: return ['projectId', 'userId'];
*/
static get UUID_FIELDS() {
return [];
}
static get RELATION_FILTERS() {
return [];
}
static get CSV_FIELDS() {
return ['id', 'createdAt'];
}
static get SORTABLE_FIELDS() {
return Object.keys(this.MODEL.rawAttributes || {});
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static get ASSOCIATIONS() {
return [];
}
static get FIND_BY_INCLUDES() {
return [];
}
static get FIND_ALL_INCLUDES() {
return [];
}
/**
* Fields that should be automatically JSON-stringified
* Override in subclass to specify fields.
* Example: return ['settings_json', 'metadata_json'];
*/
static get JSON_FIELDS() {
return [];
}
/**
* Custom field transformers for data mapping.
* Override in subclass to add custom transformations.
* Example:
* return {
* email: (value) => value?.toLowerCase().trim(),
* slug: (value) => value?.toLowerCase().replace(/\s+/g, '-'),
* };
*/
static get FIELD_TRANSFORMERS() {
return {};
}
/**
* Field mapping configuration for declarative field handling.
* Override in subclass to specify how fields should be mapped.
* Example:
* return {
* name: { default: null },
* sort_order: { default: 0 },
* is_active: { default: true },
* };
*/
static get FIELD_DEFAULTS() {
return {};
}
/**
* Transform input data for database operations.
* Template Method Pattern: Uses JSON_FIELDS, FIELD_TRANSFORMERS, and FIELD_DEFAULTS
* to declaratively transform data, reducing boilerplate in subclasses.
*
* Override this method for complex custom transformations that can't be
* expressed declaratively.
*
* @param {Object} data - Input data to transform
* @returns {Object} - Transformed data ready for database
*/
static getFieldMapping(data) {
if (!data) return data;
const mapped = { ...data };
// Apply field defaults
for (const [field, config] of Object.entries(this.FIELD_DEFAULTS)) {
if (mapped[field] === undefined) {
mapped[field] = config.default;
} else if (mapped[field] === null && config.nullDefault !== undefined) {
mapped[field] = config.nullDefault;
}
}
// Auto-stringify JSON fields
for (const field of this.JSON_FIELDS) {
if (mapped[field] !== undefined && mapped[field] !== null) {
if (typeof mapped[field] !== 'string') {
mapped[field] = JSON.stringify(mapped[field]);
}
}
}
// Apply custom transformers
for (const [field, transformer] of Object.entries(
this.FIELD_TRANSFORMERS,
)) {
if (mapped[field] !== undefined) {
mapped[field] = transformer(mapped[field]);
}
}
return mapped;
}
static async create(options) {
assertCreateOptions(options, 'DBApi');
const { data, currentUser = { id: null }, transaction } = options;
const mappedData = this.getFieldMapping(data);
const record = await this.MODEL.create(
{
...mappedData,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
for (const assoc of this.ASSOCIATIONS) {
if (data[assoc.field] !== undefined) {
await record[assoc.setter](
data[assoc.field] || (assoc.isArray ? [] : null),
{ transaction },
);
}
}
return record;
}
static async bulkImport(data, options = {}) {
const currentUser = options.currentUser || { id: null };
const transaction = options.transaction;
const recordsData = data.map((item, index) => ({
...this.getFieldMapping(item),
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
return this.MODEL.bulkCreate(recordsData, { transaction });
}
/**
* @param {Object} options
* @param {string} options.id
* @param {Object} options.data
* @param {Object} [options.currentUser]
* @param {Object} [options.transaction]
*/
static async update(options) {
assertUpdateOptions(options, 'DBApi');
const { id, data, currentUser = { id: null }, transaction } = options;
const record = await this.MODEL.findByPk(id, { transaction });
if (!record) {
throw { status: 404, message: `${this.TABLE_NAME} not found` };
}
const updatePayload = { updatedById: currentUser.id };
const mappedData = this.getFieldMapping(data);
for (const [key, value] of Object.entries(mappedData)) {
if (value !== undefined) {
updatePayload[key] = value;
}
}
await record.update(updatePayload, { transaction });
for (const assoc of this.ASSOCIATIONS) {
if (data[assoc.field] !== undefined) {
await record[assoc.setter](data[assoc.field], { transaction });
}
}
return record;
}
/**
* Partial update - only updates fields explicitly passed in data.
* Unlike update(), this doesn't go through getFieldMapping which
* converts missing fields to null.
*
* Use this when you need to update specific fields without affecting others.
*
* @param {Object} options
* @param {string} options.id - Record ID
* @param {Object} options.data - Fields to update
* @param {Object} [options.currentUser]
* @param {Object} [options.transaction]
*/
static async partialUpdate(options) {
assertUpdateOptions(options, 'DBApi');
const { id, data, currentUser = { id: null }, transaction } = options;
const record = await this.MODEL.findByPk(id, { transaction });
if (!record) {
throw { status: 404, message: `${this.TABLE_NAME} not found` };
}
const updatePayload = { updatedById: currentUser.id };
// Only include fields that are explicitly in the data object
for (const [key, value] of Object.entries(data)) {
if (value !== undefined) {
updatePayload[key] = value;
}
}
await record.update(updatePayload, { transaction });
return record;
}
static async deleteByIds(options) {
assertDeleteByIdsOptions(options, 'DBApi');
const { ids, currentUser = { id: null }, transaction } = options;
const records = await this.MODEL.findAll({
where: { id: { [Op.in]: ids } },
transaction,
});
for (const record of records) {
await record.update({ deletedBy: currentUser.id }, { transaction });
}
for (const record of records) {
await record.destroy({ transaction });
}
return records;
}
static async remove(options) {
assertIdOptions(options, 'DBApi', 'remove');
const { id, currentUser = { id: null }, transaction } = options;
const record = await this.MODEL.findByPk(id, { transaction });
if (!record) {
throw { status: 404, message: `${this.TABLE_NAME} not found` };
}
await record.update({ deletedBy: currentUser.id }, { transaction });
await record.destroy({ transaction });
return record;
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const include =
options.include !== undefined ? options.include : this.FIND_BY_INCLUDES;
const record = await this.MODEL.findOne({
where,
transaction,
include,
});
if (!record) {
return null;
}
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = Number(filter.limit) || 0;
const currentPage = Number(filter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where = {};
let include = [...this.FIND_ALL_INCLUDES];
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
// Validate UUID fields - return empty results for invalid UUIDs
for (const field of this.UUID_FIELDS) {
if (filter[field] !== undefined) {
if (!Utils.isValidUuid(filter[field])) {
return { rows: [], count: 0 };
}
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
for (const rel of this.RELATION_FILTERS) {
if (filter[rel.filterKey]) {
const searchTerms = filter[rel.filterKey].split('|');
const validUuids = Utils.filterValidUuids(searchTerms);
// Build OR conditions array
const orConditions = [];
// Add UUID condition only if there are valid UUIDs
if (validUuids.length > 0) {
orConditions.push({ id: { [Op.in]: validUuids } });
}
// Add text search condition if searchField is defined
if (rel.searchField) {
orConditions.push({
[rel.searchField]: {
[Op.or]: searchTerms.map((term) => ({
[Op.iLike]: `%${term}%`,
})),
},
});
}
const relInclude = {
model: rel.model,
as: rel.as,
required: orConditions.length > 0,
where:
orConditions.length > 0 ? { [Op.or]: orConditions } : undefined,
};
include = [relInclude, ...include];
}
}
const sortField = this.SORTABLE_FIELDS.includes(filter.field)
? filter.field
: 'createdAt';
const sortDirection =
String(filter.sort || 'desc').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
try {
if (options.countOnly) {
const count = await this.MODEL.count({
where,
include: include.filter((entry) => entry.required || entry.where),
distinct: true,
transaction: options.transaction,
});
return {
rows: [],
count,
};
}
const queryOptions = {
where,
include,
distinct: true,
order: [[sortField, sortDirection]],
transaction: options.transaction,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
};
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows,
count,
};
} catch (error) {
logger.error(
{ err: error, table: this.TABLE_NAME },
'Error executing query',
);
throw error;
}
}
static async findAllAutocomplete(options, queryOptions = {}) {
assertAutocompleteOptions(options, 'DBApi');
const { query, limit, offset } = options;
const transaction = queryOptions.transaction;
let where = {};
if (query) {
const orConditions = [
Utils.ilike(this.TABLE_NAME, this.AUTOCOMPLETE_FIELD, query),
];
if (Utils.isValidUuid(query)) {
orConditions.unshift({ id: query });
}
where = { [Op.or]: orConditions };
}
const records = await this.MODEL.findAll({
attributes: ['id', this.AUTOCOMPLETE_FIELD],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
order: [[this.AUTOCOMPLETE_FIELD, 'ASC']],
transaction,
});
return records.map((record) => ({
id: record.id,
label: record[this.AUTOCOMPLETE_FIELD],
}));
}
static toCSV(rows) {
const opts = { fields: this.CSV_FIELDS };
return parse(rows, opts);
}
}
module.exports = GenericDBApi;

View File

@ -0,0 +1,735 @@
import type { Transaction } from 'sequelize';
import { parse } from 'json2csv';
import db from '../models/index.ts';
import Utils from '../utils.ts';
import { logger } from '../../utils/logger.ts';
import {
assertAutocompleteOptions,
assertCreateOptions,
assertDeleteByIdsOptions,
assertIdOptions,
assertUpdateOptions,
} from '../../contracts/entity-options.ts';
import type {
BulkImportOptions,
DbAssociationConfig,
DbData,
DbFindAllOptions,
DbFindByOptions,
DbPrimitive,
DbRelationFilterConfig,
EntityRecord,
GenericDbListFilter,
GenericDbModel,
PaginatedResult,
ServiceOptions,
} from '../../types/index.ts';
const { Op } = db.Sequelize;
interface GenericDbFindAllOptions extends ServiceOptions {
countOnly?: boolean;
}
interface RelationInclude {
model: unknown;
as: string;
required: boolean;
where?: DbData | undefined;
}
interface GenericDbFindAndCountOptions {
where: DbData;
include: unknown[];
distinct: true;
order: string[][];
transaction?: Transaction | undefined;
limit?: number | undefined;
offset?: number | undefined;
}
type FieldTransformer = (value: unknown) => unknown;
type RelationSetter = (
value: unknown,
options: { transaction?: Transaction | undefined },
) => Promise<void>;
class DbApiNotFoundError extends Error {
status = 404;
constructor(message: string) {
super(message);
}
}
function isRecord(value: unknown): value is DbData {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isFieldTransformer(value: unknown): value is FieldTransformer {
return typeof value === 'function';
}
function isRelationSetter(value: unknown): value is RelationSetter {
return typeof value === 'function';
}
function normalizeData(value: unknown): DbData {
return isRecord(value) ? value : {};
}
function isGenericDbModel(value: unknown): value is GenericDbModel {
if (
value === null ||
(typeof value !== 'object' && typeof value !== 'function')
) {
return false;
}
return (
'getTableName' in value &&
typeof value.getTableName === 'function'
);
}
function buildTransactionOptions(
transaction: Transaction | undefined,
): { transaction?: Transaction | undefined } {
const options: { transaction?: Transaction | undefined } = {};
if (transaction !== undefined) {
options.transaction = transaction;
}
return options;
}
function getCurrentUserId(options: ServiceOptions): string | null {
return options.currentUser?.id ?? null;
}
function isTransaction(value: unknown): value is Transaction {
return Boolean(value) && typeof value === 'object';
}
function isRange(value: unknown): value is readonly [DbPrimitive, DbPrimitive] {
return Array.isArray(value) && value.length === 2;
}
function addRangeBoundary(
where: DbData,
field: string,
operator: symbol,
value: DbPrimitive,
): void {
if (value === undefined || value === null || value === '') return;
const current = isRecord(where[field]) ? where[field] : {};
where[field] = {
...current,
[operator]: value,
};
}
function addRangeFilter(where: DbData, field: string, range: unknown): void {
if (!isRange(range)) return;
const [start, end] = range;
addRangeBoundary(where, field, Op.gte, start);
addRangeBoundary(where, field, Op.lte, end);
}
function getFilterString(filter: GenericDbListFilter, field: string): string | null {
const value = filter[field];
return typeof value === 'string' ? value : null;
}
function isDbFindAllOptions(value: unknown): value is DbFindAllOptions<unknown> {
return (
isRecord(value) &&
('filter' in value ||
'offset' in value ||
('limit' in value && typeof value.limit === 'number'))
);
}
class GenericDBApi {
static get MODEL(): unknown {
throw new Error('MODEL must be defined in subclass');
}
private static getModel(): GenericDbModel {
if (!isGenericDbModel(this.MODEL)) {
throw new Error('MODEL must implement GenericDbModel contract');
}
return this.MODEL;
}
static get TABLE_NAME(): string {
return this.getModel().getTableName();
}
static get SEARCHABLE_FIELDS(): string[] {
return [];
}
static get RANGE_FIELDS(): string[] {
return [];
}
static get ENUM_FIELDS(): string[] {
return [];
}
/**
* UUID fields that require validation before querying.
* These are typically foreign key fields like 'projectId'.
* Invalid UUIDs will return empty results instead of causing DB errors.
* Override in subclass to specify fields.
* Example: return ['projectId', 'userId'];
*/
static get UUID_FIELDS(): string[] {
return [];
}
static get RELATION_FILTERS(): DbRelationFilterConfig[] {
return [];
}
static get CSV_FIELDS(): string[] {
return ['id', 'createdAt'];
}
static get SORTABLE_FIELDS(): string[] {
return Object.keys(this.getModel().rawAttributes || {});
}
static get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static get ASSOCIATIONS(): DbAssociationConfig[] {
return [];
}
static get FIND_BY_INCLUDES(): unknown[] {
return [];
}
static get FIND_ALL_INCLUDES(): unknown[] {
return [];
}
/**
* Fields that should be automatically JSON-stringified
* Override in subclass to specify fields.
* Example: return ['settings_json', 'metadata_json'];
*/
static get JSON_FIELDS(): string[] {
return [];
}
/**
* Custom field transformers for data mapping.
* Override in subclass to add custom transformations.
* Example:
* return {
* email: (value) => value?.toLowerCase().trim(),
* slug: (value) => value?.toLowerCase().replace(/\s+/g, '-'),
* };
*/
static get FIELD_TRANSFORMERS(): object {
return {};
}
/**
* Field mapping configuration for declarative field handling.
* Override in subclass to specify how fields should be mapped.
* Example:
* return {
* name: { default: null },
* sort_order: { default: 0 },
* is_active: { default: true },
* };
*/
static get FIELD_DEFAULTS(): object {
return {};
}
/**
* Transform input data for database operations.
* Template Method Pattern: Uses JSON_FIELDS, FIELD_TRANSFORMERS, and FIELD_DEFAULTS
* to declaratively transform data, reducing boilerplate in subclasses.
*
* Override this method for complex custom transformations that can't be
* expressed declaratively.
*
* @param {Object} data - Input data to transform
* @returns {Object} - Transformed data ready for database
*/
static getFieldMapping(data: unknown): object {
const mapped = { ...normalizeData(data) };
// Apply field defaults
for (const [field, rawConfig] of Object.entries(this.FIELD_DEFAULTS)) {
const config = normalizeData(rawConfig);
if (mapped[field] === undefined) {
mapped[field] = config.default;
} else if (mapped[field] === null && config.nullDefault !== undefined) {
mapped[field] = config.nullDefault;
}
}
// Auto-stringify JSON fields
for (const field of this.JSON_FIELDS) {
if (mapped[field] !== undefined && mapped[field] !== null) {
if (typeof mapped[field] !== 'string') {
mapped[field] = JSON.stringify(mapped[field]);
}
}
}
// Apply custom transformers
for (const [field, transformer] of Object.entries(
this.FIELD_TRANSFORMERS,
)) {
if (mapped[field] !== undefined && isFieldTransformer(transformer)) {
mapped[field] = transformer(mapped[field]);
}
}
return mapped;
}
static async create(options: unknown): Promise<EntityRecord> {
assertCreateOptions(options, 'DBApi');
const { transaction } = options;
const data = normalizeData(options.data);
const mappedData = normalizeData(this.getFieldMapping(data));
const record = await this.getModel().create(
{
...mappedData,
importHash: data.importHash || null,
createdById: getCurrentUserId(options),
updatedById: getCurrentUserId(options),
},
buildTransactionOptions(transaction),
);
for (const assoc of this.ASSOCIATIONS) {
if (data[assoc.field] !== undefined) {
const setter = record[assoc.setter];
if (isRelationSetter(setter)) {
await setter(
data[assoc.field] || (assoc.isArray ? [] : null),
buildTransactionOptions(transaction),
);
}
}
}
return record;
}
static async bulkImport(
data: unknown[],
options: BulkImportOptions,
): Promise<unknown> {
const transaction = options.transaction;
const recordsData = data.map((item, index) => {
const rawItem = normalizeData(item);
return {
...normalizeData(this.getFieldMapping(rawItem)),
importHash: rawItem.importHash || null,
createdById: getCurrentUserId(options),
updatedById: getCurrentUserId(options),
createdAt: new Date(Date.now() + index * 1000),
};
});
return this.getModel().bulkCreate(
recordsData,
buildTransactionOptions(transaction),
);
}
/**
* @param {Object} options
* @param {string} options.id
* @param {Object} options.data
* @param {Object} [options.currentUser]
* @param {Object} [options.transaction]
*/
static async update(options: unknown): Promise<EntityRecord> {
assertUpdateOptions(options, 'DBApi');
const { id, transaction } = options;
const data = normalizeData(options.data);
const record = await this.getModel().findByPk(
id,
buildTransactionOptions(transaction),
);
if (!record) {
throw new DbApiNotFoundError(`${this.TABLE_NAME} not found`);
}
const updatePayload: DbData = { updatedById: getCurrentUserId(options) };
const mappedData = normalizeData(this.getFieldMapping(data));
for (const [key, value] of Object.entries(mappedData)) {
if (value !== undefined) {
updatePayload[key] = value;
}
}
await record.update(updatePayload, buildTransactionOptions(transaction));
for (const assoc of this.ASSOCIATIONS) {
if (data[assoc.field] !== undefined) {
const setter = record[assoc.setter];
if (isRelationSetter(setter)) {
await setter(data[assoc.field], buildTransactionOptions(transaction));
}
}
}
return record;
}
/**
* Partial update - only updates fields explicitly passed in data.
* Unlike update(), this doesn't go through getFieldMapping which
* converts missing fields to null.
*
* Use this when you need to update specific fields without affecting others.
*
* @param {Object} options
* @param {string} options.id - Record ID
* @param {Object} options.data - Fields to update
* @param {Object} [options.currentUser]
* @param {Object} [options.transaction]
*/
static async partialUpdate(options: unknown): Promise<EntityRecord> {
assertUpdateOptions(options, 'DBApi');
const { id, transaction } = options;
const data = normalizeData(options.data);
const record = await this.getModel().findByPk(
id,
buildTransactionOptions(transaction),
);
if (!record) {
throw new DbApiNotFoundError(`${this.TABLE_NAME} not found`);
}
const updatePayload: DbData = { updatedById: getCurrentUserId(options) };
// Only include fields that are explicitly in the data object
for (const [key, value] of Object.entries(data)) {
if (value !== undefined) {
updatePayload[key] = value;
}
}
await record.update(updatePayload, buildTransactionOptions(transaction));
return record;
}
static async deleteByIds(options: unknown): Promise<EntityRecord[]> {
assertDeleteByIdsOptions(options, 'DBApi');
const { ids, transaction } = options;
const records = await this.getModel().findAll({
where: { id: { [Op.in]: ids } },
...buildTransactionOptions(transaction),
});
for (const record of records) {
await record.update(
{ deletedBy: getCurrentUserId(options) },
buildTransactionOptions(transaction),
);
}
for (const record of records) {
await record.destroy(buildTransactionOptions(transaction));
}
return records;
}
static async remove(options: unknown): Promise<EntityRecord> {
assertIdOptions(options, 'DBApi', 'remove');
const { id, transaction } = options;
const record = await this.getModel().findByPk(
id,
buildTransactionOptions(transaction),
);
if (!record) {
throw new DbApiNotFoundError(`${this.TABLE_NAME} not found`);
}
await record.update(
{ deletedBy: getCurrentUserId(options) },
buildTransactionOptions(transaction),
);
await record.destroy(buildTransactionOptions(transaction));
return record;
}
static async findBy(
options: DbFindByOptions,
): Promise<EntityRecord | null>;
static async findBy(
where: unknown,
options?: ServiceOptions & { include?: unknown[] },
): Promise<EntityRecord | null>;
static async findBy(
whereOrOptions: unknown,
options: ServiceOptions & { include?: unknown[] } = {},
): Promise<EntityRecord | null> {
const maybeOptions = normalizeData(whereOrOptions);
const hasWhereOption = isRecord(maybeOptions.where);
const where = hasWhereOption
? normalizeData(maybeOptions.where)
: normalizeData(whereOrOptions);
const rawTransaction = hasWhereOption
? maybeOptions.transaction
: options.transaction;
const transaction = isTransaction(rawTransaction) ? rawTransaction : undefined;
const include =
hasWhereOption && Array.isArray(maybeOptions.include)
? maybeOptions.include
: options.include !== undefined
? options.include
: this.FIND_BY_INCLUDES;
const record = await this.getModel().findOne({
where,
...buildTransactionOptions(transaction),
include,
});
if (!record) {
return null;
}
return record.get({ plain: true });
}
static async findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<EntityRecord>>;
static async findAll(
filter?: GenericDbListFilter,
options?: GenericDbFindAllOptions,
): Promise<PaginatedResult<EntityRecord>>;
static async findAll(
filter: GenericDbListFilter | DbFindAllOptions<unknown> = {},
options: GenericDbFindAllOptions = {},
): Promise<PaginatedResult<EntityRecord>> {
const normalizedFilter: GenericDbListFilter = isDbFindAllOptions(filter)
? isRecord(filter.filter)
? filter.filter
: {}
: filter || {};
const limit = Number(normalizedFilter.limit) || 0;
const currentPage = Number(normalizedFilter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
const where: DbData = {};
let include = [...this.FIND_ALL_INCLUDES];
if (normalizedFilter.id) {
if (!Utils.isValidUuid(normalizedFilter.id)) {
return { rows: [], count: 0 };
}
where.id = normalizedFilter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (normalizedFilter[field]) {
const value = getFilterString(normalizedFilter, field);
if (value) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, value);
}
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
addRangeFilter(where, field, normalizedFilter[rangeKey]);
}
for (const field of this.ENUM_FIELDS) {
if (normalizedFilter[field] !== undefined) {
where[field] = normalizedFilter[field];
}
}
// Validate UUID fields - return empty results for invalid UUIDs
for (const field of this.UUID_FIELDS) {
const value = normalizedFilter[field];
if (value !== undefined) {
if (!Utils.isValidUuid(value)) {
return { rows: [], count: 0 };
}
where[field] = value;
}
}
if (normalizedFilter.active !== undefined) {
where.active =
normalizedFilter.active === true || normalizedFilter.active === 'true';
}
if (normalizedFilter.createdAtRange) {
addRangeFilter(where, 'createdAt', normalizedFilter.createdAtRange);
}
for (const rel of this.RELATION_FILTERS) {
const relationFilter = getFilterString(normalizedFilter, rel.filterKey);
if (relationFilter) {
const searchTerms = relationFilter.split('|');
const validUuids = Utils.filterValidUuids(searchTerms);
// Build OR conditions array
const orConditions: DbData[] = [];
// Add UUID condition only if there are valid UUIDs
if (validUuids.length > 0) {
orConditions.push({ id: { [Op.in]: validUuids } });
}
// Add text search condition if searchField is defined
if (rel.searchField) {
orConditions.push({
[rel.searchField]: {
[Op.or]: searchTerms.map((term) => ({
[Op.iLike]: `%${term}%`,
})),
},
});
}
const relInclude: RelationInclude = {
model: rel.model,
as: rel.as,
required: orConditions.length > 0,
where:
orConditions.length > 0 ? { [Op.or]: orConditions } : undefined,
};
include = [relInclude, ...include];
}
}
const sortField =
typeof normalizedFilter.field === 'string' &&
this.SORTABLE_FIELDS.includes(normalizedFilter.field)
? normalizedFilter.field
: 'createdAt';
const sortDirection =
String(normalizedFilter.sort || 'desc').toUpperCase() === 'ASC'
? 'ASC'
: 'DESC';
try {
if (options.countOnly) {
const count = await this.getModel().count({
where,
include: include.filter(
(entry): entry is RelationInclude =>
isRecord(entry) && Boolean(entry.required || entry.where),
),
distinct: true,
...buildTransactionOptions(options.transaction),
});
return {
rows: [],
count,
};
}
const queryOptions: GenericDbFindAndCountOptions = {
where,
include,
distinct: true,
order: [[sortField, sortDirection]],
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
};
if (options.transaction !== undefined) {
queryOptions.transaction = options.transaction;
}
const { rows, count } = await this.getModel().findAndCountAll(queryOptions);
return {
rows,
count,
};
} catch (error) {
logger.error(
{ err: error, table: this.TABLE_NAME },
'Error executing query',
);
throw error;
}
}
static async findAllAutocomplete(
options: unknown,
queryOptions: ServiceOptions = {},
): Promise<EntityRecord[]> {
assertAutocompleteOptions(options, 'DBApi');
const { query, limit, offset } = options;
const transaction = queryOptions.transaction;
let where: DbData = {};
if (query) {
const orConditions: unknown[] = [
Utils.ilike(this.TABLE_NAME, this.AUTOCOMPLETE_FIELD, query),
];
if (Utils.isValidUuid(query)) {
orConditions.unshift({ id: query });
}
where = { [Op.or]: orConditions };
}
const records = await this.getModel().findAll({
attributes: ['id', this.AUTOCOMPLETE_FIELD],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
order: [[this.AUTOCOMPLETE_FIELD, 'ASC']],
...buildTransactionOptions(transaction),
});
return records.map((record) => ({
id: record.id,
label: record[this.AUTOCOMPLETE_FIELD],
}));
}
static toCSV(rows: readonly EntityRecord[]): string {
const opts = { fields: this.CSV_FIELDS };
return parse(rows, opts);
}
}
export default GenericDBApi;

View File

@ -1,28 +1,74 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
DbFindAllOptions,
DbFindByOptions,
AutocompleteOptions,
CreateOptions,
DeleteByIdsOptions,
ElementSettingsJson,
ElementTypeDefaultsData,
ElementTypeDefaultsFieldDefaults,
ElementTypeDefaultsFieldMapping,
ElementTypeDefaultsModel,
ElementTypeDefaultsSeedRow,
EntityIdOptions,
EntityRecord,
GenericDbListFilter,
PaginatedResult,
ServiceOptions,
UpdateOptions,
} from '../../types/index.ts';
function isMissingTableError(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('original' in error)) {
return false;
}
const original = error.original;
if (!original || typeof original !== 'object' || !('code' in original)) {
return false;
}
return original.code === '42P01';
}
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null {
if (value === undefined || value === null) return null;
if (typeof value === 'string') return value;
return JSON.stringify(value);
}
function isElementTypeDefaultsListFilter(
value: unknown,
): value is GenericDbListFilter {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
class Element_type_defaultsDBApi extends GenericDBApi { class Element_type_defaultsDBApi extends GenericDBApi {
static get MODEL() { static initializationPromise: Promise<void> | null = null;
static override get MODEL(): ElementTypeDefaultsModel {
return db.element_type_defaults; return db.element_type_defaults;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'element_type_defaults'; return 'element_type_defaults';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return ['name', 'element_type']; return ['name', 'element_type'];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return ['sort_order']; return ['sort_order'];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return []; return [];
} }
static get CSV_FIELDS() { static override get CSV_FIELDS(): string[] {
return [ return [
'id', 'id',
'element_type', 'element_type',
@ -33,16 +79,16 @@ class Element_type_defaultsDBApi extends GenericDBApi {
]; ];
} }
static get AUTOCOMPLETE_FIELD() { static override get AUTOCOMPLETE_FIELD(): string {
return 'name'; return 'name';
} }
// Declarative field configuration using base class patterns // Declarative field configuration using base class patterns
static get JSON_FIELDS() { static override get JSON_FIELDS(): string[] {
return ['default_settings_json']; return ['default_settings_json'];
} }
static get FIELD_DEFAULTS() { static override get FIELD_DEFAULTS(): ElementTypeDefaultsFieldDefaults {
return { return {
element_type: { default: null }, element_type: { default: null },
name: { default: null }, name: { default: null },
@ -50,20 +96,19 @@ class Element_type_defaultsDBApi extends GenericDBApi {
}; };
} }
static getFieldMapping(data) { static override getFieldMapping(
// Apply base class transformations (JSON fields, defaults, transformers) data: ElementTypeDefaultsData,
const mapped = super.getFieldMapping(data); ): ElementTypeDefaultsFieldMapping {
return { return {
id: mapped.id || undefined, id: data.id || undefined,
element_type: mapped.element_type, element_type: data.element_type ?? null,
name: mapped.name, name: data.name ?? null,
sort_order: mapped.sort_order, sort_order: data.sort_order ?? 0,
default_settings_json: mapped.default_settings_json, default_settings_json: stringifySettings(data.default_settings_json),
}; };
} }
static get DEFAULT_ROWS() { static get DEFAULT_ROWS(): ElementTypeDefaultsSeedRow[] {
return [ return [
{ {
element_type: 'navigation_next', element_type: 'navigation_next',
@ -328,7 +373,7 @@ class Element_type_defaultsDBApi extends GenericDBApi {
]; ];
} }
static async ensureInitialized() { static async ensureInitialized(): Promise<void> {
if (!this.initializationPromise) { if (!this.initializationPromise) {
this.initializationPromise = (async () => { this.initializationPromise = (async () => {
let count = 0; let count = 0;
@ -336,7 +381,7 @@ class Element_type_defaultsDBApi extends GenericDBApi {
try { try {
count = await this.MODEL.count(); count = await this.MODEL.count();
} catch (error) { } catch (error) {
if (error?.original?.code !== '42P01') { if (!isMissingTableError(error)) {
throw error; throw error;
} }
@ -363,47 +408,91 @@ class Element_type_defaultsDBApi extends GenericDBApi {
await this.initializationPromise; await this.initializationPromise;
} }
static async create(options) { static override async create(
options: CreateOptions<ElementTypeDefaultsData>,
): Promise<EntityRecord> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.create(options); return super.create(options);
} }
static async bulkImport(data, options = {}) { static override async bulkImport(
data: unknown[],
options: ServiceOptions = {},
): Promise<void> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.bulkImport(data, options); await super.bulkImport(data, options);
} }
static async update({ id, data, currentUser, transaction, runtimeContext }) { static override async update(
options: UpdateOptions<ElementTypeDefaultsData>,
): Promise<EntityRecord> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.update({ id, data, currentUser, transaction, runtimeContext }); return super.update(options);
} }
static async deleteByIds(options) { static override async deleteByIds(
options: DeleteByIdsOptions,
): Promise<EntityRecord[]> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.deleteByIds(options); return super.deleteByIds(options);
} }
static async remove(options) { static override async remove(options: EntityIdOptions): Promise<EntityRecord> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.remove(options); return super.remove(options);
} }
static async findBy(where, options = {}) { static override async findBy(
where: { id: string },
options?: ServiceOptions,
): Promise<EntityRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<EntityRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: ServiceOptions = {},
): Promise<EntityRecord | null> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.findBy(where, options);
if ('where' in whereOrOptions) {
return super.findBy(whereOrOptions);
}
const findOptions: DbFindByOptions = { where: whereOrOptions };
if (options.transaction) {
findOptions.transaction = options.transaction;
}
return super.findBy(findOptions);
} }
static async findAll(filter = {}, options = {}) { static override async findAll(
filter?: unknown,
options?: ServiceOptions,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
filterOrOptions: unknown = {},
options: ServiceOptions = {},
): Promise<PaginatedResult<EntityRecord>> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.findAll(filter, options); if (isElementTypeDefaultsListFilter(filterOrOptions)) {
return super.findAll(filterOrOptions, options);
}
return super.findAll(options);
} }
static async findAllAutocomplete(options, queryOptions = {}) { static override async findAllAutocomplete(
options: AutocompleteOptions,
): Promise<EntityRecord[]> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.findAllAutocomplete(options, queryOptions); const records = await super.findAllAutocomplete(options);
return records;
} }
} }
Element_type_defaultsDBApi.initializationPromise = null; export default Element_type_defaultsDBApi;
module.exports = Element_type_defaultsDBApi;

View File

@ -1,73 +0,0 @@
const db = require('../models');
const assert = require('assert');
const services = require('../../services/file/');
module.exports = class FileDBApi {
static async replaceRelationFiles(relation, rawFiles, options) {
assert(relation.belongsTo, 'belongsTo is required');
assert(relation.belongsToColumn, 'belongsToColumn is required');
assert(relation.belongsToId, 'belongsToId is required');
let files = [];
if (Array.isArray(rawFiles)) {
files = rawFiles;
} else {
files = rawFiles ? [rawFiles] : [];
}
await this._removeLegacyFiles(relation, files, options);
await this._addFiles(relation, files, options);
}
static async _addFiles(relation, files, options) {
const transaction = (options && options.transaction) || undefined;
const currentUser = (options && options.currentUser) || { id: null };
const inexistentFiles = files.filter((file) => !!file.new);
for (const file of inexistentFiles) {
await db.file.create(
{
belongsTo: relation.belongsTo,
belongsToColumn: relation.belongsToColumn,
belongsToId: relation.belongsToId,
name: file.name,
sizeInBytes: file.sizeInBytes,
privateUrl: file.privateUrl,
publicUrl: file.publicUrl,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{
transaction,
},
);
}
}
static async _removeLegacyFiles(relation, files, options) {
const transaction = (options && options.transaction) || undefined;
const filesToDelete = await db.file.findAll({
where: {
belongsTo: relation.belongsTo,
belongsToId: relation.belongsToId,
belongsToColumn: relation.belongsToColumn,
id: {
[db.Sequelize.Op.notIn]: files
.filter((file) => !file.new)
.map((file) => file.id),
},
},
transaction,
});
for (let file of filesToDelete) {
await services.deleteFile(file.privateUrl);
await file.destroy({
transaction,
});
}
}
};

109
backend/src/db/api/file.ts Normal file
View File

@ -0,0 +1,109 @@
import assert from 'node:assert';
import db from '../models/index.ts';
import services from '../../services/file/index.ts';
import type {
FileDbApi,
FileDbOptions,
FileModel,
FileRelationDescriptor,
RelationFileInput,
RelationFileRecord,
} from '../../types/index.ts';
function normalizeRelationFiles(rawFiles: RelationFileInput): RelationFileRecord[] {
if (Array.isArray(rawFiles)) return rawFiles;
return rawFiles ? [rawFiles] : [];
}
function isExistingRelationFile(
file: RelationFileRecord,
): file is RelationFileRecord & { id: string } {
return !file.new && typeof file.id === 'string';
}
function getExistingFileIds(files: readonly RelationFileRecord[]): string[] {
return files.filter(isExistingRelationFile).map((file) => file.id);
}
class FileDBApi {
static get MODEL(): FileModel {
return db.file;
}
static async replaceRelationFiles(
relation: FileRelationDescriptor,
rawFiles: RelationFileInput,
options: FileDbOptions = {},
): Promise<void> {
assert(relation.belongsTo, 'belongsTo is required');
assert(relation.belongsToColumn, 'belongsToColumn is required');
assert(relation.belongsToId, 'belongsToId is required');
const files = normalizeRelationFiles(rawFiles);
await this._removeLegacyFiles(relation, files, options);
await this._addFiles(relation, files, options);
}
private static async _addFiles(
relation: FileRelationDescriptor,
files: readonly RelationFileRecord[],
options: FileDbOptions,
): Promise<void> {
const transaction = options.transaction;
const currentUser = options.currentUser || { id: null };
const inexistentFiles = files.filter((file) => !!file.new);
for (const file of inexistentFiles) {
await this.MODEL.create(
{
belongsTo: relation.belongsTo,
belongsToColumn: relation.belongsToColumn,
belongsToId: relation.belongsToId,
name: file.name,
sizeInBytes: file.sizeInBytes,
privateUrl: file.privateUrl,
publicUrl: file.publicUrl,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{
transaction,
},
);
}
}
private static async _removeLegacyFiles(
relation: FileRelationDescriptor,
files: readonly RelationFileRecord[],
options: FileDbOptions,
): Promise<void> {
const transaction = options.transaction;
const filesToDelete = await this.MODEL.findAll({
where: {
belongsTo: relation.belongsTo,
belongsToId: relation.belongsToId,
belongsToColumn: relation.belongsToColumn,
id: {
[db.Sequelize.Op.notIn]: getExistingFileIds(files),
},
},
transaction,
});
for (const file of filesToDelete) {
await services.deleteFile(file.privateUrl);
await file.destroy({
transaction,
});
}
}
}
const fileDBApi: FileDbApi = FileDBApi;
export default fileDBApi;

View File

@ -1,155 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
/**
* Global Transition Defaults API
*
* Single-row table pattern for platform-wide transition settings.
* Auto-seeds default values if the table is empty.
*/
class Global_transition_defaultsDBApi extends GenericDBApi {
static get MODEL() {
return db.global_transition_defaults;
}
static get TABLE_NAME() {
return 'global_transition_defaults';
}
static get SEARCHABLE_FIELDS() {
return [];
}
static get RANGE_FIELDS() {
return [];
}
static get ENUM_FIELDS() {
return ['transition_type', 'easing'];
}
static get CSV_FIELDS() {
return [
'id',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
'createdAt',
'updatedAt',
];
}
static get AUTOCOMPLETE_FIELD() {
return 'transition_type';
}
static get FIELD_DEFAULTS() {
return {
transition_type: { default: 'fade' },
duration_ms: { default: 700 },
easing: { default: 'ease-in-out' },
overlay_color: { default: '#000000' },
};
}
static get DEFAULT_ROW() {
return {
transition_type: 'fade',
duration_ms: 700,
easing: 'ease-in-out',
overlay_color: '#000000',
};
}
static getFieldMapping(data) {
const mapped = super.getFieldMapping(data);
return {
id: mapped.id || undefined,
transition_type: mapped.transition_type,
duration_ms: mapped.duration_ms,
easing: mapped.easing,
overlay_color: mapped.overlay_color,
};
}
/**
* Ensures the singleton row exists.
* Creates the default row if table is empty.
*/
static async ensureInitialized() {
if (!this.initializationPromise) {
this.initializationPromise = (async () => {
let count = 0;
try {
count = await this.MODEL.count();
} catch (error) {
// Table doesn't exist yet (happens during initial migration)
if (error?.original?.code !== '42P01') {
throw error;
}
await this.MODEL.sync();
count = await this.MODEL.count();
}
if (count > 0) return;
const now = new Date();
await this.MODEL.create({
...this.getFieldMapping(this.DEFAULT_ROW),
createdAt: now,
updatedAt: now,
});
})().catch((error) => {
this.initializationPromise = null;
throw error;
});
}
await this.initializationPromise;
}
/**
* Get the singleton row.
* Always returns a single object, not an array.
*/
static async findOne(options = {}) {
await this.ensureInitialized();
const record = await this.MODEL.findOne({
transaction: options.transaction,
});
if (!record) return null;
return record.get({ plain: true });
}
/**
* Alias for findOne to maintain semantic clarity.
*/
static async get(options = {}) {
return this.findOne(options);
}
static async update({ id, data, currentUser, transaction, runtimeContext }) {
await this.ensureInitialized();
return super.update({ id, data, currentUser, transaction, runtimeContext });
}
static async findBy(where, options = {}) {
await this.ensureInitialized();
return super.findBy(where, options);
}
static async findAll(filter = {}, options = {}) {
await this.ensureInitialized();
return super.findAll(filter, options);
}
}
Global_transition_defaultsDBApi.initializationPromise = null;
module.exports = Global_transition_defaultsDBApi;

View File

@ -0,0 +1,264 @@
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
DbFindAllOptions,
DbFindByOptions,
EntityRecord,
GlobalTransitionDefaultsData,
GlobalTransitionDefaultsFieldDefaults,
GlobalTransitionDefaultsFieldMapping,
GlobalTransitionDefaultsModel,
GlobalTransitionDefaultsRecord,
GenericDbListFilter,
PaginatedResult,
ServiceOptions,
UpdateOptions,
} from '../../types/index.ts';
function isMissingTableError(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('original' in error)) {
return false;
}
const original = error.original;
if (!original || typeof original !== 'object' || !('code' in original)) {
return false;
}
return original.code === '42P01';
}
function isGlobalTransitionListFilter(
value: unknown,
): value is GenericDbListFilter {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isDbFindAllOptions(value: unknown): value is DbFindAllOptions<unknown> {
return (
value !== null &&
typeof value === 'object' &&
!Array.isArray(value) &&
'filter' in value
);
}
/**
* Global Transition Defaults API
*
* Single-row table pattern for platform-wide transition settings.
* Auto-seeds default values if the table is empty.
*/
class Global_transition_defaultsDBApi extends GenericDBApi {
static initializationPromise: Promise<void> | null = null;
static override get MODEL(): GlobalTransitionDefaultsModel {
return db.global_transition_defaults;
}
static override get TABLE_NAME(): string {
return 'global_transition_defaults';
}
static override get SEARCHABLE_FIELDS(): string[] {
return [];
}
static override get RANGE_FIELDS(): string[] {
return [];
}
static override get ENUM_FIELDS(): string[] {
return ['transition_type', 'easing'];
}
static override get CSV_FIELDS(): string[] {
return [
'id',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
'createdAt',
'updatedAt',
];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'transition_type';
}
static override get FIELD_DEFAULTS(): GlobalTransitionDefaultsFieldDefaults {
return {
transition_type: { default: 'fade' },
duration_ms: { default: 700 },
easing: { default: 'ease-in-out' },
overlay_color: { default: '#000000' },
};
}
static get DEFAULT_ROW(): GlobalTransitionDefaultsData {
return {
transition_type: 'fade',
duration_ms: 700,
easing: 'ease-in-out',
overlay_color: '#000000',
};
}
static override getFieldMapping(
data: GlobalTransitionDefaultsData,
): GlobalTransitionDefaultsFieldMapping {
return {
id: data.id || undefined,
transition_type:
data.transition_type ?? this.FIELD_DEFAULTS.transition_type.default,
duration_ms: data.duration_ms ?? this.FIELD_DEFAULTS.duration_ms.default,
easing: data.easing ?? this.FIELD_DEFAULTS.easing.default,
overlay_color:
data.overlay_color ?? this.FIELD_DEFAULTS.overlay_color.default,
};
}
/**
* Ensures the singleton row exists.
* Creates the default row if table is empty.
*/
static async ensureInitialized(): Promise<void> {
if (!this.initializationPromise) {
this.initializationPromise = (async () => {
let count = 0;
try {
count = await this.MODEL.count();
} catch (error) {
// Table doesn't exist yet (happens during initial migration)
if (!isMissingTableError(error)) {
throw error;
}
await this.MODEL.sync();
count = await this.MODEL.count();
}
if (count > 0) return;
const now = new Date();
await this.MODEL.create({
...this.getFieldMapping(this.DEFAULT_ROW),
createdAt: now,
updatedAt: now,
});
})().catch((error) => {
this.initializationPromise = null;
throw error;
});
}
await this.initializationPromise;
}
/**
* Get the singleton row.
* Always returns a single object, not an array.
*/
static async findOne(
options: ServiceOptions = {},
): Promise<GlobalTransitionDefaultsRecord | null> {
await this.ensureInitialized();
const record = await this.MODEL.findOne({
transaction: options.transaction,
});
if (!record) return null;
return record.get({ plain: true });
}
/**
* Alias for findOne to maintain semantic clarity.
*/
static async get(
options: ServiceOptions = {},
): Promise<GlobalTransitionDefaultsRecord | null> {
return this.findOne(options);
}
static override async update({
id,
data,
currentUser,
transaction,
runtimeContext,
}: UpdateOptions<GlobalTransitionDefaultsData>): Promise<EntityRecord> {
await this.ensureInitialized();
const updateOptions: UpdateOptions<GlobalTransitionDefaultsData> = {
id,
data,
currentUser: currentUser ?? null,
};
if (transaction) {
updateOptions.transaction = transaction;
}
if (runtimeContext) {
updateOptions.runtimeContext = runtimeContext;
}
return super.update(updateOptions);
}
static override async findBy(
where: { id: string },
options?: ServiceOptions,
): Promise<EntityRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<EntityRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: ServiceOptions = {},
): Promise<EntityRecord | null> {
await this.ensureInitialized();
if ('where' in whereOrOptions) {
return super.findBy(whereOrOptions);
}
const findOptions: DbFindByOptions = { where: whereOrOptions };
if (options.transaction) {
findOptions.transaction = options.transaction;
}
return super.findBy(findOptions);
}
static override async findAll(
filter?: GenericDbListFilter,
options?: ServiceOptions,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
options?: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
filterOrOptions: GenericDbListFilter | DbFindAllOptions<unknown> = {},
options: ServiceOptions = {},
): Promise<PaginatedResult<EntityRecord>> {
await this.ensureInitialized();
if (
isGlobalTransitionListFilter(filterOrOptions) &&
!('filter' in filterOrOptions)
) {
return super.findAll(filterOrOptions, options);
}
if (isDbFindAllOptions(filterOrOptions)) {
return super.findAll(filterOrOptions);
}
return super.findAll({});
}
}
export default Global_transition_defaultsDBApi;

View File

@ -1,7 +1,31 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
DbFindByOptions,
EntityRecord,
GlobalUiControlDefaultsData,
GlobalUiControlDefaultsFieldMapping,
GlobalUiControlDefaultsModel,
GlobalUiControlDefaultsRecord,
GlobalUiControlSettingsJson,
ServiceOptions,
UpdateOptions,
} from '../../types/index.ts';
const DEFAULT_SETTINGS = { function isMissingTableError(error: unknown): boolean {
if (!error || typeof error !== 'object' || !('original' in error)) {
return false;
}
const original = error.original;
if (!original || typeof original !== 'object' || !('code' in original)) {
return false;
}
return original.code === '42P01';
}
const DEFAULT_SETTINGS: GlobalUiControlSettingsJson = {
fullscreen: { fullscreen: {
enabled: true, enabled: true,
hidden: false, hidden: false,
@ -71,40 +95,42 @@ const DEFAULT_SETTINGS = {
}; };
class Global_ui_control_defaultsDBApi extends GenericDBApi { class Global_ui_control_defaultsDBApi extends GenericDBApi {
static get MODEL() { static initializationPromise: Promise<void> | null = null;
static override get MODEL(): GlobalUiControlDefaultsModel {
return db.global_ui_control_defaults; return db.global_ui_control_defaults;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'global_ui_control_defaults'; return 'global_ui_control_defaults';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return []; return [];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return []; return [];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return []; return [];
} }
static get DEFAULT_SETTINGS() { static get DEFAULT_SETTINGS(): GlobalUiControlSettingsJson {
return DEFAULT_SETTINGS; return DEFAULT_SETTINGS;
} }
static getFieldMapping(data) { static override getFieldMapping(
const mapped = super.getFieldMapping(data); data: GlobalUiControlDefaultsData,
): GlobalUiControlDefaultsFieldMapping {
return { return {
id: mapped.id || undefined, id: data.id || undefined,
settings_json: settings_json: data.settings_json || data.settings || DEFAULT_SETTINGS,
mapped.settings_json || mapped.settings || DEFAULT_SETTINGS,
}; };
} }
static async ensureInitialized() { static async ensureInitialized(): Promise<void> {
if (!this.initializationPromise) { if (!this.initializationPromise) {
this.initializationPromise = (async () => { this.initializationPromise = (async () => {
let count = 0; let count = 0;
@ -112,7 +138,7 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
try { try {
count = await this.MODEL.count(); count = await this.MODEL.count();
} catch (error) { } catch (error) {
if (error?.original?.code !== '42P01') { if (!isMissingTableError(error)) {
throw error; throw error;
} }
@ -137,7 +163,9 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
await this.initializationPromise; await this.initializationPromise;
} }
static async findOne(options = {}) { static async findOne(
options: ServiceOptions = {},
): Promise<GlobalUiControlDefaultsRecord | null> {
await this.ensureInitialized(); await this.ensureInitialized();
const record = await this.MODEL.findOne({ const record = await this.MODEL.findOne({
@ -148,17 +176,57 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
return record.get({ plain: true }); return record.get({ plain: true });
} }
static async update({ id, data, currentUser, transaction, runtimeContext }) { static override async update({
id,
data,
currentUser,
transaction,
runtimeContext,
}: UpdateOptions<GlobalUiControlDefaultsData>): Promise<EntityRecord> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.update({ id, data, currentUser, transaction, runtimeContext });
const updateOptions: UpdateOptions<GlobalUiControlDefaultsData> = {
id,
data,
currentUser: currentUser ?? null,
};
if (transaction) {
updateOptions.transaction = transaction;
}
if (runtimeContext) {
updateOptions.runtimeContext = runtimeContext;
}
return super.update(updateOptions);
} }
static async findBy(where, options = {}) { static override async findBy(
where: { id: string },
options?: ServiceOptions,
): Promise<EntityRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<EntityRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: ServiceOptions = {},
): Promise<EntityRecord | null> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.findBy(where, options);
if ('where' in whereOrOptions) {
return super.findBy(whereOrOptions);
}
const findOptions: DbFindByOptions = { where: whereOrOptions };
if (options.transaction) {
findOptions.transaction = options.transaction;
}
return super.findBy(findOptions);
} }
} }
Global_ui_control_defaultsDBApi.initializationPromise = null; export default Global_ui_control_defaultsDBApi;
module.exports = Global_ui_control_defaultsDBApi;

View File

@ -1,53 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
class PermissionsDBApi extends GenericDBApi {
static get MODEL() {
return db.permissions;
}
static get TABLE_NAME() {
return 'permissions';
}
static get SEARCHABLE_FIELDS() {
return ['name'];
}
static get RANGE_FIELDS() {
return [];
}
static get ENUM_FIELDS() {
return [];
}
static get CSV_FIELDS() {
return ['id', 'name', 'createdAt'];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static get ASSOCIATIONS() {
return [];
}
static get FIND_BY_INCLUDES() {
return [];
}
static get FIND_ALL_INCLUDES() {
return [];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
name: data.name || null,
};
}
}
module.exports = PermissionsDBApi;

View File

@ -0,0 +1,60 @@
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
DbAssociationConfig,
PermissionData,
PermissionFieldMapping,
} from '../../types/index.ts';
class PermissionsDBApi extends GenericDBApi {
static override get MODEL(): unknown {
return db.permissions;
}
static override get TABLE_NAME(): string {
return 'permissions';
}
static override get SEARCHABLE_FIELDS(): string[] {
return ['name'];
}
static override get RANGE_FIELDS(): string[] {
return [];
}
static override get ENUM_FIELDS(): string[] {
return [];
}
static override get CSV_FIELDS(): string[] {
return ['id', 'name', 'createdAt'];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [];
}
static override get FIND_BY_INCLUDES(): unknown[] {
return [];
}
static override get FIND_ALL_INCLUDES(): unknown[] {
return [];
}
static override getFieldMapping(
data: PermissionData,
): PermissionFieldMapping {
return {
id: data.id || undefined,
name: data.name || null,
};
}
}
export default PermissionsDBApi;

View File

@ -1,28 +1,34 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
PresignedUrlRequestAssociationConfig,
PresignedUrlRequestData,
PresignedUrlRequestFieldMapping,
PresignedUrlRequestRelationFilterConfig,
} from '../../types/index.ts';
class Presigned_url_requestsDBApi extends GenericDBApi { class Presigned_url_requestsDBApi extends GenericDBApi {
static get MODEL() { static override get MODEL(): unknown {
return db.presigned_url_requests; return db.presigned_url_requests;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'presigned_url_requests'; return 'presigned_url_requests';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return ['requested_key', 'mime_type', 'status']; return ['requested_key', 'mime_type', 'status'];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return ['requested_size_mb', 'expires_at']; return ['requested_size_mb', 'expires_at'];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return ['purpose', 'asset_type']; return ['purpose', 'asset_type'];
} }
static get CSV_FIELDS() { static override get CSV_FIELDS(): string[] {
return [ return [
'id', 'id',
'purpose', 'purpose',
@ -34,29 +40,29 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
]; ];
} }
static get AUTOCOMPLETE_FIELD() { static override get AUTOCOMPLETE_FIELD(): string {
return 'requested_key'; return 'requested_key';
} }
static get ASSOCIATIONS() { static override get ASSOCIATIONS(): PresignedUrlRequestAssociationConfig[] {
return [ return [
{ field: 'project', setter: 'setProject', isArray: false }, { field: 'project', setter: 'setProject', isArray: false },
{ field: 'user', setter: 'setUser', isArray: false }, { field: 'user', setter: 'setUser', isArray: false },
]; ];
} }
static get FIND_BY_INCLUDES() { static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'project' }, { association: 'user' }]; return [{ association: 'project' }, { association: 'user' }];
} }
static get FIND_ALL_INCLUDES() { static override get FIND_ALL_INCLUDES(): unknown[] {
return [ return [
{ model: db.projects, as: 'project', required: false }, { model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false }, { model: db.users, as: 'user', required: false },
]; ];
} }
static get RELATION_FILTERS() { static override get RELATION_FILTERS(): PresignedUrlRequestRelationFilterConfig[] {
return [ return [
{ {
filterKey: 'project', filterKey: 'project',
@ -73,7 +79,9 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
]; ];
} }
static getFieldMapping(data) { static override getFieldMapping(
data: PresignedUrlRequestData,
): PresignedUrlRequestFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
purpose: data.purpose || null, purpose: data.purpose || null,
@ -87,4 +95,4 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
} }
} }
module.exports = Presigned_url_requestsDBApi; export default Presigned_url_requestsDBApi;

View File

@ -1,199 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Project_audio_tracksDBApi extends GenericDBApi {
static get MODEL() {
return db.project_audio_tracks;
}
static get TABLE_NAME() {
return 'project_audio_tracks';
}
static get SEARCHABLE_FIELDS() {
return ['source_key', 'name', 'slug', 'url'];
}
static get RANGE_FIELDS() {
return ['volume', 'sort_order'];
}
static get ENUM_FIELDS() {
return ['environment', 'loop', 'is_enabled'];
}
static get CSV_FIELDS() {
return [
'id',
'environment',
'source_key',
'name',
'slug',
'url',
'loop',
'volume',
'createdAt',
];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
environment: data.environment || null,
source_key: data.source_key || null,
name: data.name || null,
slug: data.slug || null,
url: data.url || null,
loop: data.loop || false,
volume: data.volume || null,
sort_order: data.sort_order || null,
is_enabled: data.is_enabled || false,
};
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
options,
);
const record = await this.MODEL.findOne({
where: queryWhere,
transaction,
include: [projectInclude],
});
if (!record) return null;
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = Number(filter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where = {};
const terms = filter.project ? filter.project.split('|') : [];
const validUuids = Utils.filterValidUuids(terms);
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
where = applyRuntimeEnvironment(where, options);
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Project_audio_tracksDBApi;

View File

@ -0,0 +1,324 @@
import type { Transaction } from 'sequelize';
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import Utils from '../utils.ts';
import {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} from './runtime-context.ts';
import type {
DbAssociationConfig,
DbFindAllOptions,
DbFindByOptions,
EntityRecord,
PaginatedResult,
ProjectAudioTrackData,
ProjectAudioTrackFieldMapping,
ProjectAudioTrackListFilter,
ProjectAudioTrackModel,
ProjectAudioTrackRangeFilter,
ProjectAudioTrackRecord,
ProjectAudioTrackRuntimeOptions,
QueryWhere,
RuntimeProjectInclude,
} from '../../types/index.ts';
const { Op } = db.Sequelize;
function isDbFindAllOptions(
value: ProjectAudioTrackListFilter | DbFindAllOptions<unknown>,
): value is DbFindAllOptions<unknown> {
return Boolean(value) && typeof value === 'object' && 'filter' in value;
}
function isProjectAudioTrackListFilter(
value: unknown,
): value is ProjectAudioTrackListFilter {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isRangeFilter(value: unknown): value is ProjectAudioTrackRangeFilter {
return Array.isArray(value) && value.length === 2;
}
function getFilterString(
filter: ProjectAudioTrackListFilter,
field: string,
): string | null {
const value = filter[field];
return typeof value === 'string' ? value : null;
}
function addRangeBoundary(
where: QueryWhere,
field: string,
operator: symbol,
value: ProjectAudioTrackRangeFilter[number],
): void {
if (value === undefined || value === null || value === '') return;
const current = isQueryWhere(where[field]) ? where[field] : {};
where[field] = {
...current,
[operator]: value,
};
}
function addRangeFilter(
where: QueryWhere,
field: string,
range: unknown,
): void {
if (!isRangeFilter(range)) return;
const [start, end] = range;
addRangeBoundary(where, field, Op.gte, start);
addRangeBoundary(where, field, Op.lte, end);
}
function normalizeBooleanFilter(value: unknown): boolean {
return value === true || value === 'true';
}
function normalizeFilter(
filter?: ProjectAudioTrackListFilter | DbFindAllOptions<unknown>,
): ProjectAudioTrackListFilter {
if (!filter) return {};
if (!isDbFindAllOptions(filter)) return filter;
return isProjectAudioTrackListFilter(filter.filter) ? filter.filter : {};
}
class Project_audio_tracksDBApi extends GenericDBApi {
static override get MODEL(): ProjectAudioTrackModel {
return db.project_audio_tracks;
}
static override get TABLE_NAME(): string {
return 'project_audio_tracks';
}
static override get SEARCHABLE_FIELDS(): string[] {
return ['source_key', 'name', 'slug', 'url'];
}
static override get RANGE_FIELDS(): string[] {
return ['volume', 'sort_order'];
}
static override get ENUM_FIELDS(): string[] {
return ['environment', 'loop', 'is_enabled'];
}
static override get CSV_FIELDS(): string[] {
return [
'id',
'environment',
'source_key',
'name',
'slug',
'url',
'loop',
'volume',
'createdAt',
];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static override getFieldMapping(
data: ProjectAudioTrackData,
): ProjectAudioTrackFieldMapping {
return {
id: data.id || undefined,
environment: data.environment || null,
source_key: data.source_key || null,
name: data.name || null,
slug: data.slug || null,
url: data.url || null,
loop: data.loop || false,
volume: data.volume || null,
sort_order: data.sort_order || null,
is_enabled: data.is_enabled || false,
};
}
static override async findBy(
where: { id: string },
options?: ProjectAudioTrackRuntimeOptions,
): Promise<ProjectAudioTrackRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<ProjectAudioTrackRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: ProjectAudioTrackRuntimeOptions = {},
): Promise<ProjectAudioTrackRecord | null> {
const sourceWhere =
'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
const where = isQueryWhere(sourceWhere) ? sourceWhere : {};
const runtimeOptions: ProjectAudioTrackRuntimeOptions = {};
if ('where' in whereOrOptions) {
if (whereOrOptions.transaction) {
runtimeOptions.transaction = whereOrOptions.transaction;
}
} else if (options.transaction) {
runtimeOptions.transaction = options.transaction;
}
if (options.runtimeContext) {
runtimeOptions.runtimeContext = options.runtimeContext;
}
const queryWhere = applyRuntimeEnvironment({ ...where }, runtimeOptions);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
runtimeOptions,
);
const findOptions: {
where: QueryWhere;
include: RuntimeProjectInclude[];
transaction?: Transaction;
} = {
where: queryWhere,
include: [projectInclude],
};
if (runtimeOptions.transaction) {
findOptions.transaction = runtimeOptions.transaction;
}
const record = await this.MODEL.findOne(findOptions);
if (!record) return null;
return record.get({ plain: true });
}
static override async findAll(
filter?: ProjectAudioTrackListFilter,
options?: ProjectAudioTrackRuntimeOptions,
): Promise<PaginatedResult<ProjectAudioTrackRecord>>;
static override async findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
filter?: ProjectAudioTrackListFilter | DbFindAllOptions<unknown>,
options: ProjectAudioTrackRuntimeOptions = {},
): Promise<PaginatedResult<ProjectAudioTrackRecord>> {
const normalizedFilter = normalizeFilter(filter);
const limit = Number(normalizedFilter.limit) || 0;
const currentPage = Number(normalizedFilter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where: QueryWhere = {};
const terms = normalizedFilter.project
? normalizedFilter.project.split('|')
: [];
const validUuids = Utils.filterValidUuids(terms);
const include: RuntimeProjectInclude[] = [
{
model: db.projects,
as: 'project',
where: normalizedFilter.project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (normalizedFilter.id) {
if (!Utils.isValidUuid(normalizedFilter.id)) {
return { rows: [], count: 0 };
}
where.id = normalizedFilter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
const value = getFilterString(normalizedFilter, field);
if (value) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, value);
}
}
for (const field of this.RANGE_FIELDS) {
addRangeFilter(where, field, normalizedFilter[`${field}Range`]);
}
for (const field of this.ENUM_FIELDS) {
if (normalizedFilter[field] !== undefined) {
where[field] = normalizedFilter[field];
}
}
if (normalizedFilter.active !== undefined) {
where.active = normalizeBooleanFilter(normalizedFilter.active);
}
addRangeFilter(where, 'createdAt', normalizedFilter.createdAtRange);
where = applyRuntimeEnvironment(where, options);
const findOptions: {
where: QueryWhere;
include: RuntimeProjectInclude[];
distinct: true;
order: string[][];
transaction?: Transaction;
limit?: number;
offset?: number;
} = {
where,
include,
distinct: true,
order:
normalizedFilter.field && normalizedFilter.sort
? [[normalizedFilter.field, normalizedFilter.sort]]
: [['createdAt', 'desc']],
};
if (options.transaction) {
findOptions.transaction = options.transaction;
}
if (!options.countOnly && limit) {
findOptions.limit = Number(limit);
}
if (!options.countOnly && offset) {
findOptions.offset = Number(offset);
}
const { rows, count } = await this.MODEL.findAndCountAll(findOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
}
}
export default Project_audio_tracksDBApi;

View File

@ -1,390 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Project_element_defaultsDBApi extends GenericDBApi {
static get MODEL() {
return db.project_element_defaults;
}
static get TABLE_NAME() {
return 'project_element_defaults';
}
static get SEARCHABLE_FIELDS() {
return ['name', 'element_type'];
}
static get RANGE_FIELDS() {
return ['sort_order', 'snapshot_version'];
}
static get ENUM_FIELDS() {
return [];
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'project',
model: db.projects,
as: 'project',
searchField: 'name',
},
];
}
static get FIND_ALL_INCLUDES() {
return [{ association: 'project' }, { association: 'source_element' }];
}
static get CSV_FIELDS() {
return [
'id',
'element_type',
'name',
'sort_order',
'projectId',
'snapshot_version',
'createdAt',
];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
// Declarative field configuration using base class patterns
static get JSON_FIELDS() {
return ['settings_json'];
}
static get FIELD_DEFAULTS() {
return {
element_type: { default: null },
name: { default: null },
sort_order: { default: 0 },
source_element_id: { default: null },
snapshot_version: { default: 1 },
};
}
static getFieldMapping(data) {
// Apply base class transformations (JSON fields, defaults, transformers)
const mapped = super.getFieldMapping(data);
// Custom mapping for projectId field (accepts both projectId and project)
if (mapped.project && !mapped.projectId) {
mapped.projectId = mapped.project;
}
return {
id: mapped.id || undefined,
element_type: mapped.element_type,
name: mapped.name,
sort_order: mapped.sort_order,
settings_json: mapped.settings_json,
source_element_id: mapped.source_element_id,
snapshot_version: mapped.snapshot_version,
projectId: mapped.projectId,
};
}
/**
* Custom findAll with project filtering
* Supports both 'project' and 'projectId' query params for consistency
*/
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = Number(filter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where = {};
// Support both 'project' and 'projectId' query params
const projectFilter = filter.project || filter.projectId;
const terms = projectFilter ? projectFilter.split('|') : [];
const validUuids = Utils.filterValidUuids(terms);
let include = [
{
model: db.projects,
as: 'project',
where: projectFilter
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
{
model: db.element_type_defaults,
as: 'source_element',
required: false,
},
];
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['sort_order', 'asc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
/**
* Find project element default by element type for a specific project
*/
static async findByElementType(projectId, elementType, options = {}) {
return this.MODEL.findOne({
where: {
projectId,
element_type: elementType,
deletedAt: null,
},
...options,
});
}
/**
* Snapshot all global element defaults to a project
* Used when creating a new project
*/
static async snapshotGlobalDefaults(projectId, options = {}) {
const Element_type_defaultsDBApi = require('./element_type_defaults');
// Get all global defaults
const globalDefaults = await Element_type_defaultsDBApi.findAll({});
if (!globalDefaults?.rows?.length) {
return [];
}
// Dedupe by element_type (keep first occurrence)
// Prevents unique constraint violations if global defaults have duplicates
const seenTypes = new Set();
const dedupedDefaults = globalDefaults.rows.filter((row) => {
if (seenTypes.has(row.element_type)) {
console.warn(
`Duplicate element_type in global defaults: ${row.element_type} (skipping)`,
);
return false;
}
seenTypes.add(row.element_type);
return true;
});
const now = new Date();
const currentUserId = options.currentUser?.id || null;
// Create project defaults from global defaults
const projectDefaults = await this.MODEL.bulkCreate(
dedupedDefaults.map((globalDefault) => ({
projectId,
element_type: globalDefault.element_type,
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: globalDefault.default_settings_json,
source_element_id: globalDefault.id,
snapshot_version: 1,
createdById: currentUserId,
updatedById: currentUserId,
createdAt: now,
updatedAt: now,
})),
{
transaction: options.transaction,
returning: true,
},
);
return projectDefaults;
}
/**
* Reset a project element default to the current global default
*/
static async resetToGlobal(id, options = {}) {
const Element_type_defaultsDBApi = require('./element_type_defaults');
// Ensure global defaults are initialized
await Element_type_defaultsDBApi.ensureInitialized();
// Find the project default
const projectDefault = await this.MODEL.findByPk(id);
if (!projectDefault) {
throw new Error('Project element default not found');
}
// Find the matching global default
const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({
where: {
element_type: projectDefault.element_type,
deletedAt: null,
},
});
if (!globalDefault) {
throw new Error(
`No global default found for element type: ${projectDefault.element_type}`,
);
}
// Update with global settings and increment version
const now = new Date();
await projectDefault.update(
{
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: globalDefault.default_settings_json,
source_element_id: globalDefault.id,
snapshot_version: projectDefault.snapshot_version + 1,
updatedById: options.currentUser?.id || null,
updatedAt: now,
},
{
transaction: options.transaction,
},
);
return projectDefault.reload();
}
/**
* Get diff between project default and current global default
*/
static async getDiffFromGlobal(id) {
const Element_type_defaultsDBApi = require('./element_type_defaults');
// Ensure global defaults are initialized
await Element_type_defaultsDBApi.ensureInitialized();
// Find the project default
const projectDefault = await this.MODEL.findByPk(id);
if (!projectDefault) {
throw new Error('Project element default not found');
}
// Find the matching global default
const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({
where: {
element_type: projectDefault.element_type,
deletedAt: null,
},
});
if (!globalDefault) {
return {
projectDefault,
globalDefault: null,
hasGlobalDefault: false,
isDifferent: true,
};
}
// Parse JSON settings for comparison
const projectSettings =
typeof projectDefault.settings_json === 'string'
? JSON.parse(projectDefault.settings_json || '{}')
: projectDefault.settings_json || {};
const globalSettings =
typeof globalDefault.default_settings_json === 'string'
? JSON.parse(globalDefault.default_settings_json || '{}')
: globalDefault.default_settings_json || {};
const isDifferent =
JSON.stringify(projectSettings) !== JSON.stringify(globalSettings) ||
projectDefault.name !== globalDefault.name ||
projectDefault.sort_order !== globalDefault.sort_order;
return {
projectDefault,
globalDefault,
hasGlobalDefault: true,
isDifferent,
projectSettings,
globalSettings,
};
}
}
module.exports = Project_element_defaultsDBApi;

View File

@ -0,0 +1,520 @@
import GenericDBApi from './base.api.ts';
import Element_type_defaultsDBApi from './element_type_defaults.ts';
import db from '../models/index.ts';
import Utils from '../utils.ts';
import { logger } from '../../utils/logger.ts';
import type {
DbAssociationConfig,
DbFindAllOptions,
DbFindByOptions,
DbRelationFilterConfig,
ElementSettingsJson,
EntityRecord,
GlobalElementDefaultRecord,
PaginatedResult,
ProjectElementDefaultRecord,
ProjectElementDefaultsData,
ProjectElementDefaultsDbApi,
ProjectElementDefaultsDiff,
ProjectElementDefaultsFieldMapping,
ProjectElementDefaultsListFilter,
ProjectElementDefaultsModel,
ProjectElementDefaultsModelRecord,
ProjectElementDefaultsRangeFilter,
ProjectElementDefaultsOptions,
QueryWhere,
RuntimeProjectInclude,
} from '../../types/index.ts';
const { Op } = db.Sequelize;
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null {
if (value === undefined || value === null) return null;
if (typeof value === 'string') return value;
return JSON.stringify(value);
}
function parseSettings(value: ElementSettingsJson | string | null | undefined): ElementSettingsJson {
if (!value) return {};
if (typeof value !== 'string') return value;
const parsed: unknown = JSON.parse(value || '{}');
return isElementSettingsJson(parsed) ? parsed : {};
}
function isElementSettingsJson(value: unknown): value is ElementSettingsJson {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isGlobalElementDefaultRecord(
value: EntityRecord,
): value is GlobalElementDefaultRecord {
return (
typeof value.id === 'string' &&
'element_type' in value &&
typeof value.element_type === 'string' &&
'name' in value &&
typeof value.name === 'string' &&
'sort_order' in value &&
typeof value.sort_order === 'number'
);
}
function isDbFindAllOptions(
value: ProjectElementDefaultsListFilter | DbFindAllOptions<unknown>,
): value is DbFindAllOptions<unknown> {
return Boolean(value) && typeof value === 'object' && 'filter' in value;
}
function isProjectElementDefaultsListFilter(
value: unknown,
): value is ProjectElementDefaultsListFilter {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isRangeFilter(value: unknown): value is ProjectElementDefaultsRangeFilter {
return Array.isArray(value) && value.length === 2;
}
function normalizeFilter(
filter?: ProjectElementDefaultsListFilter | DbFindAllOptions<unknown>,
): ProjectElementDefaultsListFilter {
if (!filter) return {};
if (!isDbFindAllOptions(filter)) return filter;
return isProjectElementDefaultsListFilter(filter.filter) ? filter.filter : {};
}
function addRangeBoundary(
where: QueryWhere,
field: string,
operator: symbol,
value: ProjectElementDefaultsRangeFilter[number],
): void {
if (value === undefined || value === null || value === '') return;
const current = isQueryWhere(where[field]) ? where[field] : {};
where[field] = {
...current,
[operator]: value,
};
}
function addRangeFilter(
where: QueryWhere,
field: string,
range: unknown,
): void {
if (!isRangeFilter(range)) return;
const [start, end] = range;
addRangeBoundary(where, field, Op.gte, start);
addRangeBoundary(where, field, Op.lte, end);
}
function getFilterString(
filter: ProjectElementDefaultsListFilter,
field: string,
): string | null {
const value = filter[field];
return typeof value === 'string' ? value : null;
}
class Project_element_defaultsDBApi extends GenericDBApi {
declare static findAllAutocomplete: ProjectElementDefaultsDbApi['findAllAutocomplete'];
static override get MODEL(): ProjectElementDefaultsModel {
return db.project_element_defaults;
}
static override get TABLE_NAME(): string {
return 'project_element_defaults';
}
static override get SEARCHABLE_FIELDS(): string[] {
return ['name', 'element_type'];
}
static override get RANGE_FIELDS(): string[] {
return ['sort_order', 'snapshot_version'];
}
static override get ENUM_FIELDS(): string[] {
return [];
}
static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static override get RELATION_FILTERS(): DbRelationFilterConfig[] {
return [
{
filterKey: 'project',
model: db.projects,
as: 'project',
searchField: 'name',
},
];
}
static override get FIND_ALL_INCLUDES(): unknown[] {
return [{ association: 'project' }, { association: 'source_element' }];
}
static override get CSV_FIELDS(): string[] {
return [
'id',
'element_type',
'name',
'sort_order',
'projectId',
'snapshot_version',
'createdAt',
];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static override get JSON_FIELDS(): string[] {
return ['settings_json'];
}
static override get FIELD_DEFAULTS(): Record<string, { default: unknown }> {
return {
element_type: { default: null },
name: { default: null },
sort_order: { default: 0 },
source_element_id: { default: null },
snapshot_version: { default: 1 },
};
}
static override getFieldMapping(
data: ProjectElementDefaultsData,
): ProjectElementDefaultsFieldMapping {
return {
id: data.id || undefined,
element_type: data.element_type ?? null,
name: data.name ?? null,
sort_order: data.sort_order ?? 0,
settings_json: stringifySettings(data.settings_json),
source_element_id: data.source_element_id ?? null,
snapshot_version: data.snapshot_version ?? 1,
projectId: data.projectId || data.project || null,
};
}
static override async findBy(
where: { id: string },
options?: ProjectElementDefaultsOptions,
): Promise<ProjectElementDefaultRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<ProjectElementDefaultRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: ProjectElementDefaultsOptions = {},
): Promise<ProjectElementDefaultRecord | null> {
const where = 'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
const findOptions: {
where: QueryWhere;
include?: unknown[];
transaction?: ProjectElementDefaultsOptions['transaction'];
} = {
where: isQueryWhere(where) ? where : {},
};
if ('where' in whereOrOptions) {
if (whereOrOptions.include) {
findOptions.include = whereOrOptions.include;
}
if (whereOrOptions.transaction) {
findOptions.transaction = whereOrOptions.transaction;
}
} else if (options.transaction) {
findOptions.transaction = options.transaction;
}
const record = await this.MODEL.findOne(findOptions);
return record ? record.get({ plain: true }) : null;
}
static override async findAll(
filter?: ProjectElementDefaultsListFilter,
options?: ProjectElementDefaultsOptions,
): Promise<PaginatedResult<ProjectElementDefaultRecord>>;
static override async findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
filter?: ProjectElementDefaultsListFilter | DbFindAllOptions<unknown>,
options: ProjectElementDefaultsOptions = {},
): Promise<PaginatedResult<ProjectElementDefaultRecord>> {
const normalizedFilter = normalizeFilter(filter);
const limit = Number(normalizedFilter.limit) || 0;
const currentPage = Number(normalizedFilter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
const where: QueryWhere = {};
const projectFilter = normalizedFilter.project || normalizedFilter.projectId;
const terms = projectFilter ? projectFilter.split('|') : [];
const validUuids = Utils.filterValidUuids(terms);
const include: RuntimeProjectInclude[] = [
{
model: db.projects,
as: 'project',
where: projectFilter
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
{
model: db.element_type_defaults,
as: 'source_element',
required: false,
},
];
if (normalizedFilter.id) {
if (!Utils.isValidUuid(normalizedFilter.id)) {
return { rows: [], count: 0 };
}
where.id = normalizedFilter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
const value = getFilterString(normalizedFilter, field);
if (value) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, value);
}
}
for (const field of this.RANGE_FIELDS) {
addRangeFilter(where, field, normalizedFilter[`${field}Range`]);
}
for (const field of this.ENUM_FIELDS) {
if (normalizedFilter[field] !== undefined) {
where[field] = normalizedFilter[field];
}
}
addRangeFilter(where, 'createdAt', normalizedFilter.createdAtRange);
const findOptions: {
where: QueryWhere;
include: RuntimeProjectInclude[];
distinct: true;
order: string[][];
transaction?: ProjectElementDefaultsOptions['transaction'];
limit?: number;
offset?: number;
} = {
where,
include,
distinct: true,
order:
normalizedFilter.field && normalizedFilter.sort
? [[normalizedFilter.field, normalizedFilter.sort]]
: [['sort_order', 'asc']],
};
if (options.transaction) {
findOptions.transaction = options.transaction;
}
if (!options.countOnly && limit) {
findOptions.limit = Number(limit);
}
if (!options.countOnly && offset) {
findOptions.offset = Number(offset);
}
const { rows, count } = await this.MODEL.findAndCountAll(findOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
}
static async findByElementType(
projectId: string,
elementType: string,
options: ProjectElementDefaultsOptions = {},
): Promise<ProjectElementDefaultsModelRecord | null> {
const findOptions: {
where: QueryWhere;
transaction?: ProjectElementDefaultsOptions['transaction'];
} = {
where: {
projectId,
element_type: elementType,
deletedAt: null,
},
};
if (options.transaction) {
findOptions.transaction = options.transaction;
}
return this.MODEL.findOne(findOptions);
}
static async snapshotGlobalDefaults(
projectId: string,
options: ProjectElementDefaultsOptions = {},
): Promise<ProjectElementDefaultRecord[]> {
const globalDefaults = await Element_type_defaultsDBApi.findAll({});
if (!globalDefaults.rows.length) {
return [];
}
const seenTypes = new Set<string>();
const dedupedDefaults = globalDefaults.rows.filter(isGlobalElementDefaultRecord).filter((row) => {
if (seenTypes.has(row.element_type)) {
logger.warn(
{ elementType: row.element_type },
'Duplicate element_type in global defaults skipped',
);
return false;
}
seenTypes.add(row.element_type);
return true;
});
const now = new Date();
const currentUserId = options.currentUser?.id || null;
const projectDefaults = await this.MODEL.bulkCreate(
dedupedDefaults.map((globalDefault) => ({
projectId,
element_type: globalDefault.element_type,
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: stringifySettings(globalDefault.default_settings_json),
source_element_id: globalDefault.id,
snapshot_version: 1,
createdById: currentUserId,
updatedById: currentUserId,
createdAt: now,
updatedAt: now,
id: undefined,
})),
{
transaction: options.transaction,
returning: true,
},
);
return projectDefaults.map((projectDefault) =>
projectDefault.get({ plain: true }),
);
}
static async resetToGlobal(
id: string,
options: ProjectElementDefaultsOptions = {},
): Promise<ProjectElementDefaultRecord> {
await Element_type_defaultsDBApi.ensureInitialized();
const projectDefault = await this.MODEL.findByPk(id);
if (!projectDefault) {
throw new Error('Project element default not found');
}
const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({
where: {
element_type: projectDefault.element_type,
deletedAt: null,
},
});
if (!globalDefault) {
throw new Error(
`No global default found for element type: ${projectDefault.element_type}`,
);
}
await projectDefault.update(
{
name: globalDefault.name,
sort_order: globalDefault.sort_order,
settings_json: stringifySettings(globalDefault.default_settings_json),
source_element_id: globalDefault.id,
snapshot_version: projectDefault.snapshot_version + 1,
updatedById: options.currentUser?.id || null,
updatedAt: new Date(),
},
{
transaction: options.transaction,
},
);
const reloaded = await projectDefault.reload();
return reloaded.get({ plain: true });
}
static async getDiffFromGlobal(
id: string,
): Promise<ProjectElementDefaultsDiff> {
await Element_type_defaultsDBApi.ensureInitialized();
const projectDefault = await this.MODEL.findByPk(id);
if (!projectDefault) {
throw new Error('Project element default not found');
}
const globalDefault = await Element_type_defaultsDBApi.MODEL.findOne({
where: {
element_type: projectDefault.element_type,
deletedAt: null,
},
});
if (!globalDefault) {
return {
projectDefault: projectDefault.get({ plain: true }),
globalDefault: null,
hasGlobalDefault: false,
isDifferent: true,
};
}
const projectSettings = parseSettings(projectDefault.settings_json);
const globalSettings = parseSettings(globalDefault.default_settings_json);
const isDifferent =
JSON.stringify(projectSettings) !== JSON.stringify(globalSettings) ||
projectDefault.name !== globalDefault.name ||
projectDefault.sort_order !== globalDefault.sort_order;
return {
projectDefault: projectDefault.get({ plain: true }),
globalDefault: globalDefault.get({ plain: true }),
hasGlobalDefault: true,
isDifferent,
projectSettings,
globalSettings,
};
}
}
export default Project_element_defaultsDBApi;

View File

@ -1,28 +1,34 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
ProjectMembershipAssociationConfig,
ProjectMembershipData,
ProjectMembershipFieldMapping,
ProjectMembershipRelationFilterConfig,
} from '../../types/index.ts';
class Project_membershipsDBApi extends GenericDBApi { class Project_membershipsDBApi extends GenericDBApi {
static get MODEL() { static override get MODEL(): unknown {
return db.project_memberships; return db.project_memberships;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'project_memberships'; return 'project_memberships';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return []; return [];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return ['invited_at', 'accepted_at']; return ['invited_at', 'accepted_at'];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return ['access_level', 'is_active']; return ['access_level', 'is_active'];
} }
static get CSV_FIELDS() { static override get CSV_FIELDS(): string[] {
return [ return [
'id', 'id',
'access_level', 'access_level',
@ -33,29 +39,29 @@ class Project_membershipsDBApi extends GenericDBApi {
]; ];
} }
static get AUTOCOMPLETE_FIELD() { static override get AUTOCOMPLETE_FIELD(): string {
return 'access_level'; return 'access_level';
} }
static get ASSOCIATIONS() { static override get ASSOCIATIONS(): ProjectMembershipAssociationConfig[] {
return [ return [
{ field: 'project', setter: 'setProject', isArray: false }, { field: 'project', setter: 'setProject', isArray: false },
{ field: 'user', setter: 'setUser', isArray: false }, { field: 'user', setter: 'setUser', isArray: false },
]; ];
} }
static get FIND_BY_INCLUDES() { static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'project' }, { association: 'user' }]; return [{ association: 'project' }, { association: 'user' }];
} }
static get FIND_ALL_INCLUDES() { static override get FIND_ALL_INCLUDES(): unknown[] {
return [ return [
{ model: db.projects, as: 'project', required: false }, { model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false }, { model: db.users, as: 'user', required: false },
]; ];
} }
static get RELATION_FILTERS() { static override get RELATION_FILTERS(): ProjectMembershipRelationFilterConfig[] {
return [ return [
{ {
filterKey: 'project', filterKey: 'project',
@ -72,7 +78,9 @@ class Project_membershipsDBApi extends GenericDBApi {
]; ];
} }
static getFieldMapping(data) { static override getFieldMapping(
data: ProjectMembershipData,
): ProjectMembershipFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
access_level: data.access_level || null, access_level: data.access_level || null,
@ -83,4 +91,4 @@ class Project_membershipsDBApi extends GenericDBApi {
} }
} }
module.exports = Project_membershipsDBApi; export default Project_membershipsDBApi;

View File

@ -1,277 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Project_transition_settingsDBApi extends GenericDBApi {
static get MODEL() {
return db.project_transition_settings;
}
static get TABLE_NAME() {
return 'project_transition_settings';
}
static get SEARCHABLE_FIELDS() {
return ['source_key', 'transition_type', 'easing', 'overlay_color'];
}
static get RANGE_FIELDS() {
return ['duration_ms'];
}
static get ENUM_FIELDS() {
return ['environment'];
}
static get CSV_FIELDS() {
return [
'id',
'environment',
'source_key',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
'createdAt',
];
}
static get AUTOCOMPLETE_FIELD() {
return 'transition_type';
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static getFieldMapping(data) {
// Note: environment and projectId are NOT included here because they are
// set explicitly in upsertForProject and should never be changed via data
return {
id: data.id || undefined,
source_key: data.source_key || null,
transition_type: data.transition_type || 'fade',
duration_ms: data.duration_ms !== undefined ? data.duration_ms : 700,
easing: data.easing || 'ease-in-out',
overlay_color: data.overlay_color || '#000000',
};
}
/**
* Find settings by project ID and environment.
* This is the primary method for fetching transition settings.
*
* @param {string} projectId - Project ID
* @param {string} environment - Environment (dev, stage, production)
* @param {object} options - Query options
* @returns {object|null} Settings record or null
*/
static async findByProjectAndEnvironment(
projectId,
environment,
options = {},
) {
const transaction = options.transaction;
const record = await this.MODEL.findOne({
where: {
projectId,
environment,
},
transaction,
include: [
{
model: db.projects,
as: 'project',
},
],
});
if (!record) return null;
return record.get({ plain: true });
}
/**
* Create or update settings for a project/environment combination.
* Uses upsert semantics - creates if not exists, updates if exists.
*
* @param {string} projectId - Project ID
* @param {string} environment - Environment (dev, stage, production)
* @param {object} data - Settings data
* @param {object} options - Query options
* @returns {object} Created or updated record
*/
static async upsertForProject(projectId, environment, data, options = {}) {
const transaction = options.transaction;
const currentUser = options.currentUser;
// Check if record exists
const existing = await this.MODEL.findOne({
where: { projectId, environment },
transaction,
});
if (existing) {
// Update existing record
await existing.update(
{
...this.getFieldMapping(data),
updatedById: currentUser?.id || null,
},
{ transaction },
);
return existing.get({ plain: true });
}
// Create new record
const newRecord = await this.MODEL.create(
{
...this.getFieldMapping(data),
projectId,
environment,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
{ transaction },
);
return newRecord.get({ plain: true });
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
options,
);
const record = await this.MODEL.findOne({
where: queryWhere,
transaction,
include: [projectInclude],
});
if (!record) return null;
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = Number(filter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where = {};
const terms = filter.project ? filter.project.split('|') : [];
const validUuids = Utils.filterValidUuids(terms);
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
where = applyRuntimeEnvironment(where, options);
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Project_transition_settingsDBApi;

View File

@ -0,0 +1,407 @@
import type { Transaction } from 'sequelize';
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import Utils from '../utils.ts';
import {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} from './runtime-context.ts';
import type {
DbAssociationConfig,
DbFindAllOptions,
DbFindByOptions,
EntityRecord,
PaginatedResult,
ProjectTransitionEasing,
ProjectTransitionSettingsData,
ProjectTransitionSettingsDbApi,
ProjectTransitionSettingsFieldMapping,
ProjectTransitionSettingsListFilter,
ProjectTransitionSettingsModel,
ProjectTransitionSettingsRangeFilter,
ProjectTransitionSettingsRecord,
ProjectTransitionSettingsRuntimeOptions,
ProjectTransitionType,
QueryWhere,
RuntimeEnvironment,
RuntimeProjectInclude,
} from '../../types/index.ts';
const { Op } = db.Sequelize;
const defaultTransitionType: ProjectTransitionType = 'fade';
const defaultDurationMs = 700;
const defaultEasing: ProjectTransitionEasing = 'ease-in-out';
const defaultOverlayColor = '#000000';
function isDbFindAllOptions(
value: ProjectTransitionSettingsListFilter | DbFindAllOptions<unknown>,
): value is DbFindAllOptions<unknown> {
return Boolean(value) && typeof value === 'object' && 'filter' in value;
}
function isProjectTransitionSettingsListFilter(
value: unknown,
): value is ProjectTransitionSettingsListFilter {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isRangeFilter(
value: unknown,
): value is ProjectTransitionSettingsRangeFilter {
return Array.isArray(value) && value.length === 2;
}
function getFilterString(
filter: ProjectTransitionSettingsListFilter,
field: string,
): string | null {
const value = filter[field];
return typeof value === 'string' ? value : null;
}
function addRangeBoundary(
where: QueryWhere,
field: string,
operator: symbol,
value: ProjectTransitionSettingsRangeFilter[number],
): void {
if (value === undefined || value === null || value === '') return;
const current = isQueryWhere(where[field]) ? where[field] : {};
where[field] = {
...current,
[operator]: value,
};
}
function addRangeFilter(
where: QueryWhere,
field: string,
range: unknown,
): void {
if (!isRangeFilter(range)) return;
const [start, end] = range;
addRangeBoundary(where, field, Op.gte, start);
addRangeBoundary(where, field, Op.lte, end);
}
function normalizeBooleanFilter(value: unknown): boolean {
return value === true || value === 'true';
}
function normalizeFilter(
filter?: ProjectTransitionSettingsListFilter | DbFindAllOptions<unknown>,
): ProjectTransitionSettingsListFilter {
if (!filter) return {};
if (!isDbFindAllOptions(filter)) return filter;
return isProjectTransitionSettingsListFilter(filter.filter)
? filter.filter
: {};
}
class Project_transition_settingsDBApi extends GenericDBApi {
declare static create: ProjectTransitionSettingsDbApi['create'];
declare static update: ProjectTransitionSettingsDbApi['update'];
declare static deleteByIds: ProjectTransitionSettingsDbApi['deleteByIds'];
declare static remove: ProjectTransitionSettingsDbApi['remove'];
static override get MODEL(): ProjectTransitionSettingsModel {
return db.project_transition_settings;
}
static override get TABLE_NAME(): string {
return 'project_transition_settings';
}
static override get SEARCHABLE_FIELDS(): string[] {
return ['source_key', 'transition_type', 'easing', 'overlay_color'];
}
static override get RANGE_FIELDS(): string[] {
return ['duration_ms'];
}
static override get ENUM_FIELDS(): string[] {
return ['environment'];
}
static override get CSV_FIELDS(): string[] {
return [
'id',
'environment',
'source_key',
'transition_type',
'duration_ms',
'easing',
'overlay_color',
'createdAt',
];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'transition_type';
}
static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static override getFieldMapping(
data: ProjectTransitionSettingsData,
): ProjectTransitionSettingsFieldMapping {
return {
id: data.id || undefined,
source_key: data.source_key || null,
transition_type: data.transition_type || defaultTransitionType,
duration_ms:
data.duration_ms !== undefined ? data.duration_ms : defaultDurationMs,
easing: data.easing || defaultEasing,
overlay_color: data.overlay_color || defaultOverlayColor,
};
}
static async findByProjectAndEnvironment(
projectId: string,
environment: RuntimeEnvironment,
options: ProjectTransitionSettingsRuntimeOptions = {},
): Promise<ProjectTransitionSettingsRecord | null> {
const findOptions: {
where: QueryWhere;
include: RuntimeProjectInclude[];
transaction?: Transaction;
} = {
where: { projectId, environment },
include: [{ model: db.projects, as: 'project' }],
};
if (options.transaction) {
findOptions.transaction = options.transaction;
}
const record = await this.MODEL.findOne(findOptions);
if (!record) return null;
return record.get({ plain: true });
}
static async upsertForProject(
projectId: string,
environment: RuntimeEnvironment,
data: ProjectTransitionSettingsData,
options: ProjectTransitionSettingsRuntimeOptions = {},
): Promise<ProjectTransitionSettingsRecord> {
const transaction = options.transaction;
const currentUser = options.currentUser;
const findOptions: {
where: QueryWhere;
transaction?: Transaction;
} = {
where: { projectId, environment },
};
if (transaction) {
findOptions.transaction = transaction;
}
const existing = await this.MODEL.findOne(findOptions);
if (existing) {
await existing.update(
{
...this.getFieldMapping(data),
updatedById: currentUser?.id || null,
},
transaction ? { transaction } : {},
);
return existing.get({ plain: true });
}
const newRecord = await this.MODEL.create(
{
...this.getFieldMapping(data),
projectId,
environment,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
transaction ? { transaction } : {},
);
return newRecord.get({ plain: true });
}
static override async findBy(
where: { id: string },
options?: ProjectTransitionSettingsRuntimeOptions,
): Promise<ProjectTransitionSettingsRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<ProjectTransitionSettingsRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: ProjectTransitionSettingsRuntimeOptions = {},
): Promise<ProjectTransitionSettingsRecord | null> {
const sourceWhere =
'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
const where = isQueryWhere(sourceWhere) ? sourceWhere : {};
const runtimeOptions: ProjectTransitionSettingsRuntimeOptions = {};
if ('where' in whereOrOptions) {
if (whereOrOptions.transaction) {
runtimeOptions.transaction = whereOrOptions.transaction;
}
} else if (options.transaction) {
runtimeOptions.transaction = options.transaction;
}
if (options.runtimeContext) {
runtimeOptions.runtimeContext = options.runtimeContext;
}
const queryWhere = applyRuntimeEnvironment({ ...where }, runtimeOptions);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
runtimeOptions,
);
const findOptions: {
where: QueryWhere;
include: RuntimeProjectInclude[];
transaction?: Transaction;
} = {
where: queryWhere,
include: [projectInclude],
};
if (runtimeOptions.transaction) {
findOptions.transaction = runtimeOptions.transaction;
}
const record = await this.MODEL.findOne(findOptions);
if (!record) return null;
return record.get({ plain: true });
}
static override async findAll(
filter?: ProjectTransitionSettingsListFilter,
options?: ProjectTransitionSettingsRuntimeOptions,
): Promise<PaginatedResult<ProjectTransitionSettingsRecord>>;
static override async findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
filter?: ProjectTransitionSettingsListFilter | DbFindAllOptions<unknown>,
options: ProjectTransitionSettingsRuntimeOptions = {},
): Promise<PaginatedResult<ProjectTransitionSettingsRecord>> {
const normalizedFilter = normalizeFilter(filter);
const limit = Number(normalizedFilter.limit) || 0;
const currentPage = Number(normalizedFilter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where: QueryWhere = {};
const terms = normalizedFilter.project
? normalizedFilter.project.split('|')
: [];
const validUuids = Utils.filterValidUuids(terms);
const include: RuntimeProjectInclude[] = [
{
model: db.projects,
as: 'project',
where: normalizedFilter.project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (normalizedFilter.id) {
if (!Utils.isValidUuid(normalizedFilter.id)) {
return { rows: [], count: 0 };
}
where.id = normalizedFilter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
const value = getFilterString(normalizedFilter, field);
if (value) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, value);
}
}
for (const field of this.RANGE_FIELDS) {
addRangeFilter(where, field, normalizedFilter[`${field}Range`]);
}
for (const field of this.ENUM_FIELDS) {
if (normalizedFilter[field] !== undefined) {
where[field] = normalizedFilter[field];
}
}
if (normalizedFilter.active !== undefined) {
where.active = normalizeBooleanFilter(normalizedFilter.active);
}
addRangeFilter(where, 'createdAt', normalizedFilter.createdAtRange);
where = applyRuntimeEnvironment(where, options);
const findOptions: {
where: QueryWhere;
include: RuntimeProjectInclude[];
distinct: true;
order: string[][];
transaction?: Transaction;
limit?: number;
offset?: number;
} = {
where,
include,
distinct: true,
order:
normalizedFilter.field && normalizedFilter.sort
? [[normalizedFilter.field, normalizedFilter.sort]]
: [['createdAt', 'desc']],
};
if (options.transaction) {
findOptions.transaction = options.transaction;
}
if (!options.countOnly && limit) {
findOptions.limit = Number(limit);
}
if (!options.countOnly && offset) {
findOptions.offset = Number(offset);
}
const { rows, count } = await this.MODEL.findAndCountAll(findOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
}
}
export default Project_transition_settingsDBApi;

View File

@ -1,183 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Project_ui_control_settingsDBApi extends GenericDBApi {
static get MODEL() {
return db.project_ui_control_settings;
}
static get TABLE_NAME() {
return 'project_ui_control_settings';
}
static get SEARCHABLE_FIELDS() {
return ['source_key'];
}
static get RANGE_FIELDS() {
return [];
}
static get ENUM_FIELDS() {
return ['environment'];
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
source_key: data.source_key || null,
settings_json: data.settings_json || data.settings || {},
};
}
static async findByProjectAndEnvironment(
projectId,
environment,
options = {},
) {
const record = await this.MODEL.findOne({
where: { projectId, environment },
transaction: options.transaction,
include: [{ model: db.projects, as: 'project' }],
});
if (!record) return null;
return record.get({ plain: true });
}
static async upsertForProject(projectId, environment, data, options = {}) {
const transaction = options.transaction;
const currentUser = options.currentUser;
const existing = await this.MODEL.findOne({
where: { projectId, environment },
transaction,
});
if (existing) {
await existing.update(
{
...this.getFieldMapping(data),
updatedById: currentUser?.id || null,
},
{ transaction },
);
return existing.get({ plain: true });
}
const newRecord = await this.MODEL.create(
{
...this.getFieldMapping(data),
projectId,
environment,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
{ transaction },
);
return newRecord.get({ plain: true });
}
static async findBy(where, options = {}) {
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
options,
);
const record = await this.MODEL.findOne({
where: queryWhere,
transaction: options.transaction,
include: [projectInclude],
});
if (!record) return null;
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = Number(filter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where = {};
const terms = filter.project ? filter.project.split('|') : [];
const validUuids = Utils.filterValidUuids(terms);
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
where = applyRuntimeEnvironment(where, options);
const { rows, count } = await this.MODEL.findAndCountAll({
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
limit: !options.countOnly && limit ? Number(limit) : undefined,
offset: !options.countOnly && offset ? Number(offset) : undefined,
});
return {
rows: options.countOnly ? [] : rows,
count,
};
}
}
module.exports = Project_ui_control_settingsDBApi;

View File

@ -0,0 +1,312 @@
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import Utils from '../utils.ts';
import {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} from './runtime-context.ts';
import type {
DbAssociationConfig,
DbFindAllOptions,
DbFindByOptions,
EntityRecord,
PaginatedResult,
ProjectSettingsListFilter,
ProjectUiControlSettingsData,
ProjectUiControlSettingsFieldMapping,
ProjectUiControlSettingsModel,
ProjectUiControlSettingsRecord,
ProjectUiControlSettingsRuntimeOptions,
ProjectUiControlSettingsUpsertOptions,
QueryWhere,
RuntimeEnvironment,
RuntimeProjectInclude,
} from '../../types/index.ts';
import type { Transaction } from 'sequelize';
const { Op } = db.Sequelize;
function isDbFindAllOptions(
value: ProjectSettingsListFilter | DbFindAllOptions<unknown>,
): value is DbFindAllOptions<unknown> {
return Boolean(value) && typeof value === 'object' && 'filter' in value;
}
function isProjectSettingsListFilter(
value: unknown,
): value is ProjectSettingsListFilter {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
class Project_ui_control_settingsDBApi extends GenericDBApi {
static override get MODEL(): ProjectUiControlSettingsModel {
return db.project_ui_control_settings;
}
static override get TABLE_NAME(): string {
return 'project_ui_control_settings';
}
static override get SEARCHABLE_FIELDS(): string[] {
return ['source_key'];
}
static override get RANGE_FIELDS(): string[] {
return [];
}
static override get ENUM_FIELDS(): string[] {
return ['environment'];
}
static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static override getFieldMapping(
data: ProjectUiControlSettingsData,
): ProjectUiControlSettingsFieldMapping {
return {
id: data.id || undefined,
source_key: data.source_key || null,
settings_json: data.settings_json || data.settings || {},
};
}
static async findByProjectAndEnvironment(
projectId: string,
environment: RuntimeEnvironment,
options: ProjectUiControlSettingsRuntimeOptions = {},
): Promise<ProjectUiControlSettingsRecord | null> {
const findOptions: {
where: QueryWhere;
transaction?: Transaction;
include: RuntimeProjectInclude[];
} = {
where: { projectId, environment },
include: [{ model: db.projects, as: 'project' }],
};
if (options.transaction) {
findOptions.transaction = options.transaction;
}
const record = await this.MODEL.findOne(findOptions);
if (!record) return null;
return record.get({ plain: true });
}
static async upsertForProject(
projectId: string,
environment: RuntimeEnvironment,
data: ProjectUiControlSettingsData,
options: ProjectUiControlSettingsUpsertOptions = {},
): Promise<ProjectUiControlSettingsRecord> {
const transaction = options.transaction;
const currentUser = options.currentUser;
const findOptions: {
where: QueryWhere;
transaction?: Transaction;
} = {
where: { projectId, environment },
};
if (transaction) {
findOptions.transaction = transaction;
}
const existing = await this.MODEL.findOne(findOptions);
if (existing) {
await existing.update(
{
...this.getFieldMapping(data),
updatedById: currentUser?.id || null,
},
transaction ? { transaction } : {},
);
return existing.get({ plain: true });
}
const newRecord = await this.MODEL.create(
{
...this.getFieldMapping(data),
projectId,
environment,
createdById: currentUser?.id || null,
updatedById: currentUser?.id || null,
},
transaction ? { transaction } : {},
);
return newRecord.get({ plain: true });
}
static override async findBy(
where: { id: string },
options?: ProjectUiControlSettingsRuntimeOptions,
): Promise<ProjectUiControlSettingsRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<ProjectUiControlSettingsRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: ProjectUiControlSettingsRuntimeOptions = {},
): Promise<ProjectUiControlSettingsRecord | null> {
const sourceWhere =
'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
const where = isQueryWhere(sourceWhere) ? sourceWhere : {};
let runtimeOptions: ProjectUiControlSettingsRuntimeOptions = options;
if ('where' in whereOrOptions) {
runtimeOptions = {};
if (whereOrOptions.transaction) {
runtimeOptions.transaction = whereOrOptions.transaction;
}
if (options.runtimeContext) {
runtimeOptions.runtimeContext = options.runtimeContext;
}
}
const queryWhere = applyRuntimeEnvironment({ ...where }, runtimeOptions);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
runtimeOptions,
);
const findOptions: {
where: QueryWhere;
transaction?: Transaction;
include: RuntimeProjectInclude[];
} = {
where: queryWhere,
include: [projectInclude],
};
if (runtimeOptions.transaction) {
findOptions.transaction = runtimeOptions.transaction;
}
const record = await this.MODEL.findOne(findOptions);
if (!record) return null;
return record.get({ plain: true });
}
static override async findAll(
filter?: ProjectSettingsListFilter,
options?: ProjectUiControlSettingsRuntimeOptions,
): Promise<PaginatedResult<ProjectUiControlSettingsRecord>>;
static override async findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
filter?: ProjectSettingsListFilter | DbFindAllOptions<unknown>,
options: ProjectUiControlSettingsRuntimeOptions = {},
): Promise<PaginatedResult<ProjectUiControlSettingsRecord>> {
const normalizedFilter = filter
? isDbFindAllOptions(filter)
? isProjectSettingsListFilter(filter.filter)
? filter.filter
: {}
: filter
: {};
const limit = Number(normalizedFilter.limit) || 0;
const currentPage = Number(normalizedFilter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where: QueryWhere = {};
const terms = normalizedFilter.project
? normalizedFilter.project.split('|')
: [];
const validUuids = Utils.filterValidUuids(terms);
const include: RuntimeProjectInclude[] = [
{
model: db.projects,
as: 'project',
where: normalizedFilter.project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (normalizedFilter.id) {
if (!Utils.isValidUuid(normalizedFilter.id)) {
return { rows: [], count: 0 };
}
where.id = normalizedFilter.id;
}
if (normalizedFilter.source_key) {
where[Op.and] = Utils.ilike(
this.TABLE_NAME,
'source_key',
normalizedFilter.source_key,
);
}
if (normalizedFilter.environment !== undefined) {
where.environment = normalizedFilter.environment;
}
where = applyRuntimeEnvironment(where, options);
const findOptions: {
where: QueryWhere;
include: RuntimeProjectInclude[];
distinct: true;
order: string[][];
transaction?: Transaction;
limit?: number;
offset?: number;
} = {
where,
include,
distinct: true,
order:
normalizedFilter.field && normalizedFilter.sort
? [[normalizedFilter.field, normalizedFilter.sort]]
: [['createdAt', 'desc']],
};
if (options.transaction) {
findOptions.transaction = options.transaction;
}
if (!options.countOnly && limit) {
findOptions.limit = limit;
}
if (!options.countOnly && offset) {
findOptions.offset = offset;
}
const { rows, count } = await this.MODEL.findAndCountAll(findOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
}
}
export default Project_ui_control_settingsDBApi;

View File

@ -1,237 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const { getRuntimeProjectSlug } = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class ProjectsDBApi extends GenericDBApi {
static get MODEL() {
return db.projects;
}
static get TABLE_NAME() {
return 'projects';
}
static get SEARCHABLE_FIELDS() {
return [
'name',
'slug',
'description',
'logo_url',
'favicon_url',
'og_image_url',
'production_presentation_visibility',
];
}
static get RANGE_FIELDS() {
return [];
}
static get ENUM_FIELDS() {
return [];
}
static get CSV_FIELDS() {
return ['id', 'name', 'slug', 'description', 'logo_url', 'createdAt'];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static get ASSOCIATIONS() {
return [];
}
static getFieldMapping(data) {
// Use undefined for missing fields so they're skipped during update
// Only include fields that are explicitly provided in data
// Note: transition_settings moved to project_transition_settings table
return {
id: data.id || undefined,
name: 'name' in data ? data.name || null : undefined,
slug: 'slug' in data ? data.slug || null : undefined,
description: 'description' in data ? data.description || null : undefined,
logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
favicon_url: 'favicon_url' in data ? data.favicon_url || null : undefined,
og_image_url:
'og_image_url' in data ? data.og_image_url || null : undefined,
design_width: 'design_width' in data ? data.design_width : undefined,
design_height: 'design_height' in data ? data.design_height : undefined,
production_presentation_visibility:
'production_presentation_visibility' in data
? data.production_presentation_visibility || 'public'
: undefined,
};
}
static get DEFAULT_INCLUDES() {
return [];
}
static get ALL_INCLUDES() {
return [
{ association: 'project_memberships_project' },
{ association: 'production_presentation_access_project' },
{ association: 'assets_project' },
{ association: 'presigned_url_requests_project' },
{ association: 'tour_pages_project' },
{ association: 'project_audio_tracks_project' },
{ association: 'publish_events_project' },
{ association: 'pwa_caches_project' },
{ association: 'access_logs_project' },
{ association: 'project_ui_control_settings_project' },
];
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const runtimeProjectSlug = getRuntimeProjectSlug(options);
const queryWhere = { ...where };
// Runtime access: filter by project slug
// Skip if finding by ID (unambiguous lookup)
if (runtimeProjectSlug && !where.id) {
queryWhere.slug = runtimeProjectSlug;
}
const include =
options.include !== undefined ? options.include : this.DEFAULT_INCLUDES;
const record = await this.MODEL.findOne({
where: queryWhere,
transaction,
include,
});
if (!record) return null;
return record.get({ plain: true });
}
/**
* Create a new project and auto-snapshot global element defaults
*/
static async create(options) {
const { transaction } = options;
// Create the project using parent's create
const project = await super.create(options);
// Auto-snapshot global element defaults to the new project
// Errors propagate to service layer → transaction rollback → proper error to client
const Project_element_defaultsDBApi = require('./project_element_defaults');
await Project_element_defaultsDBApi.snapshotGlobalDefaults(project.id, {
...options,
transaction,
});
return project;
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = Number(filter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where = {};
let include = [];
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
// Runtime access: filter by project slug
const runtimeProjectSlug = getRuntimeProjectSlug(options);
if (runtimeProjectSlug) {
where.slug = runtimeProjectSlug;
}
try {
if (options.countOnly) {
const count = await this.MODEL.count({
where,
include,
distinct: true,
transaction: options.transaction,
});
return {
rows: [],
count,
};
}
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
};
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = ProjectsDBApi;

View File

@ -0,0 +1,351 @@
import GenericDBApi from './base.api.ts';
import Project_element_defaultsDBApi from './project_element_defaults.ts';
import { getRuntimeProjectSlug } from './runtime-context.ts';
import db from '../models/index.ts';
import Utils from '../utils.ts';
import type {
CreateOptions,
DbAssociationConfig,
DbFindAllOptions,
DbFindByOptions,
PaginatedResult,
ProjectData,
ProjectFieldMapping,
ProjectFindAllOptions,
ProjectListFilter,
ProjectModelApi,
ProjectElementDefaultsOptions,
ProjectRangeFilter,
ProjectRecord,
ProjectsDbApi,
QueryWhere,
} from '../../types/index.ts';
const { Op } = db.Sequelize;
function isDbFindAllOptions(
value: ProjectListFilter | DbFindAllOptions<unknown>,
): value is DbFindAllOptions<unknown> {
return Boolean(value) && typeof value === 'object' && 'filter' in value;
}
function isProjectListFilter(value: unknown): value is ProjectListFilter {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isRangeFilter(value: unknown): value is ProjectRangeFilter {
return Array.isArray(value) && value.length === 2;
}
function normalizeFilter(
filter?: ProjectListFilter | DbFindAllOptions<unknown>,
): ProjectListFilter {
if (!filter) return {};
if (!isDbFindAllOptions(filter)) return filter;
return isProjectListFilter(filter.filter) ? filter.filter : {};
}
function getFilterString(filter: ProjectListFilter, field: string): string | null {
const value = filter[field];
return typeof value === 'string' ? value : null;
}
function addRangeBoundary(
where: QueryWhere,
field: string,
operator: symbol,
value: ProjectRangeFilter[number],
): void {
if (value === undefined || value === null || value === '') return;
const current = isQueryWhere(where[field]) ? where[field] : {};
where[field] = {
...current,
[operator]: value,
};
}
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
if (!isRangeFilter(range)) return;
const [start, end] = range;
addRangeBoundary(where, field, Op.gte, start);
addRangeBoundary(where, field, Op.lte, end);
}
function getDefinedProjectFields(data: ProjectData): ProjectFieldMapping {
return {
id: data.id || undefined,
name: 'name' in data ? data.name || null : undefined,
slug: 'slug' in data ? data.slug || null : undefined,
description: 'description' in data ? data.description || null : undefined,
logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
favicon_url: 'favicon_url' in data ? data.favicon_url || null : undefined,
og_image_url: 'og_image_url' in data ? data.og_image_url || null : undefined,
design_width: 'design_width' in data ? data.design_width : undefined,
design_height: 'design_height' in data ? data.design_height : undefined,
production_presentation_visibility:
'production_presentation_visibility' in data
? data.production_presentation_visibility || 'public'
: undefined,
};
}
class ProjectsDBApi extends GenericDBApi {
declare static update: ProjectsDbApi['update'];
declare static partialUpdate: ProjectsDbApi['partialUpdate'];
declare static deleteByIds: ProjectsDbApi['deleteByIds'];
declare static remove: ProjectsDbApi['remove'];
declare static findAllAutocomplete: ProjectsDbApi['findAllAutocomplete'];
static override get MODEL(): ProjectModelApi {
return db.projects;
}
static override get TABLE_NAME(): string {
return 'projects';
}
static override get SEARCHABLE_FIELDS(): string[] {
return [
'name',
'slug',
'description',
'logo_url',
'favicon_url',
'og_image_url',
'production_presentation_visibility',
];
}
static override get RANGE_FIELDS(): string[] {
return [];
}
static override get ENUM_FIELDS(): string[] {
return [];
}
static override get CSV_FIELDS(): string[] {
return ['id', 'name', 'slug', 'description', 'logo_url', 'createdAt'];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [];
}
static override getFieldMapping(data: ProjectData): ProjectFieldMapping {
return getDefinedProjectFields(data);
}
static get DEFAULT_INCLUDES(): unknown[] {
return [];
}
static get ALL_INCLUDES(): unknown[] {
return [
{ association: 'project_memberships_project' },
{ association: 'production_presentation_access_project' },
{ association: 'assets_project' },
{ association: 'presigned_url_requests_project' },
{ association: 'tour_pages_project' },
{ association: 'project_audio_tracks_project' },
{ association: 'publish_events_project' },
{ association: 'pwa_caches_project' },
{ association: 'access_logs_project' },
{ association: 'project_ui_control_settings_project' },
];
}
static override async findBy(
where: { id: string },
options?: ProjectFindAllOptions,
): Promise<ProjectRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<ProjectRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: ProjectFindAllOptions = {},
): Promise<ProjectRecord | null> {
const sourceWhere =
'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
const queryWhere: QueryWhere = isQueryWhere(sourceWhere)
? { ...sourceWhere }
: {};
const runtimeProjectSlug = getRuntimeProjectSlug(options);
if (runtimeProjectSlug && !queryWhere.id) {
queryWhere.slug = runtimeProjectSlug;
}
const include =
'where' in whereOrOptions && whereOrOptions.include !== undefined
? whereOrOptions.include
: options.include !== undefined
? options.include
: this.DEFAULT_INCLUDES;
const findOptions: {
where: QueryWhere;
transaction?: ProjectFindAllOptions['transaction'];
include: unknown[];
} = {
where: queryWhere,
include,
};
if ('where' in whereOrOptions && whereOrOptions.transaction) {
findOptions.transaction = whereOrOptions.transaction;
} else if (options.transaction) {
findOptions.transaction = options.transaction;
}
const record = await this.MODEL.findOne(findOptions);
return record ? record.get({ plain: true }) : null;
}
static override async create(
options: CreateOptions<ProjectData>,
): Promise<ProjectRecord> {
const { data, currentUser = { id: null }, transaction } = options;
const record = await this.MODEL.create(
{
...this.getFieldMapping(data),
importHash: null,
createdById: currentUser?.id ?? null,
updatedById: currentUser?.id ?? null,
},
{ transaction },
);
const project = record.get({ plain: true });
const snapshotOptions: ProjectElementDefaultsOptions = {};
if (options.currentUser !== undefined) {
snapshotOptions.currentUser = options.currentUser;
}
if (options.runtimeContext !== undefined) {
snapshotOptions.runtimeContext = options.runtimeContext;
}
if (transaction) {
snapshotOptions.transaction = transaction;
}
await Project_element_defaultsDBApi.snapshotGlobalDefaults(
project.id,
snapshotOptions,
);
return project;
}
static override async findAll(
filter?: ProjectListFilter,
options?: ProjectFindAllOptions,
): Promise<PaginatedResult<ProjectRecord>>;
static override async findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<ProjectRecord>>;
static override async findAll(
filter?: ProjectListFilter | DbFindAllOptions<unknown>,
options: ProjectFindAllOptions = {},
): Promise<PaginatedResult<ProjectRecord>> {
const normalizedFilter = normalizeFilter(filter);
const limit = Number(normalizedFilter.limit) || 0;
const currentPage = Number(normalizedFilter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
const where: QueryWhere = {};
const include: unknown[] = [];
const id = getFilterString(normalizedFilter, 'id');
if (id) {
if (!Utils.isValidUuid(id)) {
return { rows: [], count: 0 };
}
where.id = id;
}
for (const field of this.SEARCHABLE_FIELDS) {
const value = getFilterString(normalizedFilter, field);
if (value) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, value);
}
}
for (const field of this.RANGE_FIELDS) {
addRangeFilter(where, field, normalizedFilter[`${field}Range`]);
}
for (const field of this.ENUM_FIELDS) {
if (normalizedFilter[field] !== undefined) {
where[field] = normalizedFilter[field];
}
}
const active = normalizedFilter.active;
if (active !== undefined) {
where.active = active === 'true';
}
addRangeFilter(where, 'createdAt', normalizedFilter.createdAtRange);
const runtimeProjectSlug = getRuntimeProjectSlug(options);
if (runtimeProjectSlug) {
where.slug = runtimeProjectSlug;
}
if (options.countOnly) {
const count = await this.MODEL.count({
where,
include,
distinct: true,
transaction: options.transaction,
});
return {
rows: [],
count,
};
}
const queryOptions: {
where: QueryWhere;
include: unknown[];
distinct: true;
order: string[][];
transaction?: ProjectFindAllOptions['transaction'];
limit?: number;
offset?: number;
} = {
where,
include,
distinct: true,
order:
normalizedFilter.field && normalizedFilter.sort
? [[normalizedFilter.field, normalizedFilter.sort]]
: [['createdAt', 'desc']],
};
if (options.transaction) {
queryOptions.transaction = options.transaction;
}
if (limit) {
queryOptions.limit = limit;
}
if (offset) {
queryOptions.offset = offset;
}
return this.MODEL.findAndCountAll(queryOptions);
}
}
export default ProjectsDBApi;

View File

@ -1,20 +1,26 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
PublishEventAssociationConfig,
PublishEventData,
PublishEventFieldMapping,
PublishEventRelationFilterConfig,
} from '../../types/index.ts';
class Publish_eventsDBApi extends GenericDBApi { class Publish_eventsDBApi extends GenericDBApi {
static get MODEL() { static override get MODEL(): unknown {
return db.publish_events; return db.publish_events;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'publish_events'; return 'publish_events';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return ['title', 'description', 'error_message']; return ['title', 'description', 'error_message'];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return [ return [
'started_at', 'started_at',
'finished_at', 'finished_at',
@ -24,15 +30,15 @@ class Publish_eventsDBApi extends GenericDBApi {
]; ];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return ['from_environment', 'to_environment', 'status']; return ['from_environment', 'to_environment', 'status'];
} }
static get UUID_FIELDS() { static override get UUID_FIELDS(): string[] {
return ['projectId']; return ['projectId'];
} }
static get CSV_FIELDS() { static override get CSV_FIELDS(): string[] {
return [ return [
'id', 'id',
'title', 'title',
@ -45,29 +51,29 @@ class Publish_eventsDBApi extends GenericDBApi {
]; ];
} }
static get AUTOCOMPLETE_FIELD() { static override get AUTOCOMPLETE_FIELD(): string {
return 'status'; return 'status';
} }
static get ASSOCIATIONS() { static override get ASSOCIATIONS(): PublishEventAssociationConfig[] {
return [ return [
{ field: 'project', setter: 'setProject', isArray: false }, { field: 'project', setter: 'setProject', isArray: false },
{ field: 'user', setter: 'setUser', isArray: false }, { field: 'user', setter: 'setUser', isArray: false },
]; ];
} }
static get FIND_BY_INCLUDES() { static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'project' }, { association: 'user' }]; return [{ association: 'project' }, { association: 'user' }];
} }
static get FIND_ALL_INCLUDES() { static override get FIND_ALL_INCLUDES(): unknown[] {
return [ return [
{ model: db.projects, as: 'project', required: false }, { model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false }, { model: db.users, as: 'user', required: false },
]; ];
} }
static get RELATION_FILTERS() { static override get RELATION_FILTERS(): PublishEventRelationFilterConfig[] {
return [ return [
{ {
filterKey: 'project', filterKey: 'project',
@ -84,7 +90,7 @@ class Publish_eventsDBApi extends GenericDBApi {
]; ];
} }
static getFieldMapping(data) { static override getFieldMapping(data: PublishEventData): PublishEventFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
title: data.title || null, title: data.title || null,
@ -102,4 +108,4 @@ class Publish_eventsDBApi extends GenericDBApi {
} }
} }
module.exports = Publish_eventsDBApi; export default Publish_eventsDBApi;

View File

@ -1,28 +1,34 @@
const GenericDBApi = require('./base.api'); import GenericDBApi from './base.api.ts';
const db = require('../models'); import db from '../models/index.ts';
import type {
PwaCacheAssociationConfig,
PwaCacheData,
PwaCacheFieldMapping,
PwaCacheRelationFilterConfig,
} from '../../types/index.ts';
class Pwa_cachesDBApi extends GenericDBApi { class Pwa_cachesDBApi extends GenericDBApi {
static get MODEL() { static override get MODEL(): unknown {
return db.pwa_caches; return db.pwa_caches;
} }
static get TABLE_NAME() { static override get TABLE_NAME(): string {
return 'pwa_caches'; return 'pwa_caches';
} }
static get SEARCHABLE_FIELDS() { static override get SEARCHABLE_FIELDS(): string[] {
return ['cache_version', 'manifest_json', 'asset_list_json']; return ['cache_version', 'manifest_json', 'asset_list_json'];
} }
static get RANGE_FIELDS() { static override get RANGE_FIELDS(): string[] {
return ['generated_at']; return ['generated_at'];
} }
static get ENUM_FIELDS() { static override get ENUM_FIELDS(): string[] {
return ['environment', 'is_active']; return ['environment', 'is_active'];
} }
static get CSV_FIELDS() { static override get CSV_FIELDS(): string[] {
return [ return [
'id', 'id',
'environment', 'environment',
@ -33,23 +39,23 @@ class Pwa_cachesDBApi extends GenericDBApi {
]; ];
} }
static get AUTOCOMPLETE_FIELD() { static override get AUTOCOMPLETE_FIELD(): string {
return 'cache_version'; return 'cache_version';
} }
static get ASSOCIATIONS() { static override get ASSOCIATIONS(): PwaCacheAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }]; return [{ field: 'project', setter: 'setProject', isArray: false }];
} }
static get FIND_BY_INCLUDES() { static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'project' }]; return [{ association: 'project' }];
} }
static get FIND_ALL_INCLUDES() { static override get FIND_ALL_INCLUDES(): unknown[] {
return [{ model: db.projects, as: 'project', required: false }]; return [{ model: db.projects, as: 'project', required: false }];
} }
static get RELATION_FILTERS() { static override get RELATION_FILTERS(): PwaCacheRelationFilterConfig[] {
return [ return [
{ {
filterKey: 'project', filterKey: 'project',
@ -60,7 +66,7 @@ class Pwa_cachesDBApi extends GenericDBApi {
]; ];
} }
static getFieldMapping(data) { static override getFieldMapping(data: PwaCacheData): PwaCacheFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
environment: data.environment || null, environment: data.environment || null,
@ -73,4 +79,4 @@ class Pwa_cachesDBApi extends GenericDBApi {
} }
} }
module.exports = Pwa_cachesDBApi; export default Pwa_cachesDBApi;

View File

@ -1,71 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
class RolesDBApi extends GenericDBApi {
static get MODEL() {
return db.roles;
}
static get TABLE_NAME() {
return 'roles';
}
static get SEARCHABLE_FIELDS() {
return ['name', 'role_customization'];
}
static get RANGE_FIELDS() {
return [];
}
static get ENUM_FIELDS() {
return [];
}
static get CSV_FIELDS() {
return ['id', 'name', 'role_customization', 'createdAt'];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static get ASSOCIATIONS() {
return [{ field: 'permissions', setter: 'setPermissions', isArray: true }];
}
static get FIND_BY_INCLUDES() {
return [{ association: 'users_app_role' }, { association: 'permissions' }];
}
static get FIND_ALL_INCLUDES() {
return [
{
model: db.permissions,
as: 'permissions',
required: false,
},
];
}
static get RELATION_FILTERS() {
return [
{
filterKey: 'permissions',
model: db.permissions,
as: 'permissions_filter',
searchField: 'name',
},
];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
name: data.name || null,
role_customization: data.role_customization || null,
};
}
}
module.exports = RolesDBApi;

View File

@ -0,0 +1,84 @@
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
RoleAssociationConfig,
RoleData,
RoleFieldMapping,
RoleRelationFilterConfig,
RolesDbApi,
} from '../../types/index.ts';
class RolesDBApi extends GenericDBApi {
declare static create: RolesDbApi['create'];
declare static findBy: RolesDbApi['findBy'];
declare static update: RolesDbApi['update'];
declare static deleteByIds: RolesDbApi['deleteByIds'];
declare static remove: RolesDbApi['remove'];
static override get MODEL(): unknown {
return db.roles;
}
static override get TABLE_NAME(): string {
return 'roles';
}
static override get SEARCHABLE_FIELDS(): string[] {
return ['name', 'role_customization'];
}
static override get RANGE_FIELDS(): string[] {
return [];
}
static override get ENUM_FIELDS(): string[] {
return [];
}
static override get CSV_FIELDS(): string[] {
return ['id', 'name', 'role_customization', 'createdAt'];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static override get ASSOCIATIONS(): RoleAssociationConfig[] {
return [{ field: 'permissions', setter: 'setPermissions', isArray: true }];
}
static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'users_app_role' }, { association: 'permissions' }];
}
static override get FIND_ALL_INCLUDES(): unknown[] {
return [
{
model: db.permissions,
as: 'permissions',
required: false,
},
];
}
static override get RELATION_FILTERS(): RoleRelationFilterConfig[] {
return [
{
filterKey: 'permissions',
model: db.permissions,
as: 'permissions_filter',
searchField: 'name',
},
];
}
static override getFieldMapping(data: RoleData): RoleFieldMapping {
return {
id: data.id || undefined,
name: data.name || null,
role_customization: data.role_customization || null,
};
}
}
export default RolesDBApi;

View File

@ -1,57 +0,0 @@
/**
* Runtime Context Helpers
* For route-based environment access via X-Runtime-Environment header
*/
function getRuntimeContext(options = {}) {
return (options || {}).runtimeContext || null;
}
function getRuntimeEnvironment(options = {}) {
const runtimeContext = getRuntimeContext(options);
if (!runtimeContext) return null;
// Read from header (route-based mode)
// SECURITY: Only allow 'production' and 'stage' from header
// to prevent unauthorized access to dev data
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
return null;
}
function getRuntimeProjectSlug(options = {}) {
const runtimeContext = getRuntimeContext(options);
return runtimeContext?.headerProjectSlug || null;
}
function applyRuntimeEnvironment(where = {}, options = {}) {
const environment = getRuntimeEnvironment(options);
if (!environment) return where;
return {
...where,
environment,
};
}
function applyRuntimeProjectFilter(projectInclude = {}, options = {}) {
const projectSlug = getRuntimeProjectSlug(options);
if (!projectSlug) return projectInclude;
return {
...projectInclude,
required: true,
where: {
...(projectInclude.where || {}),
slug: projectSlug,
},
};
}
module.exports = {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
getRuntimeEnvironment,
getRuntimeProjectSlug,
};

View File

@ -0,0 +1,75 @@
import type {
QueryWhere,
RuntimeContext,
RuntimeFilterOptions,
RuntimeProjectInclude,
RuntimeReadableEnvironment,
} from '../../types/index.ts';
function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function getRuntimeContext(
options: RuntimeFilterOptions = {},
): RuntimeContext | null {
return options.runtimeContext ?? null;
}
function getRuntimeEnvironment(
options: RuntimeFilterOptions = {},
): RuntimeReadableEnvironment | null {
const runtimeContext = getRuntimeContext(options);
if (!runtimeContext) return null;
if (runtimeContext.headerEnvironment === 'production') return 'production';
if (runtimeContext.headerEnvironment === 'stage') return 'stage';
return null;
}
function getRuntimeProjectSlug(options: RuntimeFilterOptions = {}): string | null {
const runtimeContext = getRuntimeContext(options);
return runtimeContext?.headerProjectSlug ?? null;
}
function applyRuntimeEnvironment(
where: QueryWhere = {},
options: RuntimeFilterOptions = {},
): QueryWhere {
const environment = getRuntimeEnvironment(options);
if (!environment) return where;
return {
...where,
environment,
};
}
function applyRuntimeProjectFilter(
projectInclude: RuntimeProjectInclude = {},
options: RuntimeFilterOptions = {},
): RuntimeProjectInclude {
const projectSlug = getRuntimeProjectSlug(options);
if (!projectSlug) return projectInclude;
const existingWhere = isQueryWhere(projectInclude.where)
? projectInclude.where
: {};
return {
...projectInclude,
required: true,
where: {
...existingWhere,
slug: projectSlug,
},
};
}
export {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
getRuntimeEnvironment,
getRuntimeProjectSlug,
};

View File

@ -1,289 +0,0 @@
const GenericDBApi = require('./base.api');
const db = require('../models');
const Utils = require('../utils');
const {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} = require('./runtime-context');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
class Tour_pagesDBApi extends GenericDBApi {
static get MODEL() {
return db.tour_pages;
}
static get TABLE_NAME() {
return 'tour_pages';
}
static get SEARCHABLE_FIELDS() {
return [
'source_key',
'name',
'slug',
'background_image_url',
'background_video_url',
'background_embed_url',
'background_audio_url',
'ui_schema_json',
];
}
static get RANGE_FIELDS() {
return ['sort_order'];
}
static get ENUM_FIELDS() {
return ['environment', 'background_loop', 'requires_auth'];
}
static get UUID_FIELDS() {
return ['projectId'];
}
static get CSV_FIELDS() {
return [
'id',
'environment',
'source_key',
'name',
'slug',
'sort_order',
'createdAt',
];
}
static get AUTOCOMPLETE_FIELD() {
return 'name';
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static getFieldMapping(data) {
return {
id: data.id || undefined,
environment: data.environment || null,
source_key: data.source_key || null,
name: data.name || null,
slug: data.slug || null,
sort_order: data.sort_order || null,
background_image_url: data.background_image_url || null,
background_video_url: data.background_video_url || null,
background_embed_url: data.background_embed_url || null,
background_audio_url: data.background_audio_url || null,
background_audio_autoplay:
data.background_audio_autoplay !== undefined
? data.background_audio_autoplay
: true,
background_audio_loop:
data.background_audio_loop !== undefined
? data.background_audio_loop
: true,
background_audio_start_time:
data.background_audio_start_time !== undefined
? data.background_audio_start_time
: null,
background_audio_end_time:
data.background_audio_end_time !== undefined
? data.background_audio_end_time
: null,
background_loop: data.background_loop || false,
background_video_autoplay:
data.background_video_autoplay !== undefined
? data.background_video_autoplay
: true,
background_video_loop:
data.background_video_loop !== undefined
? data.background_video_loop
: true,
background_video_muted:
data.background_video_muted !== undefined
? data.background_video_muted
: true,
background_video_start_time:
data.background_video_start_time !== undefined
? data.background_video_start_time
: null,
background_video_end_time:
data.background_video_end_time !== undefined
? data.background_video_end_time
: null,
design_width: data.design_width !== undefined ? data.design_width : null,
design_height:
data.design_height !== undefined ? data.design_height : null,
requires_auth: data.requires_auth || false,
ui_schema_json: data.ui_schema_json || null,
global_ui_controls_settings_json:
data.global_ui_controls_settings_json !== undefined
? data.global_ui_controls_settings_json
: null,
};
}
static async create(options) {
const { data, currentUser = { id: null }, transaction } = options;
const projectId = data.project || data.projectId || null;
const record = await this.MODEL.create(
{
...this.getFieldMapping(data),
projectId,
importHash: data.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
await record.setProject(projectId, { transaction });
return record;
}
static async findBy(where, options = {}) {
const transaction = options.transaction;
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
const projectInclude = applyRuntimeProjectFilter(
{
model: db.projects,
as: 'project',
},
options,
);
const record = await this.MODEL.findOne({
where: queryWhere,
transaction,
include: [projectInclude],
});
if (!record) return null;
return record.get({ plain: true });
}
static async findAll(filter = {}, options = {}) {
filter = filter || {};
const limit = filter.limit || 0;
const currentPage = Number(filter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where = {};
const terms = filter.project ? filter.project.split('|') : [];
const validUuids = Utils.filterValidUuids(terms);
let include = [
{
model: db.projects,
as: 'project',
where: filter.project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where.id = filter.id;
}
for (const field of this.SEARCHABLE_FIELDS) {
if (filter[field]) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, filter[field]);
}
}
for (const field of this.RANGE_FIELDS) {
const rangeKey = `${field}Range`;
if (filter[rangeKey]) {
const [start, end] = filter[rangeKey];
if (start !== undefined && start !== null && start !== '') {
where[field] = { ...where[field], [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where[field] = { ...where[field], [Op.lte]: end };
}
}
}
for (const field of this.ENUM_FIELDS) {
if (filter[field] !== undefined) {
where[field] = filter[field];
}
}
// Validate and filter by UUID fields (e.g., projectId)
for (const field of this.UUID_FIELDS) {
if (filter[field] !== undefined) {
if (!Utils.isValidUuid(filter[field])) {
return { rows: [], count: 0 };
}
where[field] = filter[field];
}
}
if (filter.active !== undefined) {
where.active = filter.active === true || filter.active === 'true';
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where.createdAt = { ...where.createdAt, [Op.gte]: start };
}
if (end !== undefined && end !== null && end !== '') {
where.createdAt = { ...where.createdAt, [Op.lte]: end };
}
}
where = applyRuntimeEnvironment(where, options);
const queryOptions = {
where,
include,
distinct: true,
order:
filter.field && filter.sort
? [[filter.field, filter.sort]]
: [['createdAt', 'desc']],
transaction: options.transaction,
};
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
} catch (error) {
console.error('Error executing query:', error);
throw error;
}
}
}
module.exports = Tour_pagesDBApi;

View File

@ -0,0 +1,363 @@
import GenericDBApi from './base.api.ts';
import {
applyRuntimeEnvironment,
applyRuntimeProjectFilter,
} from './runtime-context.ts';
import db from '../models/index.ts';
import Utils from '../utils.ts';
import type {
CreateOptions,
DbAssociationConfig,
DbFindAllOptions,
DbFindByOptions,
EntityRecord,
PaginatedResult,
QueryWhere,
RuntimeProjectInclude,
TourPageData,
TourPageFieldMapping,
TourPageFindAllOptions,
TourPageListQuery,
TourPageModelApi,
TourPageRangeFilter,
TourPageRecord,
TourPagesDbApi,
} from '../../types/index.ts';
const { Op } = db.Sequelize;
function isDbFindAllOptions(
value: TourPageListQuery | DbFindAllOptions<TourPageListQuery>,
): value is DbFindAllOptions<TourPageListQuery> {
return Boolean(value) && typeof value === 'object' && 'filter' in value;
}
function isTourPageListQuery(value: unknown): value is TourPageListQuery {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isRangeFilter(value: unknown): value is TourPageRangeFilter {
return Array.isArray(value) && value.length === 2;
}
function normalizeFilter(
filter?: TourPageListQuery | DbFindAllOptions<TourPageListQuery>,
): TourPageListQuery {
if (!filter) return {};
if (!isDbFindAllOptions(filter)) return filter;
return isTourPageListQuery(filter.filter) ? filter.filter : {};
}
function getFilterString(filter: TourPageListQuery, field: string): string | null {
const value = filter[field];
return typeof value === 'string' ? value : null;
}
function addRangeBoundary(
where: QueryWhere,
field: string,
operator: symbol,
value: TourPageRangeFilter[number],
): void {
if (value === undefined || value === null || value === '') return;
const current = isQueryWhere(where[field]) ? where[field] : {};
where[field] = {
...current,
[operator]: value,
};
}
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
if (!isRangeFilter(range)) return;
const [start, end] = range;
addRangeBoundary(where, field, Op.gte, start);
addRangeBoundary(where, field, Op.lte, end);
}
function getProjectId(data: TourPageData): string | null {
if (typeof data.projectId === 'string') return data.projectId;
if (typeof data.project === 'string') return data.project;
if (data.project && typeof data.project.id === 'string') return data.project.id;
return null;
}
class Tour_pagesDBApi extends GenericDBApi {
declare static update: TourPagesDbApi['update'];
declare static deleteByIds: TourPagesDbApi['deleteByIds'];
declare static remove: TourPagesDbApi['remove'];
declare static findAllAutocomplete: TourPagesDbApi['findAllAutocomplete'];
static override get MODEL(): TourPageModelApi {
return db.tour_pages;
}
static override get TABLE_NAME(): string {
return 'tour_pages';
}
static override get SEARCHABLE_FIELDS(): string[] {
return [
'source_key',
'name',
'slug',
'background_image_url',
'background_video_url',
'background_embed_url',
'background_audio_url',
'ui_schema_json',
];
}
static override get RANGE_FIELDS(): string[] {
return ['sort_order'];
}
static override get ENUM_FIELDS(): string[] {
return ['environment', 'background_loop', 'requires_auth'];
}
static override get UUID_FIELDS(): string[] {
return ['projectId'];
}
static override get CSV_FIELDS(): string[] {
return [
'id',
'environment',
'source_key',
'name',
'slug',
'sort_order',
'createdAt',
];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static override getFieldMapping(data: TourPageData): TourPageFieldMapping {
return {
id: data.id || undefined,
environment: data.environment || null,
source_key: data.source_key || null,
name: data.name || null,
slug: data.slug || null,
sort_order: data.sort_order || null,
background_image_url: data.background_image_url || null,
background_video_url: data.background_video_url || null,
background_embed_url: data.background_embed_url || null,
background_audio_url: data.background_audio_url || null,
background_audio_autoplay: data.background_audio_autoplay ?? true,
background_audio_loop: data.background_audio_loop ?? true,
background_audio_start_time: data.background_audio_start_time ?? null,
background_audio_end_time: data.background_audio_end_time ?? null,
background_loop: data.background_loop || false,
background_video_autoplay: data.background_video_autoplay ?? true,
background_video_loop: data.background_video_loop ?? true,
background_video_muted: data.background_video_muted ?? true,
background_video_start_time: data.background_video_start_time ?? null,
background_video_end_time: data.background_video_end_time ?? null,
design_width: data.design_width ?? null,
design_height: data.design_height ?? null,
requires_auth: data.requires_auth || false,
ui_schema_json: data.ui_schema_json || null,
global_ui_controls_settings_json:
data.global_ui_controls_settings_json ?? null,
};
}
static override async create(
options: CreateOptions<TourPageData>,
): Promise<TourPageRecord> {
const { data, currentUser = { id: null }, transaction } = options;
const projectId = getProjectId(data);
const record = await this.MODEL.create(
{
...this.getFieldMapping(data),
projectId,
importHash: data.importHash || null,
createdById: currentUser?.id ?? null,
updatedById: currentUser?.id ?? null,
},
{ transaction },
);
await record.setProject(projectId, { transaction });
return record.get({ plain: true });
}
static override async findBy(
where: { id: string },
options?: TourPageFindAllOptions,
): Promise<TourPageRecord | null>;
static override async findBy(
options: DbFindByOptions,
): Promise<TourPageRecord | null>;
static override async findBy(
whereOrOptions: { id: string } | DbFindByOptions,
options: TourPageFindAllOptions = {},
): Promise<TourPageRecord | null> {
const sourceWhere =
'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
const where = isQueryWhere(sourceWhere) ? sourceWhere : {};
const queryWhere = applyRuntimeEnvironment({ ...where }, options);
const projectInclude = applyRuntimeProjectFilter(
{ model: db.projects, as: 'project' },
options,
);
const findOptions: {
where: QueryWhere;
transaction?: TourPageFindAllOptions['transaction'];
include: RuntimeProjectInclude[];
} = {
where: queryWhere,
include: [projectInclude],
};
if ('where' in whereOrOptions && whereOrOptions.transaction) {
findOptions.transaction = whereOrOptions.transaction;
} else if (options.transaction) {
findOptions.transaction = options.transaction;
}
const record = await this.MODEL.findOne(findOptions);
return record ? record.get({ plain: true }) : null;
}
static override async findAll(
filter?: TourPageListQuery,
options?: TourPageFindAllOptions,
): Promise<PaginatedResult<TourPageRecord>>;
static override async findAll(
options: DbFindAllOptions<TourPageListQuery>,
): Promise<PaginatedResult<EntityRecord>>;
static override async findAll(
filter?: TourPageListQuery | DbFindAllOptions<TourPageListQuery>,
options: TourPageFindAllOptions = {},
): Promise<PaginatedResult<TourPageRecord>> {
const normalizedFilter = normalizeFilter(filter);
const limit = Number(normalizedFilter.limit) || 0;
const currentPage = Number(normalizedFilter.page) || 0;
const offset = Math.max(currentPage - 1, 0) * limit;
let where: QueryWhere = {};
const project = getFilterString(normalizedFilter, 'project');
const terms = project ? project.split('|') : [];
const validUuids = Utils.filterValidUuids(terms);
const include: RuntimeProjectInclude[] = [
{
model: db.projects,
as: 'project',
where: project
? {
[Op.or]: [
...(validUuids.length > 0
? [{ id: { [Op.in]: validUuids } }]
: []),
{
name: {
[Op.or]: terms.map((term) => ({ [Op.iLike]: `%${term}%` })),
},
},
],
}
: {},
},
];
include[0] = applyRuntimeProjectFilter(include[0], options);
const id = getFilterString(normalizedFilter, 'id');
if (id) {
if (!Utils.isValidUuid(id)) {
return { rows: [], count: 0 };
}
where.id = id;
}
for (const field of this.SEARCHABLE_FIELDS) {
const value = getFilterString(normalizedFilter, field);
if (value) {
where[Op.and] = Utils.ilike(this.TABLE_NAME, field, value);
}
}
for (const field of this.RANGE_FIELDS) {
addRangeFilter(where, field, normalizedFilter[`${field}Range`]);
}
for (const field of this.ENUM_FIELDS) {
if (normalizedFilter[field] !== undefined) {
where[field] = normalizedFilter[field];
}
}
for (const field of this.UUID_FIELDS) {
const value = getFilterString(normalizedFilter, field);
if (value) {
if (!Utils.isValidUuid(value)) {
return { rows: [], count: 0 };
}
where[field] = value;
}
}
const active = normalizedFilter.active;
if (active !== undefined) {
where.active = active === 'true';
}
addRangeFilter(where, 'createdAt', normalizedFilter.createdAtRange);
where = applyRuntimeEnvironment(where, options);
const queryOptions: {
where: QueryWhere;
include: RuntimeProjectInclude[];
distinct: true;
order: string[][];
transaction?: TourPageFindAllOptions['transaction'];
limit?: number;
offset?: number;
} = {
where,
include,
distinct: true,
order:
normalizedFilter.field && normalizedFilter.sort
? [[normalizedFilter.field, normalizedFilter.sort]]
: [['createdAt', 'desc']],
};
if (options.transaction) {
queryOptions.transaction = options.transaction;
}
if (!options.countOnly && limit) {
queryOptions.limit = limit;
}
if (!options.countOnly && offset) {
queryOptions.offset = offset;
}
const { rows, count } = await this.MODEL.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count,
};
}
}
export default Tour_pagesDBApi;

View File

@ -1,883 +0,0 @@
const db = require('../models');
const FileDBApi = require('./file');
const crypto = require('crypto');
const Utils = require('../utils');
const bcrypt = require('bcrypt');
const config = require('../../config');
const { logger } = require('../../utils/logger');
const {
assertAutocompleteOptions,
assertCreateOptions,
assertDeleteByIdsOptions,
assertIdOptions,
assertUpdateOptions,
} = require('../../contracts/entity-options');
const Sequelize = db.Sequelize;
const Op = Sequelize.Op;
module.exports = class UsersDBApi {
static get MODEL() {
return db.users;
}
static get SORTABLE_FIELDS() {
return Object.keys(db.users.rawAttributes || {});
}
/**
* Default includes for findBy() - minimal set for single user lookup
* Only loads avatar and app_role with permissions (needed for RBAC)
*/
static get FIND_BY_INCLUDES() {
return [
{ association: 'avatar' },
{
association: 'app_role',
include: [{ association: 'permissions' }],
},
];
}
/**
* Minimal includes for findAll() - only app_role for list display
* Excludes avatar, custom_permissions (rarely needed in list views)
*/
static get FIND_ALL_INCLUDES() {
return [
{
model: db.roles,
as: 'app_role',
required: false,
},
];
}
/**
* Sensitive fields that should be excluded from query results
*/
static get SENSITIVE_FIELDS() {
return [
'password',
'emailVerificationToken',
'emailVerificationTokenExpiresAt',
'passwordResetToken',
'passwordResetTokenExpiresAt',
];
}
static async create(options) {
assertCreateOptions(options, 'DBApi');
const { data, currentUser = { id: null }, transaction } = options;
const userData = data.data || data;
const password =
userData.password || crypto.randomBytes(20).toString('hex');
const users = await db.users.create(
{
id: userData.id || undefined,
firstName: userData.firstName || null,
lastName: userData.lastName || null,
phoneNumber: userData.phoneNumber || null,
email: userData.email || null,
disabled: userData.disabled || false,
password: bcrypt.hashSync(password, config.bcrypt.saltRounds),
emailVerified: userData.emailVerified || true,
emailVerificationToken: userData.emailVerificationToken || null,
emailVerificationTokenExpiresAt:
userData.emailVerificationTokenExpiresAt || null,
passwordResetToken: userData.passwordResetToken || null,
passwordResetTokenExpiresAt:
userData.passwordResetTokenExpiresAt || null,
provider: userData.provider || config.providers.LOCAL,
importHash: userData.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
},
{ transaction },
);
if (!userData.app_role) {
const role = await db.roles.findOne({
where: { name: 'User' },
});
if (role) {
await users.setApp_role(role, {
transaction,
});
}
} else {
await users.setApp_role(userData.app_role || null, {
transaction,
});
}
await users.setCustom_permissions(userData.custom_permissions || [], {
transaction,
});
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.users.getTableName(),
belongsToColumn: 'avatar',
belongsToId: users.id,
},
userData.avatar,
options,
);
return users;
}
static async bulkImport(data, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
// Prepare data - wrapping individual data transformations in a map() method
const usersData = data.map((item, index) => ({
id: item.id || undefined,
firstName: item.firstName || null,
lastName: item.lastName || null,
phoneNumber: item.phoneNumber || null,
email: item.email || null,
disabled: item.disabled || false,
password: bcrypt.hashSync(
item.password || crypto.randomBytes(20).toString('hex'),
config.bcrypt.saltRounds,
),
emailVerified: item.emailVerified || false,
emailVerificationToken: item.emailVerificationToken || null,
emailVerificationTokenExpiresAt:
item.emailVerificationTokenExpiresAt || null,
passwordResetToken: item.passwordResetToken || null,
passwordResetTokenExpiresAt: item.passwordResetTokenExpiresAt || null,
provider: item.provider || config.providers.LOCAL,
importHash: item.importHash || null,
createdById: currentUser.id,
updatedById: currentUser.id,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const users = await db.users.bulkCreate(usersData, { transaction });
// For each item created, replace relation files
for (let i = 0; i < users.length; i++) {
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.users.getTableName(),
belongsToColumn: 'avatar',
belongsToId: users[i].id,
},
data[i].avatar,
options,
);
}
return users;
}
static async update(updateOptions) {
assertUpdateOptions(updateOptions, 'DBApi');
const {
id,
data,
currentUser = { id: null },
transaction,
runtimeContext,
} = updateOptions;
const dbOptions = { currentUser, transaction, runtimeContext };
const users = await db.users.findByPk(id, { transaction });
if (data?.app_role && typeof data.app_role === 'object') {
data.app_role = data.app_role.id || data.app_role.value || null;
}
if (Array.isArray(data?.custom_permissions)) {
data.custom_permissions = data.custom_permissions
.map((item) => {
if (typeof item === 'string') return item;
if (item && typeof item === 'object') return item.id || item.value;
return null;
})
.filter(Boolean);
}
if (!data?.app_role) {
data.app_role = users?.app_role?.id;
}
if (!data?.custom_permissions) {
data.custom_permissions = users?.custom_permissions?.map(
(item) => item.id,
);
}
if (data.password) {
data.password = bcrypt.hashSync(data.password, config.bcrypt.saltRounds);
} else {
data.password = users.password;
}
const updatePayload = {};
if (data.firstName !== undefined) updatePayload.firstName = data.firstName;
if (data.lastName !== undefined) updatePayload.lastName = data.lastName;
if (data.phoneNumber !== undefined)
updatePayload.phoneNumber = data.phoneNumber;
if (data.email !== undefined) updatePayload.email = data.email;
if (data.disabled !== undefined) updatePayload.disabled = data.disabled;
if (data.password !== undefined) updatePayload.password = data.password;
if (data.emailVerified !== undefined)
updatePayload.emailVerified = data.emailVerified;
else updatePayload.emailVerified = true;
if (data.emailVerificationToken !== undefined)
updatePayload.emailVerificationToken = data.emailVerificationToken;
if (data.emailVerificationTokenExpiresAt !== undefined)
updatePayload.emailVerificationTokenExpiresAt =
data.emailVerificationTokenExpiresAt;
if (data.passwordResetToken !== undefined)
updatePayload.passwordResetToken = data.passwordResetToken;
if (data.passwordResetTokenExpiresAt !== undefined)
updatePayload.passwordResetTokenExpiresAt =
data.passwordResetTokenExpiresAt;
if (data.provider !== undefined) updatePayload.provider = data.provider;
updatePayload.updatedById = currentUser.id;
await users.update(updatePayload, { transaction });
if (data.app_role !== undefined) {
await users.setApp_role(
data.app_role,
{ transaction },
);
}
if (data.custom_permissions !== undefined) {
await users.setCustom_permissions(data.custom_permissions, {
transaction,
});
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.users.getTableName(),
belongsToColumn: 'avatar',
belongsToId: users.id,
},
data.avatar,
dbOptions,
);
return users;
}
static async deleteByIds(options) {
assertDeleteByIdsOptions(options, 'DBApi');
const { ids, currentUser = { id: null }, transaction } = options;
const users = await db.users.findAll({
where: {
id: {
[Op.in]: ids,
},
},
transaction,
});
for (const record of users) {
await record.update({ deletedBy: currentUser.id }, { transaction });
}
for (const record of users) {
await record.destroy({ transaction });
}
return users;
}
static async remove(options) {
assertIdOptions(options, 'DBApi', 'remove');
const { id, currentUser = { id: null }, transaction } = options;
const users = await db.users.findByPk(id, { transaction });
await users.update(
{
deletedBy: currentUser.id,
},
{
transaction,
},
);
await users.destroy({
transaction,
});
return users;
}
/**
* Find a single user by criteria
* Uses minimal includes by default (avatar + app_role with permissions)
* @param {Object} where - Query conditions
* @param {Object} options - Options including transaction and custom includes
* @param {Array} options.include - Override default includes if needed
*/
static async findBy(where, options) {
const transaction = (options && options.transaction) || undefined;
const include = options?.include ?? this.FIND_BY_INCLUDES;
const users = await db.users.findOne({
where,
transaction,
include,
});
if (!users) {
return users;
}
const output = users.get({ plain: true });
// Map nested permissions from app_role for backward compatibility
if (output.app_role) {
output.app_role_permissions = output.app_role.permissions || [];
}
const productionPresentationAccess =
await db.production_presentation_access.findAll({
where: { userId: output.id },
include: [
{
association: 'project',
attributes: ['id', 'name', 'slug'],
where: { production_presentation_visibility: 'private' },
required: true,
},
],
transaction,
});
output.allowed_private_production_project_ids = productionPresentationAccess
.map((row) => {
const plain =
typeof row.get === 'function' ? row.get({ plain: true }) : row;
const project = plain.project;
if (!project?.id) return null;
return {
id: project.id,
label: `${project.name} (${project.slug})`,
name: project.name,
slug: project.slug,
};
})
.filter(Boolean);
return output;
}
/**
* Lightweight user lookup for JWT authentication
* Only loads essential fields and app_role with permissions for RBAC
* Optimized for the auth flow that runs on every authenticated request
*/
static async findByForAuth(where, options) {
const transaction = (options && options.transaction) || undefined;
const user = await db.users.findOne({
where,
transaction,
attributes: [
'id',
'email',
'disabled',
'firstName',
'lastName',
'app_roleId',
],
include: [
{
association: 'app_role',
include: [{ association: 'permissions' }],
},
{ association: 'custom_permissions' },
],
});
if (!user) {
return user;
}
const output = user.get({ plain: true });
// Map nested permissions from app_role for backward compatibility
if (output.app_role) {
output.app_role_permissions = output.app_role.permissions || [];
}
return output;
}
static async findAll(filter, options) {
const limit = filter.limit || 0;
let offset = 0;
let where = {};
const currentPage = Number(filter.page) || 1;
offset = Math.max(currentPage - 1, 0) * limit;
const appRoleTerms = filter.app_role ? filter.app_role.split('|') : [];
const appRoleValidUuids = Utils.filterValidUuids(appRoleTerms);
// Use lightweight includes for list view (only app_role, no custom_permissions or avatar)
let include = [
{
model: db.roles,
as: 'app_role',
required: false,
where: filter.app_role
? {
[Op.or]: [
...(appRoleValidUuids.length > 0
? [{ id: { [Op.in]: appRoleValidUuids } }]
: []),
{
name: {
[Op.or]: appRoleTerms.map((term) => ({
[Op.iLike]: `%${term}%`,
})),
},
},
],
}
: {},
},
];
if (filter) {
if (filter.id) {
if (!Utils.isValidUuid(filter.id)) {
return { rows: [], count: 0 };
}
where = { ...where, id: filter.id };
}
if (filter.firstName) {
where = {
...where,
[Op.and]: Utils.ilike('users', 'firstName', filter.firstName),
};
}
if (filter.lastName) {
where = {
...where,
[Op.and]: Utils.ilike('users', 'lastName', filter.lastName),
};
}
if (filter.phoneNumber) {
where = {
...where,
[Op.and]: Utils.ilike('users', 'phoneNumber', filter.phoneNumber),
};
}
if (filter.email) {
where = {
...where,
[Op.and]: Utils.ilike('users', 'email', filter.email),
};
}
if (filter.password) {
where = {
...where,
[Op.and]: Utils.ilike('users', 'password', filter.password),
};
}
if (filter.emailVerificationToken) {
where = {
...where,
[Op.and]: Utils.ilike(
'users',
'emailVerificationToken',
filter.emailVerificationToken,
),
};
}
if (filter.passwordResetToken) {
where = {
...where,
[Op.and]: Utils.ilike(
'users',
'passwordResetToken',
filter.passwordResetToken,
),
};
}
if (filter.provider) {
where = {
...where,
[Op.and]: Utils.ilike('users', 'provider', filter.provider),
};
}
if (filter.emailVerificationTokenExpiresAtRange) {
const [start, end] = filter.emailVerificationTokenExpiresAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
emailVerificationTokenExpiresAt: {
...where.emailVerificationTokenExpiresAt,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
emailVerificationTokenExpiresAt: {
...where.emailVerificationTokenExpiresAt,
[Op.lte]: end,
},
};
}
}
if (filter.passwordResetTokenExpiresAtRange) {
const [start, end] = filter.passwordResetTokenExpiresAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
passwordResetTokenExpiresAt: {
...where.passwordResetTokenExpiresAt,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
passwordResetTokenExpiresAt: {
...where.passwordResetTokenExpiresAt,
[Op.lte]: end,
},
};
}
}
if (filter.active !== undefined) {
where = {
...where,
active: filter.active === true || filter.active === 'true',
};
}
if (filter.disabled) {
where = {
...where,
disabled: filter.disabled,
};
}
if (filter.emailVerified) {
where = {
...where,
emailVerified: filter.emailVerified,
};
}
if (filter.custom_permissions) {
const searchTerms = filter.custom_permissions.split('|');
const permissionValidUuids = Utils.filterValidUuids(searchTerms);
include = [
{
model: db.permissions,
as: 'custom_permissions_filter',
required: searchTerms.length > 0,
where:
searchTerms.length > 0
? {
[Op.or]: [
...(permissionValidUuids.length > 0
? [{ id: { [Op.in]: permissionValidUuids } }]
: []),
{
name: {
[Op.or]: searchTerms.map((term) => ({
[Op.iLike]: `%${term}%`,
})),
},
},
],
}
: undefined,
},
...include,
];
}
if (filter.createdAtRange) {
const [start, end] = filter.createdAtRange;
if (start !== undefined && start !== null && start !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.gte]: start,
},
};
}
if (end !== undefined && end !== null && end !== '') {
where = {
...where,
['createdAt']: {
...where.createdAt,
[Op.lte]: end,
},
};
}
}
}
const sortField = this.SORTABLE_FIELDS.includes(filter.field)
? filter.field
: 'createdAt';
const sortDirection =
String(filter.sort || 'desc').toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
const queryOptions = {
attributes: { exclude: this.SENSITIVE_FIELDS },
where,
include,
distinct: true,
order: [[sortField, sortDirection]],
transaction: options?.transaction,
};
if (!options?.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await db.users.findAndCountAll(queryOptions);
return {
rows: options?.countOnly ? [] : rows,
count: count,
};
} catch (error) {
logger.error({ err: error, table: 'users' }, 'Error executing query');
throw error;
}
}
static async findAllAutocomplete(options, queryOptions = {}) {
assertAutocompleteOptions(options, 'DBApi');
const { query, limit, offset } = options;
const transaction = queryOptions.transaction;
let where = {};
if (query) {
const orConditions = [Utils.ilike('users', 'firstName', query)];
if (Utils.isValidUuid(query)) {
orConditions.unshift({ id: query });
}
where = { [Op.or]: orConditions };
}
const records = await db.users.findAll({
attributes: ['id', 'firstName'],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['firstName', 'ASC']],
transaction,
});
return records.map((record) => ({
id: record.id,
label: record.firstName,
}));
}
static async createFromAuth(data, options) {
const transaction = (options && options.transaction) || undefined;
const users = await db.users.create(
{
email: data.email,
firstName: data.firstName,
authenticationUid: data.authenticationUid,
password: data.password,
},
{ transaction },
);
const app_role = await db.roles.findOne({
where: { name: config.roles?.user || 'User' },
});
if (app_role?.id) {
await users.setApp_role(app_role?.id || null, {
transaction,
});
}
await users.update(
{
authenticationUid: users.id,
},
{ transaction },
);
delete users.password;
return users;
}
static async updatePassword(id, password, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const users = await db.users.findByPk(id, {
transaction,
});
await users.update(
{
password,
authenticationUid: id,
updatedById: currentUser.id,
},
{ transaction },
);
return users;
}
static async generateEmailVerificationToken(email, options) {
return this._generateToken(
['emailVerificationToken', 'emailVerificationTokenExpiresAt'],
email,
options,
);
}
static async generatePasswordResetToken(email, options) {
return this._generateToken(
['passwordResetToken', 'passwordResetTokenExpiresAt'],
email,
options,
);
}
static async findByPasswordResetToken(token, options) {
const transaction = (options && options.transaction) || undefined;
return db.users.findOne({
where: {
passwordResetToken: token,
passwordResetTokenExpiresAt: {
[db.Sequelize.Op.gt]: Date.now(),
},
},
transaction,
});
}
static async findByEmailVerificationToken(token, options) {
const transaction = (options && options.transaction) || undefined;
return db.users.findOne({
where: {
emailVerificationToken: token,
emailVerificationTokenExpiresAt: {
[db.Sequelize.Op.gt]: Date.now(),
},
},
transaction,
});
}
static async markEmailVerified(id, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const users = await db.users.findByPk(id, {
transaction,
});
await users.update(
{
emailVerified: true,
updatedById: currentUser.id,
},
{ transaction },
);
return true;
}
static async _generateToken(keyNames, email, options) {
const currentUser = (options && options.currentUser) || { id: null };
const transaction = (options && options.transaction) || undefined;
const users = await db.users.findOne({
where: { email: email.toLowerCase() },
transaction,
});
const token = crypto.randomBytes(20).toString('hex');
// Token expires in 24 hours (was 6 minutes - too short for email verification flows)
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const tokenExpiresAt = Date.now() + TOKEN_EXPIRY_MS;
if (users) {
await users.update(
{
[keyNames[0]]: token,
[keyNames[1]]: tokenExpiresAt,
updatedById: currentUser.id,
},
{ transaction },
);
}
return token;
}
};

979
backend/src/db/api/users.ts Normal file
View File

@ -0,0 +1,979 @@
import crypto from 'node:crypto';
import bcrypt from 'bcrypt';
import type { Transaction } from 'sequelize';
import db from '../models/index.ts';
import FileDBApi from './file.ts';
import Utils from '../utils.ts';
import config from '../../config.ts';
import { logger } from '../../utils/logger.ts';
import {
assertAutocompleteOptions,
assertCreateOptions,
assertDeleteByIdsOptions,
assertIdOptions,
assertUpdateOptions,
} from '../../contracts/entity-options.ts';
import type {
AutocompleteOptions,
DbFindAllOptions,
DbFindByOptions,
PaginatedResult,
QueryWhere,
RoleRecord,
ServiceOptions,
UserAuthCreatePayload,
UserAutocompleteOption,
UserCreatePayload,
UserData,
UserFindAllOptions,
UserFindByWhere,
UserListFilter,
UserModelApi,
UserProductionPresentationAccessRecord,
UserRangeFilter,
UserRecord,
UsersDbApi,
UserTokenFields,
UserUpdatePayload,
} from '../../types/index.ts';
const { Op } = db.Sequelize;
function isDbFindAllOptions(
value: UserListFilter | DbFindAllOptions<unknown> | undefined,
): value is DbFindAllOptions<unknown> {
return Boolean(value) && typeof value === 'object' && 'filter' in value;
}
function isDbFindByOptionsInput(
value: UserFindByWhere | DbFindByOptions,
): value is DbFindByOptions {
return 'where' in value && isQueryWhere(value.where);
}
function isUserListFilter(value: unknown): value is UserListFilter {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function isRangeFilter(value: unknown): value is UserRangeFilter {
return Array.isArray(value) && value.length === 2;
}
function normalizeFilter(
filter?: UserListFilter | DbFindAllOptions<unknown>,
): UserListFilter {
if (!filter) return {};
if (!isDbFindAllOptions(filter)) return filter;
return isUserListFilter(filter.filter) ? filter.filter : {};
}
function hasObjectId(value: unknown): value is { id?: string; value?: string } {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
}
function normalizeRoleId(
role: UserData['app_role'],
): string | RoleRecord | null | undefined {
if (!hasObjectId(role)) return role;
return role.id || role.value || null;
}
function normalizePermissionIds(
permissions: UserData['custom_permissions'],
): string[] | undefined {
if (!Array.isArray(permissions)) return undefined;
return permissions.flatMap((item) => {
if (typeof item === 'string') return [item];
if (!hasObjectId(item)) return [];
const id = item.id || item.value;
return id ? [id] : [];
});
}
function addTextFilter(where: QueryWhere, field: string, value: unknown): void {
if (typeof value !== 'string' || !value) return;
where[Op.and] = Utils.ilike('users', field, value);
}
function addRangeBoundary(
where: QueryWhere,
field: string,
operator: symbol,
value: UserRangeFilter[number],
): void {
if (value === undefined || value === null || value === '') return;
const current = isQueryWhere(where[field]) ? where[field] : {};
where[field] = {
...current,
[operator]: value,
};
}
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
if (!isRangeFilter(range)) return;
const [start, end] = range;
addRangeBoundary(where, field, Op.gte, start);
addRangeBoundary(where, field, Op.lte, end);
}
function getCurrentUserId(options?: ServiceOptions): string | null {
return options?.currentUser?.id ?? null;
}
function buildTransactionOptions(
transaction: Transaction | undefined,
): { transaction?: Transaction | undefined } {
const options: { transaction?: Transaction | undefined } = {};
if (transaction !== undefined) {
options.transaction = transaction;
}
return options;
}
function buildUserCreatePayload(
userData: UserData,
currentUserId: string | null,
): UserCreatePayload {
const password = userData.password || crypto.randomBytes(20).toString('hex');
return {
id: userData.id || undefined,
firstName: userData.firstName || null,
lastName: userData.lastName || null,
phoneNumber: userData.phoneNumber || null,
email: userData.email || null,
disabled: userData.disabled || false,
password: bcrypt.hashSync(password, config.bcrypt.saltRounds),
emailVerified: userData.emailVerified || true,
emailVerificationToken: userData.emailVerificationToken || null,
emailVerificationTokenExpiresAt:
userData.emailVerificationTokenExpiresAt || null,
passwordResetToken: userData.passwordResetToken || null,
passwordResetTokenExpiresAt: userData.passwordResetTokenExpiresAt || null,
provider: userData.provider || config.providers.LOCAL,
importHash: userData.importHash || null,
createdById: currentUserId,
updatedById: currentUserId,
};
}
function buildUserUpdatePayload(
data: UserData,
currentUserId: string | null,
): UserUpdatePayload {
const updatePayload: UserUpdatePayload = {};
if (data.firstName !== undefined) updatePayload.firstName = data.firstName;
if (data.lastName !== undefined) updatePayload.lastName = data.lastName;
if (data.phoneNumber !== undefined) updatePayload.phoneNumber = data.phoneNumber;
if (data.email !== undefined) updatePayload.email = data.email;
if (data.disabled !== undefined) updatePayload.disabled = data.disabled;
if (data.password !== undefined && data.password !== null) {
updatePayload.password = data.password;
}
if (data.emailVerified !== undefined) {
updatePayload.emailVerified = data.emailVerified;
} else {
updatePayload.emailVerified = true;
}
if (data.emailVerificationToken !== undefined) {
updatePayload.emailVerificationToken = data.emailVerificationToken;
}
if (data.emailVerificationTokenExpiresAt !== undefined) {
updatePayload.emailVerificationTokenExpiresAt =
data.emailVerificationTokenExpiresAt;
}
if (data.passwordResetToken !== undefined) {
updatePayload.passwordResetToken = data.passwordResetToken;
}
if (data.passwordResetTokenExpiresAt !== undefined) {
updatePayload.passwordResetTokenExpiresAt = data.passwordResetTokenExpiresAt;
}
if (data.provider !== undefined) updatePayload.provider = data.provider;
updatePayload.updatedById = currentUserId;
return updatePayload;
}
class UsersDBApi {
declare static partialUpdate: UsersDbApi['partialUpdate'];
static get MODEL(): UserModelApi {
return db.users;
}
static get SORTABLE_FIELDS(): string[] {
return Object.keys(db.users.rawAttributes || {});
}
/**
* Default includes for findBy() - minimal set for single user lookup
* Only loads avatar and app_role with permissions (needed for RBAC)
*/
static get FIND_BY_INCLUDES(): unknown[] {
return [
{ association: 'avatar' },
{
association: 'app_role',
include: [{ association: 'permissions' }],
},
];
}
/**
* Minimal includes for findAll() - only app_role for list display
* Excludes avatar, custom_permissions (rarely needed in list views)
*/
static get FIND_ALL_INCLUDES(): unknown[] {
return [
{
model: db.roles,
as: 'app_role',
required: false,
},
];
}
/**
* Sensitive fields that should be excluded from query results
*/
static get SENSITIVE_FIELDS(): string[] {
return [
'password',
'emailVerificationToken',
'emailVerificationTokenExpiresAt',
'passwordResetToken',
'passwordResetTokenExpiresAt',
];
}
static async create(options: Parameters<UsersDbApi['create']>[0]): Promise<UserRecord> {
assertCreateOptions(options, 'DBApi');
const { data: userData, transaction } = options;
const users = await db.users.create(
buildUserCreatePayload(userData, getCurrentUserId(options)),
buildTransactionOptions(transaction),
);
if (!userData.app_role) {
const role = await db.roles.findOne({
where: { name: 'User' },
});
if (role) {
await users.setApp_role(role, {
transaction,
});
}
} else {
await users.setApp_role(normalizeRoleId(userData.app_role) || null, {
transaction,
});
}
await users.setCustom_permissions(
normalizePermissionIds(userData.custom_permissions) || [],
{
transaction,
},
);
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.users.getTableName(),
belongsToColumn: 'avatar',
belongsToId: users.id,
},
userData.avatar,
options,
);
return users;
}
static async bulkImport(
data: UserData[],
options: ServiceOptions = {},
): Promise<UserRecord[]> {
const currentUserId = getCurrentUserId(options);
const transaction = options.transaction;
// Prepare data - wrapping individual data transformations in a map() method
const usersData = data.map((item, index) => ({
...buildUserCreatePayload(item, currentUserId),
emailVerified: item.emailVerified || false,
createdAt: new Date(Date.now() + index * 1000),
}));
// Bulk create items
const users = await db.users.bulkCreate(usersData, { transaction });
// For each item created, replace relation files
for (let i = 0; i < users.length; i++) {
const user = users[i];
const item = data[i];
if (!user || !item) continue;
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.users.getTableName(),
belongsToColumn: 'avatar',
belongsToId: user.id,
},
item.avatar,
options,
);
}
return users;
}
static async update(
updateOptions: Parameters<UsersDbApi['update']>[0],
): Promise<UserRecord> {
assertUpdateOptions(updateOptions, 'DBApi');
const {
id,
data,
transaction,
runtimeContext,
} = updateOptions;
const dbOptions: ServiceOptions = {};
if (transaction !== undefined) {
dbOptions.transaction = transaction;
}
if (updateOptions.currentUser !== undefined) {
dbOptions.currentUser = updateOptions.currentUser;
}
if (runtimeContext !== undefined) {
dbOptions.runtimeContext = runtimeContext;
}
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
if (!users) {
throw new Error('UsersNotFound');
}
const normalizedRole = normalizeRoleId(data.app_role) || null;
data.app_role = normalizedRole;
const customPermissionIds = normalizePermissionIds(data.custom_permissions);
if (customPermissionIds) {
data.custom_permissions = customPermissionIds;
}
if (!data?.app_role) {
const existingRoleId = users.app_role?.id;
if (existingRoleId) {
data.app_role = existingRoleId;
}
}
if (!data?.custom_permissions) {
const existingPermissionIds = users.custom_permissions?.flatMap((item) =>
item.id ? [item.id] : [],
) || [];
if (existingPermissionIds.length) {
data.custom_permissions = existingPermissionIds;
}
}
if (data.password) {
data.password = bcrypt.hashSync(data.password, config.bcrypt.saltRounds);
} else {
data.password = users.password || null;
}
const updatePayload = buildUserUpdatePayload(
data,
getCurrentUserId(updateOptions),
);
await users.update(updatePayload, { transaction });
if (data.app_role !== undefined) {
const roleForRelation = normalizeRoleId(data.app_role) || null;
await users.setApp_role(
roleForRelation,
{ transaction },
);
}
if (data.custom_permissions !== undefined) {
await users.setCustom_permissions(normalizePermissionIds(data.custom_permissions) || [], {
transaction,
});
}
await FileDBApi.replaceRelationFiles(
{
belongsTo: db.users.getTableName(),
belongsToColumn: 'avatar',
belongsToId: users.id,
},
data.avatar,
dbOptions,
);
return users;
}
static async deleteByIds(
options: Parameters<UsersDbApi['deleteByIds']>[0],
): Promise<UserRecord[]> {
assertDeleteByIdsOptions(options, 'DBApi');
const { ids, transaction } = options;
const currentUserId = getCurrentUserId(options);
const users = await db.users.findAll({
where: {
id: {
[Op.in]: ids,
},
},
transaction,
});
for (const record of users) {
await record.update({ deletedBy: currentUserId }, { transaction });
}
for (const record of users) {
await record.destroy({ transaction });
}
return users;
}
static async remove(
options: Parameters<UsersDbApi['remove']>[0],
): Promise<UserRecord> {
assertIdOptions(options, 'DBApi', 'remove');
const { id, transaction } = options;
const currentUserId = getCurrentUserId(options);
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
if (!users) {
throw new Error('UsersNotFound');
}
await users.update(
{
deletedBy: currentUserId,
},
{
transaction,
},
);
await users.destroy({
transaction,
});
return users;
}
/**
* Find a single user by criteria
* Uses minimal includes by default (avatar + app_role with permissions)
* @param {Object} where - Query conditions
* @param {Object} options - Options including transaction and custom includes
* @param {Array} options.include - Override default includes if needed
*/
static async findBy(options: DbFindByOptions): Promise<UserRecord | null>;
static async findBy(
where: UserFindByWhere,
options?: ServiceOptions,
): Promise<UserRecord | null>;
static async findBy(
whereOrOptions: UserFindByWhere | DbFindByOptions,
options: ServiceOptions = {},
): Promise<UserRecord | null> {
const isOptionsInput = isDbFindByOptionsInput(whereOrOptions);
const where = isOptionsInput ? whereOrOptions.where : whereOrOptions;
const transaction = isOptionsInput
? whereOrOptions.transaction
: options.transaction;
const include =
isOptionsInput && whereOrOptions.include !== undefined
? whereOrOptions.include
: this.FIND_BY_INCLUDES;
const users = await db.users.findOne({
where: isQueryWhere(where) ? where : {},
...buildTransactionOptions(transaction),
include,
});
if (!users) {
return users;
}
const output = users.get({ plain: true });
// Map nested permissions from app_role for backward compatibility
if (output.app_role) {
output.app_role_permissions = output.app_role.permissions || [];
}
const productionPresentationAccess =
await db.production_presentation_access.findAll({
where: { userId: output.id },
include: [
{
association: 'project',
attributes: ['id', 'name', 'slug'],
where: { production_presentation_visibility: 'private' },
required: true,
},
],
...buildTransactionOptions(transaction),
});
output.allowed_private_production_project_ids = productionPresentationAccess
.flatMap((row: UserProductionPresentationAccessRecord) => {
const plain = row.get({ plain: true });
const project = plain.project;
if (!project?.id || !project.name || !project.slug) return [];
return {
id: project.id,
label: `${project.name} (${project.slug})`,
name: project.name,
slug: project.slug,
};
});
return output;
}
/**
* Lightweight user lookup for JWT authentication
* Only loads essential fields and app_role with permissions for RBAC
* Optimized for the auth flow that runs on every authenticated request
*/
static async findByForAuth(
where: UserFindByWhere,
options: ServiceOptions = {},
): Promise<UserRecord | null> {
const transaction = options.transaction;
const user = await db.users.findOne({
where,
...buildTransactionOptions(transaction),
attributes: [
'id',
'email',
'disabled',
'firstName',
'lastName',
'app_roleId',
],
include: [
{
association: 'app_role',
include: [{ association: 'permissions' }],
},
{ association: 'custom_permissions' },
],
});
if (!user) {
return user;
}
const output = user.get({ plain: true });
// Map nested permissions from app_role for backward compatibility
if (output.app_role) {
output.app_role_permissions = output.app_role.permissions || [];
}
return output;
}
static async findAll(
filter?: UserListFilter,
options?: UserFindAllOptions,
): Promise<PaginatedResult<UserRecord>>;
static async findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<UserRecord>>;
static async findAll(
filter?: UserListFilter | DbFindAllOptions<unknown>,
options: UserFindAllOptions = {},
): Promise<PaginatedResult<UserRecord>> {
const normalizedFilter = normalizeFilter(filter);
const limit = Number(normalizedFilter.limit) || 0;
let offset = 0;
const where: QueryWhere = {};
const currentPage = Number(normalizedFilter.page) || 1;
offset = Math.max(currentPage - 1, 0) * limit;
const appRoleTerms =
typeof normalizedFilter.app_role === 'string'
? normalizedFilter.app_role.split('|')
: [];
const appRoleValidUuids = Utils.filterValidUuids(appRoleTerms);
// Use lightweight includes for list view (only app_role, no custom_permissions or avatar)
let include: unknown[] = [
{
model: db.roles,
as: 'app_role',
required: false,
where: normalizedFilter.app_role
? {
[Op.or]: [
...(appRoleValidUuids.length > 0
? [{ id: { [Op.in]: appRoleValidUuids } }]
: []),
{
name: {
[Op.or]: appRoleTerms.map((term) => ({
[Op.iLike]: `%${term}%`,
})),
},
},
],
}
: {},
},
];
if (normalizedFilter) {
if (normalizedFilter.id) {
if (!Utils.isValidUuid(normalizedFilter.id)) {
return { rows: [], count: 0 };
}
where.id = normalizedFilter.id;
}
addTextFilter(where, 'firstName', normalizedFilter.firstName);
addTextFilter(where, 'lastName', normalizedFilter.lastName);
addTextFilter(where, 'phoneNumber', normalizedFilter.phoneNumber);
addTextFilter(where, 'email', normalizedFilter.email);
addTextFilter(where, 'password', normalizedFilter.password);
addTextFilter(
where,
'emailVerificationToken',
normalizedFilter.emailVerificationToken,
);
addTextFilter(where, 'passwordResetToken', normalizedFilter.passwordResetToken);
addTextFilter(where, 'provider', normalizedFilter.provider);
addRangeFilter(
where,
'emailVerificationTokenExpiresAt',
normalizedFilter.emailVerificationTokenExpiresAtRange,
);
addRangeFilter(
where,
'passwordResetTokenExpiresAt',
normalizedFilter.passwordResetTokenExpiresAtRange,
);
if (normalizedFilter.active !== undefined) {
where.active =
normalizedFilter.active === true || normalizedFilter.active === 'true';
}
if (normalizedFilter.disabled) {
where.disabled = normalizedFilter.disabled;
}
if (normalizedFilter.emailVerified) {
where.emailVerified = normalizedFilter.emailVerified;
}
if (typeof normalizedFilter.custom_permissions === 'string') {
const searchTerms = normalizedFilter.custom_permissions.split('|');
const permissionValidUuids = Utils.filterValidUuids(searchTerms);
include = [
{
model: db.permissions,
as: 'custom_permissions_filter',
required: searchTerms.length > 0,
where:
searchTerms.length > 0
? {
[Op.or]: [
...(permissionValidUuids.length > 0
? [{ id: { [Op.in]: permissionValidUuids } }]
: []),
{
name: {
[Op.or]: searchTerms.map((term) => ({
[Op.iLike]: `%${term}%`,
})),
},
},
],
}
: undefined,
},
...include,
];
}
addRangeFilter(where, 'createdAt', normalizedFilter.createdAtRange);
}
const sortField =
typeof normalizedFilter.field === 'string' &&
this.SORTABLE_FIELDS.includes(normalizedFilter.field)
? normalizedFilter.field
: 'createdAt';
const sortDirection =
String(normalizedFilter.sort || 'desc').toUpperCase() === 'ASC'
? 'ASC'
: 'DESC';
const queryOptions: {
attributes: { exclude: string[] };
where: QueryWhere;
include: unknown[];
distinct: true;
order: string[][];
transaction?: Transaction | undefined;
limit?: number | undefined;
offset?: number | undefined;
} = {
attributes: { exclude: this.SENSITIVE_FIELDS },
where,
include,
distinct: true,
order: [[sortField, sortDirection]],
};
if (options.transaction !== undefined) {
queryOptions.transaction = options.transaction;
}
if (!options.countOnly) {
queryOptions.limit = limit ? Number(limit) : undefined;
queryOptions.offset = offset ? Number(offset) : undefined;
}
try {
const { rows, count } = await db.users.findAndCountAll(queryOptions);
return {
rows: options.countOnly ? [] : rows,
count: count,
};
} catch (error) {
logger.error({ err: error, table: 'users' }, 'Error executing query');
throw error;
}
}
static async findAllAutocomplete(
options: AutocompleteOptions,
queryOptions: ServiceOptions = {},
): Promise<UserAutocompleteOption[]> {
assertAutocompleteOptions(options, 'DBApi');
const { query, limit, offset } = options;
const transaction = queryOptions.transaction;
const where: QueryWhere = {};
if (query) {
const orConditions: unknown[] = [Utils.ilike('users', 'firstName', query)];
if (Utils.isValidUuid(query)) {
orConditions.unshift({ id: query });
}
where[Op.or] = orConditions;
}
const records = await db.users.findAll({
attributes: ['id', 'firstName'],
where,
limit: limit ? Number(limit) : undefined,
offset: offset ? Number(offset) : undefined,
orderBy: [['firstName', 'ASC']],
transaction,
});
return records.map((record) => ({
id: record.id,
label: record.firstName,
}));
}
static async createFromAuth(
data: UserAuthCreatePayload,
options: ServiceOptions = {},
): Promise<UserRecord> {
const transaction = options.transaction;
const users = await db.users.create(
data,
buildTransactionOptions(transaction),
);
const app_role = await db.roles.findOne({
where: { name: config.roles?.user || 'User' },
});
if (app_role?.id) {
await users.setApp_role(app_role?.id || null, {
transaction,
});
}
await users.update(
{
authenticationUid: users.id,
},
{ transaction },
);
delete users.password;
return users;
}
static async updatePassword(
id: string,
password: string,
options: ServiceOptions = {},
): Promise<UserRecord> {
const currentUserId = getCurrentUserId(options);
const transaction = options.transaction;
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
if (!users) {
throw new Error('UsersNotFound');
}
await users.update(
{
password,
authenticationUid: id,
updatedById: currentUserId,
},
{ transaction },
);
return users;
}
static async generateEmailVerificationToken(
email: string,
options?: ServiceOptions,
): Promise<string> {
return this._generateToken(
['emailVerificationToken', 'emailVerificationTokenExpiresAt'],
email,
options,
);
}
static async generatePasswordResetToken(
email: string,
options?: ServiceOptions,
): Promise<string> {
return this._generateToken(
['passwordResetToken', 'passwordResetTokenExpiresAt'],
email,
options,
);
}
static async findByPasswordResetToken(
token: string,
options: ServiceOptions = {},
): Promise<UserRecord | null> {
const transaction = options.transaction;
return db.users.findOne({
where: {
passwordResetToken: token,
passwordResetTokenExpiresAt: {
[db.Sequelize.Op.gt]: Date.now(),
},
},
...buildTransactionOptions(transaction),
});
}
static async findByEmailVerificationToken(
token: string,
options: ServiceOptions = {},
): Promise<UserRecord | null> {
const transaction = options.transaction;
return db.users.findOne({
where: {
emailVerificationToken: token,
emailVerificationTokenExpiresAt: {
[db.Sequelize.Op.gt]: Date.now(),
},
},
...buildTransactionOptions(transaction),
});
}
static async markEmailVerified(
id: string,
options: ServiceOptions = {},
): Promise<boolean> {
const currentUserId = getCurrentUserId(options);
const transaction = options.transaction;
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
if (!users) {
return false;
}
await users.update(
{
emailVerified: true,
updatedById: currentUserId,
},
{ transaction },
);
return true;
}
private static async _generateToken(
keyNames: readonly [keyof UserTokenFields, keyof UserTokenFields],
email: string,
options: ServiceOptions = {},
): Promise<string> {
const currentUserId = getCurrentUserId(options);
const transaction = options.transaction;
const users = await db.users.findOne({
where: { email: email.toLowerCase() },
...buildTransactionOptions(transaction),
});
const token = crypto.randomBytes(20).toString('hex');
// Token expires in 24 hours (was 6 minutes - too short for email verification flows)
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000; // 24 hours
const tokenExpiresAt = Date.now() + TOKEN_EXPIRY_MS;
if (users) {
const updatePayload: UserTokenFields & { updatedById: string | null } = {
[keyNames[0]]: token,
[keyNames[1]]: tokenExpiresAt,
updatedById: currentUserId,
};
await users.update(updatePayload, { transaction });
}
return token;
}
}
const usersDBApi: UsersDbApi = UsersDBApi;
export default usersDBApi;

View File

@ -0,0 +1,64 @@
import '../load-env.ts';
import type { DatabaseConfigMap } from '../types/index.ts';
const sequelizeStorage = 'sequelize';
const migrationStorageTableName = 'SequelizeMeta';
type DatabaseLogging = NonNullable<DatabaseConfigMap['production']['logging']>;
function readEnvPort(name: string): number | undefined {
const value = process.env[name];
if (!value) return undefined;
const parsed = Number.parseInt(value, 10);
return Number.isFinite(parsed) ? parsed : undefined;
}
function createEnvDatabaseConfig(
logging: DatabaseLogging,
): DatabaseConfigMap['production'] {
const config: DatabaseConfigMap['production'] = {
dialect: 'postgres',
logging,
seederStorage: sequelizeStorage,
migrationStorage: sequelizeStorage,
migrationStorageTableName,
};
if (process.env.DB_USER !== undefined) {
config.username = process.env.DB_USER;
}
if (process.env.DB_PASS !== undefined) {
config.password = process.env.DB_PASS;
}
if (process.env.DB_NAME !== undefined) {
config.database = process.env.DB_NAME;
}
if (process.env.DB_HOST !== undefined) {
config.host = process.env.DB_HOST;
}
const port = readEnvPort('DB_PORT');
if (port !== undefined) {
config.port = port;
}
return config;
}
const dbConfig: DatabaseConfigMap = {
production: createEnvDatabaseConfig(false),
development: {
username: 'postgres',
dialect: 'postgres',
password: '',
database: 'db_tour_builder_platform',
host: process.env.DB_HOST || 'localhost',
logging: console.log,
seederStorage: sequelizeStorage,
migrationStorage: sequelizeStorage,
migrationStorageTableName,
},
dev_stage: createEnvDatabaseConfig(console.log),
};
export default dbConfig;

View File

@ -1,37 +0,0 @@
module.exports = {
production: {
dialect: 'postgres',
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT,
logging: false,
seederStorage: 'sequelize',
migrationStorage: 'sequelize',
migrationStorageTableName: 'SequelizeMeta',
},
development: {
username: 'postgres',
dialect: 'postgres',
password: '',
database: 'db_tour_builder_platform',
host: process.env.DB_HOST || 'localhost',
logging: console.log,
seederStorage: 'sequelize',
migrationStorage: 'sequelize',
migrationStorageTableName: 'SequelizeMeta',
},
dev_stage: {
dialect: 'postgres',
username: process.env.DB_USER,
password: process.env.DB_PASS,
database: process.env.DB_NAME,
host: process.env.DB_HOST,
port: process.env.DB_PORT,
logging: console.log,
seederStorage: 'sequelize',
migrationStorage: 'sequelize',
migrationStorageTableName: 'SequelizeMeta',
},
};

View File

@ -1,6 +1,5 @@
'use strict'; 'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = { module.exports = {
async up(queryInterface, _Sequelize) { async up(queryInterface, _Sequelize) {
await queryInterface.removeColumn('projects', 'theme_config_json'); await queryInterface.removeColumn('projects', 'theme_config_json');

View File

@ -1,6 +1,5 @@
'use strict'; 'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = { module.exports = {
async up(queryInterface, Sequelize) { async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tour_pages', 'background_video_autoplay', { await queryInterface.addColumn('tour_pages', 'background_video_autoplay', {

View File

@ -6,7 +6,6 @@
* Adds design_width and design_height columns to support * Adds design_width and design_height columns to support
* responsive canvas scaling with project-specific aspect ratios. * responsive canvas scaling with project-specific aspect ratios.
* *
* @type {import('sequelize-cli').Migration}
*/ */
module.exports = { module.exports = {
async up(queryInterface, Sequelize) { async up(queryInterface, Sequelize) {

View File

@ -7,7 +7,6 @@
* Also adds storage_key column to track the S3/local storage path. * Also adds storage_key column to track the S3/local storage path.
*/ */
/** @type {import('sequelize-cli').Migration} */
module.exports = { module.exports = {
async up(queryInterface, Sequelize) { async up(queryInterface, Sequelize) {
// Add 'reversed' to the enum_asset_variants_variant_type enum // Add 'reversed' to the enum_asset_variants_variant_type enum

View File

@ -10,7 +10,6 @@ const { v4: uuidv4 } = require('uuid');
* *
* Cascade: Element Project Global (fallback) * Cascade: Element Project Global (fallback)
* *
* @type {import('sequelize-cli').Migration}
*/ */
module.exports = { module.exports = {
async up(queryInterface, Sequelize) { async up(queryInterface, Sequelize) {

View File

@ -1,6 +1,5 @@
'use strict'; 'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = { module.exports = {
async up(queryInterface, Sequelize) { async up(queryInterface, Sequelize) {
await queryInterface.addColumn('tour_pages', 'background_audio_autoplay', { await queryInterface.addColumn('tour_pages', 'background_audio_autoplay', {

View File

@ -0,0 +1,3 @@
{
"type": "commonjs"
}

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) { import type {
const access_logs = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineAccessLogsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const accessLogs: SequelizeModel = sequelize.define(
'access_logs', 'access_logs',
{ {
id: { id: {
@ -67,11 +72,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
access_logs.associate = (db) => { accessLogs.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.access_logs.belongsTo(db.projects, { db.access_logs.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -101,5 +102,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return access_logs; return accessLogs;
}; };
export default defineAccessLogsModel;

View File

@ -1,5 +1,19 @@
module.exports = function (sequelize, DataTypes) { import type {
const asset_variants = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
function validateUrlOrEmpty(value: string | null | undefined): void {
if (value && value.length > 0 && !/^https?:\/\/.+/.test(value)) {
throw new Error('CDN URL must be a valid URL');
}
}
const defineAssetVariantsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const assetVariants: SequelizeModel = sequelize.define(
'asset_variants', 'asset_variants',
{ {
id: { id: {
@ -40,11 +54,7 @@ module.exports = function (sequelize, DataTypes) {
args: [0, 2048], args: [0, 2048],
msg: 'CDN URL must be at most 2048 characters', msg: 'CDN URL must be at most 2048 characters',
}, },
isUrlOrEmpty(value) { isUrlOrEmpty: validateUrlOrEmpty,
if (value && value.length > 0 && !/^https?:\/\/.+/.test(value)) {
throw new Error('CDN URL must be a valid URL');
}
},
}, },
}, },
@ -82,11 +92,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
asset_variants.associate = (db) => { assetVariants.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.asset_variants.belongsTo(db.assets, { db.asset_variants.belongsTo(db.assets, {
as: 'asset', as: 'asset',
foreignKey: { foreignKey: {
@ -106,5 +112,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return asset_variants; return assetVariants;
}; };
export default defineAssetVariantsModel;

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) { import type {
const assets = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineAssetsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const assets: SequelizeModel = sequelize.define(
'assets', 'assets',
{ {
id: { id: {
@ -137,8 +142,6 @@ module.exports = function (sequelize, DataTypes) {
); );
assets.associate = (db) => { assets.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
db.assets.hasMany(db.asset_variants, { db.assets.hasMany(db.asset_variants, {
as: 'asset_variants_asset', as: 'asset_variants_asset',
foreignKey: { foreignKey: {
@ -149,8 +152,6 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
}); });
//end loop
db.assets.belongsTo(db.projects, { db.assets.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -172,3 +173,5 @@ module.exports = function (sequelize, DataTypes) {
return assets; return assets;
}; };
export default defineAssetsModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const element_type_defaults = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineElementTypeDefaultsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const elementTypeDefaults: SequelizeModel = sequelize.define(
'element_type_defaults', 'element_type_defaults',
{ {
id: { id: {
@ -64,7 +72,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
element_type_defaults.associate = (db) => { elementTypeDefaults.associate = (db) => {
db.element_type_defaults.belongsTo(db.users, { db.element_type_defaults.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
}); });
@ -73,19 +81,18 @@ module.exports = function (sequelize, DataTypes) {
as: 'updatedBy', as: 'updatedBy',
}); });
// Add hasMany relationship to project_element_defaults db.element_type_defaults.hasMany(db.project_element_defaults, {
if (db.project_element_defaults) { as: 'project_defaults',
db.element_type_defaults.hasMany(db.project_element_defaults, { foreignKey: {
as: 'project_defaults', name: 'source_element_id',
foreignKey: { },
name: 'source_element_id', constraints: true,
}, onDelete: 'SET NULL',
constraints: true, onUpdate: 'CASCADE',
onDelete: 'SET NULL', });
onUpdate: 'CASCADE',
});
}
}; };
return element_type_defaults; return elementTypeDefaults;
}; };
export default defineElementTypeDefaultsModel;

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) { import type {
const file = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineFileModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const file: SequelizeModel = sequelize.define(
'file', 'file',
{ {
id: { id: {
@ -51,3 +56,5 @@ module.exports = function (sequelize, DataTypes) {
return file; return file;
}; };
export default defineFileModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const global_transition_defaults = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineGlobalTransitionDefaultsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const globalTransitionDefaults: SequelizeModel = sequelize.define(
'global_transition_defaults', 'global_transition_defaults',
{ {
id: { id: {
@ -59,7 +67,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
global_transition_defaults.associate = (db) => { globalTransitionDefaults.associate = (db) => {
db.global_transition_defaults.belongsTo(db.users, { db.global_transition_defaults.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
}); });
@ -69,5 +77,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return global_transition_defaults; return globalTransitionDefaults;
}; };
export default defineGlobalTransitionDefaultsModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const global_ui_control_defaults = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineGlobalUiControlDefaultsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const globalUiControlDefaults: SequelizeModel = sequelize.define(
'global_ui_control_defaults', 'global_ui_control_defaults',
{ {
id: { id: {
@ -20,7 +28,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
global_ui_control_defaults.associate = (db) => { globalUiControlDefaults.associate = (db) => {
db.global_ui_control_defaults.belongsTo(db.users, { db.global_ui_control_defaults.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
}); });
@ -30,5 +38,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return global_ui_control_defaults; return globalUiControlDefaults;
}; };
export default defineGlobalUiControlDefaultsModel;

View File

@ -1,46 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const Sequelize = require('sequelize');
const basename = path.basename(__filename);
const env = process.env.NODE_ENV || 'development';
const config = require('../db.config')[env];
const db = {};
let sequelize;
if (config.use_env_variable) {
sequelize = new Sequelize(process.env[config.use_env_variable], config);
} else {
sequelize = new Sequelize(
config.database,
config.username,
config.password,
config,
);
}
fs.readdirSync(__dirname)
.filter((file) => {
return (
file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'
);
})
.forEach((file) => {
const model = require(path.join(__dirname, file))(
sequelize,
Sequelize.DataTypes,
);
db[model.name] = model;
});
Object.keys(db).forEach((modelName) => {
if (db[modelName].associate) {
db[modelName].associate(db);
}
});
db.sequelize = sequelize;
db.Sequelize = Sequelize;
module.exports = db;

View File

@ -0,0 +1 @@
export { default } from './loader.ts';

View File

@ -0,0 +1,429 @@
import * as SequelizeModule from 'sequelize';
import { DataTypes, Sequelize } from 'sequelize';
import '../../load-env.ts';
import dbConfig from '../db-config.ts';
import defineAccessLogsModel from './access_logs.ts';
import defineAssetVariantsModel from './asset_variants.ts';
import defineAssetsModel from './assets.ts';
import defineElementTypeDefaultsModel from './element_type_defaults.ts';
import defineFileModel from './file.ts';
import defineGlobalTransitionDefaultsModel from './global_transition_defaults.ts';
import defineGlobalUiControlDefaultsModel from './global_ui_control_defaults.ts';
import definePermissionsModel from './permissions.ts';
import definePresignedUrlRequestsModel from './presigned_url_requests.ts';
import defineProductionPresentationAccessModel from './production_presentation_access.ts';
import defineProjectAudioTracksModel from './project_audio_tracks.ts';
import defineProjectElementDefaultsModel from './project_element_defaults.ts';
import defineProjectMembershipsModel from './project_memberships.ts';
import defineProjectTransitionSettingsModel from './project_transition_settings.ts';
import defineProjectUiControlSettingsModel from './project_ui_control_settings.ts';
import defineProjectsModel from './projects.ts';
import definePublishEventsModel from './publish_events.ts';
import definePwaCachesModel from './pwa_caches.ts';
import defineRolesModel from './roles.ts';
import defineTourPagesModel from './tour_pages.ts';
import defineUsersModel from './users.ts';
import type {
DatabaseEnvironmentConfig,
DbModels,
ElementTypeDefaultsModel,
FileModel,
GlobalTransitionDefaultsModel,
GlobalUiControlDefaultsModel,
PermissionModel,
ProductionPresentationAccessModel,
ProjectElementDefaultsModel,
ProjectModel,
ProjectTransitionSettingsModel,
ProjectUiControlSettingsModel,
RoleModel,
SampleDataModel,
SequelizeModelRegistry,
TourPageModel,
UserModel,
} from '../../types/index.ts';
type FunctionPropertyName =
| 'bulkCreate'
| 'cast'
| 'close'
| 'col'
| 'count'
| 'create'
| 'destroy'
| 'findAll'
| 'findAndCountAll'
| 'findByPk'
| 'findOne'
| 'findOrCreate'
| 'max'
| 'query'
| 'sync'
| 'transaction'
| 'where';
function isDatabaseConfigEnvironment(
value: string,
): value is keyof typeof dbConfig {
return value === 'development' || value === 'production' || value === 'dev_stage';
}
function getDatabaseConfig(): DatabaseEnvironmentConfig {
const env = process.env.NODE_ENV || 'development';
if (!isDatabaseConfigEnvironment(env)) {
throw new Error(`Unsupported database environment '${env}'.`);
}
return dbConfig[env];
}
function createSequelize(config: DatabaseEnvironmentConfig): Sequelize {
if (config.use_env_variable) {
const connectionString = process.env[config.use_env_variable];
if (!connectionString) {
throw new Error(
`Database environment variable '${config.use_env_variable}' is not set.`,
);
}
return new Sequelize(connectionString, config);
}
return new Sequelize(
config.database || '',
config.username || '',
config.password || '',
config,
);
}
function hasFunctionProperty(
value: object,
property: FunctionPropertyName,
): boolean {
return typeof Reflect.get(value, property) === 'function';
}
function hasFunctionProperties(
value: object,
properties: readonly FunctionPropertyName[],
): boolean {
return properties.every((property) => hasFunctionProperty(value, property));
}
function isSampleDataModel(value: object): value is SampleDataModel {
return hasFunctionProperties(value, ['bulkCreate', 'count', 'findOne']);
}
function isProjectModel(value: object): value is ProjectModel & SampleDataModel {
return hasFunctionProperties(value, [
'bulkCreate',
'count',
'create',
'destroy',
'findAll',
'findByPk',
'findOne',
]);
}
function isProjectCloneAssetModel(
value: object,
): value is DbModels['assets'] {
return hasFunctionProperties(value, [
'bulkCreate',
'count',
'create',
'findOne',
]);
}
function isProjectCloneVariantModel(
value: object,
): value is DbModels['asset_variants'] {
return hasFunctionProperties(value, [
'bulkCreate',
'count',
'create',
'findOne',
]);
}
function isPublishEventModel(value: object): value is DbModels['publish_events'] {
return hasFunctionProperties(value, [
'bulkCreate',
'count',
'create',
'findOne',
]);
}
function isTourPageModel(value: object): value is TourPageModel & SampleDataModel {
return hasFunctionProperties(value, [
'bulkCreate',
'count',
'create',
'destroy',
'findAll',
'findAndCountAll',
'findOne',
'max',
]);
}
function isProjectAudioTrackModel(
value: object,
): value is DbModels['project_audio_tracks'] {
return hasFunctionProperties(value, [
'bulkCreate',
'count',
'create',
'destroy',
'findAll',
'findOne',
]);
}
function isProjectElementDefaultsModel(
value: object,
): value is ProjectElementDefaultsModel & DbModels['project_element_defaults'] {
return hasFunctionProperties(value, [
'create',
'destroy',
'findAll',
'findOne',
]);
}
function isProjectTransitionSettingsModel(
value: object,
): value is ProjectTransitionSettingsModel &
DbModels['project_transition_settings'] {
return hasFunctionProperties(value, ['create', 'destroy', 'findOne']);
}
function isProjectUiControlSettingsModel(
value: object,
): value is ProjectUiControlSettingsModel &
DbModels['project_ui_control_settings'] {
return hasFunctionProperties(value, ['create', 'destroy', 'findOne']);
}
function isElementTypeDefaultsModel(
value: object,
): value is ElementTypeDefaultsModel {
return hasFunctionProperties(value, ['bulkCreate', 'findAll']);
}
function isGlobalTransitionDefaultsModel(
value: object,
): value is GlobalTransitionDefaultsModel {
return hasFunctionProperties(value, ['bulkCreate', 'destroy']);
}
function isGlobalUiControlDefaultsModel(
value: object,
): value is GlobalUiControlDefaultsModel {
return hasFunctionProperties(value, ['bulkCreate', 'destroy']);
}
function isFileModel(value: object): value is FileModel {
return hasFunctionProperties(value, ['create']);
}
function isProductionPresentationAccessModel(
value: object,
): value is ProductionPresentationAccessModel {
return hasFunctionProperties(value, [
'bulkCreate',
'create',
'destroy',
'findAll',
'findOne',
]);
}
function isRoleModel(value: object): value is RoleModel {
return hasFunctionProperties(value, ['create', 'findAll', 'findByPk', 'findOne']);
}
function isPermissionModel(value: object): value is PermissionModel {
return hasFunctionProperties(value, ['create']);
}
function isUserModel(value: object): value is UserModel & SampleDataModel {
return hasFunctionProperties(value, [
'bulkCreate',
'count',
'findAll',
'findByPk',
'findOne',
'findOrCreate',
]);
}
function requireSampleDataModel(name: string, value: object): SampleDataModel {
if (isSampleDataModel(value)) return value;
throw new Error(`Model '${name}' does not satisfy SampleDataModel contract.`);
}
function requireModel<T extends object>(
name: string,
value: object,
guard: (model: object) => model is T,
): T {
if (guard(value)) return value;
throw new Error(`Model '${name}' does not satisfy its typed DB contract.`);
}
const sequelize = createSequelize(getDatabaseConfig());
const models: SequelizeModelRegistry = {
access_logs: defineAccessLogsModel(sequelize, DataTypes),
asset_variants: defineAssetVariantsModel(sequelize, DataTypes),
assets: defineAssetsModel(sequelize, DataTypes),
element_type_defaults: defineElementTypeDefaultsModel(sequelize, DataTypes),
file: defineFileModel(sequelize, DataTypes),
global_transition_defaults: defineGlobalTransitionDefaultsModel(
sequelize,
DataTypes,
),
global_ui_control_defaults: defineGlobalUiControlDefaultsModel(
sequelize,
DataTypes,
),
permissions: definePermissionsModel(sequelize, DataTypes),
presigned_url_requests: definePresignedUrlRequestsModel(sequelize, DataTypes),
production_presentation_access: defineProductionPresentationAccessModel(
sequelize,
DataTypes,
),
project_audio_tracks: defineProjectAudioTracksModel(sequelize, DataTypes),
project_element_defaults: defineProjectElementDefaultsModel(
sequelize,
DataTypes,
),
project_memberships: defineProjectMembershipsModel(sequelize, DataTypes),
project_transition_settings: defineProjectTransitionSettingsModel(
sequelize,
DataTypes,
),
project_ui_control_settings: defineProjectUiControlSettingsModel(
sequelize,
DataTypes,
),
projects: defineProjectsModel(sequelize, DataTypes),
publish_events: definePublishEventsModel(sequelize, DataTypes),
pwa_caches: definePwaCachesModel(sequelize, DataTypes),
roles: defineRolesModel(sequelize, DataTypes),
tour_pages: defineTourPagesModel(sequelize, DataTypes),
users: defineUsersModel(sequelize, DataTypes),
};
const modelList = [
models.access_logs,
models.asset_variants,
models.assets,
models.element_type_defaults,
models.file,
models.global_transition_defaults,
models.global_ui_control_defaults,
models.permissions,
models.presigned_url_requests,
models.production_presentation_access,
models.project_audio_tracks,
models.project_element_defaults,
models.project_memberships,
models.project_transition_settings,
models.project_ui_control_settings,
models.projects,
models.publish_events,
models.pwa_caches,
models.roles,
models.tour_pages,
models.users,
];
for (const model of modelList) {
if (model.associate) {
model.associate(models);
}
}
const db: DbModels = {
sequelize,
Sequelize: SequelizeModule,
access_logs: requireSampleDataModel('access_logs', models.access_logs),
asset_variants: requireModel(
'asset_variants',
models.asset_variants,
isProjectCloneVariantModel,
),
assets: requireModel('assets', models.assets, isProjectCloneAssetModel),
element_type_defaults: requireModel(
'element_type_defaults',
models.element_type_defaults,
isElementTypeDefaultsModel,
),
file: requireModel('file', models.file, isFileModel),
global_transition_defaults: requireModel(
'global_transition_defaults',
models.global_transition_defaults,
isGlobalTransitionDefaultsModel,
),
global_ui_control_defaults: requireModel(
'global_ui_control_defaults',
models.global_ui_control_defaults,
isGlobalUiControlDefaultsModel,
),
permissions: requireModel(
'permissions',
models.permissions,
isPermissionModel,
),
presigned_url_requests: requireSampleDataModel(
'presigned_url_requests',
models.presigned_url_requests,
),
production_presentation_access: requireModel(
'production_presentation_access',
models.production_presentation_access,
isProductionPresentationAccessModel,
),
project_audio_tracks: requireModel(
'project_audio_tracks',
models.project_audio_tracks,
isProjectAudioTrackModel,
),
project_element_defaults: requireModel(
'project_element_defaults',
models.project_element_defaults,
isProjectElementDefaultsModel,
),
project_memberships: requireSampleDataModel(
'project_memberships',
models.project_memberships,
),
project_transition_settings: requireModel(
'project_transition_settings',
models.project_transition_settings,
isProjectTransitionSettingsModel,
),
project_ui_control_settings: requireModel(
'project_ui_control_settings',
models.project_ui_control_settings,
isProjectUiControlSettingsModel,
),
projects: requireModel('projects', models.projects, isProjectModel),
publish_events: requireModel(
'publish_events',
models.publish_events,
isPublishEventModel,
),
pwa_caches: requireSampleDataModel('pwa_caches', models.pwa_caches),
roles: requireModel('roles', models.roles, isRoleModel),
tour_pages: requireModel('tour_pages', models.tour_pages, isTourPageModel),
users: requireModel('users', models.users, isUserModel),
};
export default db;

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) { import type {
const permissions = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const definePermissionsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const permissions: SequelizeModel = sequelize.define(
'permissions', 'permissions',
{ {
id: { id: {
@ -35,10 +40,6 @@ module.exports = function (sequelize, DataTypes) {
); );
permissions.associate = (db) => { permissions.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.permissions.belongsTo(db.users, { db.permissions.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
}); });
@ -50,3 +51,5 @@ module.exports = function (sequelize, DataTypes) {
return permissions; return permissions;
}; };
export default definePermissionsModel;

View File

@ -1,5 +1,19 @@
module.exports = function (sequelize, DataTypes) { import type {
const presigned_url_requests = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
function validateMimeTypeOrEmpty(value: string | null | undefined): void {
if (value && value.length > 0 && !/^[\w.-]+\/[\w.+-]+$/.test(value)) {
throw new Error('MIME type must be in format type/subtype');
}
}
const definePresignedUrlRequestsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const presignedUrlRequests: SequelizeModel = sequelize.define(
'presigned_url_requests', 'presigned_url_requests',
{ {
id: { id: {
@ -37,15 +51,7 @@ module.exports = function (sequelize, DataTypes) {
args: [0, 255], args: [0, 255],
msg: 'MIME type must be at most 255 characters', msg: 'MIME type must be at most 255 characters',
}, },
isMimeTypeOrEmpty(value) { isMimeTypeOrEmpty: validateMimeTypeOrEmpty,
if (
value &&
value.length > 0 &&
!/^[\w.-]+\/[\w.+-]+$/.test(value)
) {
throw new Error('MIME type must be in format type/subtype');
}
},
}, },
}, },
@ -80,11 +86,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
presigned_url_requests.associate = (db) => { presignedUrlRequests.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.presigned_url_requests.belongsTo(db.projects, { db.presigned_url_requests.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -114,5 +116,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return presigned_url_requests; return presignedUrlRequests;
}; };
export default definePresignedUrlRequestsModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const production_presentation_access = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProductionPresentationAccessModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const productionPresentationAccess: SequelizeModel = sequelize.define(
'production_presentation_access', 'production_presentation_access',
{ {
id: { id: {
@ -26,7 +34,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
production_presentation_access.associate = (db) => { productionPresentationAccess.associate = (db) => {
db.production_presentation_access.belongsTo(db.projects, { db.production_presentation_access.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -56,5 +64,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return production_presentation_access; return productionPresentationAccess;
}; };
export default defineProductionPresentationAccessModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const project_audio_tracks = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectAudioTracksModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectAudioTracks: SequelizeModel = sequelize.define(
'project_audio_tracks', 'project_audio_tracks',
{ {
id: { id: {
@ -75,11 +83,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
project_audio_tracks.associate = (db) => { projectAudioTracks.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.project_audio_tracks.belongsTo(db.projects, { db.project_audio_tracks.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -99,5 +103,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return project_audio_tracks; return projectAudioTracks;
}; };
export default defineProjectAudioTracksModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const project_element_defaults = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectElementDefaultsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectElementDefaults: SequelizeModel = sequelize.define(
'project_element_defaults', 'project_element_defaults',
{ {
id: { id: {
@ -8,7 +16,6 @@ module.exports = function (sequelize, DataTypes) {
primaryKey: true, primaryKey: true,
}, },
element_type: { element_type: {
// TEXT for flexibility - matches element_type_defaults and page_elements
type: DataTypes.TEXT, type: DataTypes.TEXT,
allowNull: false, allowNull: false,
validate: { validate: {
@ -35,13 +42,10 @@ module.exports = function (sequelize, DataTypes) {
allowNull: true, allowNull: true,
}, },
source_element_id: { source_element_id: {
// Optional FK - tracks which global default this was snapshotted from
// SET NULL on global delete to preserve project overrides
type: DataTypes.UUID, type: DataTypes.UUID,
allowNull: true, allowNull: true,
}, },
snapshot_version: { snapshot_version: {
// Increments when resetting from global - enables "check for updates" feature
type: DataTypes.INTEGER, type: DataTypes.INTEGER,
allowNull: false, allowNull: false,
defaultValue: 1, defaultValue: 1,
@ -66,7 +70,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
project_element_defaults.associate = (db) => { projectElementDefaults.associate = (db) => {
db.project_element_defaults.belongsTo(db.projects, { db.project_element_defaults.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -97,5 +101,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return project_element_defaults; return projectElementDefaults;
}; };
export default defineProjectElementDefaultsModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const project_memberships = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectMembershipsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectMemberships: SequelizeModel = sequelize.define(
'project_memberships', 'project_memberships',
{ {
id: { id: {
@ -51,11 +59,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
project_memberships.associate = (db) => { projectMemberships.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.project_memberships.belongsTo(db.projects, { db.project_memberships.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -85,5 +89,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return project_memberships; return projectMemberships;
}; };
export default defineProjectMembershipsModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const project_transition_settings = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectTransitionSettingsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectTransitionSettings: SequelizeModel = sequelize.define(
'project_transition_settings', 'project_transition_settings',
{ {
id: { id: {
@ -79,7 +87,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
project_transition_settings.associate = (db) => { projectTransitionSettings.associate = (db) => {
db.project_transition_settings.belongsTo(db.projects, { db.project_transition_settings.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -99,5 +107,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return project_transition_settings; return projectTransitionSettings;
}; };
export default defineProjectTransitionSettingsModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const project_ui_control_settings = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectUiControlSettingsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectUiControlSettings: SequelizeModel = sequelize.define(
'project_ui_control_settings', 'project_ui_control_settings',
{ {
id: { id: {
@ -38,7 +46,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
project_ui_control_settings.associate = (db) => { projectUiControlSettings.associate = (db) => {
db.project_ui_control_settings.belongsTo(db.projects, { db.project_ui_control_settings.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -58,5 +66,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return project_ui_control_settings; return projectUiControlSettings;
}; };
export default defineProjectUiControlSettingsModel;

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) { import type {
const projects = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const projects: SequelizeModel = sequelize.define(
'projects', 'projects',
{ {
id: { id: {
@ -71,9 +76,6 @@ module.exports = function (sequelize, DataTypes) {
defaultValue: 'public', defaultValue: 'public',
}, },
// Note: transition_settings moved to project_transition_settings table
// for environment-aware storage (dev, stage, production)
importHash: { importHash: {
type: DataTypes.STRING(255), type: DataTypes.STRING(255),
allowNull: true, allowNull: true,
@ -89,8 +91,6 @@ module.exports = function (sequelize, DataTypes) {
); );
projects.associate = (db) => { projects.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
db.projects.hasMany(db.project_memberships, { db.projects.hasMany(db.project_memberships, {
as: 'project_memberships_project', as: 'project_memberships_project',
foreignKey: { foreignKey: {
@ -211,8 +211,6 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
}); });
//end loop
db.projects.belongsTo(db.users, { db.projects.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
}); });
@ -224,3 +222,5 @@ module.exports = function (sequelize, DataTypes) {
return projects; return projects;
}; };
export default defineProjectsModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) { import type {
const publish_events = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const definePublishEventsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const publishEvents: SequelizeModel = sequelize.define(
'publish_events', 'publish_events',
{ {
id: { id: {
@ -110,11 +118,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
publish_events.associate = (db) => { publishEvents.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.publish_events.belongsTo(db.projects, { db.publish_events.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -144,5 +148,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return publish_events; return publishEvents;
}; };
export default definePublishEventsModel;

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) { import type {
const pwa_caches = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const definePwaCachesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const pwaCaches: SequelizeModel = sequelize.define(
'pwa_caches', 'pwa_caches',
{ {
id: { id: {
@ -56,11 +61,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
pwa_caches.associate = (db) => { pwaCaches.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.pwa_caches.belongsTo(db.projects, { db.pwa_caches.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -80,5 +81,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return pwa_caches; return pwaCaches;
}; };
export default definePwaCachesModel;

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) { import type {
const roles = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineRolesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const roles: SequelizeModel = sequelize.define(
'roles', 'roles',
{ {
id: { id: {
@ -58,8 +63,6 @@ module.exports = function (sequelize, DataTypes) {
through: 'rolesPermissionsPermissions', through: 'rolesPermissionsPermissions',
}); });
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
db.roles.hasMany(db.users, { db.roles.hasMany(db.users, {
as: 'users_app_role', as: 'users_app_role',
foreignKey: { foreignKey: {
@ -70,8 +73,6 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
}); });
//end loop
db.roles.belongsTo(db.users, { db.roles.belongsTo(db.users, {
as: 'createdBy', as: 'createdBy',
}); });
@ -83,3 +84,5 @@ module.exports = function (sequelize, DataTypes) {
return roles; return roles;
}; };
export default defineRolesModel;

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) { import type {
const tour_pages = sequelize.define( SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineTourPagesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const tourPages: SequelizeModel = sequelize.define(
'tour_pages', 'tour_pages',
{ {
id: { id: {
@ -177,11 +182,7 @@ module.exports = function (sequelize, DataTypes) {
}, },
); );
tour_pages.associate = (db) => { tourPages.associate = (db) => {
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
//end loop
db.tour_pages.belongsTo(db.projects, { db.tour_pages.belongsTo(db.projects, {
as: 'project', as: 'project',
foreignKey: { foreignKey: {
@ -201,5 +202,7 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
return tour_pages; return tourPages;
}; };
export default defineTourPagesModel;

View File

@ -1,10 +1,59 @@
const config = require('../../config'); import crypto from 'node:crypto';
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
module.exports = function (sequelize, DataTypes) { import bcrypt from 'bcrypt';
const users = sequelize.define( import type { Model, ModelStatic } from 'sequelize';
import config from '../../config.ts';
import type {
SequelizeModelFactory,
SequelizeModelRegistry,
} from '../../types/index.ts';
const providers = config.providers;
interface UserModelInstance extends Model {
email: string;
firstName: string | null;
lastName: string | null;
password: string;
provider: string;
emailVerified: boolean;
}
interface UsersSequelizeModel extends ModelStatic<UserModelInstance> {
associate?: (db: SequelizeModelRegistry) => void;
}
function isKnownExternalProvider(provider: string): boolean {
return provider !== providers.LOCAL && Object.values(providers).includes(provider);
}
function trimStringFields(user: UserModelInstance): UserModelInstance {
user.email = user.email.trim();
user.firstName = user.firstName ? user.firstName.trim() : null;
user.lastName = user.lastName ? user.lastName.trim() : null;
return user;
}
function ensureExternalProviderPassword(user: UserModelInstance): void {
if (!isKnownExternalProvider(user.provider)) return;
user.emailVerified = true;
if (!user.password) {
const password = crypto.randomBytes(20).toString('hex');
const hashedPassword = bcrypt.hashSync(password, config.bcrypt.saltRounds);
user.password = hashedPassword;
}
}
const defineUsersModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const users: UsersSequelizeModel = sequelize.define<UserModelInstance>(
'users', 'users',
{ {
id: { id: {
@ -115,8 +164,6 @@ module.exports = function (sequelize, DataTypes) {
through: 'usersCustom_permissionsPermissions', through: 'usersCustom_permissionsPermissions',
}); });
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
db.users.hasMany(db.project_memberships, { db.users.hasMany(db.project_memberships, {
as: 'project_memberships_user', as: 'project_memberships_user',
foreignKey: { foreignKey: {
@ -167,8 +214,6 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE', onUpdate: 'CASCADE',
}); });
//end loop
db.users.belongsTo(db.roles, { db.users.belongsTo(db.roles, {
as: 'app_role', as: 'app_role',
foreignKey: { foreignKey: {
@ -200,41 +245,16 @@ module.exports = function (sequelize, DataTypes) {
}); });
}; };
users.beforeCreate((users) => { users.beforeCreate((user: UserModelInstance) => {
users = trimStringFields(users); trimStringFields(user);
ensureExternalProviderPassword(user);
if (
users.provider !== providers.LOCAL &&
Object.values(providers).indexOf(users.provider) > -1
) {
users.emailVerified = true;
if (!users.password) {
const password = crypto.randomBytes(20).toString('hex');
const hashedPassword = bcrypt.hashSync(
password,
config.bcrypt.saltRounds,
);
users.password = hashedPassword;
}
}
}); });
users.beforeUpdate((users) => { users.beforeUpdate((user: UserModelInstance) => {
trimStringFields(users); trimStringFields(user);
}); });
return users; return users;
}; };
function trimStringFields(users) { export default defineUsersModel;
users.email = users.email.trim();
users.firstName = users.firstName ? users.firstName.trim() : null;
users.lastName = users.lastName ? users.lastName.trim() : null;
return users;
}

View File

@ -1,16 +0,0 @@
const db = require('./models');
const { execSync } = require('child_process');
console.log('Resetting Database');
db.sequelize
.sync({ force: true })
.then(() => {
execSync('sequelize db:seed:all');
console.log('OK');
process.exit();
})
.catch((error) => {
console.error(error);
process.exit(1);
});

19
backend/src/db/reset.ts Normal file
View File

@ -0,0 +1,19 @@
import { execSync } from 'node:child_process';
import db from './models/index.ts';
async function resetDatabase(): Promise<void> {
console.log('Resetting Database');
try {
await db.sequelize.sync({ force: true });
execSync('sequelize db:seed:all');
console.log('OK');
process.exit(0);
} catch (error) {
console.error(error);
process.exit(1);
}
}
void resetDatabase();

View File

@ -1,73 +0,0 @@
'use strict';
const bcrypt = require('bcrypt');
const config = require('../../config');
const ids = [
'193bf4b5-9f07-4bd5-9a43-e7e41f3e96af',
'af5a87be-8f9c-4630-902a-37a60b7005ba',
'5bc531ab-611f-41f3-9373-b7cc5d09c93d',
];
module.exports = {
up: async (queryInterface) => {
let admin_hash = bcrypt.hashSync(
config.admin_pass,
config.bcrypt.saltRounds,
);
let user_hash = bcrypt.hashSync(config.user_pass, config.bcrypt.saltRounds);
try {
await queryInterface.bulkInsert('users', [
{
id: ids[0],
firstName: 'Admin',
email: config.admin_email,
emailVerified: true,
provider: config.providers.LOCAL,
password: admin_hash,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: ids[1],
firstName: 'John',
email: 'john@doe.com',
emailVerified: true,
provider: config.providers.LOCAL,
password: user_hash,
createdAt: new Date(),
updatedAt: new Date(),
},
{
id: ids[2],
firstName: 'Client',
email: 'client@hello.com',
emailVerified: true,
provider: config.providers.LOCAL,
password: user_hash,
createdAt: new Date(),
updatedAt: new Date(),
},
]);
} catch (error) {
console.error('Error during bulkInsert:', error);
throw error;
}
},
down: async (queryInterface, Sequelize) => {
try {
await queryInterface.bulkDelete(
'users',
{
id: {
[Sequelize.Op.in]: ids,
},
},
{},
);
} catch (error) {
console.error('Error during bulkDelete:', error);
throw error;
}
},
};

View File

@ -0,0 +1,92 @@
import bcrypt from 'bcrypt';
import { Op, QueryTypes } from 'sequelize';
import config from '../../config.ts';
import type {
AdminUserSeedExistingIdRow,
AdminUserSeedRow,
SequelizeSeeder,
} from '../../types/index.ts';
const seedUserIds: readonly [string, string, string] = [
'193bf4b5-9f07-4bd5-9a43-e7e41f3e96af',
'af5a87be-8f9c-4630-902a-37a60b7005ba',
'5bc531ab-611f-41f3-9373-b7cc5d09c93d',
];
function createAdminUserRows(): AdminUserSeedRow[] {
const adminHash = bcrypt.hashSync(
config.admin_pass,
config.bcrypt.saltRounds,
);
const userHash = bcrypt.hashSync(config.user_pass, config.bcrypt.saltRounds);
const now = new Date();
return [
{
id: seedUserIds[0],
firstName: 'Admin',
email: config.admin_email,
emailVerified: true,
provider: config.providers.LOCAL,
password: adminHash,
createdAt: now,
updatedAt: now,
},
{
id: seedUserIds[1],
firstName: 'John',
email: 'john@doe.com',
emailVerified: true,
provider: config.providers.LOCAL,
password: userHash,
createdAt: now,
updatedAt: now,
},
{
id: seedUserIds[2],
firstName: 'Client',
email: 'client@hello.com',
emailVerified: true,
provider: config.providers.LOCAL,
password: userHash,
createdAt: now,
updatedAt: now,
},
];
}
const adminUserSeeder: SequelizeSeeder = {
async up(queryInterface) {
const existingRows =
await queryInterface.sequelize.query<AdminUserSeedExistingIdRow>(
'SELECT "id" FROM "users" WHERE "id" IN (:ids)',
{
replacements: { ids: seedUserIds },
type: QueryTypes.SELECT,
},
);
const existingIds = new Set(existingRows.map((row) => row.id));
const rowsToInsert = createAdminUserRows().filter(
(row) => !existingIds.has(row.id),
);
if (rowsToInsert.length > 0) {
await queryInterface.bulkInsert('users', rowsToInsert);
}
},
async down(queryInterface) {
await queryInterface.bulkDelete(
'users',
{
id: {
[Op.in]: seedUserIds,
},
},
{},
);
},
};
export default adminUserSeeder;

View File

@ -1,80 +1,91 @@
const { v4: uuid } = require('uuid'); import type { QueryInterface } from 'sequelize';
import { QueryTypes } from 'sequelize';
import { v4 as uuid } from 'uuid';
module.exports = { import type {
/** RbacSeedExistingNamedIdRow,
* @param{import("sequelize").QueryInterface} queryInterface RbacSeedExistingRolePermissionRow,
* @return {Promise<void>} RbacSeedPermissionRow,
*/ RbacSeedRoleDefinition,
async up(queryInterface) { RbacSeedRolePermissionRow,
RbacSeedRoleRow,
SequelizeSeeder,
} from '../../types/index.ts';
function toRolePermissionKey(row: {
roles_permissionsId: string;
permissionId: string;
}): string {
return `${row.roles_permissionsId}:${row.permissionId}`;
}
const userRolesSeeder: SequelizeSeeder = {
async up(queryInterface: QueryInterface) {
const createdAt = new Date(); const createdAt = new Date();
const updatedAt = new Date(); const updatedAt = new Date();
/** @type {Map<string, string>} */ const idMap = new Map<string, string>();
const idMap = new Map();
/** function getId(key: string): string {
* @param {string} key const existingId = idMap.get(key);
* @return {string} if (existingId) {
*/ return existingId;
function getId(key) {
if (idMap.has(key)) {
return idMap.get(key);
} }
const id = uuid(); const id = uuid();
idMap.set(key, id); idMap.set(key, id);
return id; return id;
} }
await queryInterface.bulkInsert('roles', [ const roleDefinitions: RbacSeedRoleDefinition[] = [
{ { key: 'Administrator', name: 'Administrator' },
id: getId('Administrator'), { key: 'PlatformOwner', name: 'Platform Owner' },
name: 'Administrator', { key: 'AccountManager', name: 'Account Manager' },
createdAt, { key: 'TourDesigner', name: 'Tour Designer' },
updatedAt, { key: 'ContentReviewer', name: 'Content Reviewer' },
}, { key: 'AnalyticsViewer', name: 'Analytics Viewer' },
{ key: 'Public', name: 'Public' },
];
const roleKeyByName = new Map(
roleDefinitions.map((role) => [role.name, role.key]),
);
const existingRoles =
await queryInterface.sequelize.query<RbacSeedExistingNamedIdRow>(
`SELECT DISTINCT ON ("name") "id", "name"
FROM "roles"
WHERE "name" IN (:names)
ORDER BY "name", "createdAt" ASC`,
{
replacements: {
names: roleDefinitions.map((role) => role.name),
},
type: QueryTypes.SELECT,
},
);
{ for (const role of existingRoles) {
id: getId('PlatformOwner'), const key = roleKeyByName.get(role.name);
name: 'Platform Owner', if (key) {
createdAt, idMap.set(key, role.id);
updatedAt, }
}, }
{ const existingRoleNames = new Set(existingRoles.map((role) => role.name));
id: getId('AccountManager'), const roleRows: RbacSeedRoleRow[] = roleDefinitions.map((role) => ({
name: 'Account Manager', id: getId(role.key),
createdAt, name: role.name,
updatedAt, createdAt,
}, updatedAt,
}));
const rolesToInsert = roleRows.filter(
(role) => !existingRoleNames.has(role.name),
);
{ if (rolesToInsert.length > 0) {
id: getId('TourDesigner'), await queryInterface.bulkInsert('roles', rolesToInsert);
name: 'Tour Designer', }
createdAt,
updatedAt,
},
{ function createPermissions(name: string): RbacSeedPermissionRow[] {
id: getId('ContentReviewer'),
name: 'Content Reviewer',
createdAt,
updatedAt,
},
{
id: getId('AnalyticsViewer'),
name: 'Analytics Viewer',
createdAt,
updatedAt,
},
{ id: getId('Public'), name: 'Public', createdAt, updatedAt },
]);
/**
* @param {string} name
*/
function createPermissions(name) {
return [ return [
{ {
id: getId(`CREATE_${name.toUpperCase()}`), id: getId(`CREATE_${name.toUpperCase()}`),
@ -118,26 +129,49 @@ module.exports = {
'pwa_caches', 'pwa_caches',
'access_logs', 'access_logs',
]; ];
await queryInterface.bulkInsert( const permissionRows: RbacSeedPermissionRow[] = [
'permissions', ...entities.flatMap(createPermissions),
entities.flatMap(createPermissions),
);
await queryInterface.bulkInsert('permissions', [
{ {
id: getId(`READ_API_DOCS`), id: getId(`READ_API_DOCS`),
createdAt, createdAt,
updatedAt, updatedAt,
name: `READ_API_DOCS`, name: `READ_API_DOCS`,
}, },
]);
await queryInterface.bulkInsert('permissions', [
{ {
id: getId(`CREATE_SEARCH`), id: getId(`CREATE_SEARCH`),
createdAt, createdAt,
updatedAt, updatedAt,
name: `CREATE_SEARCH`, name: `CREATE_SEARCH`,
}, },
]); ];
const existingPermissions =
await queryInterface.sequelize.query<RbacSeedExistingNamedIdRow>(
`SELECT DISTINCT ON ("name") "id", "name"
FROM "permissions"
WHERE "name" IN (:names)
ORDER BY "name", "createdAt" ASC`,
{
replacements: {
names: permissionRows.map((permission) => permission.name),
},
type: QueryTypes.SELECT,
},
);
for (const permission of existingPermissions) {
idMap.set(permission.name, permission.id);
}
const existingPermissionNames = new Set(
existingPermissions.map((permission) => permission.name),
);
const permissionsToInsert = permissionRows.filter(
(permission) => !existingPermissionNames.has(permission.name),
);
if (permissionsToInsert.length > 0) {
await queryInterface.bulkInsert('permissions', permissionsToInsert);
}
await queryInterface.sequelize await queryInterface.sequelize
.query(`CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions" .query(`CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions"
@ -159,7 +193,7 @@ constraint "rolesPermissionsPermissions_permission_fk"
'CREATE INDEX IF NOT EXISTS "rolesPermissionsPermissions_permission_idx" ON "rolesPermissionsPermissions" ("permissionId");', 'CREATE INDEX IF NOT EXISTS "rolesPermissionsPermissions_permission_idx" ON "rolesPermissionsPermissions" ("permissionId");',
); );
await queryInterface.bulkInsert('rolesPermissionsPermissions', [ const rolePermissionRows: RbacSeedRolePermissionRow[] = [
{ {
createdAt, createdAt,
updatedAt, updatedAt,
@ -1748,7 +1782,29 @@ constraint "rolesPermissionsPermissions_permission_fk"
roles_permissionsId: getId('Administrator'), roles_permissionsId: getId('Administrator'),
permissionId: getId('CREATE_SEARCH'), permissionId: getId('CREATE_SEARCH'),
}, },
]); ];
const existingRolePermissions =
await queryInterface.sequelize.query<RbacSeedExistingRolePermissionRow>(
`SELECT "roles_permissionsId", "permissionId"
FROM "rolesPermissionsPermissions"`,
{
type: QueryTypes.SELECT,
},
);
const existingRolePermissionKeys = new Set(
existingRolePermissions.map(toRolePermissionKey),
);
const rolePermissionsToInsert = rolePermissionRows.filter(
(row) => !existingRolePermissionKeys.has(toRolePermissionKey(row)),
);
if (rolePermissionsToInsert.length > 0) {
await queryInterface.bulkInsert(
'rolesPermissionsPermissions',
rolePermissionsToInsert,
);
}
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
`UPDATE "users" SET "app_roleId"='${getId('SuperAdmin')}' WHERE "email"='super_admin@flatlogic.com'`, `UPDATE "users" SET "app_roleId"='${getId('SuperAdmin')}' WHERE "email"='super_admin@flatlogic.com'`,
@ -1765,3 +1821,5 @@ constraint "rolesPermissionsPermissions_permission_fk"
); );
}, },
}; };
export default userRolesSeeder;

View File

@ -1,25 +1,27 @@
const db = require('../models'); import db from '../models/index.ts';
const Users = db.users; import type { SampleDataModel, SequelizeSeeder } from '../../types/index.ts';
const Projects = db.projects; const Users: SampleDataModel = db.users;
const ProjectMemberships = db.project_memberships; const Projects: SampleDataModel = db.projects;
const Assets = db.assets; const ProjectMemberships: SampleDataModel = db.project_memberships;
const AssetVariants = db.asset_variants; const Assets: SampleDataModel = db.assets;
const PresignedUrlRequests = db.presigned_url_requests; const AssetVariants: SampleDataModel = db.asset_variants;
const TourPages = db.tour_pages; const PresignedUrlRequests: SampleDataModel = db.presigned_url_requests;
const ProjectAudioTracks = db.project_audio_tracks; const TourPages: SampleDataModel = db.tour_pages;
const PublishEvents = db.publish_events; const ProjectAudioTracks: SampleDataModel = db.project_audio_tracks;
const PwaCaches = db.pwa_caches; const PublishEvents: SampleDataModel = db.publish_events;
const AccessLogs = db.access_logs; const PwaCaches: SampleDataModel = db.pwa_caches;
const AccessLogs: SampleDataModel = db.access_logs;
const ProjectsData = [ const ProjectsData = [
{ {
@ -1084,7 +1086,7 @@ async function associateAccessLogWithUser() {
} }
} }
module.exports = { const sampleDataSeeder: SequelizeSeeder = {
up: async () => { up: async () => {
// Keep production-like schema strict; skip auto sample payload inserts by default. // Keep production-like schema strict; skip auto sample payload inserts by default.
if (process.env.ENABLE_SAMPLE_DATA !== 'true') { if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
@ -1114,31 +1116,31 @@ module.exports = {
await Promise.all([ await Promise.all([
// Similar logic for "relation_many" // Similar logic for "relation_many"
await associateProjectMembershipWithProject(), associateProjectMembershipWithProject(),
await associateProjectMembershipWithUser(), associateProjectMembershipWithUser(),
await associateAssetWithProject(), associateAssetWithProject(),
await associateAssetVariantWithAsset(), associateAssetVariantWithAsset(),
await associatePresignedUrlRequestWithProject(), associatePresignedUrlRequestWithProject(),
await associatePresignedUrlRequestWithUser(), associatePresignedUrlRequestWithUser(),
await associateTourPageWithProject(), associateTourPageWithProject(),
await associateProjectAudioTrackWithProject(), associateProjectAudioTrackWithProject(),
await associatePublishEventWithProject(), associatePublishEventWithProject(),
await associatePublishEventWithUser(), associatePublishEventWithUser(),
await associatePwaCacheWithProject(), associatePwaCacheWithProject(),
await associateAccessLogWithProject(), associateAccessLogWithProject(),
await associateAccessLogWithUser(), associateAccessLogWithUser(),
]); ]);
}, },
@ -1147,24 +1149,26 @@ module.exports = {
return; return;
} }
await queryInterface.bulkDelete('projects', null, {}); await queryInterface.bulkDelete('projects', {}, {});
await queryInterface.bulkDelete('project_memberships', null, {}); await queryInterface.bulkDelete('project_memberships', {}, {});
await queryInterface.bulkDelete('assets', null, {}); await queryInterface.bulkDelete('assets', {}, {});
await queryInterface.bulkDelete('asset_variants', null, {}); await queryInterface.bulkDelete('asset_variants', {}, {});
await queryInterface.bulkDelete('presigned_url_requests', null, {}); await queryInterface.bulkDelete('presigned_url_requests', {}, {});
await queryInterface.bulkDelete('tour_pages', null, {}); await queryInterface.bulkDelete('tour_pages', {}, {});
await queryInterface.bulkDelete('project_audio_tracks', null, {}); await queryInterface.bulkDelete('project_audio_tracks', {}, {});
await queryInterface.bulkDelete('publish_events', null, {}); await queryInterface.bulkDelete('publish_events', {}, {});
await queryInterface.bulkDelete('pwa_caches', null, {}); await queryInterface.bulkDelete('pwa_caches', {}, {});
await queryInterface.bulkDelete('access_logs', null, {}); await queryInterface.bulkDelete('access_logs', {}, {});
}, },
}; };
export default sampleDataSeeder;

View File

@ -1,9 +1,9 @@
const db = require('./models'); import db from './models/index.ts';
async function syncDatabase() { async function syncDatabase(): Promise<void> {
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
console.error( console.error(
'ERROR: sync.js should not be run in production. Use migrations instead.', 'ERROR: sync.ts should not be run in production. Use migrations instead.',
); );
process.exit(1); process.exit(1);
} }
@ -19,4 +19,4 @@ async function syncDatabase() {
} }
} }
syncDatabase(); void syncDatabase();

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