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
frontend/.env.local
frontend/.next
frontend/tsconfig.tsbuildinfo
frontend/node_modules
frontend/build

2
.gitignore vendored
View File

@ -4,10 +4,12 @@ node_modules/
*/node_modules/
**/node_modules/
*/build/
backend/dist/
frontend/.next/
frontend/out/
frontend/public/sw.js
frontend/next-env.d.ts
package-lock.json
!backend/package-lock.json
AGENTS.md
.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
WORKDIR /app
COPY frontend/package.json frontend/yarn.lock ./
RUN yarn install --pure-lockfile
COPY frontend/package*.json ./
RUN npm ci
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
WORKDIR /app
COPY backend/package.json backend/yarn.lock ./
RUN yarn install --pure-lockfile
COPY backend/package*.json ./
RUN npm ci
COPY backend .
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
FROM node:20.15.1-alpine AS frontend-deps
FROM node:24-alpine AS frontend-deps
RUN apk add --no-cache git
WORKDIR /app/frontend
COPY frontend/package.json frontend/yarn.lock ./
RUN yarn install --pure-lockfile
COPY frontend/package*.json ./
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
WORKDIR /app/backend
COPY backend/package.json backend/yarn.lock ./
RUN yarn install --pure-lockfile
COPY backend/package*.json ./
RUN npm ci
# 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 lsof procps
# FFmpeg is bundled via npm package ffmpeg-static
RUN yarn global add concurrently
RUN apk add --no-cache \
chromium \
@ -31,9 +30,6 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
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
WORKDIR /app
COPY --from=frontend-deps /app/frontend /app/frontend
@ -63,8 +59,8 @@ ENV FRONT_PORT=3001
ENV BACKEND_PORT=3000
CMD ["sh", "-c", "\
yarn --cwd /app/frontend dev & echo $! > /app/pids/frontend.pid && \
yarn --cwd /app/backend start & echo $! > /app/pids/backend.pid && \
npm --prefix /app/frontend run dev & echo $! > /app/pids/frontend.pid && \
npm --prefix /app/backend run start & echo $! > /app/pids/backend.pid && \
sleep 10 && nginx -g 'daemon off;' & \
NGINX_PID=$! && \
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
- Node.js 18+
- Node.js 24 LTS for backend
- PostgreSQL 14+
- Yarn (backend) / npm (frontend)
- npm for backend; frontend uses its existing package scripts
### Database Setup (First Time)
@ -46,11 +46,11 @@ PGPASSWORD='postgres' psql -U postgres -c "CREATE DATABASE app_39215 OWNER app_3
```bash
cd backend
yarn install
npm install
npm run start-dev
```
Backend runs on **http://localhost:8080**
Backend runs on **http://localhost:3000**
### Start Frontend (Terminal 2)
@ -60,7 +60,7 @@ npm install
npm run dev
```
Frontend runs on **http://localhost:3000**
Frontend runs on **http://localhost:3001**
### Login
@ -155,7 +155,7 @@ Pages have an `environment` field (`dev`, `stage`, `production`) that determines
## API Overview
Base URL: `http://localhost:8080/api`
Base URL: `http://localhost:3000/api`
| Endpoint | Description |
|----------|-------------|
@ -168,7 +168,7 @@ Base URL: `http://localhost:8080/api`
| `GET /assets` | List assets |
| `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
@ -224,7 +224,7 @@ EMAIL_PASS=...
### Frontend (`frontend/.env.local`)
```env
NEXT_PUBLIC_BACK_API=http://localhost:8080/api
NEXT_PUBLIC_BACK_API=http://localhost:3000/api
```
## Common Commands
@ -233,11 +233,13 @@ NEXT_PUBLIC_BACK_API=http://localhost:8080/api
```bash
cd backend
yarn start # Start server (migrate + seed + watch)
yarn db:migrate # Run migrations
yarn db:seed # Seed data
yarn db:reset # Drop + create + migrate + seed
yarn lint # ESLint
npm run start # Start server (migrate + seed + watch)
npm run db:migrate # Run migrations
npm run db:seed # Seed data
npm run db:reset # Drop + create + migrate + seed
npm run lint # ESLint
npm run typecheck # Strict TypeScript check for migrated backend scope
npm run build # Compile migrated TypeScript files
```
### Frontend
@ -255,7 +257,7 @@ npm run format # Prettier
### Connection Refused
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`
### Database Issues
@ -263,7 +265,7 @@ npm run format # Prettier
```bash
# Reset database completely
cd backend
yarn db:reset
npm run db:reset
```
### 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)
RUN apk update && apk add --no-cache bash ffmpeg
# Bash is required by docker/wait-for-it.sh when this image is used by docker-compose.
# FFmpeg is bundled by ffmpeg-static/ffprobe-static npm packages.
RUN apk add --no-cache bash
# Create app directory
WORKDIR /usr/src/app
# 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 ./
RUN yarn install
# If you are building your code for production
# RUN npm ci --only=production
RUN npm ci
# Bundle app source
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
- **Runtime**: Node.js 18+
- **Runtime**: Node.js 24 LTS
- **Framework**: Express 4.x
- **Database**: PostgreSQL with Sequelize ORM
- **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
- Node.js 18+
- Node.js 24 LTS
- PostgreSQL 14+
- Yarn package manager
- npm package manager
## Quick Start
```bash
# Install dependencies
yarn install
npm install
# Create database (first time only)
yarn db:create
npm run db:create
# Start server (runs migrations, seeds, and watches for changes)
npm run start-dev
```
The server runs on **port 8080** by default.
The server runs on **port 3000** by default.
## Checks
```bash
npm run lint
npm run typecheck
npm run build
npm run test
npm run test:integration
npm run check:public-access
```
- `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
when a valid database configuration is available; otherwise the tests skip.
- `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_PASS=ses-smtp-password
# OpenAI (optional)
GPT_KEY=your-openai-key
```
## Project Structure
```
backend/src/
├── index.js # Express app entry point
├── config.js # Environment configuration
├── helpers.js # Utility functions (wrapAsync)
├── index.ts # Express app entry point
├── config.ts # Environment configuration
├── 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.js # JWT, Google, Microsoft strategies
│ └── auth.ts # JWT, Google, Microsoft strategies
├── db/
│ ├── db.config.js # Database connection config (per environment)
│ ├── models/ # Sequelize model definitions (16 models)
│ ├── api/ # Database access layer (CRUD per model)
│ ├── migrations/ # Database migrations
│ └── seeders/ # Seed data (admin users, permissions, roles)
│ ├── db-config.ts # Typed database connection config
│ ├── umzug.ts # Typed Umzug runner for migrations and seeders
│ ├── models/ # Sequelize model definitions
│ ├── api/ # Database access layer
│ ├── migrations/ # Applied migration history; do not rewrite
│ └── seeders/ # Typed seed data files
├── routes/ # Express route handlers (22 routes)
│ ├── auth.js # Authentication endpoints
│ ├── projects.js # Project CRUD
│ ├── tour_pages.js # Tour page management
│ ├── assets.js # Asset management
│ ├── file.js # File upload/download, presigned URLs
│ ├── publish.js # Publishing workflow
│ ├── search.js # Global search
├── routes/ # Express route handlers
│ ├── auth.ts # Authentication endpoints
│ ├── projects.ts # Project CRUD
│ ├── tour_pages.ts # Tour page management
│ ├── assets.ts # Asset management
│ ├── file.ts # File upload/download, presigned URLs
│ ├── publish.ts # Publishing workflow
│ ├── search.ts # Global search
│ └── ... # Other entity routes
├── services/ # Business logic layer (21 services)
│ ├── auth.js # Auth service (JWT, OAuth)
│ ├── publish.js # Publishing workflow logic
│ ├── file.js # File storage abstraction
│ ├── search.js # Global search service
├── services/ # Business logic layer
│ ├── auth.ts # Auth service (JWT, OAuth)
│ ├── publish.ts # Publishing workflow logic
│ ├── file.ts # File storage abstraction
│ ├── search.ts # Global search service
│ ├── email/ # Email templates and sending
│ ├── notifications/ # Error classes and i18n messages
│ └── ... # Other entity services
├── middlewares/
│ ├── check-permissions.js # RBAC permission checking
│ ├── runtime-context.js # Environment detection from headers
│ ├── runtime-public.js # Public runtime access (no auth)
│ ├── upload.js # File upload handling (multer)
│ └── rateLimiter.js # Rate limiting for API endpoints
│ ├── check-permissions.ts # RBAC permission checking
│ ├── runtime-context.ts # Environment detection from headers
│ ├── runtime-public.ts # Public runtime access (no auth)
│ ├── upload.ts # File upload handling (multer)
│ └── rateLimiter.ts # Rate limiting for API endpoints
├── factories/
│ ├── router.factory.js # Generate CRUD routes
│ └── service.factory.js # Generate service classes
│ ├── router.factory.ts # Generate CRUD routes
│ └── service.factory.ts # Generate service classes
└── utils/
├── env-validation.js # Environment variable validation (Joi)
├── errors.js # Custom error classes
├── logger.js # Pino logger configuration
└── index.js # Utils barrel export
├── env-validation.ts # Environment variable validation (Joi)
├── errors.ts # Custom error classes
├── logger.ts # Pino logger configuration
├── request-context.ts # Request-scoped currentUser/runtime/log storage
└── index.ts # Utils barrel export
```
## Database Setup
@ -169,22 +175,24 @@ GRANT ALL PRIVILEGES ON DATABASE app_39215 TO app_39215;
### Available Commands
```bash
yarn db:create # Create database
yarn db:drop # Drop database
yarn db:migrate # Run pending migrations
yarn db:migrate:undo # Undo last migration
yarn db:migrate:undo:all # Undo all migrations
yarn db:migrate:status # Show migration status
yarn db:seed # Run all seeders
yarn db:seed:undo # Undo all seeders
yarn db:reset # Drop, create, migrate, and seed
yarn start # Migrate, seed, and start with watch
yarn lint # Run ESLint
npm run db:create # Create database
npm run db:drop # Drop database
npm run db:migrate # Run pending migrations
npm run db:migrate:undo # Undo last migration
npm run db:migrate:undo:all # Undo all migrations
npm run db:migrate:status # Show migration status
npm run db:seed # Run all seeders
npm run db:seed:undo # Undo all seeders
npm run db:reset # Drop, create, migrate, and seed
npm run start # Migrate, seed, and start with watch
npm run lint # Run ESLint
npm run typecheck # Run strict TypeScript check
npm run build # Compile migrated TypeScript files
```
## API Documentation
Swagger UI available at: `http://localhost:8080/api-docs`
Swagger UI available at: `http://localhost:3000/api-docs`
### Core Endpoints
@ -363,7 +371,7 @@ Separate from server environment, tour pages have a content environment field:
| `stage` | Stage preview | Pre-production review |
| `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

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",
"description": "Tour Builder Platform - template backend",
"type": "module",
"scripts": {
"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",
"test": "node --test tests/*.test.js",
"test:integration": "node --test tests/integration/*.test.js",
"check:public-access": "node scripts/check-public-access-hardening.js",
"fix:public-access": "node scripts/check-public-access-hardening.js --fix",
"lint": "eslint . --ext .js",
"db:migrate": "sequelize-cli db:migrate",
"db:migrate:undo": "sequelize-cli db:migrate:undo",
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
"db:migrate:status": "sequelize-cli db:migrate:status",
"db:seed": "sequelize-cli db:seed:all",
"db:seed:undo": "sequelize-cli db:seed:undo:all",
"db:drop": "sequelize-cli db:drop",
"db:create": "sequelize-cli db:create",
"start-dev": "LOG_PRETTY=true npm run start",
"typecheck": "tsc -p tsconfig.json --noEmit",
"build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.ts",
"test": "node --test tests/*.test.ts",
"test:integration": "node --test tests/integration/*.test.ts",
"verify": "npm run typecheck && npm run lint && npm run check:esm-boundaries && npm run test",
"check:esm-boundaries": "node scripts/check-esm-boundaries.ts",
"check:public-access": "node scripts/check-public-access-hardening.ts",
"fix:public-access": "node scripts/check-public-access-hardening.ts --fix",
"lint": "eslint .",
"db:migrate": "node src/db/umzug.ts migrate:up",
"db:migrate:undo": "node src/db/umzug.ts migrate:down",
"db:migrate:undo:all": "node src/db/umzug.ts migrate:down:all",
"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",
"watch": "node watcher.js"
"watch": "node watcher.ts"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.1011.0",
"@aws-sdk/s3-request-presigner": "^3.1016.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",
"body-parser": "^2.3.0",
"chokidar": "^4.0.3",
"cors": "^2.8.6",
"csv-parser": "^3.2.0",
"dotenv": "^16.4.0",
"express": "4.18.2",
"express-validator": "^7.0.0",
"express": "^4.22.2",
"ffmpeg-static": "^5.2.0",
"ffprobe-static": "^3.1.0",
"fluent-ffmpeg": "^2.1.3",
"formidable": "1.2.2",
"helmet": "^8.0.0",
"joi": "^17.13.0",
"json2csv": "^5.0.7",
"jsonwebtoken": "^9.0.0",
"lodash": "^4.17.23",
"moment": "2.30.1",
"multer": "^2.0.0",
"mysql2": "2.2.5",
"nodemailer": "6.9.9",
"nodemailer": "^9.0.3",
"passport": "^0.7.0",
"passport-google-oauth2": "^0.2.0",
"passport-jwt": "^4.0.1",
"passport-microsoft": "^2.0.0",
"pg": "^8.20.0",
"pg-hstore": "2.3.4",
"pino": "^9.0.0",
"pino-pretty": "^11.0.0",
"sequelize": "^6.37.0",
"sequelize-json-schema": "^2.1.1",
"sqlite": "4.0.15",
"swagger-jsdoc": "^6.2.8",
"swagger-ui-express": "^5.0.0",
"tedious": "^18.6.0"
"uuid": "^14.0.1",
"validator": "^13.15.35"
},
"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,
"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-import-resolver-typescript": "^4.4.5",
"eslint-plugin-import": "^2.29.1",
"mocha": "^10.0.0",
"globals": "^15.15.0",
"node-mocks-http": "^1.17.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
const db = require('../src/db/models');
const AccessPolicyAuditService = require('../src/services/access-policy-audit');
import db from '../src/db/models/index.ts';
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 EXIT_TIMEOUT_MS = 1500;
function summarizeReport(report) {
function summarizeReport(
report: AccessPolicyAuditReport,
): PublicAccessHardeningSummary {
return {
publicRolePermissions: report.publicRolePermissions.length,
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) {
const result = await db.sequelize.transaction((transaction) =>
AccessPolicyAuditService.cleanupViolations({ transaction }),
);
console.log(
JSON.stringify(
{
fixed: true,
summary: {
removedPublicRolePermissions: result.removedPublicRolePermissions,
clearedPublicUserCustomPermissions:
result.clearedPublicUserCustomPermissions,
removedNonPublicProductionPresentationGrants:
result.removedNonPublicProductionPresentationGrants,
},
},
null,
2,
),
);
logJson({
fixed: true,
summary: {
removedPublicRolePermissions: result.removedPublicRolePermissions,
clearedPublicUserCustomPermissions:
result.clearedPublicUserCustomPermissions,
removedNonPublicProductionPresentationGrants:
result.removedNonPublicProductionPresentationGrants,
},
});
return;
}
const report = await AccessPolicyAuditService.findViolations();
const hasViolations = AccessPolicyAuditService.hasViolations(report);
console.log(
JSON.stringify(
{
ok: !hasViolations,
summary: summarizeReport(report),
report,
},
null,
2,
),
);
logJson({
ok: !hasViolations,
summary: summarizeReport(report),
report,
});
if (hasViolations) {
process.exitCode = 1;
@ -63,7 +65,7 @@ async function main() {
main()
.catch((error) => {
console.error(error);
logError(error);
process.exitCode = 1;
})
.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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
AccessLogAssociationConfig,
AccessLogData,
AccessLogFieldMapping,
AccessLogRelationFilterConfig,
} from '../../types/index.ts';
class Access_logsDBApi extends GenericDBApi {
static get MODEL() {
static override get MODEL(): unknown {
return db.access_logs;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'access_logs';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return ['path', 'ip_address', 'user_agent'];
}
static get RANGE_FIELDS() {
static override get RANGE_FIELDS(): string[] {
return ['accessed_at'];
}
static get ENUM_FIELDS() {
static override get ENUM_FIELDS(): string[] {
return ['environment'];
}
static get CSV_FIELDS() {
static override get CSV_FIELDS(): string[] {
return [
'id',
'environment',
@ -34,29 +40,29 @@ class Access_logsDBApi extends GenericDBApi {
];
}
static get AUTOCOMPLETE_FIELD() {
static override get AUTOCOMPLETE_FIELD(): string {
return 'path';
}
static get ASSOCIATIONS() {
static override get ASSOCIATIONS(): AccessLogAssociationConfig[] {
return [
{ field: 'project', setter: 'setProject', 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' }];
}
static get FIND_ALL_INCLUDES() {
static override get FIND_ALL_INCLUDES(): unknown[] {
return [
{ model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false },
];
}
static get RELATION_FILTERS() {
static override get RELATION_FILTERS(): AccessLogRelationFilterConfig[] {
return [
{
filterKey: 'project',
@ -73,7 +79,7 @@ class Access_logsDBApi extends GenericDBApi {
];
}
static getFieldMapping(data) {
static override getFieldMapping(data: AccessLogData): AccessLogFieldMapping {
return {
id: data.id || undefined,
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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
AssetVariantAssociationConfig,
AssetVariantData,
AssetVariantFieldMapping,
AssetVariantRelationFilterConfig,
} from '../../types/index.ts';
class Asset_variantsDBApi extends GenericDBApi {
static get MODEL() {
static override get MODEL(): unknown {
return db.asset_variants;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'asset_variants';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return ['cdn_url'];
}
static get RANGE_FIELDS() {
static override get RANGE_FIELDS(): string[] {
return ['width_px', 'height_px', 'size_mb'];
}
static get ENUM_FIELDS() {
static override get ENUM_FIELDS(): string[] {
return ['variant_type'];
}
static get CSV_FIELDS() {
static override get CSV_FIELDS(): string[] {
return [
'id',
'variant_type',
@ -34,19 +40,19 @@ class Asset_variantsDBApi extends GenericDBApi {
];
}
static get AUTOCOMPLETE_FIELD() {
static override get AUTOCOMPLETE_FIELD(): string {
return 'variant_type';
}
static get ASSOCIATIONS() {
static override get ASSOCIATIONS(): AssetVariantAssociationConfig[] {
return [{ field: 'asset', setter: 'setAsset', isArray: false }];
}
static get FIND_BY_INCLUDES() {
static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'asset' }];
}
static get FIND_ALL_INCLUDES() {
static override get FIND_ALL_INCLUDES(): unknown[] {
return [
{
model: db.assets,
@ -56,7 +62,7 @@ class Asset_variantsDBApi extends GenericDBApi {
];
}
static get RELATION_FILTERS() {
static override get RELATION_FILTERS(): AssetVariantRelationFilterConfig[] {
return [
{
filterKey: 'asset',
@ -67,7 +73,7 @@ class Asset_variantsDBApi extends GenericDBApi {
];
}
static getFieldMapping(data) {
static override getFieldMapping(data: AssetVariantData): AssetVariantFieldMapping {
return {
id: data.id || undefined,
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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
AssetData,
AssetFieldMapping,
AssetUsageType,
AssetsDbApi,
DbAssociationConfig,
DbRelationFilterConfig,
} from '../../types/index.ts';
class AssetsDBApi extends GenericDBApi {
static get MODEL() {
declare static findBy: AssetsDbApi['findBy'];
static override get MODEL(): unknown {
return db.assets;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'assets';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return [
'name',
'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'];
}
static get ENUM_FIELDS() {
static override get ENUM_FIELDS(): string[] {
return ['asset_type', 'type', 'is_public'];
}
static get UUID_FIELDS() {
static override get UUID_FIELDS(): string[] {
return ['projectId'];
}
static get CSV_FIELDS() {
static override get CSV_FIELDS(): string[] {
return [
'id',
'name',
@ -47,26 +57,26 @@ class AssetsDBApi extends GenericDBApi {
];
}
static get AUTOCOMPLETE_FIELD() {
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static get ASSOCIATIONS() {
static override get ASSOCIATIONS(): DbAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static get FIND_BY_INCLUDES() {
static override get FIND_BY_INCLUDES(): unknown[] {
return [
{ association: 'asset_variants_asset' },
{ association: 'project' },
];
}
static get FIND_ALL_INCLUDES() {
static override get FIND_ALL_INCLUDES(): unknown[] {
return [{ model: db.projects, as: 'project', required: false }];
}
static get RELATION_FILTERS() {
static override get RELATION_FILTERS(): DbRelationFilterConfig[] {
return [
{
filterKey: 'project',
@ -77,12 +87,12 @@ class AssetsDBApi extends GenericDBApi {
];
}
static getFieldMapping(data) {
static override getFieldMapping(data: AssetData): AssetFieldMapping {
return {
id: data.id || undefined,
name: data.name || null,
asset_type: data.asset_type || null,
type: data.type || 'general',
type: data.type || defaultAssetUsageType,
cdn_url: data.cdn_url || null,
storage_key: data.storage_key || 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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
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 {
static get MODEL() {
static initializationPromise: Promise<void> | null = null;
static override get MODEL(): ElementTypeDefaultsModel {
return db.element_type_defaults;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'element_type_defaults';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return ['name', 'element_type'];
}
static get RANGE_FIELDS() {
static override get RANGE_FIELDS(): string[] {
return ['sort_order'];
}
static get ENUM_FIELDS() {
static override get ENUM_FIELDS(): string[] {
return [];
}
static get CSV_FIELDS() {
static override get CSV_FIELDS(): string[] {
return [
'id',
'element_type',
@ -33,16 +79,16 @@ class Element_type_defaultsDBApi extends GenericDBApi {
];
}
static get AUTOCOMPLETE_FIELD() {
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
// Declarative field configuration using base class patterns
static get JSON_FIELDS() {
static override get JSON_FIELDS(): string[] {
return ['default_settings_json'];
}
static get FIELD_DEFAULTS() {
static override get FIELD_DEFAULTS(): ElementTypeDefaultsFieldDefaults {
return {
element_type: { default: null },
name: { default: null },
@ -50,20 +96,19 @@ class Element_type_defaultsDBApi extends GenericDBApi {
};
}
static getFieldMapping(data) {
// Apply base class transformations (JSON fields, defaults, transformers)
const mapped = super.getFieldMapping(data);
static override getFieldMapping(
data: ElementTypeDefaultsData,
): ElementTypeDefaultsFieldMapping {
return {
id: mapped.id || undefined,
element_type: mapped.element_type,
name: mapped.name,
sort_order: mapped.sort_order,
default_settings_json: mapped.default_settings_json,
id: data.id || undefined,
element_type: data.element_type ?? null,
name: data.name ?? null,
sort_order: data.sort_order ?? 0,
default_settings_json: stringifySettings(data.default_settings_json),
};
}
static get DEFAULT_ROWS() {
static get DEFAULT_ROWS(): ElementTypeDefaultsSeedRow[] {
return [
{
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) {
this.initializationPromise = (async () => {
let count = 0;
@ -336,7 +381,7 @@ class Element_type_defaultsDBApi extends GenericDBApi {
try {
count = await this.MODEL.count();
} catch (error) {
if (error?.original?.code !== '42P01') {
if (!isMissingTableError(error)) {
throw error;
}
@ -363,47 +408,91 @@ class Element_type_defaultsDBApi extends GenericDBApi {
await this.initializationPromise;
}
static async create(options) {
static override async create(
options: CreateOptions<ElementTypeDefaultsData>,
): Promise<EntityRecord> {
await this.ensureInitialized();
return super.create(options);
}
static async bulkImport(data, options = {}) {
static override async bulkImport(
data: unknown[],
options: ServiceOptions = {},
): Promise<void> {
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();
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();
return super.deleteByIds(options);
}
static async remove(options) {
static override async remove(options: EntityIdOptions): Promise<EntityRecord> {
await this.ensureInitialized();
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();
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();
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();
return super.findAllAutocomplete(options, queryOptions);
const records = await super.findAllAutocomplete(options);
return records;
}
}
Element_type_defaultsDBApi.initializationPromise = null;
module.exports = Element_type_defaultsDBApi;
export default 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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
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: {
enabled: true,
hidden: false,
@ -71,40 +95,42 @@ const DEFAULT_SETTINGS = {
};
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;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'global_ui_control_defaults';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return [];
}
static get RANGE_FIELDS() {
static override get RANGE_FIELDS(): string[] {
return [];
}
static get ENUM_FIELDS() {
static override get ENUM_FIELDS(): string[] {
return [];
}
static get DEFAULT_SETTINGS() {
static get DEFAULT_SETTINGS(): GlobalUiControlSettingsJson {
return DEFAULT_SETTINGS;
}
static getFieldMapping(data) {
const mapped = super.getFieldMapping(data);
static override getFieldMapping(
data: GlobalUiControlDefaultsData,
): GlobalUiControlDefaultsFieldMapping {
return {
id: mapped.id || undefined,
settings_json:
mapped.settings_json || mapped.settings || DEFAULT_SETTINGS,
id: data.id || undefined,
settings_json: data.settings_json || data.settings || DEFAULT_SETTINGS,
};
}
static async ensureInitialized() {
static async ensureInitialized(): Promise<void> {
if (!this.initializationPromise) {
this.initializationPromise = (async () => {
let count = 0;
@ -112,7 +138,7 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
try {
count = await this.MODEL.count();
} catch (error) {
if (error?.original?.code !== '42P01') {
if (!isMissingTableError(error)) {
throw error;
}
@ -137,7 +163,9 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
await this.initializationPromise;
}
static async findOne(options = {}) {
static async findOne(
options: ServiceOptions = {},
): Promise<GlobalUiControlDefaultsRecord | null> {
await this.ensureInitialized();
const record = await this.MODEL.findOne({
@ -148,17 +176,57 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
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();
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();
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;
module.exports = Global_ui_control_defaultsDBApi;
export default 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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
PresignedUrlRequestAssociationConfig,
PresignedUrlRequestData,
PresignedUrlRequestFieldMapping,
PresignedUrlRequestRelationFilterConfig,
} from '../../types/index.ts';
class Presigned_url_requestsDBApi extends GenericDBApi {
static get MODEL() {
static override get MODEL(): unknown {
return db.presigned_url_requests;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'presigned_url_requests';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return ['requested_key', 'mime_type', 'status'];
}
static get RANGE_FIELDS() {
static override get RANGE_FIELDS(): string[] {
return ['requested_size_mb', 'expires_at'];
}
static get ENUM_FIELDS() {
static override get ENUM_FIELDS(): string[] {
return ['purpose', 'asset_type'];
}
static get CSV_FIELDS() {
static override get CSV_FIELDS(): string[] {
return [
'id',
'purpose',
@ -34,29 +40,29 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
];
}
static get AUTOCOMPLETE_FIELD() {
static override get AUTOCOMPLETE_FIELD(): string {
return 'requested_key';
}
static get ASSOCIATIONS() {
static override get ASSOCIATIONS(): PresignedUrlRequestAssociationConfig[] {
return [
{ field: 'project', setter: 'setProject', 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' }];
}
static get FIND_ALL_INCLUDES() {
static override get FIND_ALL_INCLUDES(): unknown[] {
return [
{ model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false },
];
}
static get RELATION_FILTERS() {
static override get RELATION_FILTERS(): PresignedUrlRequestRelationFilterConfig[] {
return [
{
filterKey: 'project',
@ -73,7 +79,9 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
];
}
static getFieldMapping(data) {
static override getFieldMapping(
data: PresignedUrlRequestData,
): PresignedUrlRequestFieldMapping {
return {
id: data.id || undefined,
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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
ProjectMembershipAssociationConfig,
ProjectMembershipData,
ProjectMembershipFieldMapping,
ProjectMembershipRelationFilterConfig,
} from '../../types/index.ts';
class Project_membershipsDBApi extends GenericDBApi {
static get MODEL() {
static override get MODEL(): unknown {
return db.project_memberships;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'project_memberships';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return [];
}
static get RANGE_FIELDS() {
static override get RANGE_FIELDS(): string[] {
return ['invited_at', 'accepted_at'];
}
static get ENUM_FIELDS() {
static override get ENUM_FIELDS(): string[] {
return ['access_level', 'is_active'];
}
static get CSV_FIELDS() {
static override get CSV_FIELDS(): string[] {
return [
'id',
'access_level',
@ -33,29 +39,29 @@ class Project_membershipsDBApi extends GenericDBApi {
];
}
static get AUTOCOMPLETE_FIELD() {
static override get AUTOCOMPLETE_FIELD(): string {
return 'access_level';
}
static get ASSOCIATIONS() {
static override get ASSOCIATIONS(): ProjectMembershipAssociationConfig[] {
return [
{ field: 'project', setter: 'setProject', 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' }];
}
static get FIND_ALL_INCLUDES() {
static override get FIND_ALL_INCLUDES(): unknown[] {
return [
{ model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false },
];
}
static get RELATION_FILTERS() {
static override get RELATION_FILTERS(): ProjectMembershipRelationFilterConfig[] {
return [
{
filterKey: 'project',
@ -72,7 +78,9 @@ class Project_membershipsDBApi extends GenericDBApi {
];
}
static getFieldMapping(data) {
static override getFieldMapping(
data: ProjectMembershipData,
): ProjectMembershipFieldMapping {
return {
id: data.id || undefined,
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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
PublishEventAssociationConfig,
PublishEventData,
PublishEventFieldMapping,
PublishEventRelationFilterConfig,
} from '../../types/index.ts';
class Publish_eventsDBApi extends GenericDBApi {
static get MODEL() {
static override get MODEL(): unknown {
return db.publish_events;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'publish_events';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return ['title', 'description', 'error_message'];
}
static get RANGE_FIELDS() {
static override get RANGE_FIELDS(): string[] {
return [
'started_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'];
}
static get UUID_FIELDS() {
static override get UUID_FIELDS(): string[] {
return ['projectId'];
}
static get CSV_FIELDS() {
static override get CSV_FIELDS(): string[] {
return [
'id',
'title',
@ -45,29 +51,29 @@ class Publish_eventsDBApi extends GenericDBApi {
];
}
static get AUTOCOMPLETE_FIELD() {
static override get AUTOCOMPLETE_FIELD(): string {
return 'status';
}
static get ASSOCIATIONS() {
static override get ASSOCIATIONS(): PublishEventAssociationConfig[] {
return [
{ field: 'project', setter: 'setProject', 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' }];
}
static get FIND_ALL_INCLUDES() {
static override get FIND_ALL_INCLUDES(): unknown[] {
return [
{ model: db.projects, as: 'project', required: false },
{ model: db.users, as: 'user', required: false },
];
}
static get RELATION_FILTERS() {
static override get RELATION_FILTERS(): PublishEventRelationFilterConfig[] {
return [
{
filterKey: 'project',
@ -84,7 +90,7 @@ class Publish_eventsDBApi extends GenericDBApi {
];
}
static getFieldMapping(data) {
static override getFieldMapping(data: PublishEventData): PublishEventFieldMapping {
return {
id: data.id || undefined,
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');
const db = require('../models');
import GenericDBApi from './base.api.ts';
import db from '../models/index.ts';
import type {
PwaCacheAssociationConfig,
PwaCacheData,
PwaCacheFieldMapping,
PwaCacheRelationFilterConfig,
} from '../../types/index.ts';
class Pwa_cachesDBApi extends GenericDBApi {
static get MODEL() {
static override get MODEL(): unknown {
return db.pwa_caches;
}
static get TABLE_NAME() {
static override get TABLE_NAME(): string {
return 'pwa_caches';
}
static get SEARCHABLE_FIELDS() {
static override get SEARCHABLE_FIELDS(): string[] {
return ['cache_version', 'manifest_json', 'asset_list_json'];
}
static get RANGE_FIELDS() {
static override get RANGE_FIELDS(): string[] {
return ['generated_at'];
}
static get ENUM_FIELDS() {
static override get ENUM_FIELDS(): string[] {
return ['environment', 'is_active'];
}
static get CSV_FIELDS() {
static override get CSV_FIELDS(): string[] {
return [
'id',
'environment',
@ -33,23 +39,23 @@ class Pwa_cachesDBApi extends GenericDBApi {
];
}
static get AUTOCOMPLETE_FIELD() {
static override get AUTOCOMPLETE_FIELD(): string {
return 'cache_version';
}
static get ASSOCIATIONS() {
static override get ASSOCIATIONS(): PwaCacheAssociationConfig[] {
return [{ field: 'project', setter: 'setProject', isArray: false }];
}
static get FIND_BY_INCLUDES() {
static override get FIND_BY_INCLUDES(): unknown[] {
return [{ association: 'project' }];
}
static get FIND_ALL_INCLUDES() {
static override get FIND_ALL_INCLUDES(): unknown[] {
return [{ model: db.projects, as: 'project', required: false }];
}
static get RELATION_FILTERS() {
static override get RELATION_FILTERS(): PwaCacheRelationFilterConfig[] {
return [
{
filterKey: 'project',
@ -60,7 +66,7 @@ class Pwa_cachesDBApi extends GenericDBApi {
];
}
static getFieldMapping(data) {
static override getFieldMapping(data: PwaCacheData): PwaCacheFieldMapping {
return {
id: data.id || undefined,
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';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, _Sequelize) {
await queryInterface.removeColumn('projects', 'theme_config_json');

View File

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

View File

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

View File

@ -7,7 +7,6 @@
* Also adds storage_key column to track the S3/local storage path.
*/
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
// 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)
*
* @type {import('sequelize-cli').Migration}
*/
module.exports = {
async up(queryInterface, Sequelize) {

View File

@ -1,6 +1,5 @@
'use strict';
/** @type {import('sequelize-cli').Migration} */
module.exports = {
async up(queryInterface, Sequelize) {
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) {
const access_logs = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineAccessLogsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const accessLogs: SequelizeModel = sequelize.define(
'access_logs',
{
id: {
@ -67,11 +72,7 @@ module.exports = function (sequelize, DataTypes) {
},
);
access_logs.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
accessLogs.associate = (db) => {
db.access_logs.belongsTo(db.projects, {
as: 'project',
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) {
const asset_variants = sequelize.define(
import type {
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',
{
id: {
@ -40,11 +54,7 @@ module.exports = function (sequelize, DataTypes) {
args: [0, 2048],
msg: 'CDN URL must be at most 2048 characters',
},
isUrlOrEmpty(value) {
if (value && value.length > 0 && !/^https?:\/\/.+/.test(value)) {
throw new Error('CDN URL must be a valid URL');
}
},
isUrlOrEmpty: validateUrlOrEmpty,
},
},
@ -82,11 +92,7 @@ module.exports = function (sequelize, DataTypes) {
},
);
asset_variants.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
assetVariants.associate = (db) => {
db.asset_variants.belongsTo(db.assets, {
as: 'asset',
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) {
const assets = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineAssetsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const assets: SequelizeModel = sequelize.define(
'assets',
{
id: {
@ -137,8 +142,6 @@ module.exports = function (sequelize, DataTypes) {
);
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, {
as: 'asset_variants_asset',
foreignKey: {
@ -149,8 +152,6 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE',
});
//end loop
db.assets.belongsTo(db.projects, {
as: 'project',
foreignKey: {
@ -172,3 +173,5 @@ module.exports = function (sequelize, DataTypes) {
return assets;
};
export default defineAssetsModel;

View File

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

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) {
const global_transition_defaults = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineGlobalTransitionDefaultsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const globalTransitionDefaults: SequelizeModel = sequelize.define(
'global_transition_defaults',
{
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, {
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) {
const global_ui_control_defaults = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineGlobalUiControlDefaultsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const globalUiControlDefaults: SequelizeModel = sequelize.define(
'global_ui_control_defaults',
{
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, {
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) {
const permissions = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const definePermissionsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const permissions: SequelizeModel = sequelize.define(
'permissions',
{
id: {
@ -35,10 +40,6 @@ module.exports = function (sequelize, DataTypes) {
);
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, {
as: 'createdBy',
});
@ -50,3 +51,5 @@ module.exports = function (sequelize, DataTypes) {
return permissions;
};
export default definePermissionsModel;

View File

@ -1,5 +1,19 @@
module.exports = function (sequelize, DataTypes) {
const presigned_url_requests = sequelize.define(
import type {
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',
{
id: {
@ -37,15 +51,7 @@ module.exports = function (sequelize, DataTypes) {
args: [0, 255],
msg: 'MIME type must be at most 255 characters',
},
isMimeTypeOrEmpty(value) {
if (
value &&
value.length > 0 &&
!/^[\w.-]+\/[\w.+-]+$/.test(value)
) {
throw new Error('MIME type must be in format type/subtype');
}
},
isMimeTypeOrEmpty: validateMimeTypeOrEmpty,
},
},
@ -80,11 +86,7 @@ module.exports = function (sequelize, DataTypes) {
},
);
presigned_url_requests.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
presignedUrlRequests.associate = (db) => {
db.presigned_url_requests.belongsTo(db.projects, {
as: 'project',
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) {
const production_presentation_access = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProductionPresentationAccessModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const productionPresentationAccess: SequelizeModel = sequelize.define(
'production_presentation_access',
{
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, {
as: 'project',
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) {
const project_audio_tracks = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectAudioTracksModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectAudioTracks: SequelizeModel = sequelize.define(
'project_audio_tracks',
{
id: {
@ -75,11 +83,7 @@ module.exports = function (sequelize, DataTypes) {
},
);
project_audio_tracks.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
projectAudioTracks.associate = (db) => {
db.project_audio_tracks.belongsTo(db.projects, {
as: 'project',
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) {
const project_element_defaults = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectElementDefaultsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectElementDefaults: SequelizeModel = sequelize.define(
'project_element_defaults',
{
id: {
@ -8,7 +16,6 @@ module.exports = function (sequelize, DataTypes) {
primaryKey: true,
},
element_type: {
// TEXT for flexibility - matches element_type_defaults and page_elements
type: DataTypes.TEXT,
allowNull: false,
validate: {
@ -35,13 +42,10 @@ module.exports = function (sequelize, DataTypes) {
allowNull: true,
},
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,
allowNull: true,
},
snapshot_version: {
// Increments when resetting from global - enables "check for updates" feature
type: DataTypes.INTEGER,
allowNull: false,
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, {
as: 'project',
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) {
const project_memberships = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectMembershipsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectMemberships: SequelizeModel = sequelize.define(
'project_memberships',
{
id: {
@ -51,11 +59,7 @@ module.exports = function (sequelize, DataTypes) {
},
);
project_memberships.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
projectMemberships.associate = (db) => {
db.project_memberships.belongsTo(db.projects, {
as: 'project',
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) {
const project_transition_settings = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectTransitionSettingsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectTransitionSettings: SequelizeModel = sequelize.define(
'project_transition_settings',
{
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, {
as: 'project',
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) {
const project_ui_control_settings = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectUiControlSettingsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const projectUiControlSettings: SequelizeModel = sequelize.define(
'project_ui_control_settings',
{
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, {
as: 'project',
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) {
const projects = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineProjectsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const projects: SequelizeModel = sequelize.define(
'projects',
{
id: {
@ -71,9 +76,6 @@ module.exports = function (sequelize, DataTypes) {
defaultValue: 'public',
},
// Note: transition_settings moved to project_transition_settings table
// for environment-aware storage (dev, stage, production)
importHash: {
type: DataTypes.STRING(255),
allowNull: true,
@ -89,8 +91,6 @@ module.exports = function (sequelize, DataTypes) {
);
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, {
as: 'project_memberships_project',
foreignKey: {
@ -211,8 +211,6 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE',
});
//end loop
db.projects.belongsTo(db.users, {
as: 'createdBy',
});
@ -224,3 +222,5 @@ module.exports = function (sequelize, DataTypes) {
return projects;
};
export default defineProjectsModel;

View File

@ -1,5 +1,13 @@
module.exports = function (sequelize, DataTypes) {
const publish_events = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const definePublishEventsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const publishEvents: SequelizeModel = sequelize.define(
'publish_events',
{
id: {
@ -110,11 +118,7 @@ module.exports = function (sequelize, DataTypes) {
},
);
publish_events.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
publishEvents.associate = (db) => {
db.publish_events.belongsTo(db.projects, {
as: 'project',
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) {
const pwa_caches = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const definePwaCachesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const pwaCaches: SequelizeModel = sequelize.define(
'pwa_caches',
{
id: {
@ -56,11 +61,7 @@ module.exports = function (sequelize, DataTypes) {
},
);
pwa_caches.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
pwaCaches.associate = (db) => {
db.pwa_caches.belongsTo(db.projects, {
as: 'project',
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) {
const roles = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineRolesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const roles: SequelizeModel = sequelize.define(
'roles',
{
id: {
@ -58,8 +63,6 @@ module.exports = function (sequelize, DataTypes) {
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, {
as: 'users_app_role',
foreignKey: {
@ -70,8 +73,6 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE',
});
//end loop
db.roles.belongsTo(db.users, {
as: 'createdBy',
});
@ -83,3 +84,5 @@ module.exports = function (sequelize, DataTypes) {
return roles;
};
export default defineRolesModel;

View File

@ -1,5 +1,10 @@
module.exports = function (sequelize, DataTypes) {
const tour_pages = sequelize.define(
import type {
SequelizeModel,
SequelizeModelFactory,
} from '../../types/index.ts';
const defineTourPagesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
const tourPages: SequelizeModel = sequelize.define(
'tour_pages',
{
id: {
@ -177,11 +182,7 @@ module.exports = function (sequelize, DataTypes) {
},
);
tour_pages.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
tourPages.associate = (db) => {
db.tour_pages.belongsTo(db.projects, {
as: 'project',
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');
const providers = config.providers;
const crypto = require('crypto');
const bcrypt = require('bcrypt');
import crypto from 'node:crypto';
module.exports = function (sequelize, DataTypes) {
const users = sequelize.define(
import bcrypt from 'bcrypt';
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',
{
id: {
@ -115,8 +164,6 @@ module.exports = function (sequelize, DataTypes) {
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, {
as: 'project_memberships_user',
foreignKey: {
@ -167,8 +214,6 @@ module.exports = function (sequelize, DataTypes) {
onUpdate: 'CASCADE',
});
//end loop
db.users.belongsTo(db.roles, {
as: 'app_role',
foreignKey: {
@ -200,41 +245,16 @@ module.exports = function (sequelize, DataTypes) {
});
};
users.beforeCreate((users) => {
users = trimStringFields(users);
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.beforeCreate((user: UserModelInstance) => {
trimStringFields(user);
ensureExternalProviderPassword(user);
});
users.beforeUpdate((users) => {
trimStringFields(users);
users.beforeUpdate((user: UserModelInstance) => {
trimStringFields(user);
});
return users;
};
function trimStringFields(users) {
users.email = users.email.trim();
users.firstName = users.firstName ? users.firstName.trim() : null;
users.lastName = users.lastName ? users.lastName.trim() : null;
return users;
}
export default defineUsersModel;

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 = {
/**
* @param{import("sequelize").QueryInterface} queryInterface
* @return {Promise<void>}
*/
async up(queryInterface) {
import type {
RbacSeedExistingNamedIdRow,
RbacSeedExistingRolePermissionRow,
RbacSeedPermissionRow,
RbacSeedRoleDefinition,
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 updatedAt = new Date();
/** @type {Map<string, string>} */
const idMap = new Map();
const idMap = new Map<string, string>();
/**
* @param {string} key
* @return {string}
*/
function getId(key) {
if (idMap.has(key)) {
return idMap.get(key);
function getId(key: string): string {
const existingId = idMap.get(key);
if (existingId) {
return existingId;
}
const id = uuid();
idMap.set(key, id);
return id;
}
await queryInterface.bulkInsert('roles', [
{
id: getId('Administrator'),
name: 'Administrator',
createdAt,
updatedAt,
},
const roleDefinitions: RbacSeedRoleDefinition[] = [
{ key: 'Administrator', name: 'Administrator' },
{ key: 'PlatformOwner', name: 'Platform Owner' },
{ key: 'AccountManager', name: 'Account Manager' },
{ key: 'TourDesigner', name: 'Tour Designer' },
{ 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,
},
);
{
id: getId('PlatformOwner'),
name: 'Platform Owner',
createdAt,
updatedAt,
},
for (const role of existingRoles) {
const key = roleKeyByName.get(role.name);
if (key) {
idMap.set(key, role.id);
}
}
{
id: getId('AccountManager'),
name: 'Account Manager',
createdAt,
updatedAt,
},
const existingRoleNames = new Set(existingRoles.map((role) => role.name));
const roleRows: RbacSeedRoleRow[] = roleDefinitions.map((role) => ({
id: getId(role.key),
name: role.name,
createdAt,
updatedAt,
}));
const rolesToInsert = roleRows.filter(
(role) => !existingRoleNames.has(role.name),
);
{
id: getId('TourDesigner'),
name: 'Tour Designer',
createdAt,
updatedAt,
},
if (rolesToInsert.length > 0) {
await queryInterface.bulkInsert('roles', rolesToInsert);
}
{
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) {
function createPermissions(name: string): RbacSeedPermissionRow[] {
return [
{
id: getId(`CREATE_${name.toUpperCase()}`),
@ -118,26 +129,49 @@ module.exports = {
'pwa_caches',
'access_logs',
];
await queryInterface.bulkInsert(
'permissions',
entities.flatMap(createPermissions),
);
await queryInterface.bulkInsert('permissions', [
const permissionRows: RbacSeedPermissionRow[] = [
...entities.flatMap(createPermissions),
{
id: getId(`READ_API_DOCS`),
createdAt,
updatedAt,
name: `READ_API_DOCS`,
},
]);
await queryInterface.bulkInsert('permissions', [
{
id: getId(`CREATE_SEARCH`),
createdAt,
updatedAt,
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
.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");',
);
await queryInterface.bulkInsert('rolesPermissionsPermissions', [
const rolePermissionRows: RbacSeedRolePermissionRow[] = [
{
createdAt,
updatedAt,
@ -1748,7 +1782,29 @@ constraint "rolesPermissionsPermissions_permission_fk"
roles_permissionsId: getId('Administrator'),
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(
`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');
const Users = db.users;
import db from '../models/index.ts';
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 = [
{
@ -1084,7 +1086,7 @@ async function associateAccessLogWithUser() {
}
}
module.exports = {
const sampleDataSeeder: SequelizeSeeder = {
up: async () => {
// Keep production-like schema strict; skip auto sample payload inserts by default.
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
@ -1114,31 +1116,31 @@ module.exports = {
await Promise.all([
// 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;
}
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') {
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);
}
@ -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