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
|
backend/node_modules
|
||||||
|
frontend/.env.local
|
||||||
|
frontend/.next
|
||||||
|
frontend/tsconfig.tsbuildinfo
|
||||||
frontend/node_modules
|
frontend/node_modules
|
||||||
frontend/build
|
frontend/build
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@ -4,10 +4,12 @@ node_modules/
|
|||||||
*/node_modules/
|
*/node_modules/
|
||||||
**/node_modules/
|
**/node_modules/
|
||||||
*/build/
|
*/build/
|
||||||
|
backend/dist/
|
||||||
frontend/.next/
|
frontend/.next/
|
||||||
frontend/out/
|
frontend/out/
|
||||||
frontend/public/sw.js
|
frontend/public/sw.js
|
||||||
frontend/next-env.d.ts
|
frontend/next-env.d.ts
|
||||||
package-lock.json
|
package-lock.json
|
||||||
|
!backend/package-lock.json
|
||||||
AGENTS.md
|
AGENTS.md
|
||||||
.codex/
|
.codex/
|
||||||
|
|||||||
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
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY frontend/package.json frontend/yarn.lock ./
|
COPY frontend/package*.json ./
|
||||||
RUN yarn install --pure-lockfile
|
RUN npm ci
|
||||||
COPY frontend .
|
COPY frontend .
|
||||||
RUN yarn build
|
RUN npm run build
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
FROM node:20.15.1-alpine
|
FROM node:24-alpine
|
||||||
# FFmpeg is bundled via npm package ffmpeg-static
|
# FFmpeg is bundled via npm package ffmpeg-static
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY backend/package.json backend/yarn.lock ./
|
COPY backend/package*.json ./
|
||||||
RUN yarn install --pure-lockfile
|
RUN npm ci
|
||||||
COPY backend .
|
COPY backend .
|
||||||
|
|
||||||
COPY --from=builder /app/build /app/public
|
COPY --from=builder /app/build /app/public
|
||||||
CMD ["yarn", "start"]
|
CMD ["npm", "run", "start"]
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -1,22 +1,21 @@
|
|||||||
# Base image for Node.js dependencies
|
# Base image for Node.js dependencies
|
||||||
FROM node:20.15.1-alpine AS frontend-deps
|
FROM node:24-alpine AS frontend-deps
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app/frontend
|
WORKDIR /app/frontend
|
||||||
COPY frontend/package.json frontend/yarn.lock ./
|
COPY frontend/package*.json ./
|
||||||
RUN yarn install --pure-lockfile
|
RUN npm ci
|
||||||
|
|
||||||
FROM node:20.15.1-alpine AS backend-deps
|
FROM node:24-alpine AS backend-deps
|
||||||
RUN apk add --no-cache git
|
RUN apk add --no-cache git
|
||||||
WORKDIR /app/backend
|
WORKDIR /app/backend
|
||||||
COPY backend/package.json backend/yarn.lock ./
|
COPY backend/package*.json ./
|
||||||
RUN yarn install --pure-lockfile
|
RUN npm ci
|
||||||
|
|
||||||
# Nginx setup and application build
|
# Nginx setup and application build
|
||||||
FROM node:20.15.1-alpine AS build
|
FROM node:24-alpine AS build
|
||||||
RUN apk add --no-cache git nginx curl
|
RUN apk add --no-cache git nginx curl
|
||||||
RUN apk add --no-cache lsof procps
|
RUN apk add --no-cache lsof procps
|
||||||
# FFmpeg is bundled via npm package ffmpeg-static
|
# FFmpeg is bundled via npm package ffmpeg-static
|
||||||
RUN yarn global add concurrently
|
|
||||||
|
|
||||||
RUN apk add --no-cache \
|
RUN apk add --no-cache \
|
||||||
chromium \
|
chromium \
|
||||||
@ -31,9 +30,6 @@ ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
|
|||||||
|
|
||||||
RUN mkdir -p /app/pids
|
RUN mkdir -p /app/pids
|
||||||
|
|
||||||
# Make sure to add yarn global bin to PATH
|
|
||||||
ENV PATH /root/.yarn/bin:/root/.config/yarn/global/node_modules/.bin:$PATH
|
|
||||||
|
|
||||||
# Copy dependencies
|
# Copy dependencies
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY --from=frontend-deps /app/frontend /app/frontend
|
COPY --from=frontend-deps /app/frontend /app/frontend
|
||||||
@ -63,8 +59,8 @@ ENV FRONT_PORT=3001
|
|||||||
ENV BACKEND_PORT=3000
|
ENV BACKEND_PORT=3000
|
||||||
|
|
||||||
CMD ["sh", "-c", "\
|
CMD ["sh", "-c", "\
|
||||||
yarn --cwd /app/frontend dev & echo $! > /app/pids/frontend.pid && \
|
npm --prefix /app/frontend run dev & echo $! > /app/pids/frontend.pid && \
|
||||||
yarn --cwd /app/backend start & echo $! > /app/pids/backend.pid && \
|
npm --prefix /app/backend run start & echo $! > /app/pids/backend.pid && \
|
||||||
sleep 10 && nginx -g 'daemon off;' & \
|
sleep 10 && nginx -g 'daemon off;' & \
|
||||||
NGINX_PID=$! && \
|
NGINX_PID=$! && \
|
||||||
echo 'Waiting for backend (port 3000) to be available...' && \
|
echo 'Waiting for backend (port 3000) to be available...' && \
|
||||||
|
|||||||
32
README.md
32
README.md
@ -30,9 +30,9 @@ A web application for building and managing interactive virtual tours with drag-
|
|||||||
|
|
||||||
### Prerequisites
|
### Prerequisites
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 24 LTS for backend
|
||||||
- PostgreSQL 14+
|
- PostgreSQL 14+
|
||||||
- Yarn (backend) / npm (frontend)
|
- npm for backend; frontend uses its existing package scripts
|
||||||
|
|
||||||
### Database Setup (First Time)
|
### Database Setup (First Time)
|
||||||
|
|
||||||
@ -46,11 +46,11 @@ PGPASSWORD='postgres' psql -U postgres -c "CREATE DATABASE app_39215 OWNER app_3
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
yarn install
|
npm install
|
||||||
npm run start-dev
|
npm run start-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Backend runs on **http://localhost:8080**
|
Backend runs on **http://localhost:3000**
|
||||||
|
|
||||||
### Start Frontend (Terminal 2)
|
### Start Frontend (Terminal 2)
|
||||||
|
|
||||||
@ -60,7 +60,7 @@ npm install
|
|||||||
npm run dev
|
npm run dev
|
||||||
```
|
```
|
||||||
|
|
||||||
Frontend runs on **http://localhost:3000**
|
Frontend runs on **http://localhost:3001**
|
||||||
|
|
||||||
### Login
|
### Login
|
||||||
|
|
||||||
@ -155,7 +155,7 @@ Pages have an `environment` field (`dev`, `stage`, `production`) that determines
|
|||||||
|
|
||||||
## API Overview
|
## API Overview
|
||||||
|
|
||||||
Base URL: `http://localhost:8080/api`
|
Base URL: `http://localhost:3000/api`
|
||||||
|
|
||||||
| Endpoint | Description |
|
| Endpoint | Description |
|
||||||
|----------|-------------|
|
|----------|-------------|
|
||||||
@ -168,7 +168,7 @@ Base URL: `http://localhost:8080/api`
|
|||||||
| `GET /assets` | List assets |
|
| `GET /assets` | List assets |
|
||||||
| `POST /file/presign` | Get S3 presigned URLs for asset download (public) |
|
| `POST /file/presign` | Get S3 presigned URLs for asset download (public) |
|
||||||
|
|
||||||
Full API documentation: `http://localhost:8080/api-docs` (Swagger)
|
Full API documentation: `http://localhost:3000/api-docs` (Swagger)
|
||||||
|
|
||||||
## Docker Setup
|
## Docker Setup
|
||||||
|
|
||||||
@ -224,7 +224,7 @@ EMAIL_PASS=...
|
|||||||
### Frontend (`frontend/.env.local`)
|
### Frontend (`frontend/.env.local`)
|
||||||
|
|
||||||
```env
|
```env
|
||||||
NEXT_PUBLIC_BACK_API=http://localhost:8080/api
|
NEXT_PUBLIC_BACK_API=http://localhost:3000/api
|
||||||
```
|
```
|
||||||
|
|
||||||
## Common Commands
|
## Common Commands
|
||||||
@ -233,11 +233,13 @@ NEXT_PUBLIC_BACK_API=http://localhost:8080/api
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
yarn start # Start server (migrate + seed + watch)
|
npm run start # Start server (migrate + seed + watch)
|
||||||
yarn db:migrate # Run migrations
|
npm run db:migrate # Run migrations
|
||||||
yarn db:seed # Seed data
|
npm run db:seed # Seed data
|
||||||
yarn db:reset # Drop + create + migrate + seed
|
npm run db:reset # Drop + create + migrate + seed
|
||||||
yarn lint # ESLint
|
npm run lint # ESLint
|
||||||
|
npm run typecheck # Strict TypeScript check for migrated backend scope
|
||||||
|
npm run build # Compile migrated TypeScript files
|
||||||
```
|
```
|
||||||
|
|
||||||
### Frontend
|
### Frontend
|
||||||
@ -255,7 +257,7 @@ npm run format # Prettier
|
|||||||
### Connection Refused
|
### Connection Refused
|
||||||
|
|
||||||
1. Ensure PostgreSQL is running
|
1. Ensure PostgreSQL is running
|
||||||
2. Check that port 5432 (db), 8080 (backend), 3000 (frontend) are available
|
2. Check that port 5432 (db), 3000 (backend), 3001 (frontend) are available
|
||||||
3. Verify database credentials in `.env`
|
3. Verify database credentials in `.env`
|
||||||
|
|
||||||
### Database Issues
|
### Database Issues
|
||||||
@ -263,7 +265,7 @@ npm run format # Prettier
|
|||||||
```bash
|
```bash
|
||||||
# Reset database completely
|
# Reset database completely
|
||||||
cd backend
|
cd backend
|
||||||
yarn db:reset
|
npm run db:reset
|
||||||
```
|
```
|
||||||
|
|
||||||
### Permission Denied
|
### Permission Denied
|
||||||
|
|||||||
7
backend/.dockerignore
Normal file
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)
|
# Bash is required by docker/wait-for-it.sh when this image is used by docker-compose.
|
||||||
RUN apk update && apk add --no-cache bash ffmpeg
|
# FFmpeg is bundled by ffmpeg-static/ffprobe-static npm packages.
|
||||||
|
RUN apk add --no-cache bash
|
||||||
|
|
||||||
# Create app directory
|
# Create app directory
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
# Install app dependencies
|
# Install app dependencies
|
||||||
# A wildcard is used to ensure both package.json AND package-lock.json are copied
|
|
||||||
# where available (npm@5+)
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
|
|
||||||
RUN yarn install
|
RUN npm ci
|
||||||
# If you are building your code for production
|
|
||||||
# RUN npm ci --only=production
|
|
||||||
|
|
||||||
|
|
||||||
# Bundle app source
|
# Bundle app source
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
|
||||||
EXPOSE 8080
|
EXPOSE 3000
|
||||||
|
|
||||||
CMD [ "yarn", "start" ]
|
CMD [ "npm", "run", "start" ]
|
||||||
|
|||||||
@ -4,7 +4,7 @@ Node.js/Express REST API server with Sequelize ORM for the Tour Builder Platform
|
|||||||
|
|
||||||
## Tech Stack
|
## Tech Stack
|
||||||
|
|
||||||
- **Runtime**: Node.js 18+
|
- **Runtime**: Node.js 24 LTS
|
||||||
- **Framework**: Express 4.x
|
- **Framework**: Express 4.x
|
||||||
- **Database**: PostgreSQL with Sequelize ORM
|
- **Database**: PostgreSQL with Sequelize ORM
|
||||||
- **Authentication**: Passport.js (JWT, Google OAuth, Microsoft OAuth)
|
- **Authentication**: Passport.js (JWT, Google OAuth, Microsoft OAuth)
|
||||||
@ -14,35 +14,39 @@ Node.js/Express REST API server with Sequelize ORM for the Tour Builder Platform
|
|||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js 18+
|
- Node.js 24 LTS
|
||||||
- PostgreSQL 14+
|
- PostgreSQL 14+
|
||||||
- Yarn package manager
|
- npm package manager
|
||||||
|
|
||||||
## Quick Start
|
## Quick Start
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Install dependencies
|
# Install dependencies
|
||||||
yarn install
|
npm install
|
||||||
|
|
||||||
# Create database (first time only)
|
# Create database (first time only)
|
||||||
yarn db:create
|
npm run db:create
|
||||||
|
|
||||||
# Start server (runs migrations, seeds, and watches for changes)
|
# Start server (runs migrations, seeds, and watches for changes)
|
||||||
npm run start-dev
|
npm run start-dev
|
||||||
```
|
```
|
||||||
|
|
||||||
The server runs on **port 8080** by default.
|
The server runs on **port 3000** by default.
|
||||||
|
|
||||||
## Checks
|
## Checks
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run lint
|
npm run lint
|
||||||
|
npm run typecheck
|
||||||
|
npm run build
|
||||||
npm run test
|
npm run test
|
||||||
npm run test:integration
|
npm run test:integration
|
||||||
npm run check:public-access
|
npm run check:public-access
|
||||||
```
|
```
|
||||||
|
|
||||||
- `npm run test` runs fast unit tests without requiring PostgreSQL.
|
- `npm run test` runs fast unit tests without requiring PostgreSQL.
|
||||||
|
- `npm run typecheck` checks the migrated TypeScript scope with `strict: true`.
|
||||||
|
- `npm run build` compiles the migrated TypeScript scope into `dist/`.
|
||||||
- `npm run test:integration` runs rollback-based PostgreSQL integration tests
|
- `npm run test:integration` runs rollback-based PostgreSQL integration tests
|
||||||
when a valid database configuration is available; otherwise the tests skip.
|
when a valid database configuration is available; otherwise the tests skip.
|
||||||
- `npm run check:public-access` audits stale Public role/user permissions and
|
- `npm run check:public-access` audits stale Public role/user permissions and
|
||||||
@ -88,63 +92,65 @@ MS_CLIENT_SECRET=your-client-secret
|
|||||||
EMAIL_USER=ses-smtp-user
|
EMAIL_USER=ses-smtp-user
|
||||||
EMAIL_PASS=ses-smtp-password
|
EMAIL_PASS=ses-smtp-password
|
||||||
|
|
||||||
# OpenAI (optional)
|
|
||||||
GPT_KEY=your-openai-key
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Project Structure
|
## Project Structure
|
||||||
|
|
||||||
```
|
```
|
||||||
backend/src/
|
backend/src/
|
||||||
├── index.js # Express app entry point
|
├── index.ts # Express app entry point
|
||||||
├── config.js # Environment configuration
|
├── config.ts # Environment configuration
|
||||||
├── helpers.js # Utility functions (wrapAsync)
|
├── load-env.ts # Central .env bootstrap for app and DB entrypoints
|
||||||
|
├── helpers.ts # Utility functions (wrapAsync)
|
||||||
|
├── types/ # Shared TypeScript contracts
|
||||||
│
|
│
|
||||||
├── auth/ # Passport.js authentication strategies
|
├── auth/ # Passport.js authentication strategies
|
||||||
│ └── auth.js # JWT, Google, Microsoft strategies
|
│ └── auth.ts # JWT, Google, Microsoft strategies
|
||||||
│
|
│
|
||||||
├── db/
|
├── db/
|
||||||
│ ├── db.config.js # Database connection config (per environment)
|
│ ├── db-config.ts # Typed database connection config
|
||||||
│ ├── models/ # Sequelize model definitions (16 models)
|
│ ├── umzug.ts # Typed Umzug runner for migrations and seeders
|
||||||
│ ├── api/ # Database access layer (CRUD per model)
|
│ ├── models/ # Sequelize model definitions
|
||||||
│ ├── migrations/ # Database migrations
|
│ ├── api/ # Database access layer
|
||||||
│ └── seeders/ # Seed data (admin users, permissions, roles)
|
│ ├── migrations/ # Applied migration history; do not rewrite
|
||||||
|
│ └── seeders/ # Typed seed data files
|
||||||
│
|
│
|
||||||
├── routes/ # Express route handlers (22 routes)
|
├── routes/ # Express route handlers
|
||||||
│ ├── auth.js # Authentication endpoints
|
│ ├── auth.ts # Authentication endpoints
|
||||||
│ ├── projects.js # Project CRUD
|
│ ├── projects.ts # Project CRUD
|
||||||
│ ├── tour_pages.js # Tour page management
|
│ ├── tour_pages.ts # Tour page management
|
||||||
│ ├── assets.js # Asset management
|
│ ├── assets.ts # Asset management
|
||||||
│ ├── file.js # File upload/download, presigned URLs
|
│ ├── file.ts # File upload/download, presigned URLs
|
||||||
│ ├── publish.js # Publishing workflow
|
│ ├── publish.ts # Publishing workflow
|
||||||
│ ├── search.js # Global search
|
│ ├── search.ts # Global search
|
||||||
│ └── ... # Other entity routes
|
│ └── ... # Other entity routes
|
||||||
│
|
│
|
||||||
├── services/ # Business logic layer (21 services)
|
├── services/ # Business logic layer
|
||||||
│ ├── auth.js # Auth service (JWT, OAuth)
|
│ ├── auth.ts # Auth service (JWT, OAuth)
|
||||||
│ ├── publish.js # Publishing workflow logic
|
│ ├── publish.ts # Publishing workflow logic
|
||||||
│ ├── file.js # File storage abstraction
|
│ ├── file.ts # File storage abstraction
|
||||||
│ ├── search.js # Global search service
|
│ ├── search.ts # Global search service
|
||||||
│ ├── email/ # Email templates and sending
|
│ ├── email/ # Email templates and sending
|
||||||
│ ├── notifications/ # Error classes and i18n messages
|
│ ├── notifications/ # Error classes and i18n messages
|
||||||
│ └── ... # Other entity services
|
│ └── ... # Other entity services
|
||||||
│
|
│
|
||||||
├── middlewares/
|
├── middlewares/
|
||||||
│ ├── check-permissions.js # RBAC permission checking
|
│ ├── check-permissions.ts # RBAC permission checking
|
||||||
│ ├── runtime-context.js # Environment detection from headers
|
│ ├── runtime-context.ts # Environment detection from headers
|
||||||
│ ├── runtime-public.js # Public runtime access (no auth)
|
│ ├── runtime-public.ts # Public runtime access (no auth)
|
||||||
│ ├── upload.js # File upload handling (multer)
|
│ ├── upload.ts # File upload handling (multer)
|
||||||
│ └── rateLimiter.js # Rate limiting for API endpoints
|
│ └── rateLimiter.ts # Rate limiting for API endpoints
|
||||||
│
|
│
|
||||||
├── factories/
|
├── factories/
|
||||||
│ ├── router.factory.js # Generate CRUD routes
|
│ ├── router.factory.ts # Generate CRUD routes
|
||||||
│ └── service.factory.js # Generate service classes
|
│ └── service.factory.ts # Generate service classes
|
||||||
│
|
│
|
||||||
└── utils/
|
└── utils/
|
||||||
├── env-validation.js # Environment variable validation (Joi)
|
├── env-validation.ts # Environment variable validation (Joi)
|
||||||
├── errors.js # Custom error classes
|
├── errors.ts # Custom error classes
|
||||||
├── logger.js # Pino logger configuration
|
├── logger.ts # Pino logger configuration
|
||||||
└── index.js # Utils barrel export
|
├── request-context.ts # Request-scoped currentUser/runtime/log storage
|
||||||
|
└── index.ts # Utils barrel export
|
||||||
```
|
```
|
||||||
|
|
||||||
## Database Setup
|
## Database Setup
|
||||||
@ -169,22 +175,24 @@ GRANT ALL PRIVILEGES ON DATABASE app_39215 TO app_39215;
|
|||||||
### Available Commands
|
### Available Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
yarn db:create # Create database
|
npm run db:create # Create database
|
||||||
yarn db:drop # Drop database
|
npm run db:drop # Drop database
|
||||||
yarn db:migrate # Run pending migrations
|
npm run db:migrate # Run pending migrations
|
||||||
yarn db:migrate:undo # Undo last migration
|
npm run db:migrate:undo # Undo last migration
|
||||||
yarn db:migrate:undo:all # Undo all migrations
|
npm run db:migrate:undo:all # Undo all migrations
|
||||||
yarn db:migrate:status # Show migration status
|
npm run db:migrate:status # Show migration status
|
||||||
yarn db:seed # Run all seeders
|
npm run db:seed # Run all seeders
|
||||||
yarn db:seed:undo # Undo all seeders
|
npm run db:seed:undo # Undo all seeders
|
||||||
yarn db:reset # Drop, create, migrate, and seed
|
npm run db:reset # Drop, create, migrate, and seed
|
||||||
yarn start # Migrate, seed, and start with watch
|
npm run start # Migrate, seed, and start with watch
|
||||||
yarn lint # Run ESLint
|
npm run lint # Run ESLint
|
||||||
|
npm run typecheck # Run strict TypeScript check
|
||||||
|
npm run build # Compile migrated TypeScript files
|
||||||
```
|
```
|
||||||
|
|
||||||
## API Documentation
|
## API Documentation
|
||||||
|
|
||||||
Swagger UI available at: `http://localhost:8080/api-docs`
|
Swagger UI available at: `http://localhost:3000/api-docs`
|
||||||
|
|
||||||
### Core Endpoints
|
### Core Endpoints
|
||||||
|
|
||||||
@ -363,7 +371,7 @@ Separate from server environment, tour pages have a content environment field:
|
|||||||
| `stage` | Stage preview | Pre-production review |
|
| `stage` | Stage preview | Pre-production review |
|
||||||
| `production` | Public runtime | Published content |
|
| `production` | Public runtime | Published content |
|
||||||
|
|
||||||
The `X-Runtime-Environment` header (set by frontend) determines which content environment to query. The `runtime-context.js` middleware resolves this for API requests.
|
The `X-Runtime-Environment` header (set by frontend) determines which content environment to query. The `runtime-context.ts` middleware resolves this for API requests.
|
||||||
|
|
||||||
## Docker
|
## Docker
|
||||||
|
|
||||||
|
|||||||
99
backend/eslint.config.js
Normal file
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",
|
"name": "tourbuilderplatform",
|
||||||
"description": "Tour Builder Platform - template backend",
|
"description": "Tour Builder Platform - template backend",
|
||||||
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "npm run db:migrate && npm run db:seed && npm run watch",
|
"start": "npm run db:migrate && npm run db:seed && npm run watch",
|
||||||
"start-dev": "cross-env NODE_ENV=production LOG_PRETTY=true DOTENV_CONFIG_PATH=.env NODE_OPTIONS=\"-r dotenv/config\" npm run start",
|
"start-dev": "LOG_PRETTY=true npm run start",
|
||||||
"test": "node --test tests/*.test.js",
|
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||||
"test:integration": "node --test tests/integration/*.test.js",
|
"build": "tsc -p tsconfig.json && node scripts/copy-runtime-assets.ts",
|
||||||
"check:public-access": "node scripts/check-public-access-hardening.js",
|
"test": "node --test tests/*.test.ts",
|
||||||
"fix:public-access": "node scripts/check-public-access-hardening.js --fix",
|
"test:integration": "node --test tests/integration/*.test.ts",
|
||||||
"lint": "eslint . --ext .js",
|
"verify": "npm run typecheck && npm run lint && npm run check:esm-boundaries && npm run test",
|
||||||
"db:migrate": "sequelize-cli db:migrate",
|
"check:esm-boundaries": "node scripts/check-esm-boundaries.ts",
|
||||||
"db:migrate:undo": "sequelize-cli db:migrate:undo",
|
"check:public-access": "node scripts/check-public-access-hardening.ts",
|
||||||
"db:migrate:undo:all": "sequelize-cli db:migrate:undo:all",
|
"fix:public-access": "node scripts/check-public-access-hardening.ts --fix",
|
||||||
"db:migrate:status": "sequelize-cli db:migrate:status",
|
"lint": "eslint .",
|
||||||
"db:seed": "sequelize-cli db:seed:all",
|
"db:migrate": "node src/db/umzug.ts migrate:up",
|
||||||
"db:seed:undo": "sequelize-cli db:seed:undo:all",
|
"db:migrate:undo": "node src/db/umzug.ts migrate:down",
|
||||||
"db:drop": "sequelize-cli db:drop",
|
"db:migrate:undo:all": "node src/db/umzug.ts migrate:down:all",
|
||||||
"db:create": "sequelize-cli db:create",
|
"db:migrate:status": "node src/db/umzug.ts migrate:status",
|
||||||
|
"db:seed": "node src/db/umzug.ts seed:up",
|
||||||
|
"db:seed:undo": "node src/db/umzug.ts seed:down:all",
|
||||||
|
"db:drop": "node src/db/umzug.ts db:drop",
|
||||||
|
"db:create": "node src/db/umzug.ts db:create",
|
||||||
"db:reset": "npm run db:drop && npm run db:create && npm run db:migrate && npm run db:seed",
|
"db:reset": "npm run db:drop && npm run db:create && npm run db:migrate && npm run db:seed",
|
||||||
"watch": "node watcher.js"
|
"watch": "node watcher.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@aws-sdk/client-s3": "^3.1011.0",
|
"@aws-sdk/client-s3": "^3.1011.0",
|
||||||
"@aws-sdk/s3-request-presigner": "^3.1016.0",
|
"@aws-sdk/s3-request-presigner": "^3.1016.0",
|
||||||
"@google-cloud/storage": "^7.0.0",
|
"@google-cloud/storage": "^7.0.0",
|
||||||
"axios": "^1.13.0",
|
"@smithy/node-http-handler": "^4.9.1",
|
||||||
|
"@smithy/types": "^4.15.0",
|
||||||
"bcrypt": "^6.0.0",
|
"bcrypt": "^6.0.0",
|
||||||
|
"body-parser": "^2.3.0",
|
||||||
"chokidar": "^4.0.3",
|
"chokidar": "^4.0.3",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"csv-parser": "^3.2.0",
|
"csv-parser": "^3.2.0",
|
||||||
"dotenv": "^16.4.0",
|
"dotenv": "^16.4.0",
|
||||||
"express": "4.18.2",
|
"express": "^4.22.2",
|
||||||
"express-validator": "^7.0.0",
|
|
||||||
"ffmpeg-static": "^5.2.0",
|
"ffmpeg-static": "^5.2.0",
|
||||||
"ffprobe-static": "^3.1.0",
|
"ffprobe-static": "^3.1.0",
|
||||||
"fluent-ffmpeg": "^2.1.3",
|
"fluent-ffmpeg": "^2.1.3",
|
||||||
"formidable": "1.2.2",
|
|
||||||
"helmet": "^8.0.0",
|
"helmet": "^8.0.0",
|
||||||
"joi": "^17.13.0",
|
"joi": "^17.13.0",
|
||||||
"json2csv": "^5.0.7",
|
"json2csv": "^5.0.7",
|
||||||
"jsonwebtoken": "^9.0.0",
|
"jsonwebtoken": "^9.0.0",
|
||||||
"lodash": "^4.17.23",
|
|
||||||
"moment": "2.30.1",
|
|
||||||
"multer": "^2.0.0",
|
"multer": "^2.0.0",
|
||||||
"mysql2": "2.2.5",
|
"nodemailer": "^9.0.3",
|
||||||
"nodemailer": "6.9.9",
|
|
||||||
"passport": "^0.7.0",
|
"passport": "^0.7.0",
|
||||||
"passport-google-oauth2": "^0.2.0",
|
"passport-google-oauth2": "^0.2.0",
|
||||||
"passport-jwt": "^4.0.1",
|
"passport-jwt": "^4.0.1",
|
||||||
"passport-microsoft": "^2.0.0",
|
"passport-microsoft": "^2.0.0",
|
||||||
"pg": "^8.20.0",
|
"pg": "^8.20.0",
|
||||||
"pg-hstore": "2.3.4",
|
|
||||||
"pino": "^9.0.0",
|
"pino": "^9.0.0",
|
||||||
"pino-pretty": "^11.0.0",
|
"pino-pretty": "^11.0.0",
|
||||||
"sequelize": "^6.37.0",
|
"sequelize": "^6.37.0",
|
||||||
"sequelize-json-schema": "^2.1.1",
|
|
||||||
"sqlite": "4.0.15",
|
|
||||||
"swagger-jsdoc": "^6.2.8",
|
"swagger-jsdoc": "^6.2.8",
|
||||||
"swagger-ui-express": "^5.0.0",
|
"swagger-ui-express": "^5.0.0",
|
||||||
"tedious": "^18.6.0"
|
"uuid": "^14.0.1",
|
||||||
|
"validator": "^13.15.35"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=18"
|
"node": ">=24 <25"
|
||||||
|
},
|
||||||
|
"overrides": {
|
||||||
|
"gaxios": {
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
},
|
||||||
|
"sequelize": {
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
},
|
||||||
|
"teeny-request": {
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"cross-env": "^7.0.3",
|
"@eslint/js": "^8.57.1",
|
||||||
|
"@types/bcrypt": "^6.0.0",
|
||||||
|
"@types/body-parser": "^1.19.6",
|
||||||
|
"@types/cors": "^2.8.19",
|
||||||
|
"@types/express": "^4.17.25",
|
||||||
|
"@types/express-serve-static-core": "^4.19.8",
|
||||||
|
"@types/ffprobe-static": "^2.0.3",
|
||||||
|
"@types/fluent-ffmpeg": "^2.1.28",
|
||||||
|
"@types/json2csv": "^5.0.7",
|
||||||
|
"@types/jsonwebtoken": "^9.0.10",
|
||||||
|
"@types/multer": "^2.1.0",
|
||||||
|
"@types/node": "^24.13.2",
|
||||||
|
"@types/nodemailer": "^8.0.1",
|
||||||
|
"@types/passport": "^1.0.17",
|
||||||
|
"@types/passport-google-oauth2": "^0.1.10",
|
||||||
|
"@types/passport-jwt": "^4.0.1",
|
||||||
|
"@types/passport-microsoft": "^2.1.1",
|
||||||
|
"@types/swagger-jsdoc": "^6.0.4",
|
||||||
|
"@types/swagger-ui-express": "^4.1.8",
|
||||||
|
"@types/validator": "^13.15.10",
|
||||||
|
"@typescript-eslint/eslint-plugin": "^8.62.1",
|
||||||
|
"@typescript-eslint/parser": "^8.62.1",
|
||||||
"eslint": "^8.57.0",
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-import-resolver-typescript": "^4.4.5",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"eslint-plugin-import": "^2.29.1",
|
||||||
"mocha": "^10.0.0",
|
"globals": "^15.15.0",
|
||||||
"node-mocks-http": "^1.17.0",
|
"node-mocks-http": "^1.17.0",
|
||||||
"nodemon": "^3.0.0",
|
"nodemon": "^3.0.0",
|
||||||
"sequelize-cli": "^6.6.5"
|
"typescript": "^6.0.3",
|
||||||
|
"umzug": "^3.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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
|
#!/usr/bin/env node
|
||||||
|
|
||||||
const db = require('../src/db/models');
|
import db from '../src/db/models/index.ts';
|
||||||
const AccessPolicyAuditService = require('../src/services/access-policy-audit');
|
import AccessPolicyAuditService from '../src/services/access-policy-audit.ts';
|
||||||
|
import type {
|
||||||
|
AccessPolicyAuditReport,
|
||||||
|
PublicAccessHardeningSummary,
|
||||||
|
} from '../src/types/index.ts';
|
||||||
|
|
||||||
const shouldFix = process.argv.includes('--fix');
|
const shouldFix = process.argv.includes('--fix');
|
||||||
const EXIT_TIMEOUT_MS = 1500;
|
const EXIT_TIMEOUT_MS = 1500;
|
||||||
|
|
||||||
function summarizeReport(report) {
|
function summarizeReport(
|
||||||
|
report: AccessPolicyAuditReport,
|
||||||
|
): PublicAccessHardeningSummary {
|
||||||
return {
|
return {
|
||||||
publicRolePermissions: report.publicRolePermissions.length,
|
publicRolePermissions: report.publicRolePermissions.length,
|
||||||
publicUsersWithCustomPermissions:
|
publicUsersWithCustomPermissions:
|
||||||
@ -16,45 +22,41 @@ function summarizeReport(report) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function main() {
|
function logJson(value: unknown): void {
|
||||||
|
console.log(JSON.stringify(value, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function logError(error: unknown): void {
|
||||||
|
console.error(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function main(): Promise<void> {
|
||||||
if (shouldFix) {
|
if (shouldFix) {
|
||||||
const result = await db.sequelize.transaction((transaction) =>
|
const result = await db.sequelize.transaction((transaction) =>
|
||||||
AccessPolicyAuditService.cleanupViolations({ transaction }),
|
AccessPolicyAuditService.cleanupViolations({ transaction }),
|
||||||
);
|
);
|
||||||
|
|
||||||
console.log(
|
logJson({
|
||||||
JSON.stringify(
|
fixed: true,
|
||||||
{
|
summary: {
|
||||||
fixed: true,
|
removedPublicRolePermissions: result.removedPublicRolePermissions,
|
||||||
summary: {
|
clearedPublicUserCustomPermissions:
|
||||||
removedPublicRolePermissions: result.removedPublicRolePermissions,
|
result.clearedPublicUserCustomPermissions,
|
||||||
clearedPublicUserCustomPermissions:
|
removedNonPublicProductionPresentationGrants:
|
||||||
result.clearedPublicUserCustomPermissions,
|
result.removedNonPublicProductionPresentationGrants,
|
||||||
removedNonPublicProductionPresentationGrants:
|
},
|
||||||
result.removedNonPublicProductionPresentationGrants,
|
});
|
||||||
},
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const report = await AccessPolicyAuditService.findViolations();
|
const report = await AccessPolicyAuditService.findViolations();
|
||||||
const hasViolations = AccessPolicyAuditService.hasViolations(report);
|
const hasViolations = AccessPolicyAuditService.hasViolations(report);
|
||||||
|
|
||||||
console.log(
|
logJson({
|
||||||
JSON.stringify(
|
ok: !hasViolations,
|
||||||
{
|
summary: summarizeReport(report),
|
||||||
ok: !hasViolations,
|
report,
|
||||||
summary: summarizeReport(report),
|
});
|
||||||
report,
|
|
||||||
},
|
|
||||||
null,
|
|
||||||
2,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (hasViolations) {
|
if (hasViolations) {
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
@ -63,7 +65,7 @@ async function main() {
|
|||||||
|
|
||||||
main()
|
main()
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
console.error(error);
|
logError(error);
|
||||||
process.exitCode = 1;
|
process.exitCode = 1;
|
||||||
})
|
})
|
||||||
.finally(async () => {
|
.finally(async () => {
|
||||||
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');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
AccessLogAssociationConfig,
|
||||||
|
AccessLogData,
|
||||||
|
AccessLogFieldMapping,
|
||||||
|
AccessLogRelationFilterConfig,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
class Access_logsDBApi extends GenericDBApi {
|
class Access_logsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static override get MODEL(): unknown {
|
||||||
return db.access_logs;
|
return db.access_logs;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'access_logs';
|
return 'access_logs';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return ['path', 'ip_address', 'user_agent'];
|
return ['path', 'ip_address', 'user_agent'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return ['accessed_at'];
|
return ['accessed_at'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return ['environment'];
|
return ['environment'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static override get CSV_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'environment',
|
'environment',
|
||||||
@ -34,29 +40,29 @@ class Access_logsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
return 'path';
|
return 'path';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ASSOCIATIONS() {
|
static override get ASSOCIATIONS(): AccessLogAssociationConfig[] {
|
||||||
return [
|
return [
|
||||||
{ field: 'project', setter: 'setProject', isArray: false },
|
{ field: 'project', setter: 'setProject', isArray: false },
|
||||||
{ field: 'user', setter: 'setUser', isArray: false },
|
{ field: 'user', setter: 'setUser', isArray: false },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_BY_INCLUDES() {
|
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||||
return [{ association: 'project' }, { association: 'user' }];
|
return [{ association: 'project' }, { association: 'user' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_ALL_INCLUDES() {
|
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||||
return [
|
return [
|
||||||
{ model: db.projects, as: 'project', required: false },
|
{ model: db.projects, as: 'project', required: false },
|
||||||
{ model: db.users, as: 'user', required: false },
|
{ model: db.users, as: 'user', required: false },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static override get RELATION_FILTERS(): AccessLogRelationFilterConfig[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filterKey: 'project',
|
filterKey: 'project',
|
||||||
@ -73,7 +79,7 @@ class Access_logsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(data: AccessLogData): AccessLogFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
environment: data.environment || null,
|
environment: data.environment || null,
|
||||||
@ -85,4 +91,4 @@ class Access_logsDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Access_logsDBApi;
|
export default Access_logsDBApi;
|
||||||
@ -1,28 +1,34 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
AssetVariantAssociationConfig,
|
||||||
|
AssetVariantData,
|
||||||
|
AssetVariantFieldMapping,
|
||||||
|
AssetVariantRelationFilterConfig,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
class Asset_variantsDBApi extends GenericDBApi {
|
class Asset_variantsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static override get MODEL(): unknown {
|
||||||
return db.asset_variants;
|
return db.asset_variants;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'asset_variants';
|
return 'asset_variants';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return ['cdn_url'];
|
return ['cdn_url'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return ['width_px', 'height_px', 'size_mb'];
|
return ['width_px', 'height_px', 'size_mb'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return ['variant_type'];
|
return ['variant_type'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static override get CSV_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'variant_type',
|
'variant_type',
|
||||||
@ -34,19 +40,19 @@ class Asset_variantsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
return 'variant_type';
|
return 'variant_type';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ASSOCIATIONS() {
|
static override get ASSOCIATIONS(): AssetVariantAssociationConfig[] {
|
||||||
return [{ field: 'asset', setter: 'setAsset', isArray: false }];
|
return [{ field: 'asset', setter: 'setAsset', isArray: false }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_BY_INCLUDES() {
|
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||||
return [{ association: 'asset' }];
|
return [{ association: 'asset' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_ALL_INCLUDES() {
|
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
model: db.assets,
|
model: db.assets,
|
||||||
@ -56,7 +62,7 @@ class Asset_variantsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static override get RELATION_FILTERS(): AssetVariantRelationFilterConfig[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filterKey: 'asset',
|
filterKey: 'asset',
|
||||||
@ -67,7 +73,7 @@ class Asset_variantsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(data: AssetVariantData): AssetVariantFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
assetId: data.assetId || null,
|
assetId: data.assetId || null,
|
||||||
@ -81,4 +87,4 @@ class Asset_variantsDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Asset_variantsDBApi;
|
export default Asset_variantsDBApi;
|
||||||
@ -1,16 +1,26 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
AssetData,
|
||||||
|
AssetFieldMapping,
|
||||||
|
AssetUsageType,
|
||||||
|
AssetsDbApi,
|
||||||
|
DbAssociationConfig,
|
||||||
|
DbRelationFilterConfig,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
class AssetsDBApi extends GenericDBApi {
|
class AssetsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
declare static findBy: AssetsDbApi['findBy'];
|
||||||
|
|
||||||
|
static override get MODEL(): unknown {
|
||||||
return db.assets;
|
return db.assets;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'assets';
|
return 'assets';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'name',
|
'name',
|
||||||
'cdn_url',
|
'cdn_url',
|
||||||
@ -21,19 +31,19 @@ class AssetsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return ['size_mb', 'width_px', 'height_px', 'duration_sec', 'frame_rate'];
|
return ['size_mb', 'width_px', 'height_px', 'duration_sec', 'frame_rate'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return ['asset_type', 'type', 'is_public'];
|
return ['asset_type', 'type', 'is_public'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get UUID_FIELDS() {
|
static override get UUID_FIELDS(): string[] {
|
||||||
return ['projectId'];
|
return ['projectId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static override get CSV_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'name',
|
'name',
|
||||||
@ -47,26 +57,26 @@ class AssetsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
return 'name';
|
return 'name';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ASSOCIATIONS() {
|
static override get ASSOCIATIONS(): DbAssociationConfig[] {
|
||||||
return [{ field: 'project', setter: 'setProject', isArray: false }];
|
return [{ field: 'project', setter: 'setProject', isArray: false }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_BY_INCLUDES() {
|
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||||
return [
|
return [
|
||||||
{ association: 'asset_variants_asset' },
|
{ association: 'asset_variants_asset' },
|
||||||
{ association: 'project' },
|
{ association: 'project' },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_ALL_INCLUDES() {
|
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||||
return [{ model: db.projects, as: 'project', required: false }];
|
return [{ model: db.projects, as: 'project', required: false }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static override get RELATION_FILTERS(): DbRelationFilterConfig[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filterKey: 'project',
|
filterKey: 'project',
|
||||||
@ -77,12 +87,12 @@ class AssetsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(data: AssetData): AssetFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
name: data.name || null,
|
name: data.name || null,
|
||||||
asset_type: data.asset_type || null,
|
asset_type: data.asset_type || null,
|
||||||
type: data.type || 'general',
|
type: data.type || defaultAssetUsageType,
|
||||||
cdn_url: data.cdn_url || null,
|
cdn_url: data.cdn_url || null,
|
||||||
storage_key: data.storage_key || null,
|
storage_key: data.storage_key || null,
|
||||||
mime_type: data.mime_type || null,
|
mime_type: data.mime_type || null,
|
||||||
@ -100,4 +110,6 @@ class AssetsDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = AssetsDBApi;
|
const defaultAssetUsageType: AssetUsageType = 'general';
|
||||||
|
|
||||||
|
export default AssetsDBApi;
|
||||||
@ -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');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
DbFindAllOptions,
|
||||||
|
DbFindByOptions,
|
||||||
|
AutocompleteOptions,
|
||||||
|
CreateOptions,
|
||||||
|
DeleteByIdsOptions,
|
||||||
|
ElementSettingsJson,
|
||||||
|
ElementTypeDefaultsData,
|
||||||
|
ElementTypeDefaultsFieldDefaults,
|
||||||
|
ElementTypeDefaultsFieldMapping,
|
||||||
|
ElementTypeDefaultsModel,
|
||||||
|
ElementTypeDefaultsSeedRow,
|
||||||
|
EntityIdOptions,
|
||||||
|
EntityRecord,
|
||||||
|
GenericDbListFilter,
|
||||||
|
PaginatedResult,
|
||||||
|
ServiceOptions,
|
||||||
|
UpdateOptions,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
function isMissingTableError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object' || !('original' in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = error.original;
|
||||||
|
if (!original || typeof original !== 'object' || !('code' in original)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return original.code === '42P01';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null {
|
||||||
|
if (value === undefined || value === null) return null;
|
||||||
|
if (typeof value === 'string') return value;
|
||||||
|
return JSON.stringify(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isElementTypeDefaultsListFilter(
|
||||||
|
value: unknown,
|
||||||
|
): value is GenericDbListFilter {
|
||||||
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
|
}
|
||||||
|
|
||||||
class Element_type_defaultsDBApi extends GenericDBApi {
|
class Element_type_defaultsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static initializationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
static override get MODEL(): ElementTypeDefaultsModel {
|
||||||
return db.element_type_defaults;
|
return db.element_type_defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'element_type_defaults';
|
return 'element_type_defaults';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return ['name', 'element_type'];
|
return ['name', 'element_type'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return ['sort_order'];
|
return ['sort_order'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static override get CSV_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'element_type',
|
'element_type',
|
||||||
@ -33,16 +79,16 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
return 'name';
|
return 'name';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Declarative field configuration using base class patterns
|
// Declarative field configuration using base class patterns
|
||||||
static get JSON_FIELDS() {
|
static override get JSON_FIELDS(): string[] {
|
||||||
return ['default_settings_json'];
|
return ['default_settings_json'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIELD_DEFAULTS() {
|
static override get FIELD_DEFAULTS(): ElementTypeDefaultsFieldDefaults {
|
||||||
return {
|
return {
|
||||||
element_type: { default: null },
|
element_type: { default: null },
|
||||||
name: { default: null },
|
name: { default: null },
|
||||||
@ -50,20 +96,19 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(
|
||||||
// Apply base class transformations (JSON fields, defaults, transformers)
|
data: ElementTypeDefaultsData,
|
||||||
const mapped = super.getFieldMapping(data);
|
): ElementTypeDefaultsFieldMapping {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: mapped.id || undefined,
|
id: data.id || undefined,
|
||||||
element_type: mapped.element_type,
|
element_type: data.element_type ?? null,
|
||||||
name: mapped.name,
|
name: data.name ?? null,
|
||||||
sort_order: mapped.sort_order,
|
sort_order: data.sort_order ?? 0,
|
||||||
default_settings_json: mapped.default_settings_json,
|
default_settings_json: stringifySettings(data.default_settings_json),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static get DEFAULT_ROWS() {
|
static get DEFAULT_ROWS(): ElementTypeDefaultsSeedRow[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
element_type: 'navigation_next',
|
element_type: 'navigation_next',
|
||||||
@ -328,7 +373,7 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static async ensureInitialized() {
|
static async ensureInitialized(): Promise<void> {
|
||||||
if (!this.initializationPromise) {
|
if (!this.initializationPromise) {
|
||||||
this.initializationPromise = (async () => {
|
this.initializationPromise = (async () => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@ -336,7 +381,7 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
|||||||
try {
|
try {
|
||||||
count = await this.MODEL.count();
|
count = await this.MODEL.count();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.original?.code !== '42P01') {
|
if (!isMissingTableError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -363,47 +408,91 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
|||||||
await this.initializationPromise;
|
await this.initializationPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(options) {
|
static override async create(
|
||||||
|
options: CreateOptions<ElementTypeDefaultsData>,
|
||||||
|
): Promise<EntityRecord> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.create(options);
|
return super.create(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async bulkImport(data, options = {}) {
|
static override async bulkImport(
|
||||||
|
data: unknown[],
|
||||||
|
options: ServiceOptions = {},
|
||||||
|
): Promise<void> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.bulkImport(data, options);
|
await super.bulkImport(data, options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async update({ id, data, currentUser, transaction, runtimeContext }) {
|
static override async update(
|
||||||
|
options: UpdateOptions<ElementTypeDefaultsData>,
|
||||||
|
): Promise<EntityRecord> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.update({ id, data, currentUser, transaction, runtimeContext });
|
return super.update(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async deleteByIds(options) {
|
static override async deleteByIds(
|
||||||
|
options: DeleteByIdsOptions,
|
||||||
|
): Promise<EntityRecord[]> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.deleteByIds(options);
|
return super.deleteByIds(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async remove(options) {
|
static override async remove(options: EntityIdOptions): Promise<EntityRecord> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.remove(options);
|
return super.remove(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findBy(where, options = {}) {
|
static override async findBy(
|
||||||
|
where: { id: string },
|
||||||
|
options?: ServiceOptions,
|
||||||
|
): Promise<EntityRecord | null>;
|
||||||
|
static override async findBy(
|
||||||
|
options: DbFindByOptions,
|
||||||
|
): Promise<EntityRecord | null>;
|
||||||
|
static override async findBy(
|
||||||
|
whereOrOptions: { id: string } | DbFindByOptions,
|
||||||
|
options: ServiceOptions = {},
|
||||||
|
): Promise<EntityRecord | null> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.findBy(where, options);
|
|
||||||
|
if ('where' in whereOrOptions) {
|
||||||
|
return super.findBy(whereOrOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const findOptions: DbFindByOptions = { where: whereOrOptions };
|
||||||
|
|
||||||
|
if (options.transaction) {
|
||||||
|
findOptions.transaction = options.transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.findBy(findOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAll(filter = {}, options = {}) {
|
static override async findAll(
|
||||||
|
filter?: unknown,
|
||||||
|
options?: ServiceOptions,
|
||||||
|
): Promise<PaginatedResult<EntityRecord>>;
|
||||||
|
static override async findAll(
|
||||||
|
options: DbFindAllOptions<unknown>,
|
||||||
|
): Promise<PaginatedResult<EntityRecord>>;
|
||||||
|
static override async findAll(
|
||||||
|
filterOrOptions: unknown = {},
|
||||||
|
options: ServiceOptions = {},
|
||||||
|
): Promise<PaginatedResult<EntityRecord>> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.findAll(filter, options);
|
if (isElementTypeDefaultsListFilter(filterOrOptions)) {
|
||||||
|
return super.findAll(filterOrOptions, options);
|
||||||
|
}
|
||||||
|
return super.findAll(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findAllAutocomplete(options, queryOptions = {}) {
|
static override async findAllAutocomplete(
|
||||||
|
options: AutocompleteOptions,
|
||||||
|
): Promise<EntityRecord[]> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.findAllAutocomplete(options, queryOptions);
|
const records = await super.findAllAutocomplete(options);
|
||||||
|
return records;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Element_type_defaultsDBApi.initializationPromise = null;
|
export default Element_type_defaultsDBApi;
|
||||||
|
|
||||||
module.exports = Element_type_defaultsDBApi;
|
|
||||||
@ -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');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
DbFindByOptions,
|
||||||
|
EntityRecord,
|
||||||
|
GlobalUiControlDefaultsData,
|
||||||
|
GlobalUiControlDefaultsFieldMapping,
|
||||||
|
GlobalUiControlDefaultsModel,
|
||||||
|
GlobalUiControlDefaultsRecord,
|
||||||
|
GlobalUiControlSettingsJson,
|
||||||
|
ServiceOptions,
|
||||||
|
UpdateOptions,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
const DEFAULT_SETTINGS = {
|
function isMissingTableError(error: unknown): boolean {
|
||||||
|
if (!error || typeof error !== 'object' || !('original' in error)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const original = error.original;
|
||||||
|
if (!original || typeof original !== 'object' || !('code' in original)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return original.code === '42P01';
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS: GlobalUiControlSettingsJson = {
|
||||||
fullscreen: {
|
fullscreen: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
hidden: false,
|
hidden: false,
|
||||||
@ -71,40 +95,42 @@ const DEFAULT_SETTINGS = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
class Global_ui_control_defaultsDBApi extends GenericDBApi {
|
class Global_ui_control_defaultsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static initializationPromise: Promise<void> | null = null;
|
||||||
|
|
||||||
|
static override get MODEL(): GlobalUiControlDefaultsModel {
|
||||||
return db.global_ui_control_defaults;
|
return db.global_ui_control_defaults;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'global_ui_control_defaults';
|
return 'global_ui_control_defaults';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get DEFAULT_SETTINGS() {
|
static get DEFAULT_SETTINGS(): GlobalUiControlSettingsJson {
|
||||||
return DEFAULT_SETTINGS;
|
return DEFAULT_SETTINGS;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(
|
||||||
const mapped = super.getFieldMapping(data);
|
data: GlobalUiControlDefaultsData,
|
||||||
|
): GlobalUiControlDefaultsFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: mapped.id || undefined,
|
id: data.id || undefined,
|
||||||
settings_json:
|
settings_json: data.settings_json || data.settings || DEFAULT_SETTINGS,
|
||||||
mapped.settings_json || mapped.settings || DEFAULT_SETTINGS,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
static async ensureInitialized() {
|
static async ensureInitialized(): Promise<void> {
|
||||||
if (!this.initializationPromise) {
|
if (!this.initializationPromise) {
|
||||||
this.initializationPromise = (async () => {
|
this.initializationPromise = (async () => {
|
||||||
let count = 0;
|
let count = 0;
|
||||||
@ -112,7 +138,7 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
|
|||||||
try {
|
try {
|
||||||
count = await this.MODEL.count();
|
count = await this.MODEL.count();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.original?.code !== '42P01') {
|
if (!isMissingTableError(error)) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -137,7 +163,9 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
|
|||||||
await this.initializationPromise;
|
await this.initializationPromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findOne(options = {}) {
|
static async findOne(
|
||||||
|
options: ServiceOptions = {},
|
||||||
|
): Promise<GlobalUiControlDefaultsRecord | null> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
|
|
||||||
const record = await this.MODEL.findOne({
|
const record = await this.MODEL.findOne({
|
||||||
@ -148,17 +176,57 @@ class Global_ui_control_defaultsDBApi extends GenericDBApi {
|
|||||||
return record.get({ plain: true });
|
return record.get({ plain: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
static async update({ id, data, currentUser, transaction, runtimeContext }) {
|
static override async update({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
runtimeContext,
|
||||||
|
}: UpdateOptions<GlobalUiControlDefaultsData>): Promise<EntityRecord> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.update({ id, data, currentUser, transaction, runtimeContext });
|
|
||||||
|
const updateOptions: UpdateOptions<GlobalUiControlDefaultsData> = {
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
currentUser: currentUser ?? null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (transaction) {
|
||||||
|
updateOptions.transaction = transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeContext) {
|
||||||
|
updateOptions.runtimeContext = runtimeContext;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.update(updateOptions);
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findBy(where, options = {}) {
|
static override async findBy(
|
||||||
|
where: { id: string },
|
||||||
|
options?: ServiceOptions,
|
||||||
|
): Promise<EntityRecord | null>;
|
||||||
|
static override async findBy(
|
||||||
|
options: DbFindByOptions,
|
||||||
|
): Promise<EntityRecord | null>;
|
||||||
|
static override async findBy(
|
||||||
|
whereOrOptions: { id: string } | DbFindByOptions,
|
||||||
|
options: ServiceOptions = {},
|
||||||
|
): Promise<EntityRecord | null> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.findBy(where, options);
|
|
||||||
|
if ('where' in whereOrOptions) {
|
||||||
|
return super.findBy(whereOrOptions);
|
||||||
|
}
|
||||||
|
|
||||||
|
const findOptions: DbFindByOptions = { where: whereOrOptions };
|
||||||
|
|
||||||
|
if (options.transaction) {
|
||||||
|
findOptions.transaction = options.transaction;
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.findBy(findOptions);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Global_ui_control_defaultsDBApi.initializationPromise = null;
|
export default Global_ui_control_defaultsDBApi;
|
||||||
|
|
||||||
module.exports = Global_ui_control_defaultsDBApi;
|
|
||||||
@ -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');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
PresignedUrlRequestAssociationConfig,
|
||||||
|
PresignedUrlRequestData,
|
||||||
|
PresignedUrlRequestFieldMapping,
|
||||||
|
PresignedUrlRequestRelationFilterConfig,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
class Presigned_url_requestsDBApi extends GenericDBApi {
|
class Presigned_url_requestsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static override get MODEL(): unknown {
|
||||||
return db.presigned_url_requests;
|
return db.presigned_url_requests;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'presigned_url_requests';
|
return 'presigned_url_requests';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return ['requested_key', 'mime_type', 'status'];
|
return ['requested_key', 'mime_type', 'status'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return ['requested_size_mb', 'expires_at'];
|
return ['requested_size_mb', 'expires_at'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return ['purpose', 'asset_type'];
|
return ['purpose', 'asset_type'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static override get CSV_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'purpose',
|
'purpose',
|
||||||
@ -34,29 +40,29 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
return 'requested_key';
|
return 'requested_key';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ASSOCIATIONS() {
|
static override get ASSOCIATIONS(): PresignedUrlRequestAssociationConfig[] {
|
||||||
return [
|
return [
|
||||||
{ field: 'project', setter: 'setProject', isArray: false },
|
{ field: 'project', setter: 'setProject', isArray: false },
|
||||||
{ field: 'user', setter: 'setUser', isArray: false },
|
{ field: 'user', setter: 'setUser', isArray: false },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_BY_INCLUDES() {
|
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||||
return [{ association: 'project' }, { association: 'user' }];
|
return [{ association: 'project' }, { association: 'user' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_ALL_INCLUDES() {
|
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||||
return [
|
return [
|
||||||
{ model: db.projects, as: 'project', required: false },
|
{ model: db.projects, as: 'project', required: false },
|
||||||
{ model: db.users, as: 'user', required: false },
|
{ model: db.users, as: 'user', required: false },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static override get RELATION_FILTERS(): PresignedUrlRequestRelationFilterConfig[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filterKey: 'project',
|
filterKey: 'project',
|
||||||
@ -73,7 +79,9 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(
|
||||||
|
data: PresignedUrlRequestData,
|
||||||
|
): PresignedUrlRequestFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
purpose: data.purpose || null,
|
purpose: data.purpose || null,
|
||||||
@ -87,4 +95,4 @@ class Presigned_url_requestsDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Presigned_url_requestsDBApi;
|
export default Presigned_url_requestsDBApi;
|
||||||
@ -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');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
ProjectMembershipAssociationConfig,
|
||||||
|
ProjectMembershipData,
|
||||||
|
ProjectMembershipFieldMapping,
|
||||||
|
ProjectMembershipRelationFilterConfig,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
class Project_membershipsDBApi extends GenericDBApi {
|
class Project_membershipsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static override get MODEL(): unknown {
|
||||||
return db.project_memberships;
|
return db.project_memberships;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'project_memberships';
|
return 'project_memberships';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return ['invited_at', 'accepted_at'];
|
return ['invited_at', 'accepted_at'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return ['access_level', 'is_active'];
|
return ['access_level', 'is_active'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static override get CSV_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'access_level',
|
'access_level',
|
||||||
@ -33,29 +39,29 @@ class Project_membershipsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
return 'access_level';
|
return 'access_level';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ASSOCIATIONS() {
|
static override get ASSOCIATIONS(): ProjectMembershipAssociationConfig[] {
|
||||||
return [
|
return [
|
||||||
{ field: 'project', setter: 'setProject', isArray: false },
|
{ field: 'project', setter: 'setProject', isArray: false },
|
||||||
{ field: 'user', setter: 'setUser', isArray: false },
|
{ field: 'user', setter: 'setUser', isArray: false },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_BY_INCLUDES() {
|
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||||
return [{ association: 'project' }, { association: 'user' }];
|
return [{ association: 'project' }, { association: 'user' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_ALL_INCLUDES() {
|
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||||
return [
|
return [
|
||||||
{ model: db.projects, as: 'project', required: false },
|
{ model: db.projects, as: 'project', required: false },
|
||||||
{ model: db.users, as: 'user', required: false },
|
{ model: db.users, as: 'user', required: false },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static override get RELATION_FILTERS(): ProjectMembershipRelationFilterConfig[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filterKey: 'project',
|
filterKey: 'project',
|
||||||
@ -72,7 +78,9 @@ class Project_membershipsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(
|
||||||
|
data: ProjectMembershipData,
|
||||||
|
): ProjectMembershipFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
access_level: data.access_level || null,
|
access_level: data.access_level || null,
|
||||||
@ -83,4 +91,4 @@ class Project_membershipsDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Project_membershipsDBApi;
|
export default Project_membershipsDBApi;
|
||||||
@ -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');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
PublishEventAssociationConfig,
|
||||||
|
PublishEventData,
|
||||||
|
PublishEventFieldMapping,
|
||||||
|
PublishEventRelationFilterConfig,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
class Publish_eventsDBApi extends GenericDBApi {
|
class Publish_eventsDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static override get MODEL(): unknown {
|
||||||
return db.publish_events;
|
return db.publish_events;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'publish_events';
|
return 'publish_events';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return ['title', 'description', 'error_message'];
|
return ['title', 'description', 'error_message'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'started_at',
|
'started_at',
|
||||||
'finished_at',
|
'finished_at',
|
||||||
@ -24,15 +30,15 @@ class Publish_eventsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return ['from_environment', 'to_environment', 'status'];
|
return ['from_environment', 'to_environment', 'status'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get UUID_FIELDS() {
|
static override get UUID_FIELDS(): string[] {
|
||||||
return ['projectId'];
|
return ['projectId'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static override get CSV_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'title',
|
'title',
|
||||||
@ -45,29 +51,29 @@ class Publish_eventsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
return 'status';
|
return 'status';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ASSOCIATIONS() {
|
static override get ASSOCIATIONS(): PublishEventAssociationConfig[] {
|
||||||
return [
|
return [
|
||||||
{ field: 'project', setter: 'setProject', isArray: false },
|
{ field: 'project', setter: 'setProject', isArray: false },
|
||||||
{ field: 'user', setter: 'setUser', isArray: false },
|
{ field: 'user', setter: 'setUser', isArray: false },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_BY_INCLUDES() {
|
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||||
return [{ association: 'project' }, { association: 'user' }];
|
return [{ association: 'project' }, { association: 'user' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_ALL_INCLUDES() {
|
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||||
return [
|
return [
|
||||||
{ model: db.projects, as: 'project', required: false },
|
{ model: db.projects, as: 'project', required: false },
|
||||||
{ model: db.users, as: 'user', required: false },
|
{ model: db.users, as: 'user', required: false },
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static override get RELATION_FILTERS(): PublishEventRelationFilterConfig[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filterKey: 'project',
|
filterKey: 'project',
|
||||||
@ -84,7 +90,7 @@ class Publish_eventsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(data: PublishEventData): PublishEventFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
title: data.title || null,
|
title: data.title || null,
|
||||||
@ -102,4 +108,4 @@ class Publish_eventsDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Publish_eventsDBApi;
|
export default Publish_eventsDBApi;
|
||||||
@ -1,28 +1,34 @@
|
|||||||
const GenericDBApi = require('./base.api');
|
import GenericDBApi from './base.api.ts';
|
||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
|
import type {
|
||||||
|
PwaCacheAssociationConfig,
|
||||||
|
PwaCacheData,
|
||||||
|
PwaCacheFieldMapping,
|
||||||
|
PwaCacheRelationFilterConfig,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
class Pwa_cachesDBApi extends GenericDBApi {
|
class Pwa_cachesDBApi extends GenericDBApi {
|
||||||
static get MODEL() {
|
static override get MODEL(): unknown {
|
||||||
return db.pwa_caches;
|
return db.pwa_caches;
|
||||||
}
|
}
|
||||||
|
|
||||||
static get TABLE_NAME() {
|
static override get TABLE_NAME(): string {
|
||||||
return 'pwa_caches';
|
return 'pwa_caches';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
return ['cache_version', 'manifest_json', 'asset_list_json'];
|
return ['cache_version', 'manifest_json', 'asset_list_json'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RANGE_FIELDS() {
|
static override get RANGE_FIELDS(): string[] {
|
||||||
return ['generated_at'];
|
return ['generated_at'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ENUM_FIELDS() {
|
static override get ENUM_FIELDS(): string[] {
|
||||||
return ['environment', 'is_active'];
|
return ['environment', 'is_active'];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get CSV_FIELDS() {
|
static override get CSV_FIELDS(): string[] {
|
||||||
return [
|
return [
|
||||||
'id',
|
'id',
|
||||||
'environment',
|
'environment',
|
||||||
@ -33,23 +39,23 @@ class Pwa_cachesDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get AUTOCOMPLETE_FIELD() {
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
return 'cache_version';
|
return 'cache_version';
|
||||||
}
|
}
|
||||||
|
|
||||||
static get ASSOCIATIONS() {
|
static override get ASSOCIATIONS(): PwaCacheAssociationConfig[] {
|
||||||
return [{ field: 'project', setter: 'setProject', isArray: false }];
|
return [{ field: 'project', setter: 'setProject', isArray: false }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_BY_INCLUDES() {
|
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||||
return [{ association: 'project' }];
|
return [{ association: 'project' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get FIND_ALL_INCLUDES() {
|
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||||
return [{ model: db.projects, as: 'project', required: false }];
|
return [{ model: db.projects, as: 'project', required: false }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static override get RELATION_FILTERS(): PwaCacheRelationFilterConfig[] {
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
filterKey: 'project',
|
filterKey: 'project',
|
||||||
@ -60,7 +66,7 @@ class Pwa_cachesDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static getFieldMapping(data) {
|
static override getFieldMapping(data: PwaCacheData): PwaCacheFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
environment: data.environment || null,
|
environment: data.environment || null,
|
||||||
@ -73,4 +79,4 @@ class Pwa_cachesDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = Pwa_cachesDBApi;
|
export default Pwa_cachesDBApi;
|
||||||
@ -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';
|
'use strict';
|
||||||
|
|
||||||
/** @type {import('sequelize-cli').Migration} */
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async up(queryInterface, _Sequelize) {
|
async up(queryInterface, _Sequelize) {
|
||||||
await queryInterface.removeColumn('projects', 'theme_config_json');
|
await queryInterface.removeColumn('projects', 'theme_config_json');
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/** @type {import('sequelize-cli').Migration} */
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.addColumn('tour_pages', 'background_video_autoplay', {
|
await queryInterface.addColumn('tour_pages', 'background_video_autoplay', {
|
||||||
|
|||||||
@ -6,7 +6,6 @@
|
|||||||
* Adds design_width and design_height columns to support
|
* Adds design_width and design_height columns to support
|
||||||
* responsive canvas scaling with project-specific aspect ratios.
|
* responsive canvas scaling with project-specific aspect ratios.
|
||||||
*
|
*
|
||||||
* @type {import('sequelize-cli').Migration}
|
|
||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
|
|||||||
@ -7,7 +7,6 @@
|
|||||||
* Also adds storage_key column to track the S3/local storage path.
|
* Also adds storage_key column to track the S3/local storage path.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** @type {import('sequelize-cli').Migration} */
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
// Add 'reversed' to the enum_asset_variants_variant_type enum
|
// Add 'reversed' to the enum_asset_variants_variant_type enum
|
||||||
|
|||||||
@ -10,7 +10,6 @@ const { v4: uuidv4 } = require('uuid');
|
|||||||
*
|
*
|
||||||
* Cascade: Element → Project → Global (fallback)
|
* Cascade: Element → Project → Global (fallback)
|
||||||
*
|
*
|
||||||
* @type {import('sequelize-cli').Migration}
|
|
||||||
*/
|
*/
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
/** @type {import('sequelize-cli').Migration} */
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
await queryInterface.addColumn('tour_pages', 'background_audio_autoplay', {
|
await queryInterface.addColumn('tour_pages', 'background_audio_autoplay', {
|
||||||
|
|||||||
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) {
|
import type {
|
||||||
const access_logs = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineAccessLogsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const accessLogs: SequelizeModel = sequelize.define(
|
||||||
'access_logs',
|
'access_logs',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -67,11 +72,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
access_logs.associate = (db) => {
|
accessLogs.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.access_logs.belongsTo(db.projects, {
|
db.access_logs.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -101,5 +102,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return access_logs;
|
return accessLogs;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineAccessLogsModel;
|
||||||
@ -1,5 +1,19 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const asset_variants = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
function validateUrlOrEmpty(value: string | null | undefined): void {
|
||||||
|
if (value && value.length > 0 && !/^https?:\/\/.+/.test(value)) {
|
||||||
|
throw new Error('CDN URL must be a valid URL');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defineAssetVariantsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const assetVariants: SequelizeModel = sequelize.define(
|
||||||
'asset_variants',
|
'asset_variants',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -40,11 +54,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
args: [0, 2048],
|
args: [0, 2048],
|
||||||
msg: 'CDN URL must be at most 2048 characters',
|
msg: 'CDN URL must be at most 2048 characters',
|
||||||
},
|
},
|
||||||
isUrlOrEmpty(value) {
|
isUrlOrEmpty: validateUrlOrEmpty,
|
||||||
if (value && value.length > 0 && !/^https?:\/\/.+/.test(value)) {
|
|
||||||
throw new Error('CDN URL must be a valid URL');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -82,11 +92,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
asset_variants.associate = (db) => {
|
assetVariants.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.asset_variants.belongsTo(db.assets, {
|
db.asset_variants.belongsTo(db.assets, {
|
||||||
as: 'asset',
|
as: 'asset',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -106,5 +112,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return asset_variants;
|
return assetVariants;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineAssetVariantsModel;
|
||||||
@ -1,5 +1,10 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const assets = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineAssetsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const assets: SequelizeModel = sequelize.define(
|
||||||
'assets',
|
'assets',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -137,8 +142,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
assets.associate = (db) => {
|
assets.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
db.assets.hasMany(db.asset_variants, {
|
db.assets.hasMany(db.asset_variants, {
|
||||||
as: 'asset_variants_asset',
|
as: 'asset_variants_asset',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -149,8 +152,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
onUpdate: 'CASCADE',
|
onUpdate: 'CASCADE',
|
||||||
});
|
});
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.assets.belongsTo(db.projects, {
|
db.assets.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -172,3 +173,5 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
|
|
||||||
return assets;
|
return assets;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineAssetsModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const element_type_defaults = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineElementTypeDefaultsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const elementTypeDefaults: SequelizeModel = sequelize.define(
|
||||||
'element_type_defaults',
|
'element_type_defaults',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -64,7 +72,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
element_type_defaults.associate = (db) => {
|
elementTypeDefaults.associate = (db) => {
|
||||||
db.element_type_defaults.belongsTo(db.users, {
|
db.element_type_defaults.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
@ -73,19 +81,18 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
as: 'updatedBy',
|
as: 'updatedBy',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add hasMany relationship to project_element_defaults
|
db.element_type_defaults.hasMany(db.project_element_defaults, {
|
||||||
if (db.project_element_defaults) {
|
as: 'project_defaults',
|
||||||
db.element_type_defaults.hasMany(db.project_element_defaults, {
|
foreignKey: {
|
||||||
as: 'project_defaults',
|
name: 'source_element_id',
|
||||||
foreignKey: {
|
},
|
||||||
name: 'source_element_id',
|
constraints: true,
|
||||||
},
|
onDelete: 'SET NULL',
|
||||||
constraints: true,
|
onUpdate: 'CASCADE',
|
||||||
onDelete: 'SET NULL',
|
});
|
||||||
onUpdate: 'CASCADE',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return element_type_defaults;
|
return elementTypeDefaults;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineElementTypeDefaultsModel;
|
||||||
@ -1,5 +1,10 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const file = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineFileModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const file: SequelizeModel = sequelize.define(
|
||||||
'file',
|
'file',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -51,3 +56,5 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
|
|
||||||
return file;
|
return file;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineFileModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const global_transition_defaults = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineGlobalTransitionDefaultsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const globalTransitionDefaults: SequelizeModel = sequelize.define(
|
||||||
'global_transition_defaults',
|
'global_transition_defaults',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -59,7 +67,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
global_transition_defaults.associate = (db) => {
|
globalTransitionDefaults.associate = (db) => {
|
||||||
db.global_transition_defaults.belongsTo(db.users, {
|
db.global_transition_defaults.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
@ -69,5 +77,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return global_transition_defaults;
|
return globalTransitionDefaults;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineGlobalTransitionDefaultsModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const global_ui_control_defaults = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineGlobalUiControlDefaultsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const globalUiControlDefaults: SequelizeModel = sequelize.define(
|
||||||
'global_ui_control_defaults',
|
'global_ui_control_defaults',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -20,7 +28,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
global_ui_control_defaults.associate = (db) => {
|
globalUiControlDefaults.associate = (db) => {
|
||||||
db.global_ui_control_defaults.belongsTo(db.users, {
|
db.global_ui_control_defaults.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
@ -30,5 +38,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return global_ui_control_defaults;
|
return globalUiControlDefaults;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineGlobalUiControlDefaultsModel;
|
||||||
@ -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) {
|
import type {
|
||||||
const permissions = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const definePermissionsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const permissions: SequelizeModel = sequelize.define(
|
||||||
'permissions',
|
'permissions',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -35,10 +40,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
permissions.associate = (db) => {
|
permissions.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.permissions.belongsTo(db.users, {
|
db.permissions.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
@ -50,3 +51,5 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
|
|
||||||
return permissions;
|
return permissions;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default definePermissionsModel;
|
||||||
@ -1,5 +1,19 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const presigned_url_requests = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
function validateMimeTypeOrEmpty(value: string | null | undefined): void {
|
||||||
|
if (value && value.length > 0 && !/^[\w.-]+\/[\w.+-]+$/.test(value)) {
|
||||||
|
throw new Error('MIME type must be in format type/subtype');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const definePresignedUrlRequestsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const presignedUrlRequests: SequelizeModel = sequelize.define(
|
||||||
'presigned_url_requests',
|
'presigned_url_requests',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -37,15 +51,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
args: [0, 255],
|
args: [0, 255],
|
||||||
msg: 'MIME type must be at most 255 characters',
|
msg: 'MIME type must be at most 255 characters',
|
||||||
},
|
},
|
||||||
isMimeTypeOrEmpty(value) {
|
isMimeTypeOrEmpty: validateMimeTypeOrEmpty,
|
||||||
if (
|
|
||||||
value &&
|
|
||||||
value.length > 0 &&
|
|
||||||
!/^[\w.-]+\/[\w.+-]+$/.test(value)
|
|
||||||
) {
|
|
||||||
throw new Error('MIME type must be in format type/subtype');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -80,11 +86,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
presigned_url_requests.associate = (db) => {
|
presignedUrlRequests.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.presigned_url_requests.belongsTo(db.projects, {
|
db.presigned_url_requests.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -114,5 +116,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return presigned_url_requests;
|
return presignedUrlRequests;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default definePresignedUrlRequestsModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const production_presentation_access = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineProductionPresentationAccessModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const productionPresentationAccess: SequelizeModel = sequelize.define(
|
||||||
'production_presentation_access',
|
'production_presentation_access',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -26,7 +34,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
production_presentation_access.associate = (db) => {
|
productionPresentationAccess.associate = (db) => {
|
||||||
db.production_presentation_access.belongsTo(db.projects, {
|
db.production_presentation_access.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -56,5 +64,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return production_presentation_access;
|
return productionPresentationAccess;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineProductionPresentationAccessModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const project_audio_tracks = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineProjectAudioTracksModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const projectAudioTracks: SequelizeModel = sequelize.define(
|
||||||
'project_audio_tracks',
|
'project_audio_tracks',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -75,11 +83,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
project_audio_tracks.associate = (db) => {
|
projectAudioTracks.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.project_audio_tracks.belongsTo(db.projects, {
|
db.project_audio_tracks.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -99,5 +103,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return project_audio_tracks;
|
return projectAudioTracks;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineProjectAudioTracksModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const project_element_defaults = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineProjectElementDefaultsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const projectElementDefaults: SequelizeModel = sequelize.define(
|
||||||
'project_element_defaults',
|
'project_element_defaults',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -8,7 +16,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
primaryKey: true,
|
primaryKey: true,
|
||||||
},
|
},
|
||||||
element_type: {
|
element_type: {
|
||||||
// TEXT for flexibility - matches element_type_defaults and page_elements
|
|
||||||
type: DataTypes.TEXT,
|
type: DataTypes.TEXT,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
validate: {
|
validate: {
|
||||||
@ -35,13 +42,10 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
source_element_id: {
|
source_element_id: {
|
||||||
// Optional FK - tracks which global default this was snapshotted from
|
|
||||||
// SET NULL on global delete to preserve project overrides
|
|
||||||
type: DataTypes.UUID,
|
type: DataTypes.UUID,
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
},
|
},
|
||||||
snapshot_version: {
|
snapshot_version: {
|
||||||
// Increments when resetting from global - enables "check for updates" feature
|
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: false,
|
||||||
defaultValue: 1,
|
defaultValue: 1,
|
||||||
@ -66,7 +70,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
project_element_defaults.associate = (db) => {
|
projectElementDefaults.associate = (db) => {
|
||||||
db.project_element_defaults.belongsTo(db.projects, {
|
db.project_element_defaults.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -97,5 +101,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return project_element_defaults;
|
return projectElementDefaults;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineProjectElementDefaultsModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const project_memberships = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineProjectMembershipsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const projectMemberships: SequelizeModel = sequelize.define(
|
||||||
'project_memberships',
|
'project_memberships',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -51,11 +59,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
project_memberships.associate = (db) => {
|
projectMemberships.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.project_memberships.belongsTo(db.projects, {
|
db.project_memberships.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -85,5 +89,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return project_memberships;
|
return projectMemberships;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineProjectMembershipsModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const project_transition_settings = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineProjectTransitionSettingsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const projectTransitionSettings: SequelizeModel = sequelize.define(
|
||||||
'project_transition_settings',
|
'project_transition_settings',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -79,7 +87,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
project_transition_settings.associate = (db) => {
|
projectTransitionSettings.associate = (db) => {
|
||||||
db.project_transition_settings.belongsTo(db.projects, {
|
db.project_transition_settings.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -99,5 +107,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return project_transition_settings;
|
return projectTransitionSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineProjectTransitionSettingsModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const project_ui_control_settings = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineProjectUiControlSettingsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const projectUiControlSettings: SequelizeModel = sequelize.define(
|
||||||
'project_ui_control_settings',
|
'project_ui_control_settings',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -38,7 +46,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
project_ui_control_settings.associate = (db) => {
|
projectUiControlSettings.associate = (db) => {
|
||||||
db.project_ui_control_settings.belongsTo(db.projects, {
|
db.project_ui_control_settings.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -58,5 +66,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return project_ui_control_settings;
|
return projectUiControlSettings;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineProjectUiControlSettingsModel;
|
||||||
@ -1,5 +1,10 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const projects = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineProjectsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const projects: SequelizeModel = sequelize.define(
|
||||||
'projects',
|
'projects',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -71,9 +76,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
defaultValue: 'public',
|
defaultValue: 'public',
|
||||||
},
|
},
|
||||||
|
|
||||||
// Note: transition_settings moved to project_transition_settings table
|
|
||||||
// for environment-aware storage (dev, stage, production)
|
|
||||||
|
|
||||||
importHash: {
|
importHash: {
|
||||||
type: DataTypes.STRING(255),
|
type: DataTypes.STRING(255),
|
||||||
allowNull: true,
|
allowNull: true,
|
||||||
@ -89,8 +91,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
projects.associate = (db) => {
|
projects.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
db.projects.hasMany(db.project_memberships, {
|
db.projects.hasMany(db.project_memberships, {
|
||||||
as: 'project_memberships_project',
|
as: 'project_memberships_project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -211,8 +211,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
onUpdate: 'CASCADE',
|
onUpdate: 'CASCADE',
|
||||||
});
|
});
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.projects.belongsTo(db.users, {
|
db.projects.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
@ -224,3 +222,5 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
|
|
||||||
return projects;
|
return projects;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineProjectsModel;
|
||||||
@ -1,5 +1,13 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const publish_events = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const definePublishEventsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
|
const publishEvents: SequelizeModel = sequelize.define(
|
||||||
'publish_events',
|
'publish_events',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -110,11 +118,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
publish_events.associate = (db) => {
|
publishEvents.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.publish_events.belongsTo(db.projects, {
|
db.publish_events.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -144,5 +148,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return publish_events;
|
return publishEvents;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default definePublishEventsModel;
|
||||||
@ -1,5 +1,10 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const pwa_caches = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const definePwaCachesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const pwaCaches: SequelizeModel = sequelize.define(
|
||||||
'pwa_caches',
|
'pwa_caches',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -56,11 +61,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
pwa_caches.associate = (db) => {
|
pwaCaches.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.pwa_caches.belongsTo(db.projects, {
|
db.pwa_caches.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -80,5 +81,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return pwa_caches;
|
return pwaCaches;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default definePwaCachesModel;
|
||||||
@ -1,5 +1,10 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const roles = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineRolesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const roles: SequelizeModel = sequelize.define(
|
||||||
'roles',
|
'roles',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -58,8 +63,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
through: 'rolesPermissionsPermissions',
|
through: 'rolesPermissionsPermissions',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
db.roles.hasMany(db.users, {
|
db.roles.hasMany(db.users, {
|
||||||
as: 'users_app_role',
|
as: 'users_app_role',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -70,8 +73,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
onUpdate: 'CASCADE',
|
onUpdate: 'CASCADE',
|
||||||
});
|
});
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.roles.belongsTo(db.users, {
|
db.roles.belongsTo(db.users, {
|
||||||
as: 'createdBy',
|
as: 'createdBy',
|
||||||
});
|
});
|
||||||
@ -83,3 +84,5 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
|
|
||||||
return roles;
|
return roles;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineRolesModel;
|
||||||
@ -1,5 +1,10 @@
|
|||||||
module.exports = function (sequelize, DataTypes) {
|
import type {
|
||||||
const tour_pages = sequelize.define(
|
SequelizeModel,
|
||||||
|
SequelizeModelFactory,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const defineTourPagesModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const tourPages: SequelizeModel = sequelize.define(
|
||||||
'tour_pages',
|
'tour_pages',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -177,11 +182,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
tour_pages.associate = (db) => {
|
tourPages.associate = (db) => {
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.tour_pages.belongsTo(db.projects, {
|
db.tour_pages.belongsTo(db.projects, {
|
||||||
as: 'project',
|
as: 'project',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -201,5 +202,7 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return tour_pages;
|
return tourPages;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default defineTourPagesModel;
|
||||||
@ -1,10 +1,59 @@
|
|||||||
const config = require('../../config');
|
import crypto from 'node:crypto';
|
||||||
const providers = config.providers;
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const bcrypt = require('bcrypt');
|
|
||||||
|
|
||||||
module.exports = function (sequelize, DataTypes) {
|
import bcrypt from 'bcrypt';
|
||||||
const users = sequelize.define(
|
import type { Model, ModelStatic } from 'sequelize';
|
||||||
|
|
||||||
|
import config from '../../config.ts';
|
||||||
|
import type {
|
||||||
|
SequelizeModelFactory,
|
||||||
|
SequelizeModelRegistry,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
const providers = config.providers;
|
||||||
|
|
||||||
|
interface UserModelInstance extends Model {
|
||||||
|
email: string;
|
||||||
|
firstName: string | null;
|
||||||
|
lastName: string | null;
|
||||||
|
password: string;
|
||||||
|
provider: string;
|
||||||
|
emailVerified: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UsersSequelizeModel extends ModelStatic<UserModelInstance> {
|
||||||
|
associate?: (db: SequelizeModelRegistry) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isKnownExternalProvider(provider: string): boolean {
|
||||||
|
return provider !== providers.LOCAL && Object.values(providers).includes(provider);
|
||||||
|
}
|
||||||
|
|
||||||
|
function trimStringFields(user: UserModelInstance): UserModelInstance {
|
||||||
|
user.email = user.email.trim();
|
||||||
|
|
||||||
|
user.firstName = user.firstName ? user.firstName.trim() : null;
|
||||||
|
|
||||||
|
user.lastName = user.lastName ? user.lastName.trim() : null;
|
||||||
|
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureExternalProviderPassword(user: UserModelInstance): void {
|
||||||
|
if (!isKnownExternalProvider(user.provider)) return;
|
||||||
|
|
||||||
|
user.emailVerified = true;
|
||||||
|
|
||||||
|
if (!user.password) {
|
||||||
|
const password = crypto.randomBytes(20).toString('hex');
|
||||||
|
|
||||||
|
const hashedPassword = bcrypt.hashSync(password, config.bcrypt.saltRounds);
|
||||||
|
|
||||||
|
user.password = hashedPassword;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const defineUsersModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||||
|
const users: UsersSequelizeModel = sequelize.define<UserModelInstance>(
|
||||||
'users',
|
'users',
|
||||||
{
|
{
|
||||||
id: {
|
id: {
|
||||||
@ -115,8 +164,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
through: 'usersCustom_permissionsPermissions',
|
through: 'usersCustom_permissionsPermissions',
|
||||||
});
|
});
|
||||||
|
|
||||||
/// loop through entities and it's fields, and if ref === current e[name] and create relation has many on parent entity
|
|
||||||
|
|
||||||
db.users.hasMany(db.project_memberships, {
|
db.users.hasMany(db.project_memberships, {
|
||||||
as: 'project_memberships_user',
|
as: 'project_memberships_user',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -167,8 +214,6 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
onUpdate: 'CASCADE',
|
onUpdate: 'CASCADE',
|
||||||
});
|
});
|
||||||
|
|
||||||
//end loop
|
|
||||||
|
|
||||||
db.users.belongsTo(db.roles, {
|
db.users.belongsTo(db.roles, {
|
||||||
as: 'app_role',
|
as: 'app_role',
|
||||||
foreignKey: {
|
foreignKey: {
|
||||||
@ -200,41 +245,16 @@ module.exports = function (sequelize, DataTypes) {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
users.beforeCreate((users) => {
|
users.beforeCreate((user: UserModelInstance) => {
|
||||||
users = trimStringFields(users);
|
trimStringFields(user);
|
||||||
|
ensureExternalProviderPassword(user);
|
||||||
if (
|
|
||||||
users.provider !== providers.LOCAL &&
|
|
||||||
Object.values(providers).indexOf(users.provider) > -1
|
|
||||||
) {
|
|
||||||
users.emailVerified = true;
|
|
||||||
|
|
||||||
if (!users.password) {
|
|
||||||
const password = crypto.randomBytes(20).toString('hex');
|
|
||||||
|
|
||||||
const hashedPassword = bcrypt.hashSync(
|
|
||||||
password,
|
|
||||||
config.bcrypt.saltRounds,
|
|
||||||
);
|
|
||||||
|
|
||||||
users.password = hashedPassword;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
users.beforeUpdate((users) => {
|
users.beforeUpdate((user: UserModelInstance) => {
|
||||||
trimStringFields(users);
|
trimStringFields(user);
|
||||||
});
|
});
|
||||||
|
|
||||||
return users;
|
return users;
|
||||||
};
|
};
|
||||||
|
|
||||||
function trimStringFields(users) {
|
export default defineUsersModel;
|
||||||
users.email = users.email.trim();
|
|
||||||
|
|
||||||
users.firstName = users.firstName ? users.firstName.trim() : null;
|
|
||||||
|
|
||||||
users.lastName = users.lastName ? users.lastName.trim() : null;
|
|
||||||
|
|
||||||
return users;
|
|
||||||
}
|
|
||||||
@ -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 = {
|
import type {
|
||||||
/**
|
RbacSeedExistingNamedIdRow,
|
||||||
* @param{import("sequelize").QueryInterface} queryInterface
|
RbacSeedExistingRolePermissionRow,
|
||||||
* @return {Promise<void>}
|
RbacSeedPermissionRow,
|
||||||
*/
|
RbacSeedRoleDefinition,
|
||||||
async up(queryInterface) {
|
RbacSeedRolePermissionRow,
|
||||||
|
RbacSeedRoleRow,
|
||||||
|
SequelizeSeeder,
|
||||||
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
|
function toRolePermissionKey(row: {
|
||||||
|
roles_permissionsId: string;
|
||||||
|
permissionId: string;
|
||||||
|
}): string {
|
||||||
|
return `${row.roles_permissionsId}:${row.permissionId}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userRolesSeeder: SequelizeSeeder = {
|
||||||
|
async up(queryInterface: QueryInterface) {
|
||||||
const createdAt = new Date();
|
const createdAt = new Date();
|
||||||
const updatedAt = new Date();
|
const updatedAt = new Date();
|
||||||
|
|
||||||
/** @type {Map<string, string>} */
|
const idMap = new Map<string, string>();
|
||||||
const idMap = new Map();
|
|
||||||
|
|
||||||
/**
|
function getId(key: string): string {
|
||||||
* @param {string} key
|
const existingId = idMap.get(key);
|
||||||
* @return {string}
|
if (existingId) {
|
||||||
*/
|
return existingId;
|
||||||
function getId(key) {
|
|
||||||
if (idMap.has(key)) {
|
|
||||||
return idMap.get(key);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const id = uuid();
|
const id = uuid();
|
||||||
idMap.set(key, id);
|
idMap.set(key, id);
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
await queryInterface.bulkInsert('roles', [
|
const roleDefinitions: RbacSeedRoleDefinition[] = [
|
||||||
{
|
{ key: 'Administrator', name: 'Administrator' },
|
||||||
id: getId('Administrator'),
|
{ key: 'PlatformOwner', name: 'Platform Owner' },
|
||||||
name: 'Administrator',
|
{ key: 'AccountManager', name: 'Account Manager' },
|
||||||
createdAt,
|
{ key: 'TourDesigner', name: 'Tour Designer' },
|
||||||
updatedAt,
|
{ key: 'ContentReviewer', name: 'Content Reviewer' },
|
||||||
},
|
{ key: 'AnalyticsViewer', name: 'Analytics Viewer' },
|
||||||
|
{ key: 'Public', name: 'Public' },
|
||||||
|
];
|
||||||
|
const roleKeyByName = new Map(
|
||||||
|
roleDefinitions.map((role) => [role.name, role.key]),
|
||||||
|
);
|
||||||
|
const existingRoles =
|
||||||
|
await queryInterface.sequelize.query<RbacSeedExistingNamedIdRow>(
|
||||||
|
`SELECT DISTINCT ON ("name") "id", "name"
|
||||||
|
FROM "roles"
|
||||||
|
WHERE "name" IN (:names)
|
||||||
|
ORDER BY "name", "createdAt" ASC`,
|
||||||
|
{
|
||||||
|
replacements: {
|
||||||
|
names: roleDefinitions.map((role) => role.name),
|
||||||
|
},
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
{
|
for (const role of existingRoles) {
|
||||||
id: getId('PlatformOwner'),
|
const key = roleKeyByName.get(role.name);
|
||||||
name: 'Platform Owner',
|
if (key) {
|
||||||
createdAt,
|
idMap.set(key, role.id);
|
||||||
updatedAt,
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
{
|
const existingRoleNames = new Set(existingRoles.map((role) => role.name));
|
||||||
id: getId('AccountManager'),
|
const roleRows: RbacSeedRoleRow[] = roleDefinitions.map((role) => ({
|
||||||
name: 'Account Manager',
|
id: getId(role.key),
|
||||||
createdAt,
|
name: role.name,
|
||||||
updatedAt,
|
createdAt,
|
||||||
},
|
updatedAt,
|
||||||
|
}));
|
||||||
|
const rolesToInsert = roleRows.filter(
|
||||||
|
(role) => !existingRoleNames.has(role.name),
|
||||||
|
);
|
||||||
|
|
||||||
{
|
if (rolesToInsert.length > 0) {
|
||||||
id: getId('TourDesigner'),
|
await queryInterface.bulkInsert('roles', rolesToInsert);
|
||||||
name: 'Tour Designer',
|
}
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
function createPermissions(name: string): RbacSeedPermissionRow[] {
|
||||||
id: getId('ContentReviewer'),
|
|
||||||
name: 'Content Reviewer',
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
id: getId('AnalyticsViewer'),
|
|
||||||
name: 'Analytics Viewer',
|
|
||||||
createdAt,
|
|
||||||
updatedAt,
|
|
||||||
},
|
|
||||||
|
|
||||||
{ id: getId('Public'), name: 'Public', createdAt, updatedAt },
|
|
||||||
]);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @param {string} name
|
|
||||||
*/
|
|
||||||
function createPermissions(name) {
|
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
id: getId(`CREATE_${name.toUpperCase()}`),
|
id: getId(`CREATE_${name.toUpperCase()}`),
|
||||||
@ -118,26 +129,49 @@ module.exports = {
|
|||||||
'pwa_caches',
|
'pwa_caches',
|
||||||
'access_logs',
|
'access_logs',
|
||||||
];
|
];
|
||||||
await queryInterface.bulkInsert(
|
const permissionRows: RbacSeedPermissionRow[] = [
|
||||||
'permissions',
|
...entities.flatMap(createPermissions),
|
||||||
entities.flatMap(createPermissions),
|
|
||||||
);
|
|
||||||
await queryInterface.bulkInsert('permissions', [
|
|
||||||
{
|
{
|
||||||
id: getId(`READ_API_DOCS`),
|
id: getId(`READ_API_DOCS`),
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
name: `READ_API_DOCS`,
|
name: `READ_API_DOCS`,
|
||||||
},
|
},
|
||||||
]);
|
|
||||||
await queryInterface.bulkInsert('permissions', [
|
|
||||||
{
|
{
|
||||||
id: getId(`CREATE_SEARCH`),
|
id: getId(`CREATE_SEARCH`),
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
name: `CREATE_SEARCH`,
|
name: `CREATE_SEARCH`,
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
const existingPermissions =
|
||||||
|
await queryInterface.sequelize.query<RbacSeedExistingNamedIdRow>(
|
||||||
|
`SELECT DISTINCT ON ("name") "id", "name"
|
||||||
|
FROM "permissions"
|
||||||
|
WHERE "name" IN (:names)
|
||||||
|
ORDER BY "name", "createdAt" ASC`,
|
||||||
|
{
|
||||||
|
replacements: {
|
||||||
|
names: permissionRows.map((permission) => permission.name),
|
||||||
|
},
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const permission of existingPermissions) {
|
||||||
|
idMap.set(permission.name, permission.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingPermissionNames = new Set(
|
||||||
|
existingPermissions.map((permission) => permission.name),
|
||||||
|
);
|
||||||
|
const permissionsToInsert = permissionRows.filter(
|
||||||
|
(permission) => !existingPermissionNames.has(permission.name),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (permissionsToInsert.length > 0) {
|
||||||
|
await queryInterface.bulkInsert('permissions', permissionsToInsert);
|
||||||
|
}
|
||||||
|
|
||||||
await queryInterface.sequelize
|
await queryInterface.sequelize
|
||||||
.query(`CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions"
|
.query(`CREATE TABLE IF NOT EXISTS "rolesPermissionsPermissions"
|
||||||
@ -159,7 +193,7 @@ constraint "rolesPermissionsPermissions_permission_fk"
|
|||||||
'CREATE INDEX IF NOT EXISTS "rolesPermissionsPermissions_permission_idx" ON "rolesPermissionsPermissions" ("permissionId");',
|
'CREATE INDEX IF NOT EXISTS "rolesPermissionsPermissions_permission_idx" ON "rolesPermissionsPermissions" ("permissionId");',
|
||||||
);
|
);
|
||||||
|
|
||||||
await queryInterface.bulkInsert('rolesPermissionsPermissions', [
|
const rolePermissionRows: RbacSeedRolePermissionRow[] = [
|
||||||
{
|
{
|
||||||
createdAt,
|
createdAt,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
@ -1748,7 +1782,29 @@ constraint "rolesPermissionsPermissions_permission_fk"
|
|||||||
roles_permissionsId: getId('Administrator'),
|
roles_permissionsId: getId('Administrator'),
|
||||||
permissionId: getId('CREATE_SEARCH'),
|
permissionId: getId('CREATE_SEARCH'),
|
||||||
},
|
},
|
||||||
]);
|
];
|
||||||
|
|
||||||
|
const existingRolePermissions =
|
||||||
|
await queryInterface.sequelize.query<RbacSeedExistingRolePermissionRow>(
|
||||||
|
`SELECT "roles_permissionsId", "permissionId"
|
||||||
|
FROM "rolesPermissionsPermissions"`,
|
||||||
|
{
|
||||||
|
type: QueryTypes.SELECT,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
const existingRolePermissionKeys = new Set(
|
||||||
|
existingRolePermissions.map(toRolePermissionKey),
|
||||||
|
);
|
||||||
|
const rolePermissionsToInsert = rolePermissionRows.filter(
|
||||||
|
(row) => !existingRolePermissionKeys.has(toRolePermissionKey(row)),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (rolePermissionsToInsert.length > 0) {
|
||||||
|
await queryInterface.bulkInsert(
|
||||||
|
'rolesPermissionsPermissions',
|
||||||
|
rolePermissionsToInsert,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
await queryInterface.sequelize.query(
|
await queryInterface.sequelize.query(
|
||||||
`UPDATE "users" SET "app_roleId"='${getId('SuperAdmin')}' WHERE "email"='super_admin@flatlogic.com'`,
|
`UPDATE "users" SET "app_roleId"='${getId('SuperAdmin')}' WHERE "email"='super_admin@flatlogic.com'`,
|
||||||
@ -1765,3 +1821,5 @@ constraint "rolesPermissionsPermissions_permission_fk"
|
|||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default userRolesSeeder;
|
||||||
@ -1,25 +1,27 @@
|
|||||||
const db = require('../models');
|
import db from '../models/index.ts';
|
||||||
const Users = db.users;
|
import type { SampleDataModel, SequelizeSeeder } from '../../types/index.ts';
|
||||||
|
|
||||||
const Projects = db.projects;
|
const Users: SampleDataModel = db.users;
|
||||||
|
|
||||||
const ProjectMemberships = db.project_memberships;
|
const Projects: SampleDataModel = db.projects;
|
||||||
|
|
||||||
const Assets = db.assets;
|
const ProjectMemberships: SampleDataModel = db.project_memberships;
|
||||||
|
|
||||||
const AssetVariants = db.asset_variants;
|
const Assets: SampleDataModel = db.assets;
|
||||||
|
|
||||||
const PresignedUrlRequests = db.presigned_url_requests;
|
const AssetVariants: SampleDataModel = db.asset_variants;
|
||||||
|
|
||||||
const TourPages = db.tour_pages;
|
const PresignedUrlRequests: SampleDataModel = db.presigned_url_requests;
|
||||||
|
|
||||||
const ProjectAudioTracks = db.project_audio_tracks;
|
const TourPages: SampleDataModel = db.tour_pages;
|
||||||
|
|
||||||
const PublishEvents = db.publish_events;
|
const ProjectAudioTracks: SampleDataModel = db.project_audio_tracks;
|
||||||
|
|
||||||
const PwaCaches = db.pwa_caches;
|
const PublishEvents: SampleDataModel = db.publish_events;
|
||||||
|
|
||||||
const AccessLogs = db.access_logs;
|
const PwaCaches: SampleDataModel = db.pwa_caches;
|
||||||
|
|
||||||
|
const AccessLogs: SampleDataModel = db.access_logs;
|
||||||
|
|
||||||
const ProjectsData = [
|
const ProjectsData = [
|
||||||
{
|
{
|
||||||
@ -1084,7 +1086,7 @@ async function associateAccessLogWithUser() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = {
|
const sampleDataSeeder: SequelizeSeeder = {
|
||||||
up: async () => {
|
up: async () => {
|
||||||
// Keep production-like schema strict; skip auto sample payload inserts by default.
|
// Keep production-like schema strict; skip auto sample payload inserts by default.
|
||||||
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
|
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
|
||||||
@ -1114,31 +1116,31 @@ module.exports = {
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
// Similar logic for "relation_many"
|
// Similar logic for "relation_many"
|
||||||
|
|
||||||
await associateProjectMembershipWithProject(),
|
associateProjectMembershipWithProject(),
|
||||||
|
|
||||||
await associateProjectMembershipWithUser(),
|
associateProjectMembershipWithUser(),
|
||||||
|
|
||||||
await associateAssetWithProject(),
|
associateAssetWithProject(),
|
||||||
|
|
||||||
await associateAssetVariantWithAsset(),
|
associateAssetVariantWithAsset(),
|
||||||
|
|
||||||
await associatePresignedUrlRequestWithProject(),
|
associatePresignedUrlRequestWithProject(),
|
||||||
|
|
||||||
await associatePresignedUrlRequestWithUser(),
|
associatePresignedUrlRequestWithUser(),
|
||||||
|
|
||||||
await associateTourPageWithProject(),
|
associateTourPageWithProject(),
|
||||||
|
|
||||||
await associateProjectAudioTrackWithProject(),
|
associateProjectAudioTrackWithProject(),
|
||||||
|
|
||||||
await associatePublishEventWithProject(),
|
associatePublishEventWithProject(),
|
||||||
|
|
||||||
await associatePublishEventWithUser(),
|
associatePublishEventWithUser(),
|
||||||
|
|
||||||
await associatePwaCacheWithProject(),
|
associatePwaCacheWithProject(),
|
||||||
|
|
||||||
await associateAccessLogWithProject(),
|
associateAccessLogWithProject(),
|
||||||
|
|
||||||
await associateAccessLogWithUser(),
|
associateAccessLogWithUser(),
|
||||||
]);
|
]);
|
||||||
},
|
},
|
||||||
|
|
||||||
@ -1147,24 +1149,26 @@ module.exports = {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await queryInterface.bulkDelete('projects', null, {});
|
await queryInterface.bulkDelete('projects', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('project_memberships', null, {});
|
await queryInterface.bulkDelete('project_memberships', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('assets', null, {});
|
await queryInterface.bulkDelete('assets', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('asset_variants', null, {});
|
await queryInterface.bulkDelete('asset_variants', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('presigned_url_requests', null, {});
|
await queryInterface.bulkDelete('presigned_url_requests', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('tour_pages', null, {});
|
await queryInterface.bulkDelete('tour_pages', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('project_audio_tracks', null, {});
|
await queryInterface.bulkDelete('project_audio_tracks', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('publish_events', null, {});
|
await queryInterface.bulkDelete('publish_events', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('pwa_caches', null, {});
|
await queryInterface.bulkDelete('pwa_caches', {}, {});
|
||||||
|
|
||||||
await queryInterface.bulkDelete('access_logs', null, {});
|
await queryInterface.bulkDelete('access_logs', {}, {});
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export default sampleDataSeeder;
|
||||||
@ -1,9 +1,9 @@
|
|||||||
const db = require('./models');
|
import db from './models/index.ts';
|
||||||
|
|
||||||
async function syncDatabase() {
|
async function syncDatabase(): Promise<void> {
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
console.error(
|
console.error(
|
||||||
'ERROR: sync.js should not be run in production. Use migrations instead.',
|
'ERROR: sync.ts should not be run in production. Use migrations instead.',
|
||||||
);
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
@ -19,4 +19,4 @@ async function syncDatabase() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
syncDatabase();
|
void syncDatabase();
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user