migration to typescript and ESM modules
This commit is contained in:
parent
5fc54b1894
commit
df3eb45bbf
@ -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
2
.gitignore
vendored
@ -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/
|
||||
|
||||
17
Dockerfile
17
Dockerfile
@ -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"]
|
||||
|
||||
|
||||
|
||||
@ -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...' && \
|
||||
|
||||
32
README.md
32
README.md
@ -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
7
backend/.dockerignore
Normal file
@ -0,0 +1,7 @@
|
||||
node_modules
|
||||
dist
|
||||
tmp
|
||||
logs
|
||||
.env
|
||||
.DS_Store
|
||||
npm-debug.log*
|
||||
@ -1,4 +0,0 @@
|
||||
# Ignore generated and runtime files
|
||||
node_modules/
|
||||
tmp/
|
||||
logs/
|
||||
@ -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: '^_' }]
|
||||
}
|
||||
};
|
||||
@ -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")
|
||||
};
|
||||
@ -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" ]
|
||||
|
||||
@ -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
99
backend/eslint.config.js
Normal 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
10244
backend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
90
backend/scripts/check-esm-boundaries.ts
Normal file
90
backend/scripts/check-esm-boundaries.ts
Normal 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();
|
||||
@ -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 () => {
|
||||
45
backend/scripts/copy-runtime-assets.ts
Normal file
45
backend/scripts/copy-runtime-assets.ts
Normal 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();
|
||||
@ -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,
|
||||
};
|
||||
@ -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
228
backend/src/auth/auth.ts
Normal 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,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
56
backend/src/auth/passport-middleware.ts
Normal file
56
backend/src/auth/passport-middleware.ts
Normal 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 };
|
||||
@ -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
96
backend/src/config.ts
Normal 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;
|
||||
@ -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,
|
||||
};
|
||||
87
backend/src/contracts/entity-options.ts
Normal file
87
backend/src/contracts/entity-options.ts
Normal 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,
|
||||
};
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
735
backend/src/db/api/base.api.ts
Normal file
735
backend/src/db/api/base.api.ts
Normal 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;
|
||||
@ -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;
|
||||
@ -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
109
backend/src/db/api/file.ts
Normal 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;
|
||||
@ -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;
|
||||
264
backend/src/db/api/global_transition_defaults.ts
Normal file
264
backend/src/db/api/global_transition_defaults.ts
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
60
backend/src/db/api/permissions.ts
Normal file
60
backend/src/db/api/permissions.ts
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
324
backend/src/db/api/project_audio_tracks.ts
Normal file
324
backend/src/db/api/project_audio_tracks.ts
Normal 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;
|
||||
@ -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;
|
||||
520
backend/src/db/api/project_element_defaults.ts
Normal file
520
backend/src/db/api/project_element_defaults.ts
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
407
backend/src/db/api/project_transition_settings.ts
Normal file
407
backend/src/db/api/project_transition_settings.ts
Normal 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;
|
||||
@ -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;
|
||||
312
backend/src/db/api/project_ui_control_settings.ts
Normal file
312
backend/src/db/api/project_ui_control_settings.ts
Normal 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;
|
||||
@ -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;
|
||||
351
backend/src/db/api/projects.ts
Normal file
351
backend/src/db/api/projects.ts
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
84
backend/src/db/api/roles.ts
Normal file
84
backend/src/db/api/roles.ts
Normal 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;
|
||||
@ -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,
|
||||
};
|
||||
75
backend/src/db/api/runtime-context.ts
Normal file
75
backend/src/db/api/runtime-context.ts
Normal 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,
|
||||
};
|
||||
@ -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;
|
||||
363
backend/src/db/api/tour_pages.ts
Normal file
363
backend/src/db/api/tour_pages.ts
Normal 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;
|
||||
@ -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
979
backend/src/db/api/users.ts
Normal 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;
|
||||
64
backend/src/db/db-config.ts
Normal file
64
backend/src/db/db-config.ts
Normal 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;
|
||||
@ -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',
|
||||
},
|
||||
};
|
||||
@ -1,6 +1,5 @@
|
||||
'use strict';
|
||||
|
||||
/** @type {import('sequelize-cli').Migration} */
|
||||
module.exports = {
|
||||
async up(queryInterface, _Sequelize) {
|
||||
await queryInterface.removeColumn('projects', 'theme_config_json');
|
||||
|
||||
@ -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', {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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', {
|
||||
|
||||
3
backend/src/db/migrations/package.json
Normal file
3
backend/src/db/migrations/package.json
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "commonjs"
|
||||
}
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
1
backend/src/db/models/index.ts
Normal file
1
backend/src/db/models/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { default } from './loader.ts';
|
||||
429
backend/src/db/models/loader.ts
Normal file
429
backend/src/db/models/loader.ts
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
19
backend/src/db/reset.ts
Normal 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();
|
||||
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
92
backend/src/db/seeders/20200430130759-admin-user.ts
Normal file
92
backend/src/db/seeders/20200430130759-admin-user.ts
Normal 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;
|
||||
@ -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;
|
||||
@ -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;
|
||||
@ -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
Loading…
x
Reference in New Issue
Block a user