fixed fullscreen issue and big videos errors persistance

This commit is contained in:
Dmitri 2026-07-09 17:54:16 +02:00
parent 9c12c3f539
commit 76871f4542
119 changed files with 4831 additions and 3254 deletions

File diff suppressed because it is too large Load Diff

View File

@ -213,17 +213,35 @@ Base class with configurable hooks for entity-specific behavior:
```javascript ```javascript
class AssetsDBApi extends GenericDBApi { class AssetsDBApi extends GenericDBApi {
// Required: Define the Sequelize model // Required: Define the Sequelize model
static get MODEL() { return db.assets; } static get MODEL() {
return db.assets;
}
// Configurable behavior via static getters // Configurable behavior via static getters
static get SEARCHABLE_FIELDS() { return ['name', 'cdn_url']; } static get SEARCHABLE_FIELDS() {
static get RANGE_FIELDS() { return ['size_mb', 'width_px']; } return ['name', 'cdn_url'];
static get ENUM_FIELDS() { return ['asset_type', 'is_public']; } }
static get JSON_FIELDS() { return ['settings_json']; } static get RANGE_FIELDS() {
static get FIELD_DEFAULTS() { return { type: { default: 'general' } }; } return ['size_mb', 'width_px'];
static get ASSOCIATIONS() { return [{ field: 'project', setter: 'setProject' }]; } }
static get FIND_BY_INCLUDES() { return [{ association: 'project' }]; } static get ENUM_FIELDS() {
static get FIND_ALL_INCLUDES() { return [{ model: db.projects, as: 'project' }]; } return ['asset_type', 'is_public'];
}
static get JSON_FIELDS() {
return ['settings_json'];
}
static get FIELD_DEFAULTS() {
return { type: { default: 'general' } };
}
static get ASSOCIATIONS() {
return [{ field: 'project', setter: 'setProject' }];
}
static get FIND_BY_INCLUDES() {
return [{ association: 'project' }];
}
static get FIND_ALL_INCLUDES() {
return [{ model: db.projects, as: 'project' }];
}
// Custom field transformation // Custom field transformation
static getFieldMapping(data) { static getFieldMapping(data) {
@ -253,6 +271,7 @@ BaseStorageProvider (abstract)
The storage provider base, S3 provider, and local provider are migrated TS/ESM modules. The S3 implementation uses official AWS SDK v3 types; shared provider-domain contracts are in `src/types/file.ts`. The storage provider base, S3 provider, and local provider are migrated TS/ESM modules. The S3 implementation uses official AWS SDK v3 types; shared provider-domain contracts are in `src/types/file.ts`.
Interface: Interface:
- `upload(key, data, options)``{ key, url }` - `upload(key, data, options)``{ key, url }`
- `download(key)``{ body, contentType }` - `download(key)``{ body, contentType }`
- `delete(key)``void` - `delete(key)``void`
@ -292,16 +311,17 @@ Application bootstrap:
```javascript ```javascript
// Key route mounting patterns // Key route mounting patterns
app.use('/api/auth', authRoutes); // No JWT required app.use('/api/auth', authRoutes); // No JWT required
app.use('/api/users', jwtAuth, usersRoutes); // JWT required app.use('/api/users', jwtAuth, usersRoutes); // JWT required
// Runtime public routes (production content accessible without auth) // Runtime public routes (production content accessible without auth)
const mountRuntimeEntityRoute = (path, entityName, router) => { const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use(path, app.use(
requireRuntimeReadOrAuth, // JWT or public production path,
requireRuntimeReadOrAuth, // JWT or public production
blockNonPublicRuntimeListEndpoints, // Block non-list endpoints blockNonPublicRuntimeListEndpoints, // Block non-list endpoints
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
router router,
); );
}; };
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes); mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
@ -312,27 +332,27 @@ mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
**Factory-Generated Routes** provide standard CRUD: **Factory-Generated Routes** provide standard CRUD:
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | ------ | --------------- | ------------------------------ |
| POST | `/` | Create record | | POST | `/` | Create record |
| POST | `/bulk-import` | Bulk import from CSV | | POST | `/bulk-import` | Bulk import from CSV |
| PUT | `/:id` | Update record | | PUT | `/:id` | Update record |
| DELETE | `/:id` | Delete record | | DELETE | `/:id` | Delete record |
| POST | `/deleteByIds` | Bulk delete | | POST | `/deleteByIds` | Bulk delete |
| GET | `/` | List with pagination & filters | | GET | `/` | List with pagination & filters |
| GET | `/count` | Count only | | GET | `/count` | Count only |
| GET | `/autocomplete` | Autocomplete search | | GET | `/autocomplete` | Autocomplete search |
| GET | `/:id` | Get single record | | GET | `/:id` | Get single record |
**Custom Routes** (auth, file, publish, search, runtime-context): **Custom Routes** (auth, file, publish, search, runtime-context):
| Route | Endpoints | | Route | Endpoints |
|-------|-----------| | ---------------------- | ------------------------------------------------------------------------ |
| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth | | `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth |
| `/api/file` | upload, download, presign, upload-sessions (chunked) | | `/api/file` | upload, download, presign, upload-sessions (chunked) |
| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) | | `/api/publish` | publish (stage→production), save-to-stage (dev→stage) |
| `/api/search` | Global full-text search | | `/api/search` | Global full-text search |
| `/api/runtime-context` | Runtime environment detection | | `/api/runtime-context` | Runtime environment detection |
### Service Layer ### Service Layer
@ -356,6 +376,7 @@ static async create({ data, currentUser, transaction: externalTransaction, runti
**Publish Service** (`services/publish.ts`): **Publish Service** (`services/publish.ts`):
Implements the dev→stage→production workflow with: Implements the dev→stage→production workflow with:
- Transaction locking to prevent concurrent publishes - Transaction locking to prevent concurrent publishes
- Source key tracking for content lineage - Source key tracking for content lineage
- Bulk copy operations for pages and audio tracks - Bulk copy operations for pages and audio tracks
@ -364,14 +385,14 @@ Implements the dev→stage→production workflow with:
**Query Building** in `findAll()`: **Query Building** in `findAll()`:
| Filter Type | Example | SQL | | Filter Type | Example | SQL |
|-------------|---------|-----| | ----------- | ----------------------- | --------------------------------- |
| Text search | `?name=foo` | `name ILIKE '%foo%'` | | Text search | `?name=foo` | `name ILIKE '%foo%'` |
| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` | | Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` |
| Enum | `?asset_type=image` | `asset_type = 'image'` | | Enum | `?asset_type=image` | `asset_type = 'image'` |
| Relation | `?project=uuid` | JOIN with projects table | | Relation | `?project=uuid` | JOIN with projects table |
| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` | | Sort | `?field=name&sort=asc` | `ORDER BY name ASC` |
| Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` | | Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` |
--- ---
@ -402,6 +423,7 @@ Implements the dev→stage→production workflow with:
4. Fallback to Public role for unauthenticated 4. Fallback to Public role for unauthenticated
**Permission Naming Convention**: **Permission Naming Convention**:
- `CREATE_<ENTITY>` - Create records - `CREATE_<ENTITY>` - Create records
- `READ_<ENTITY>` - Read records - `READ_<ENTITY>` - Read records
- `UPDATE_<ENTITY>` - Modify records - `UPDATE_<ENTITY>` - Modify records
@ -420,15 +442,16 @@ For production content accessible without authentication:
```javascript ```javascript
const requireRuntimeReadOrAuth = (req, res, next) => { const requireRuntimeReadOrAuth = (req, res, next) => {
const isPublicEnvironment = req.runtimeContext?.headerEnvironment === 'production'; const isPublicEnvironment =
req.runtimeContext?.headerEnvironment === 'production';
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method); const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
req.isRuntimePublicRequest = true; req.isRuntimePublicRequest = true;
return next(); // Allow without JWT return next(); // Allow without JWT
} }
return jwtAuth(req, res, next); // Require JWT return jwtAuth(req, res, next); // Require JWT
}; };
``` ```
@ -438,16 +461,17 @@ const requireRuntimeReadOrAuth = (req, res, next) => {
Pre-configured limiters (`middlewares/rateLimiter.ts`): Pre-configured limiters (`middlewares/rateLimiter.ts`):
| Limiter | Window | Max Requests | Use Case | | Limiter | Window | Max Requests | Use Case |
|---------|--------|--------------|----------| | ---------------------- | ------ | ------------ | ------------------------ |
| `authLimiter` | 15 min | 10 | Authentication endpoints | | `authLimiter` | 15 min | 10 | Authentication endpoints |
| `passwordResetLimiter` | 1 hour | 5 | Password reset | | `passwordResetLimiter` | 1 hour | 5 | Password reset |
| `apiLimiter` | 1 min | 100 | General API | | `apiLimiter` | 1 min | 100 | General API |
| `uploadLimiter` | 1 min | 10 | File uploads | | `uploadLimiter` | 1 min | 10 | File uploads |
| `downloadLimiter` | 1 min | 200 | File downloads | | `downloadLimiter` | 1 min | 200 | File downloads |
| `searchLimiter` | 1 min | 30 | Search queries | | `searchLimiter` | 1 min | 30 | Search queries |
Headers returned: Headers returned:
- `X-RateLimit-Limit`: Maximum requests - `X-RateLimit-Limit`: Maximum requests
- `X-RateLimit-Remaining`: Remaining requests - `X-RateLimit-Remaining`: Remaining requests
- `X-RateLimit-Reset`: Reset time (ISO timestamp) - `X-RateLimit-Reset`: Reset time (ISO timestamp)
@ -460,24 +484,26 @@ Headers returned:
**Storage Provider Selection**: **Storage Provider Selection**:
```javascript ```javascript
const provider = config.fileStorage.provider || const provider =
config.fileStorage.provider ||
(hasS3Credentials ? 's3' : hasGCloudCredentials ? 'gcloud' : 'local'); (hasS3Credentials ? 's3' : hasGCloudCredentials ? 'gcloud' : 'local');
``` ```
**S3 Operations**: **S3 Operations**:
| Operation | Method | Description | | Operation | Method | Description |
|-----------|--------|-------------| | --------- | ---------------------------------- | ------------------------ |
| Upload | `upload(key, data, options)` | Put object with metadata | | Upload | `upload(key, data, options)` | Put object with metadata |
| Download | `download(key)` | Get object stream | | Download | `download(key)` | Get object stream |
| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL | | Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL |
| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects | | Delete | `delete(key)` / `deleteMany(keys)` | Remove objects |
| Check | `exists(key)` | Head object | | Check | `exists(key)` | Head object |
| List | `list(prefix)` | List objects with prefix | | List | `list(prefix)` | List objects with prefix |
**Chunked Uploads** (`UploadSessionManager`): **Chunked Uploads** (`UploadSessionManager`):
For large files, supports multipart upload sessions: For large files, supports multipart upload sessions:
1. `POST /upload-sessions/init` - Create session 1. `POST /upload-sessions/init` - Create session
2. `POST /upload-sessions/:id/chunk` - Upload chunk 2. `POST /upload-sessions/:id/chunk` - Upload chunk
3. `POST /upload-sessions/:id/finalize` - Complete upload 3. `POST /upload-sessions/:id/finalize` - Complete upload
@ -498,11 +524,21 @@ class AppError extends Error {
} }
} }
class NotFoundError extends AppError { statusCode = 404 } class NotFoundError extends AppError {
class ValidationError extends AppError { statusCode = 400 } statusCode = 404;
class ForbiddenError extends AppError { statusCode = 403 } }
class UnauthorizedError extends AppError { statusCode = 401 } class ValidationError extends AppError {
class ConflictError extends AppError { statusCode = 409 } statusCode = 400;
}
class ForbiddenError extends AppError {
statusCode = 403;
}
class UnauthorizedError extends AppError {
statusCode = 401;
}
class ConflictError extends AppError {
statusCode = 409;
}
``` ```
**Async Handler** (`helpers.ts`): **Async Handler** (`helpers.ts`):
@ -563,12 +599,15 @@ function requestLogger(req, res, next) {
res.setHeader('X-Request-Id', requestId); res.setHeader('X-Request-Id', requestId);
res.on('finish', () => { res.on('finish', () => {
req.log.info({ req.log.info(
method: req.method, {
url: req.originalUrl, method: req.method,
status: res.statusCode, url: req.originalUrl,
duration: Date.now() - start, status: res.statusCode,
}, 'Request completed'); duration: Date.now() - start,
},
'Request completed',
);
}); });
} }
``` ```
@ -579,30 +618,30 @@ function requestLogger(req, res, next) {
**Environment Variables** (`config.ts`): **Environment Variables** (`config.ts`):
| Variable | Description | Default | | Variable | Description | Default |
|----------|-------------|---------| | ----------------------- | ----------------------------- | --------------------- |
| `SECRET_KEY` | JWT signing key | UUID-based default | | `SECRET_KEY` | JWT signing key | UUID-based default |
| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` | | `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` |
| `ADMIN_PASS` | Admin user password | Generated | | `ADMIN_PASS` | Admin user password | Generated |
| `AWS_S3_BUCKET` | S3 bucket name | - | | `AWS_S3_BUCKET` | S3 bucket name | - |
| `AWS_S3_REGION` | S3 region | `us-east-1` | | `AWS_S3_REGION` | S3 region | `us-east-1` |
| `AWS_ACCESS_KEY_ID` | AWS access key | - | | `AWS_ACCESS_KEY_ID` | AWS access key | - |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - | | `AWS_SECRET_ACCESS_KEY` | AWS secret key | - |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - | | `GOOGLE_CLIENT_ID` | Google OAuth client ID | - |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - |
| `MS_CLIENT_ID` | Microsoft OAuth client ID | - | | `MS_CLIENT_ID` | Microsoft OAuth client ID | - |
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - | | `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - |
| `EMAIL_USER` | SMTP username | - | | `EMAIL_USER` | SMTP username | - |
| `EMAIL_PASS` | SMTP password | - | | `EMAIL_PASS` | SMTP password | - |
| `LOG_LEVEL` | Logging level | `info` | | `LOG_LEVEL` | Logging level | `info` |
**Database Configuration** (`db/db-config.ts`): **Database Configuration** (`db/db-config.ts`):
| Environment | Database | Logging | | Environment | Database | Logging |
|-------------|----------|---------| | ------------- | -------------------------- | -------- |
| `production` | `DB_*` env vars | Disabled | | `production` | `DB_*` env vars | Disabled |
| `development` | `db_tour_builder_platform` | Console | | `development` | `db_tour_builder_platform` | Console |
| `dev_stage` | `DB_*` env vars | Console | | `dev_stage` | `DB_*` env vars | Console |
--- ---
@ -647,23 +686,23 @@ GET /api/health
## Key Implementation Files ## Key Implementation Files
| File | Purpose | | File | Purpose |
|------|---------| | ---------------------------------------- | --------------------------------------------------------- |
| `src/index.ts` | Application entry, middleware setup, route mounting | | `src/index.ts` | Application entry, middleware setup, route mounting |
| `src/config.ts` | Environment configuration | | `src/config.ts` | Environment configuration |
| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 | | `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 |
| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) | | `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) |
| `src/factories/router.factory.ts` | Route generator for entities | | `src/factories/router.factory.ts` | Route generator for entities |
| `src/factories/service.factory.ts` | Service generator for entities | | `src/factories/service.factory.ts` | Service generator for entities |
| `src/db/api/base.api.ts` | GenericDBApi base class | | `src/db/api/base.api.ts` | GenericDBApi base class |
| `src/middlewares/check-permissions.ts` | RBAC permission checking | | `src/middlewares/check-permissions.ts` | RBAC permission checking |
| `src/middlewares/rateLimiter.ts` | Rate limiting configuration | | `src/middlewares/rateLimiter.ts` | Rate limiting configuration |
| `src/middlewares/runtime-context.ts` | Runtime environment detection | | `src/middlewares/runtime-context.ts` | Runtime environment detection |
| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization | | `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization |
| `src/services/publish.ts` | Publishing workflow service | | `src/services/publish.ts` | Publishing workflow service |
| `src/services/file/S3StorageProvider.ts` | S3 storage implementation using official AWS SDK v3 types | | `src/services/file/S3StorageProvider.ts` | S3 storage implementation using official AWS SDK v3 types |
| `src/utils/logger.ts` | Pino logger configuration | | `src/utils/logger.ts` | Pino logger configuration |
| `src/utils/errors.ts` | Error class definitions | | `src/utils/errors.ts` | Error class definitions |
--- ---

File diff suppressed because it is too large Load Diff

View File

@ -5,14 +5,15 @@
The Auth module provides comprehensive authentication and authorization for the application. It supports local email/password authentication, OAuth 2.0 (Google, Microsoft), JWT-based session management, email verification, and password reset flows. The Auth module provides comprehensive authentication and authorization for the application. It supports local email/password authentication, OAuth 2.0 (Google, Microsoft), JWT-based session management, email verification, and password reset flows.
**Files:** **Files:**
| File | Purpose |
|------|---------| | File | Purpose |
| `src/auth/auth.ts` | Passport.js strategy configurations (JWT, Google, Microsoft) | | -------------------------------- | ----------------------------------------------------------------------- |
| `src/services/auth.ts` | Auth business logic (signin, password reset/update, email verification) | | `src/auth/auth.ts` | Passport.js strategy configurations (JWT, Google, Microsoft) |
| `src/routes/auth.ts` | REST API endpoints for authentication | | `src/services/auth.ts` | Auth business logic (signin, password reset/update, email verification) |
| `src/helpers.ts` | JWT signing utility (`jwtSign`) | | `src/routes/auth.ts` | REST API endpoints for authentication |
| `src/db/api/users.js` | User database operations (tokens, password updates) | | `src/helpers.ts` | JWT signing utility (`jwtSign`) |
| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters | | `src/db/api/users.js` | User database operations (tokens, password updates) |
| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters |
--- ---
@ -103,26 +104,31 @@ The Auth module provides comprehensive authentication and authorization for the
Used for API authentication on all protected routes. Used for API authentication on all protected routes.
**Configuration (auth/auth.ts):** **Configuration (auth/auth.ts):**
```javascript ```javascript
passport.use( passport.use(
new JWTstrategy({ new JWTstrategy(
passReqToCallback: true, {
secretOrKey: config.secret_key, passReqToCallback: true,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(), secretOrKey: config.secret_key,
}, async (req, token, done) => { jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
const user = await UsersDBApi.findBy({ email: token.user.email }); },
async (req, token, done) => {
const user = await UsersDBApi.findBy({ email: token.user.email });
if (user && user.disabled) { if (user && user.disabled) {
return done(new Error(`User '${user.email}' is disabled`)); return done(new Error(`User '${user.email}' is disabled`));
} }
req.currentUser = user; req.currentUser = user;
return done(null, user); return done(null, user);
}) },
),
); );
``` ```
**Token Structure:** **Token Structure:**
```javascript ```javascript
{ {
user: { user: {
@ -135,6 +141,7 @@ passport.use(
``` ```
**Usage:** **Usage:**
```javascript ```javascript
// Protect route with JWT // Protect route with JWT
router.get('/me', passport.authenticate('jwt', { session: false }), handler); router.get('/me', passport.authenticate('jwt', { session: false }), handler);
@ -146,23 +153,28 @@ const currentUser = req.currentUser;
### 2. Google OAuth Strategy ### 2. Google OAuth Strategy
**Configuration (auth/auth.ts):** **Configuration (auth/auth.ts):**
```javascript ```javascript
passport.use( passport.use(
new GoogleStrategy({ new GoogleStrategy(
clientID: config.google.clientId, {
clientSecret: config.google.clientSecret, clientID: config.google.clientId,
callbackURL: config.apiUrl + '/auth/signin/google/callback', clientSecret: config.google.clientSecret,
passReqToCallback: true, callbackURL: config.apiUrl + '/auth/signin/google/callback',
}, (request, accessToken, refreshToken, profile, done) => { passReqToCallback: true,
socialStrategy(profile.email, profile, providers.GOOGLE, done); },
}) (request, accessToken, refreshToken, profile, done) => {
socialStrategy(profile.email, profile, providers.GOOGLE, done);
},
),
); );
``` ```
**Environment Variables:** **Environment Variables:**
| Variable | Description |
|----------|-------------| | Variable | Description |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | | ---------------------- | -------------------------- |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | | `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
**OAuth Scopes:** `profile`, `email` **OAuth Scopes:** `profile`, `email`
@ -170,24 +182,29 @@ passport.use(
### 3. Microsoft OAuth Strategy ### 3. Microsoft OAuth Strategy
**Configuration (auth/auth.ts):** **Configuration (auth/auth.ts):**
```javascript ```javascript
passport.use( passport.use(
new MicrosoftStrategy({ new MicrosoftStrategy(
clientID: config.microsoft.clientId, {
clientSecret: config.microsoft.clientSecret, clientID: config.microsoft.clientId,
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback', clientSecret: config.microsoft.clientSecret,
passReqToCallback: true, callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
}, (request, accessToken, refreshToken, profile, done) => { passReqToCallback: true,
const email = profile._json.mail || profile._json.userPrincipalName; },
socialStrategy(email, profile, providers.MICROSOFT, done); (request, accessToken, refreshToken, profile, done) => {
}) const email = profile._json.mail || profile._json.userPrincipalName;
socialStrategy(email, profile, providers.MICROSOFT, done);
},
),
); );
``` ```
**Environment Variables:** **Environment Variables:**
| Variable | Description |
|----------|-------------| | Variable | Description |
| `MS_CLIENT_ID` | Microsoft OAuth client ID | | ------------------ | ----------------------------- |
| `MS_CLIENT_ID` | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | | `MS_CLIENT_SECRET` | Microsoft OAuth client secret |
**OAuth Scopes:** `https://graph.microsoft.com/user.read`, `openid` **OAuth Scopes:** `https://graph.microsoft.com/user.read`, `openid`
@ -222,13 +239,13 @@ Core authentication business logic.
```typescript ```typescript
class Auth { class Auth {
static async signin(email, password) static async signin(email, password);
static async verifyEmail(token, options) static async verifyEmail(token, options);
static async passwordUpdate(currentPassword, newPassword, options) static async passwordUpdate(currentPassword, newPassword, options);
static async passwordReset(token, password, options) static async passwordReset(token, password, options);
static async sendEmailAddressVerificationEmail(email, host) static async sendEmailAddressVerificationEmail(email, host);
static async sendPasswordResetEmail(email, type, host) static async sendPasswordResetEmail(email, type, host);
static async updateProfile(data, currentUser) static async updateProfile(data, currentUser);
} }
``` ```
@ -237,6 +254,7 @@ class Auth {
Registers a new user or updates password for existing unverified user. Registers a new user or updates password for existing unverified user.
**Flow:** **Flow:**
``` ```
1. Check if user exists by email 1. Check if user exists by email
├── User exists with authenticationUid → Error: emailAlreadyInUse ├── User exists with authenticationUid → Error: emailAlreadyInUse
@ -257,6 +275,7 @@ Registers a new user or updates password for existing unverified user.
Authenticates user with email and password. Authenticates user with email and password.
**Flow:** **Flow:**
``` ```
1. Find user by email 1. Find user by email
└── Not found → Error: userNotFound └── Not found → Error: userNotFound
@ -278,6 +297,7 @@ Authenticates user with email and password.
Verifies user email address using token. Verifies user email address using token.
**Flow:** **Flow:**
``` ```
1. Find user by email verification token 1. Find user by email verification token
└── Not found or expired → Error: invalidToken └── Not found or expired → Error: invalidToken
@ -290,6 +310,7 @@ Verifies user email address using token.
Updates password for authenticated user. Updates password for authenticated user.
**Flow:** **Flow:**
``` ```
1. Verify currentUser exists 1. Verify currentUser exists
└── Not authenticated → ForbiddenError └── Not authenticated → ForbiddenError
@ -305,6 +326,7 @@ Updates password for authenticated user.
Resets password using reset token. Resets password using reset token.
**Flow:** **Flow:**
``` ```
1. Find user by password reset token 1. Find user by password reset token
└── Not found or expired → Error: invalidToken └── Not found or expired → Error: invalidToken
@ -320,27 +342,28 @@ REST API endpoints for authentication.
#### Endpoints Overview #### Endpoints Overview
| Method | Path | Auth | Rate Limit | Description | | Method | Path | Auth | Rate Limit | Description |
|--------|------|------|------------|-------------| | ------ | ---------------------------------------- | ---- | -------------------- | ---------------------------- |
| POST | `/signin/local` | No | authLimiter | Login with email/password | | POST | `/signin/local` | No | authLimiter | Login with email/password |
| GET | `/me` | JWT | - | Get current user | | GET | `/me` | JWT | - | Get current user |
| PUT | `/password-reset` | No | - | Reset password with token | | PUT | `/password-reset` | No | - | Reset password with token |
| PUT | `/password-update` | JWT | - | Change password | | PUT | `/password-update` | JWT | - | Change password |
| PUT | `/profile` | JWT | - | Update user profile | | PUT | `/profile` | JWT | - | Update user profile |
| PUT | `/verify-email` | No | - | Verify email with token | | PUT | `/verify-email` | No | - | Verify email with token |
| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email | | POST | `/send-email-address-verification-email` | JWT | - | Resend verification email |
| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email | | POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email |
| GET | `/email-configured` | No | - | Check if email is configured | | GET | `/email-configured` | No | - | Check if email is configured |
| GET | `/signin/google` | No | - | Initiate Google OAuth | | GET | `/signin/google` | No | - | Initiate Google OAuth |
| GET | `/signin/google/callback` | No | - | Google OAuth callback | | GET | `/signin/google/callback` | No | - | Google OAuth callback |
| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth | | GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth |
| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback | | GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback |
#### POST /api/auth/signin/local #### POST /api/auth/signin/local
Login with email and password. Login with email and password.
**Request:** **Request:**
```json ```json
{ {
"email": "user@example.com", "email": "user@example.com",
@ -349,18 +372,20 @@ Login with email and password.
``` ```
**Response (200):** **Response (200):**
```json ```json
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..." "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
``` ```
**Errors:** **Errors:**
| Code | Message | Cause |
|------|---------|-------| | Code | Message | Cause |
| 400 | `auth.userNotFound` | User doesn't exist | | ---- | ---------------------- | ------------------------ |
| 400 | `auth.userDisabled` | User account is disabled | | 400 | `auth.userNotFound` | User doesn't exist |
| 400 | `auth.wrongPassword` | Invalid password | | 400 | `auth.userDisabled` | User account is disabled |
| 400 | `auth.userNotVerified` | Email not verified | | 400 | `auth.wrongPassword` | Invalid password |
| 429 | Too Many Requests | Rate limit exceeded | | 400 | `auth.userNotVerified` | Email not verified |
| 429 | Too Many Requests | Rate limit exceeded |
#### Self-Registration #### Self-Registration
@ -373,11 +398,13 @@ invitation/setup link.
Get current authenticated user. Get current authenticated user.
**Headers:** **Headers:**
``` ```
Authorization: Bearer <JWT_TOKEN> Authorization: Bearer <JWT_TOKEN>
``` ```
**Response (200):** **Response (200):**
```json ```json
{ {
"id": "uuid", "id": "uuid",
@ -404,6 +431,7 @@ Authorization: Bearer <JWT_TOKEN>
Reset password using token from email. Reset password using token from email.
**Request:** **Request:**
```json ```json
{ {
"token": "abc123...", "token": "abc123...",
@ -412,6 +440,7 @@ Reset password using token from email.
``` ```
**Response (200):** **Response (200):**
```json ```json
{ "success": true } { "success": true }
``` ```
@ -421,11 +450,13 @@ Reset password using token from email.
Change password for authenticated user. Change password for authenticated user.
**Headers:** **Headers:**
``` ```
Authorization: Bearer <JWT_TOKEN> Authorization: Bearer <JWT_TOKEN>
``` ```
**Request:** **Request:**
```json ```json
{ {
"currentPassword": "oldPassword123", "currentPassword": "oldPassword123",
@ -434,22 +465,25 @@ Authorization: Bearer <JWT_TOKEN>
``` ```
**Errors:** **Errors:**
| Code | Message | Cause |
|------|---------|-------| | Code | Message | Cause |
| 400 | `auth.wrongPassword` | Current password incorrect | | ---- | ---------------------------------- | -------------------------- |
| 400 | `auth.passwordUpdate.samePassword` | New password same as old | | 400 | `auth.wrongPassword` | Current password incorrect |
| 403 | Forbidden | Not authenticated | | 400 | `auth.passwordUpdate.samePassword` | New password same as old |
| 403 | Forbidden | Not authenticated |
#### PUT /api/auth/profile #### PUT /api/auth/profile
Update user profile. Update user profile.
**Headers:** **Headers:**
``` ```
Authorization: Bearer <JWT_TOKEN> Authorization: Bearer <JWT_TOKEN>
``` ```
**Request:** **Request:**
```json ```json
{ {
"profile": { "profile": {
@ -463,18 +497,22 @@ Authorization: Bearer <JWT_TOKEN>
#### OAuth Endpoints #### OAuth Endpoints
**GET /api/auth/signin/google** **GET /api/auth/signin/google**
- Redirects to Google OAuth consent screen - Redirects to Google OAuth consent screen
- Query param: `app` (passed as state) - Query param: `app` (passed as state)
**GET /api/auth/signin/google/callback** **GET /api/auth/signin/google/callback**
- Handles Google OAuth callback - Handles Google OAuth callback
- Redirects to: `{uiUrl}/login?token={jwt}` - Redirects to: `{uiUrl}/login?token={jwt}`
**GET /api/auth/signin/microsoft** **GET /api/auth/signin/microsoft**
- Redirects to Microsoft OAuth consent screen - Redirects to Microsoft OAuth consent screen
- Query param: `app` (passed as state) - Query param: `app` (passed as state)
**GET /api/auth/signin/microsoft/callback** **GET /api/auth/signin/microsoft/callback**
- Handles Microsoft OAuth callback - Handles Microsoft OAuth callback
- Redirects to: `{uiUrl}/login?token={jwt}` - Redirects to: `{uiUrl}/login?token={jwt}`
@ -495,11 +533,12 @@ static jwtSign(data) {
``` ```
**Configuration:** **Configuration:**
| Setting | Value | Description |
|---------|-------|-------------| | Setting | Value | Description |
| ---------- | ------------------- | ------------------------- |
| Secret Key | `config.secret_key` | From `SECRET_KEY` env var | | Secret Key | `config.secret_key` | From `SECRET_KEY` env var |
| Expiration | `6h` | Token valid for 6 hours | | Expiration | `6h` | Token valid for 6 hours |
| Algorithm | `HS256` | Default HMAC SHA-256 | | Algorithm | `HS256` | Default HMAC SHA-256 |
--- ---
@ -549,17 +588,19 @@ static async generateEmailVerificationToken(email, options) {
``` ```
**Token Properties:** **Token Properties:**
| Property | Value |
|----------|-------| | Property | Value |
| Length | 40 hex characters | | -------- | ------------------------------- |
| Expiry | 24 hours | | Length | 40 hex characters |
| Storage | `emailVerificationToken` column | | Expiry | 24 hours |
| Storage | `emailVerificationToken` column |
#### Method: generatePasswordResetToken(email) #### Method: generatePasswordResetToken(email)
Generates secure token for password reset. Generates secure token for password reset.
Same implementation as `generateEmailVerificationToken` but stores in: Same implementation as `generateEmailVerificationToken` but stores in:
- `passwordResetToken` - `passwordResetToken`
- `passwordResetTokenExpiresAt` - `passwordResetTokenExpiresAt`
@ -610,11 +651,11 @@ const authLimiter = createRateLimiter({
}); });
``` ```
| Setting | Value | | Setting | Value |
|---------|-------| | ------------ | --------------- |
| Window | 15 minutes | | Window | 15 minutes |
| Max Requests | 10 | | Max Requests | 10 |
| Applied To | `/signin/local` | | Applied To | `/signin/local` |
### Signup Limiter ### Signup Limiter
@ -631,11 +672,11 @@ const passwordResetLimiter = createRateLimiter({
}); });
``` ```
| Setting | Value | | Setting | Value |
|---------|-------| | ------------ | ---------------------------- |
| Window | 1 hour | | Window | 1 hour |
| Max Requests | 5 | | Max Requests | 5 |
| Applied To | `/send-password-reset-email` | | Applied To | `/send-password-reset-email` |
### Rate Limit Response ### Rate Limit Response
@ -648,6 +689,7 @@ const passwordResetLimiter = createRateLimiter({
``` ```
**Headers:** **Headers:**
``` ```
X-RateLimit-Limit: 10 X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0 X-RateLimit-Remaining: 0
@ -668,15 +710,16 @@ bcrypt: {
} }
``` ```
| Setting | Value | Security Impact | | Setting | Value | Security Impact |
|---------|-------|-----------------| | ----------- | -------------- | ------------------- |
| Algorithm | bcrypt | Industry standard | | Algorithm | bcrypt | Industry standard |
| Salt Rounds | 12 | ~200ms hash time | | Salt Rounds | 12 | ~200ms hash time |
| Salt | Auto-generated | Per-password unique | | Salt | Auto-generated | Per-password unique |
### Password Validation ### Password Validation
Passwords are: Passwords are:
1. Hashed before storage (never stored in plain text) 1. Hashed before storage (never stored in plain text)
2. Compared using `bcrypt.compare()` (timing-attack safe) 2. Compared using `bcrypt.compare()` (timing-attack safe)
3. Required for local authentication 3. Required for local authentication
@ -696,6 +739,7 @@ if (EmailSender.isConfigured) {
``` ```
When email is NOT configured: When email is NOT configured:
- Signup succeeds without verification email - Signup succeeds without verification email
- Users are auto-verified on signin - Users are auto-verified on signin
- Password reset emails not sent - Password reset emails not sent
@ -705,6 +749,7 @@ When email is NOT configured:
**Email Class:** `EmailAddressVerificationEmail` **Email Class:** `EmailAddressVerificationEmail`
**Link Format:** **Link Format:**
``` ```
{host}/verify-email?token={token} {host}/verify-email?token={token}
``` ```
@ -712,10 +757,12 @@ When email is NOT configured:
### Password Reset Email ### Password Reset Email
**Email Classes:** **Email Classes:**
- `PasswordResetEmail` - Standard reset - `PasswordResetEmail` - Standard reset
- `InvitationEmail` - New user invitation - `InvitationEmail` - New user invitation
**Link Format:** **Link Format:**
``` ```
{host}/password-reset?token={token} {host}/password-reset?token={token}
``` ```
@ -726,16 +773,16 @@ When email is NOT configured:
### Environment Variables ### Environment Variables
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|----------|----------|---------|-------------| | ---------------------- | -------- | -------------------------------------- | ----------------------------- |
| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret | | `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret |
| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID | | `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret | | `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret |
| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID | | `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret | | `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret |
| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email | | `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email |
| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password | | `ADMIN_PASS` | No | `88dbeaf8` | Default admin password |
| `USER_PASS` | No | `c3baadeda5c6` | Default user password | | `USER_PASS` | No | `c3baadeda5c6` | Default user password |
### config.ts Settings ### config.ts Settings
@ -862,18 +909,18 @@ When email is NOT configured:
## Error Codes ## Error Codes
| Error Key | HTTP Status | Description | | Error Key | HTTP Status | Description |
|-----------|-------------|-------------| | ------------------------------------------------- | ----------- | ------------------------------ |
| `auth.userNotFound` | 400 | User with email doesn't exist | | `auth.userNotFound` | 400 | User with email doesn't exist |
| `auth.userDisabled` | 400 | User account is disabled | | `auth.userDisabled` | 400 | User account is disabled |
| `auth.wrongPassword` | 400 | Password doesn't match | | `auth.wrongPassword` | 400 | Password doesn't match |
| `auth.userNotVerified` | 400 | Email not verified | | `auth.userNotVerified` | 400 | Email not verified |
| `auth.emailAlreadyInUse` | 400 | Email already registered | | `auth.emailAlreadyInUse` | 400 | Email already registered |
| `auth.passwordUpdate.samePassword` | 400 | New password same as current | | `auth.passwordUpdate.samePassword` | 400 | New password same as current |
| `auth.passwordReset.error` | 400 | Token generation failed | | `auth.passwordReset.error` | 400 | Token generation failed |
| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token | | `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token |
| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed | | `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed |
| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token | | `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token |
--- ---
@ -913,18 +960,18 @@ Use the authenticated Users API/UI to create invited users.
## Dependencies ## Dependencies
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| | ------------------------------- | -------- | -------------------------------------------------------------------- |
| `passport` | ^0.6.0 | Authentication middleware | | `passport` | ^0.6.0 | Authentication middleware |
| `passport-jwt` | ^4.0.0 | JWT strategy for Passport | | `passport-jwt` | ^4.0.0 | JWT strategy for Passport |
| `passport-google-oauth2` | ^0.2.0 | Google OAuth strategy | | `passport-google-oauth2` | ^0.2.0 | Google OAuth strategy |
| `passport-microsoft` | ^2.0.0 | Microsoft OAuth strategy | | `passport-microsoft` | ^2.0.0 | Microsoft OAuth strategy |
| `@types/passport-jwt` | ^4.0.1 | Maintained TypeScript definitions for JWT Passport strategy | | `@types/passport-jwt` | ^4.0.1 | Maintained TypeScript definitions for JWT Passport strategy |
| `@types/passport-google-oauth2` | ^0.1.10 | Maintained TypeScript definitions for Google OAuth Passport strategy | | `@types/passport-google-oauth2` | ^0.1.10 | Maintained TypeScript definitions for Google OAuth Passport strategy |
| `@types/passport-microsoft` | ^2.1.1 | Maintained TypeScript definitions for Microsoft Passport strategy | | `@types/passport-microsoft` | ^2.1.1 | Maintained TypeScript definitions for Microsoft Passport strategy |
| `jsonwebtoken` | ^9.0.0 | JWT sign/verify | | `jsonwebtoken` | ^9.0.0 | JWT sign/verify |
| `bcrypt` | ^5.1.0 | Password hashing | | `bcrypt` | ^5.1.0 | Password hashing |
| `crypto` | built-in | Token generation | | `crypto` | built-in | Token generation |
--- ---

View File

@ -4,13 +4,13 @@ The Core module provides the foundational components of the backend application:
## Overview ## Overview
| File | Purpose | Lines | | File | Purpose | Lines |
|------|---------|-------| | ----------------- | ------------------------------------------------------------------ | ------ |
| `src/index.ts` | Application entry point, Express setup, middleware, route mounting | varies | | `src/index.ts` | Application entry point, Express setup, middleware, route mounting | varies |
| `src/config.ts` | Environment configuration and settings | varies | | `src/config.ts` | Environment configuration and settings | varies |
| `src/helpers.js` | Utility functions (wrapAsync, JWT, validation) | 32 | | `src/helpers.js` | Utility functions (wrapAsync, JWT, validation) | 32 |
| `src/types/` | Shared strict TypeScript contracts for migrated backend code | varies | | `src/types/` | Shared strict TypeScript contracts for migrated backend code | varies |
| `src/load-env.ts` | Central backend `.env` bootstrap for app and DB entrypoints | varies | | `src/load-env.ts` | Central backend `.env` bootstrap for app and DB entrypoints | varies |
--- ---
@ -29,11 +29,18 @@ import express from 'express';
import helmet from 'helmet'; import helmet from 'helmet';
import * as swaggerUI from 'swagger-ui-express'; import * as swaggerUI from 'swagger-ui-express';
import { authenticateJwt, authenticateJwtWithCallback } from './auth/passport-middleware.ts'; import {
authenticateJwt,
authenticateJwtWithCallback,
} from './auth/passport-middleware.ts';
import config from './config.ts'; import config from './config.ts';
import { wrapAsync } from './helpers.ts'; import { wrapAsync } from './helpers.ts';
import { runtimeContextMiddleware } from './middlewares/runtime-context.ts'; import { runtimeContextMiddleware } from './middlewares/runtime-context.ts';
import { downloadLimiter, searchLimiter, uploadLimiter } from './middlewares/rateLimiter.ts'; import {
downloadLimiter,
searchLimiter,
uploadLimiter,
} from './middlewares/rateLimiter.ts';
import { createOpenApiDocument } from './openapi/document.ts'; import { createOpenApiDocument } from './openapi/document.ts';
import { import {
exitAfterLogging, exitAfterLogging,
@ -159,21 +166,25 @@ app.use('/api/file', fileRoutes) // File download/presign (partial)
#### Protected Routes (JWT Required) #### Protected Routes (JWT Required)
```javascript ```javascript
app.use('/api/users', jwtAuth, usersRoutes) app.use('/api/users', jwtAuth, usersRoutes);
app.use('/api/roles', jwtAuth, rolesRoutes) app.use('/api/roles', jwtAuth, rolesRoutes);
app.use('/api/permissions', jwtAuth, permissionsRoutes) app.use('/api/permissions', jwtAuth, permissionsRoutes);
app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes) app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes);
app.use('/api/assets', jwtAuth, assetsRoutes) app.use('/api/assets', jwtAuth, assetsRoutes);
app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes) app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes);
app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes) app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes);
app.use('/api/publish_events', jwtAuth, publish_eventsRoutes) app.use('/api/publish_events', jwtAuth, publish_eventsRoutes);
app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes) app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes);
app.use('/api/access_logs', jwtAuth, access_logsRoutes) app.use('/api/access_logs', jwtAuth, access_logsRoutes);
app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes) app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes);
app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes) // Alias app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes); // Alias
app.use('/api/project-element-defaults', jwtAuth, project_element_defaultsRoutes) app.use(
app.use('/api/publish', jwtAuth, publishRoutes) '/api/project-element-defaults',
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes) jwtAuth,
project_element_defaultsRoutes,
);
app.use('/api/publish', jwtAuth, publishRoutes);
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
``` ```
#### Runtime Public Routes (Production Content Without Auth) #### Runtime Public Routes (Production Content Without Auth)
@ -182,9 +193,13 @@ app.use('/api/search', jwtAuth, searchLimiter, searchRoutes)
// These routes use requireRuntimeReadOrAuth middleware // These routes use requireRuntimeReadOrAuth middleware
// Allows unauthenticated GET requests in production environment // Allows unauthenticated GET requests in production environment
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes) mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes) mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', project_audio_tracksRoutes) mountRuntimeEntityRoute(
'/api/project_audio_tracks',
'project_audio_tracks',
project_audio_tracksRoutes,
);
``` ```
### Key Functions ### Key Functions
@ -204,11 +219,11 @@ const requireRuntimeReadOrAuth = (req, res, next) => {
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) { if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
req.isRuntimePublicRequest = true; req.isRuntimePublicRequest = true;
return next(); // Allow without JWT return next(); // Allow without JWT
} }
req.isRuntimePublicRequest = false; req.isRuntimePublicRequest = false;
return jwtAuth(req, res, next); // Require JWT return jwtAuth(req, res, next); // Require JWT
}; };
``` ```
@ -220,9 +235,9 @@ Helper to mount routes with runtime public access middleware stack:
const mountRuntimeEntityRoute = (path, entityName, router) => { const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use( app.use(
path, path,
requireRuntimeReadOrAuth, // JWT or public production requireRuntimeReadOrAuth, // JWT or public production
blockNonPublicRuntimeListEndpoints, // Block non-list for public blockNonPublicRuntimeListEndpoints, // Block non-list for public
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
router, router,
); );
}; };
@ -309,10 +324,7 @@ breaker rejections use status `503` instead of being collapsed to `500`.
const PORT = config.server.port; const PORT = config.server.port;
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
logger.info( logger.info({ port: PORT, env: config.server.env }, 'Server started');
{ port: PORT, env: config.server.env },
'Server started',
);
}); });
server.on('error', (err) => { server.on('error', (err) => {
@ -454,33 +466,32 @@ const config = {
port: serverPort, port: serverPort,
swaggerServerUrl, swaggerServerUrl,
}, },
}; };
``` ```
### Environment Variables Reference ### Environment Variables Reference
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
|----------|------|---------|-------------| | ------------------------------- | ------ | --------------------- | ------------------------------------------------------------- |
| `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` | | `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` |
| `PORT` | number | `8080` | Server port | | `PORT` | number | `8080` | Server port |
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) | | `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
| `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email | | `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email |
| `ADMIN_PASS` | string | Generated | Admin user password | | `ADMIN_PASS` | string | Generated | Admin user password |
| `USER_PASS` | string | Generated | Default user password | | `USER_PASS` | string | Generated | Default user password |
| `AWS_S3_BUCKET` | string | - | S3 bucket name | | `AWS_S3_BUCKET` | string | - | S3 bucket name |
| `AWS_S3_REGION` | string | `us-east-1` | S3 region | | `AWS_S3_REGION` | string | `us-east-1` | S3 region |
| `AWS_ACCESS_KEY_ID` | string | - | AWS access key | | `AWS_ACCESS_KEY_ID` | string | - | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | string | - | AWS secret key | | `AWS_SECRET_ACCESS_KEY` | string | - | AWS secret key |
| `AWS_S3_PREFIX` | string | Hash | S3 key prefix | | `AWS_S3_PREFIX` | string | Hash | S3 key prefix |
| `GOOGLE_CLIENT_ID` | string | - | Google OAuth client ID | | `GOOGLE_CLIENT_ID` | string | - | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | string | - | Google OAuth client secret | | `GOOGLE_CLIENT_SECRET` | string | - | Google OAuth client secret |
| `MS_CLIENT_ID` | string | - | Microsoft OAuth client ID | | `MS_CLIENT_ID` | string | - | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | string | - | Microsoft OAuth client secret | | `MS_CLIENT_SECRET` | string | - | Microsoft OAuth client secret |
| `EMAIL_USER` | string | - | SMTP username | | `EMAIL_USER` | string | - | SMTP username |
| `EMAIL_PASS` | string | - | SMTP password | | `EMAIL_PASS` | string | - | SMTP password |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS validation | | `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS validation |
| `LOG_LEVEL` | string | `info` | Pino log level | | `LOG_LEVEL` | string | `info` | Pino log level |
### Environment Validation ### Environment Validation
@ -520,7 +531,7 @@ function validateEnv() {
logger.error({ errors: messages }, 'Environment validation failed'); logger.error({ errors: messages }, 'Environment validation failed');
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
process.exit(1); // Fatal in production process.exit(1); // Fatal in production
} else { } else {
logger.warn('Continuing with default values in non-production mode'); logger.warn('Continuing with default values in non-production mode');
} }
@ -617,10 +628,13 @@ router.get('/', async (req, res, next) => {
// With wrapAsync - cleaner code // With wrapAsync - cleaner code
const wrapAsync = require('../helpers').wrapAsync; const wrapAsync = require('../helpers').wrapAsync;
router.get('/', wrapAsync(async (req, res) => { router.get(
const data = await Service.findAll(); '/',
res.json(data); wrapAsync(async (req, res) => {
})); const data = await Service.findAll();
res.json(data);
}),
);
``` ```
#### commonErrorHandler #### commonErrorHandler
@ -634,10 +648,10 @@ router.use('/', commonErrorHandler);
// Errors with code/status are returned as-is // Errors with code/status are returned as-is
const error = new Error('Not found'); const error = new Error('Not found');
error.code = 404; error.code = 404;
throw error; // → 404 "Not found" throw error; // → 404 "Not found"
// Unknown errors return 500 // Unknown errors return 500
throw new Error('Database connection failed'); // → 500 "Internal server error" throw new Error('Database connection failed'); // → 500 "Internal server error"
``` ```
#### jwtSign #### jwtSign
@ -663,10 +677,10 @@ const token = jwtSign({
const { isUuidV4 } = require('./helpers'); const { isUuidV4 } = require('./helpers');
// Validate UUID format // Validate UUID format
isUuidV4('550e8400-e29b-41d4-a716-446655440000'); // true isUuidV4('550e8400-e29b-41d4-a716-446655440000'); // true
isUuidV4('550e8400-e29b-31d4-a716-446655440000'); // false (version 3) isUuidV4('550e8400-e29b-31d4-a716-446655440000'); // false (version 3)
isUuidV4('not-a-uuid'); // false isUuidV4('not-a-uuid'); // false
isUuidV4(''); // false isUuidV4(''); // false
``` ```
--- ---
@ -767,12 +781,12 @@ External Dependencies:
## Server Modes ## Server Modes
| NODE_ENV | Port | Database | Swagger | Description | | NODE_ENV | Port | Database | Swagger | Description |
|----------|------|----------|---------|-------------| | ------------- | ---- | -------- | -------------- | ------------------------ |
| `development` | 8080 | Local | localhost:8080 | Legacy local development | | `development` | 8080 | Local | localhost:8080 | Legacy local development |
| `dev_stage` | 3000 | Remote | localhost:3000 | Staging preview | | `dev_stage` | 3000 | Remote | localhost:3000 | Staging preview |
| `production` | 8080 | Remote | Disabled | Production deployment | | `production` | 8080 | Remote | Disabled | Production deployment |
| `test` | 8080 | Test DB | Disabled | Automated testing | | `test` | 8080 | Test DB | Disabled | Automated testing |
**Standard VM note:** the VM PM2 setup runs the backend with **Standard VM note:** the VM PM2 setup runs the backend with
`NODE_ENV=dev_stage`, so the backend listens on port `3000`. The frontend runs `NODE_ENV=dev_stage`, so the backend listens on port `3000`. The frontend runs

View File

@ -8,30 +8,30 @@ The DB API module provides the data access layer that sits between services and
**Files:** 20 files (1 base class + 18 entity APIs + 1 utility) **Files:** 20 files (1 base class + 18 entity APIs + 1 utility)
| File | Class/Purpose | LOC | Extends GenericDBApi | | File | Class/Purpose | LOC | Extends GenericDBApi |
|------|---------------|-----|---------------------| | -------------------------------- | ---------------------------------------------------------------- | ---- | -------------------- |
| `base.api.ts` | `GenericDBApi` - Base class | 726 | - | | `base.api.ts` | `GenericDBApi` - Base class | 726 | - |
| `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) | | `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) |
| `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes | | `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes |
| `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes | | `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes |
| `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes | | `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes |
| `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes | | `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes |
| `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes | | `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes |
| `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes | | `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes |
| `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes | | `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes |
| `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes | | `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes |
| `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes | | `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes |
| `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | Yes | | `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | Yes |
| `project_transition_settings.ts` | `Project_transition_settingsDBApi` - Project transition settings | ~277 | Yes | | `project_transition_settings.ts` | `Project_transition_settingsDBApi` - Project transition settings | ~277 | Yes |
| `global_transition_defaults.ts` | `Global_transition_defaultsDBApi` - Global transition defaults | ~155 | Yes | | `global_transition_defaults.ts` | `Global_transition_defaultsDBApi` - Global transition defaults | ~155 | Yes |
| `global_ui_control_defaults.ts` | `Global_ui_control_defaultsDBApi` - Global UI control defaults | ~160 | Yes | | `global_ui_control_defaults.ts` | `Global_ui_control_defaultsDBApi` - Global UI control defaults | ~160 | Yes |
| `project_ui_control_settings.ts` | `Project_ui_control_settingsDBApi` - Project UI control settings | ~150 | Yes | | `project_ui_control_settings.ts` | `Project_ui_control_settingsDBApi` - Project UI control settings | ~150 | Yes |
| `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes | | `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes |
| `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes | | `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes |
| `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes | | `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes |
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes | | `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) | | `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
| `runtime-context.ts` | Runtime context helpers | 57 | - | | `runtime-context.ts` | Runtime context helpers | 57 | - |
--- ---
@ -111,23 +111,23 @@ The base class provides a Template Method pattern where subclasses configure beh
### Static Getters (Configuration) ### Static Getters (Configuration)
| Getter | Type | Default | Description | | Getter | Type | Default | Description |
|--------|------|---------|-------------| | -------------------- | -------- | --------------------- | ------------------------------------------------ |
| `MODEL` | Model | (required) | Sequelize model reference | | `MODEL` | Model | (required) | Sequelize model reference |
| `TABLE_NAME` | string | From MODEL | Database table name | | `TABLE_NAME` | string | From MODEL | Database table name |
| `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search | | `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search |
| `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) | | `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) |
| `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering | | `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering |
| `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) | | `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) |
| `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs | | `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs |
| `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters | | `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters |
| `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() | | `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() |
| `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() | | `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() |
| `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export | | `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export |
| `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete | | `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete |
| `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify | | `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify |
| `FIELD_DEFAULTS` | object | `{}` | Default values for fields | | `FIELD_DEFAULTS` | object | `{}` | Default values for fields |
| `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations | | `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations |
### Methods ### Methods
@ -320,14 +320,14 @@ static async findAll(filter = {}, options = {}) {
#### Other Methods #### Other Methods
| Method | Description | | Method | Description |
|--------|-------------| | ---------------------------------------------------------------- | ---------------------------------- |
| `bulkImport(data, options)` | Bulk create with timestamps offset | | `bulkImport(data, options)` | Bulk create with timestamps offset |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records | | `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records |
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record | | `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record |
| `findBy(where, options)` | Find single record by criteria | | `findBy(where, options)` | Find single record by criteria |
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search | | `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
| `toCSV(rows)` | Convert rows to CSV string | | `toCSV(rows)` | Convert rows to CSV string |
--- ---
@ -421,13 +421,25 @@ Extend `GenericDBApi` with minimal configuration. Only override static getters a
```typescript ```typescript
class PermissionsDBApi extends GenericDBApi { class PermissionsDBApi extends GenericDBApi {
static override get MODEL(): unknown { return db.permissions; } static override get MODEL(): unknown {
static override get TABLE_NAME(): string { return 'permissions'; } return db.permissions;
static override get SEARCHABLE_FIELDS(): string[] { return ['name']; } }
static override get CSV_FIELDS(): string[] { return ['id', 'name', 'createdAt']; } static override get TABLE_NAME(): string {
static override get AUTOCOMPLETE_FIELD(): string { return 'name'; } return 'permissions';
}
static override get SEARCHABLE_FIELDS(): string[] {
return ['name'];
}
static override get CSV_FIELDS(): string[] {
return ['id', 'name', 'createdAt'];
}
static override get AUTOCOMPLETE_FIELD(): string {
return 'name';
}
static override getFieldMapping(data: PermissionData): PermissionFieldMapping { static override getFieldMapping(
data: PermissionData,
): PermissionFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
name: data.name || null, name: data.name || null,
@ -437,6 +449,7 @@ class PermissionsDBApi extends GenericDBApi {
``` ```
**Entities using this pattern:** **Entities using this pattern:**
- `PermissionsDBApi` - `PermissionsDBApi`
- `AssetsDBApi` - `AssetsDBApi`
- `Asset_variantsDBApi` - `Asset_variantsDBApi`
@ -542,6 +555,7 @@ class ProjectsDBApi extends GenericDBApi {
``` ```
**Entities using this pattern:** **Entities using this pattern:**
- `Tour_pagesDBApi` - Environment filtering via `applyRuntimeEnvironment()` - `Tour_pagesDBApi` - Environment filtering via `applyRuntimeEnvironment()`
- `ProjectsDBApi` - Slug filtering with ID bypass and auto-snapshot on create - `ProjectsDBApi` - Slug filtering with ID bypass and auto-snapshot on create
- `Project_audio_tracksDBApi` - Environment filtering - `Project_audio_tracksDBApi` - Environment filtering
@ -642,6 +656,7 @@ Don't extend `GenericDBApi` due to significantly different requirements.
**Example: UsersDBApi** **Example: UsersDBApi**
Complex user management with: Complex user management with:
- Password hashing (bcrypt) - Password hashing (bcrypt)
- File avatar handling - File avatar handling
- Token generation for email verification and password reset - Token generation for email verification and password reset
@ -651,13 +666,16 @@ Complex user management with:
class UsersDBApi { class UsersDBApi {
static async create(options: UserCreateOptions): Promise<UserRecord> { static async create(options: UserCreateOptions): Promise<UserRecord> {
const { data, currentUser = { id: null }, transaction } = options; const { data, currentUser = { id: null }, transaction } = options;
const users = await db.users.create({ const users = await db.users.create(
firstName: data.firstName || null, {
lastName: data.lastName || null, firstName: data.firstName || null,
email: data.email || null, lastName: data.lastName || null,
password: data.password || null, // Already hashed by service email: data.email || null,
// ... password: data.password || null, // Already hashed by service
}, { transaction }); // ...
},
{ transaction },
);
// Auto-assign default role // Auto-assign default role
if (!data.app_role) { if (!data.app_role) {
@ -698,9 +716,11 @@ class UsersDBApi {
} }
static async _generateToken(keyNames, email, options) { static async _generateToken(keyNames, email, options) {
const users = await db.users.findOne({ where: { email: email.toLowerCase() } }); const users = await db.users.findOne({
where: { email: email.toLowerCase() },
});
const token = crypto.randomBytes(20).toString('hex'); const token = crypto.randomBytes(20).toString('hex');
const tokenExpiresAt = Date.now() + (24 * 60 * 60 * 1000); // 24 hours const tokenExpiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
await users.update({ await users.update({
[keyNames[0]]: token, [keyNames[0]]: token,
@ -719,7 +739,7 @@ class UsersDBApi {
}, },
}); });
} }
}; }
``` ```
**Example: FileDBApi** **Example: FileDBApi**
@ -733,7 +753,11 @@ export default class FileDBApi {
assert(relation.belongsToColumn); assert(relation.belongsToColumn);
assert(relation.belongsToId); assert(relation.belongsToId);
const files = Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : []; const files = Array.isArray(rawFiles)
? rawFiles
: rawFiles
? [rawFiles]
: [];
await this._removeLegacyFiles(relation, files, options); await this._removeLegacyFiles(relation, files, options);
await this._addFiles(relation, files, options); await this._addFiles(relation, files, options);
@ -743,14 +767,17 @@ export default class FileDBApi {
const inexistentFiles = files.filter((file) => !!file.new); const inexistentFiles = files.filter((file) => !!file.new);
for (const file of inexistentFiles) { for (const file of inexistentFiles) {
await db.file.create({ await db.file.create(
belongsTo: relation.belongsTo, {
belongsToColumn: relation.belongsToColumn, belongsTo: relation.belongsTo,
belongsToId: relation.belongsToId, belongsToColumn: relation.belongsToColumn,
name: file.name, belongsToId: relation.belongsToId,
publicUrl: file.publicUrl, name: file.name,
privateUrl: file.privateUrl, publicUrl: file.publicUrl,
}, { transaction: options.transaction }); privateUrl: file.privateUrl,
},
{ transaction: options.transaction },
);
} }
} }
@ -769,7 +796,7 @@ export default class FileDBApi {
await file.destroy({ transaction: options.transaction }); await file.destroy({ transaction: options.transaction });
} }
} }
}; }
``` ```
--- ---
@ -875,14 +902,15 @@ module.exports = class Utils {
**UUID Utility Functions:** **UUID Utility Functions:**
| Function | Purpose | Returns | | Function | Purpose | Returns |
|----------|---------|---------| | ----------------------------- | -------------------------------- | ---------------------- |
| `isValidUuid(value)` | Check if valid UUID | `boolean` | | `isValidUuid(value)` | Check if valid UUID | `boolean` |
| `generateUuid()` | Create new UUID v4 | `string` | | `generateUuid()` | Create new UUID v4 | `string` |
| `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` | | `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` |
| `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause | | `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause |
**UUID Validation Behavior:** **UUID Validation Behavior:**
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately - Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
- Invalid UUID in relation filter (`?project=uuid|name`) → filters out invalid UUIDs for ID search, keeps all terms for text search - Invalid UUID in relation filter (`?project=uuid|name`) → filters out invalid UUIDs for ID search, keeps all terms for text search
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately - Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
@ -895,8 +923,12 @@ module.exports = class Utils {
```javascript ```javascript
class AssetsDBApi extends GenericDBApi { class AssetsDBApi extends GenericDBApi {
static get MODEL() { return db.assets; } static get MODEL() {
static get TABLE_NAME() { return 'assets'; } return db.assets;
}
static get TABLE_NAME() {
return 'assets';
}
static get SEARCHABLE_FIELDS() { static get SEARCHABLE_FIELDS() {
return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum']; return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
@ -928,7 +960,12 @@ class AssetsDBApi extends GenericDBApi {
static get RELATION_FILTERS() { static get RELATION_FILTERS() {
return [ return [
{ filterKey: 'project', model: db.projects, as: 'project', searchField: 'name' }, {
filterKey: 'project',
model: db.projects,
as: 'project',
searchField: 'name',
},
]; ];
} }
@ -958,6 +995,7 @@ class AssetsDBApi extends GenericDBApi {
**Two ways to handle foreign keys:** **Two ways to handle foreign keys:**
1. **Direct field mapping** (preferred for programmatic use): 1. **Direct field mapping** (preferred for programmatic use):
```javascript ```javascript
// In getFieldMapping() // In getFieldMapping()
static getFieldMapping(data) { static getFieldMapping(data) {
@ -973,6 +1011,7 @@ await Asset_variantsDBApi.create({ assetId: asset.id, ... });
``` ```
2. **Via ASSOCIATIONS setter** (used by frontend forms): 2. **Via ASSOCIATIONS setter** (used by frontend forms):
```javascript ```javascript
// ASSOCIATIONS config uses 'asset' (relation name) // ASSOCIATIONS config uses 'asset' (relation name)
static get ASSOCIATIONS() { static get ASSOCIATIONS() {
@ -996,28 +1035,30 @@ undefined source instance and fail before the foreign key can be saved.
```typescript ```typescript
class RolesDBApi extends GenericDBApi { class RolesDBApi extends GenericDBApi {
static override get MODEL(): unknown { return db.roles; } static override get MODEL(): unknown {
return db.roles;
}
static override get ASSOCIATIONS(): RoleAssociationConfig[] { static override get ASSOCIATIONS(): RoleAssociationConfig[] {
return [{ field: 'permissions', setter: 'setPermissions', isArray: true }]; return [{ field: 'permissions', setter: 'setPermissions', isArray: true }];
} }
static override get FIND_BY_INCLUDES(): unknown[] { static override get FIND_BY_INCLUDES(): unknown[] {
return [ return [{ association: 'users_app_role' }, { association: 'permissions' }];
{ association: 'users_app_role' },
{ association: 'permissions' },
];
} }
static override get FIND_ALL_INCLUDES(): unknown[] { static override get FIND_ALL_INCLUDES(): unknown[] {
return [ return [{ model: db.permissions, as: 'permissions', required: false }];
{ model: db.permissions, as: 'permissions', required: false },
];
} }
static get RELATION_FILTERS() { static get RELATION_FILTERS() {
return [ return [
{ filterKey: 'permissions', model: db.permissions, as: 'permissions_filter', searchField: 'name' }, {
filterKey: 'permissions',
model: db.permissions,
as: 'permissions_filter',
searchField: 'name',
},
]; ];
} }
} }
@ -1060,18 +1101,18 @@ class Element_type_defaultsDBApi extends GenericDBApi {
## API Summary Table ## API Summary Table
| API | Pattern | Getters | Custom Methods | Notes | | API | Pattern | Getters | Custom Methods | Notes |
|-----|---------|---------|----------------|-------| | ------------------------------- | ------------- | ------- | -------------- | ----------------------------------------------------------- |
| `PermissionsDBApi` | Simple | 6 | 0 | Minimal config | | `PermissionsDBApi` | Simple | 6 | 0 | Minimal config |
| `AssetsDBApi` | Simple | 9 | 0 | With associations | | `AssetsDBApi` | Simple | 9 | 0 | With associations |
| `RolesDBApi` | Simple | 10 | 0 | M:N permissions | | `RolesDBApi` | Simple | 10 | 0 | M:N permissions |
| `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups | | `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups |
| `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering | | `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering |
| `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering | | `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering |
| `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding | | `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding |
| `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff | | `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff |
| `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files | | `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files |
| `FileDBApi` | Fully custom | - | 3 | Polymorphic files | | `FileDBApi` | Fully custom | - | 3 | Polymorphic files |
--- ---

View File

@ -7,6 +7,7 @@ The DB Config module manages database connection settings, environment validatio
**Location:** `backend/src/db/` **Location:** `backend/src/db/`
**Key Files:** **Key Files:**
- `db-config.ts` - Typed ESM database connection settings per environment - `db-config.ts` - Typed ESM database connection settings per environment
- `umzug.ts` - Typed Umzug runner for migrations, seeders, create/drop - `umzug.ts` - Typed Umzug runner for migrations, seeders, create/drop
- `utils.ts` - Database utility functions - `utils.ts` - Database utility functions
@ -14,6 +15,7 @@ The DB Config module manages database connection settings, environment validatio
- `reset.ts` - Database reset script - `reset.ts` - Database reset script
**Related Files:** **Related Files:**
- `backend/src/config.ts` - Application configuration - `backend/src/config.ts` - Application configuration
- `backend/src/utils/env-validation.ts` - Environment variable validation - `backend/src/utils/env-validation.ts` - Environment variable validation
@ -58,14 +60,14 @@ env var is absent or invalid, the `port` property is omitted.
### Environment Comparison ### Environment Comparison
| Setting | Production | Development | Dev Stage | | Setting | Production | Development | Dev Stage |
|---------|------------|-------------|-----------| | --------------------- | ------------- | ------------- | ------------- |
| **Dialect** | postgres | postgres | postgres | | **Dialect** | postgres | postgres | postgres |
| **Credentials** | Env vars | Hardcoded | Env vars | | **Credentials** | Env vars | Hardcoded | Env vars |
| **Logging** | Disabled | Pino debug | Pino debug | | **Logging** | Disabled | Pino debug | Pino debug |
| **Host** | Env var | localhost | Env var | | **Host** | Env var | localhost | Env var |
| **Migration Storage** | SequelizeMeta | SequelizeMeta | SequelizeMeta | | **Migration Storage** | SequelizeMeta | SequelizeMeta | SequelizeMeta |
| **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData | | **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData |
--- ---
@ -73,14 +75,14 @@ env var is absent or invalid, the `port` property is omitted.
### Database Variables ### Database Variables
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
|----------|----------|---------|-------------| | ---------- | ---------- | ------------------------ | --------------------- |
| `NODE_ENV` | No | development | Environment selection | | `NODE_ENV` | No | development | Environment selection |
| `DB_HOST` | Prod/Stage | localhost | Database host | | `DB_HOST` | Prod/Stage | localhost | Database host |
| `DB_PORT` | Prod/Stage | 5432 | Database port | | `DB_PORT` | Prod/Stage | 5432 | Database port |
| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name | | `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name |
| `DB_USER` | Prod/Stage | postgres | Database username | | `DB_USER` | Prod/Stage | postgres | Database username |
| `DB_PASS` | Prod/Stage | (empty) | Database password | | `DB_PASS` | Prod/Stage | (empty) | Database password |
### Environment Validation ### Environment Validation
@ -112,47 +114,47 @@ const envSchema = Joi.object({
### Complete Environment Variable Schema ### Complete Environment Variable Schema
| Category | Variable | Validation | Default | | Category | Variable | Validation | Default |
|----------|----------|------------|---------| | ----------------- | ----------------------------- | ---------------------------------------------- | ------------------------ |
| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development | | **Server** | NODE_ENV | enum: development, test, production, dev_stage | development |
| | PORT | number | 8080 | | | PORT | number | 8080 |
| **Database** | DB_HOST | string | localhost | | **Database** | DB_HOST | string | localhost |
| | DB_PORT | number | 5432 | | | DB_PORT | number | 5432 |
| | DB_NAME | string | db_tour_builder_platform | | | DB_NAME | string | db_tour_builder_platform |
| | DB_USER | string | postgres | | | DB_USER | string | postgres |
| | DB_PASS | string (allow empty) | (empty) | | | DB_PASS | string (allow empty) | (empty) |
| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) | | **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) |
| | ADMIN_PASS | string | 88dbeaf8 | | | ADMIN_PASS | string | 88dbeaf8 |
| | USER_PASS | string | c3baadeda5c6 | | | USER_PASS | string | c3baadeda5c6 |
| | ADMIN_EMAIL | email | admin@flatlogic.com | | | ADMIN_EMAIL | email | admin@flatlogic.com |
| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) | | **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) |
| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) | | | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) |
| | MS_CLIENT_ID | string (allow empty) | (empty) | | | MS_CLIENT_ID | string (allow empty) | (empty) |
| | MS_CLIENT_SECRET | string (allow empty) | (empty) | | | MS_CLIENT_SECRET | string (allow empty) | (empty) |
| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) | | **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) |
| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) | | | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) |
| | AWS_S3_BUCKET | string (allow empty) | (empty) | | | AWS_S3_BUCKET | string (allow empty) | (empty) |
| | AWS_S3_REGION | string | us-east-1 | | | AWS_S3_REGION | string | us-east-1 |
| | AWS_S3_PREFIX | string | (default hash) | | | AWS_S3_PREFIX | string | (default hash) |
| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 | | | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 |
| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 | | | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 |
| | AWS_S3_MAX_ATTEMPTS | number | 3 | | | AWS_S3_MAX_ATTEMPTS | number | 3 |
| | AWS_S3_MAX_SOCKETS | number | 50 | | | AWS_S3_MAX_SOCKETS | number | 50 |
| | AWS_S3_KEEP_ALIVE | boolean string | true | | | AWS_S3_KEEP_ALIVE | boolean string | true |
| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 | | | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 |
| **Email** | EMAIL_USER | string (allow empty) | (empty) | | **Email** | EMAIL_USER | string (allow empty) | (empty) |
| | EMAIL_PASS | string (allow empty) | (empty) | | | EMAIL_PASS | string (allow empty) | (empty) |
| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true | | | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true |
| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) | | **External APIs** | PEXELS_KEY | string (allow empty) | (empty) |
| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info | | **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info |
### Validation Behavior ### Validation Behavior
```javascript ```javascript
function validateEnv() { function validateEnv() {
const { error, value } = envSchema.validate(process.env, { const { error, value } = envSchema.validate(process.env, {
abortEarly: false, // Report all errors, not just first abortEarly: false, // Report all errors, not just first
stripUnknown: false, // Keep unknown env vars stripUnknown: false, // Keep unknown env vars
}); });
if (error) { if (error) {
@ -160,7 +162,7 @@ function validateEnv() {
logger.error({ errors: messages }, 'Environment validation failed'); logger.error({ errors: messages }, 'Environment validation failed');
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
process.exit(1); // Fatal in production process.exit(1); // Fatal in production
} else { } else {
logger.warn('Continuing with default values in non-production mode'); logger.warn('Continuing with default values in non-production mode');
} }
@ -178,13 +180,13 @@ The database command entrypoint is `backend/src/db/umzug.ts`.
### Runtime Paths ### Runtime Paths
| Setting | Path | | Setting | Path |
|---------|------| | ---------- | --------------------- |
| Config | `src/db/db-config.ts` | | Config | `src/db/db-config.ts` |
| Runner | `src/db/umzug.ts` | | Runner | `src/db/umzug.ts` |
| Models | `src/db/models/` | | Models | `src/db/models/` |
| Seeders | `src/db/seeders/` | | Seeders | `src/db/seeders/` |
| Migrations | `src/db/migrations/` | | Migrations | `src/db/migrations/` |
--- ---
@ -245,11 +247,11 @@ through `src/db/models/index.ts`, whose typed facade is provided by
import Utils from '../db/utils.ts'; import Utils from '../db/utils.ts';
// UUID validation // UUID validation
Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true
Utils.isValidUuid('not-a-uuid'); // false Utils.isValidUuid('not-a-uuid'); // false
// Generate new UUID // Generate new UUID
const id = Utils.generateUuid(); // Returns new UUID v4 const id = Utils.generateUuid(); // Returns new UUID v4
// Filter array to valid UUIDs only // Filter array to valid UUIDs only
const validIds = Utils.filterValidUuids(['uuid1', 'invalid', 'uuid2']); const validIds = Utils.filterValidUuids(['uuid1', 'invalid', 'uuid2']);
@ -260,7 +262,7 @@ const where = {
Utils.ilike('users', 'firstName', searchTerm), Utils.ilike('users', 'firstName', searchTerm),
Utils.ilike('users', 'lastName', searchTerm), Utils.ilike('users', 'lastName', searchTerm),
Utils.ilike('users', 'email', searchTerm), Utils.ilike('users', 'email', searchTerm),
] ],
}; };
``` ```
@ -276,7 +278,9 @@ Synchronizes models to database schema using Sequelize's `alter` mode.
async function syncDatabase() { async function syncDatabase() {
// Safety check - never run in production // Safety check - never run in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
console.error('ERROR: sync.ts should not be run in production. Use migrations instead.'); console.error(
'ERROR: sync.ts should not be run in production. Use migrations instead.',
);
process.exit(1); process.exit(1);
} }
@ -293,17 +297,18 @@ async function syncDatabase() {
``` ```
**Usage:** **Usage:**
```bash ```bash
node src/db/sync.ts node src/db/sync.ts
``` ```
**Sync Modes:** **Sync Modes:**
| Mode | Description | Use Case | | Mode | Description | Use Case |
|------|-------------|----------| | ----------------- | ----------------------------- | ------------ |
| `{ force: true }` | Drop and recreate all tables | Fresh start | | `{ force: true }` | Drop and recreate all tables | Fresh start |
| `{ alter: true }` | Modify tables to match models | Development | | `{ alter: true }` | Modify tables to match models | Development |
| (none) | Create only missing tables | Safe default | | (none) | Create only missing tables | Safe default |
### reset.ts ### reset.ts
@ -324,6 +329,7 @@ db.sequelize
``` ```
**Usage:** **Usage:**
```bash ```bash
node src/db/reset.ts node src/db/reset.ts
``` ```
@ -397,45 +403,46 @@ const config = {
### Storage Configuration ### Storage Configuration
| Provider | Variables | Purpose | | Provider | Variables | Purpose |
|----------|-----------|---------| | ---------- | ---------------------------------------------------------------------- | -------------------- |
| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage | | **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage |
| **GCloud** | (hardcoded bucket) | Legacy support | | **GCloud** | (hardcoded bucket) | Legacy support |
| **Local** | uploadDir (os.tmpdir()) | Development fallback | | **Local** | uploadDir (os.tmpdir()) | Development fallback |
### S3 Performance Tuning ### S3 Performance Tuning
| Variable | Default | Description | | Variable | Default | Description |
|----------|---------|-------------| | ------------------------- | ------- | ------------------------------- |
| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout | | AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout |
| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout | | AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout |
| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure | | AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure |
| AWS_S3_MAX_SOCKETS | 50 | Connection pool size | | AWS_S3_MAX_SOCKETS | 50 | Connection pool size |
| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections | | AWS_S3_KEEP_ALIVE | true | Reuse TCP connections |
| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) | | AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) |
### Security Configuration ### Security Configuration
| Setting | Value | Purpose | | Setting | Value | Purpose |
|---------|-------|---------| | ----------------------------- | --------------- | -------------------------- |
| bcrypt.saltRounds | 12 | Password hashing strength | | bcrypt.saltRounds | 12 | Password hashing strength |
| SECRET_KEY | 16+ char string | JWT signing key | | SECRET_KEY | 16+ char string | JWT signing key |
| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation | | EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation |
### URL Configuration ### URL Configuration
| URL | Development | Production | | URL | Development | Production |
|-----|-------------|------------| | ---------- | ------------------------- | ------------ |
| apiUrl | http://localhost:3000/api | (remote)/api | | apiUrl | http://localhost:3000/api | (remote)/api |
| swaggerUrl | http://localhost:3000 | (remote) | | swaggerUrl | http://localhost:3000 | (remote) |
| uiUrl | http://localhost:3001/# | (remote)/# | | uiUrl | http://localhost:3001/# | (remote)/# |
| backUrl | http://localhost:3001 | (remote) | | backUrl | http://localhost:3001 | (remote) |
--- ---
## Running Commands ## Running Commands
### Development ### Development
```bash ```bash
cd backend cd backend
npm run start-dev npm run start-dev
@ -447,6 +454,7 @@ DB config selection; when `NODE_ENV` is absent it defaults to `dev_stage`, which
matches the standard VM backend flow and listens on port `3000`. matches the standard VM backend flow and listens on port `3000`.
### VM / Dev Stage ### VM / Dev Stage
```bash ```bash
cd backend cd backend
npm run start npm run start
@ -465,12 +473,14 @@ flow loads `.env` through `src/load-env.ts` and defaults missing `NODE_ENV` to
## Best Practices ## Best Practices
### 1. Never Commit Secrets ### 1. Never Commit Secrets
```bash ```bash
# .env file should be in .gitignore # .env file should be in .gitignore
# Use environment variables in deployment # Use environment variables in deployment
``` ```
### 2. Use Migrations in Production ### 2. Use Migrations in Production
```javascript ```javascript
// Never use sync.ts or reset.ts in production // Never use sync.ts or reset.ts in production
if (process.env.NODE_ENV === 'production') { if (process.env.NODE_ENV === 'production') {
@ -479,13 +489,15 @@ if (process.env.NODE_ENV === 'production') {
``` ```
### 3. Validate Environment Early ### 3. Validate Environment Early
```javascript ```javascript
// config.ts loads validation at import time // config.ts loads validation at import time
import { validateEnv } from './utils/env-validation.ts'; import { validateEnv } from './utils/env-validation.ts';
validateEnv(); // Called before app starts validateEnv(); // Called before app starts
``` ```
### 4. Environment-Specific Logging ### 4. Environment-Specific Logging
```javascript ```javascript
// Production: logging disabled (performance) // Production: logging disabled (performance)
// Development/dev_stage: SQL logs use structured Pino debug entries // Development/dev_stage: SQL logs use structured Pino debug entries

View File

@ -65,15 +65,16 @@ backend/
`backend/src/db/umzug.ts` owns migration and seeder execution. It uses official `backend/src/db/umzug.ts` owns migration and seeder execution. It uses official
Umzug types, `SequelizeStorage`, and the existing storage tables: Umzug types, `SequelizeStorage`, and the existing storage tables:
| Flow | Files | Storage Table | Stored Names | | Flow | Files | Storage Table | Stored Names |
|------|-------|---------------|--------------| | ---------- | -------------------------------------------------------------------- | --------------- | ------------------- |
| Migrations | `src/db/migrations/*.js` | `SequelizeMeta` | `*.js` | | Migrations | `src/db/migrations/*.js` | `SequelizeMeta` | `*.js` |
| Seeders | `src/db/seeders/*.ts` in source, `dist/src/db/seeders/*.js` in build | `SequelizeData` | stable `*.js` names | | Seeders | `src/db/seeders/*.ts` in source, `dist/src/db/seeders/*.js` in build | `SequelizeData` | stable `*.js` names |
Seeder files are typed ESM source, and the runner stores stable execution names Seeder files are typed ESM source, and the runner stores stable execution names
so already executed seeders are not treated as pending. so already executed seeders are not treated as pending.
### NPM Scripts ### NPM Scripts
```bash ```bash
# Run pending migrations # Run pending migrations
npm run db:migrate npm run db:migrate
@ -99,6 +100,7 @@ npm run db:seed
## Migration File Structure ## Migration File Structure
### Standard Template ### Standard Template
```javascript ```javascript
'use strict'; 'use strict';
@ -122,6 +124,7 @@ Use one project-wide migration template for new schema changes. Do not modify
already applied migration files to match newer style choices. already applied migration files to match newer style choices.
### Naming Convention ### Naming Convention
``` ```
YYYYMMDDHHMMSS-descriptive-name.js YYYYMMDDHHMMSS-descriptive-name.js
@ -138,6 +141,7 @@ Examples:
## Migration Patterns ## Migration Patterns
### 1. Transaction Wrapper Pattern ### 1. Transaction Wrapper Pattern
**Purpose:** Ensure atomic operations - all changes succeed or all fail. **Purpose:** Ensure atomic operations - all changes succeed or all fail.
```javascript ```javascript
@ -164,6 +168,7 @@ module.exports = {
--- ---
### 2. Idempotent Check Pattern ### 2. Idempotent Check Pattern
**Purpose:** Safely re-run migrations without errors. **Purpose:** Safely re-run migrations without errors.
```javascript ```javascript
@ -188,6 +193,7 @@ await queryInterface.addColumn('tableName', 'columnName', { ... });
--- ---
### 3. Helper Function Pattern ### 3. Helper Function Pattern
**Purpose:** Reduce repetition for bulk operations. **Purpose:** Reduce repetition for bulk operations.
```javascript ```javascript
@ -196,14 +202,19 @@ module.exports = {
const transaction = await queryInterface.sequelize.transaction(); const transaction = await queryInterface.sequelize.transaction();
// Define reusable helper // Define reusable helper
const addForeignKey = async (tableName, columnName, references, onDelete) => { const addForeignKey = async (
tableName,
columnName,
references,
onDelete,
) => {
const constraintName = `${tableName}_${columnName}_fkey`; const constraintName = `${tableName}_${columnName}_fkey`;
// Check existence // Check existence
const [results] = await queryInterface.sequelize.query( const [results] = await queryInterface.sequelize.query(
`SELECT constraint_name FROM information_schema.table_constraints `SELECT constraint_name FROM information_schema.table_constraints
WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`, WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`,
{ transaction } { transaction },
); );
if (results.length === 0) { if (results.length === 0) {
@ -221,8 +232,18 @@ module.exports = {
}; };
// Use helper multiple times // Use helper multiple times
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE'); await addForeignKey(
await addForeignKey('tour_pages', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE'); 'assets',
'projectId',
{ table: 'projects', field: 'id' },
'CASCADE',
);
await addForeignKey(
'tour_pages',
'projectId',
{ table: 'projects', field: 'id' },
'CASCADE',
);
// ... more FKs // ... more FKs
}, },
}; };
@ -233,6 +254,7 @@ module.exports = {
--- ---
### 4. Safe Table Drop Pattern ### 4. Safe Table Drop Pattern
**Purpose:** Prevent accidental data loss when dropping tables. **Purpose:** Prevent accidental data loss when dropping tables.
```javascript ```javascript
@ -270,6 +292,7 @@ module.exports = {
--- ---
### 5. ENUM to TEXT Conversion Pattern ### 5. ENUM to TEXT Conversion Pattern
**Purpose:** Convert restrictive ENUMs to flexible TEXT while preserving data. **Purpose:** Convert restrictive ENUMs to flexible TEXT while preserving data.
```javascript ```javascript
@ -279,33 +302,45 @@ module.exports = {
try { try {
// 1. Create temporary TEXT column // 1. Create temporary TEXT column
await queryInterface.addColumn('table', 'column_text', { await queryInterface.addColumn(
type: Sequelize.TEXT, 'table',
allowNull: true, 'column_text',
}, { transaction }); {
type: Sequelize.TEXT,
allowNull: true,
},
{ transaction },
);
// 2. Copy ENUM values to TEXT // 2. Copy ENUM values to TEXT
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
`UPDATE table SET column_text = column::TEXT`, `UPDATE table SET column_text = column::TEXT`,
{ transaction } { transaction },
); );
// 3. Drop old ENUM column // 3. Drop old ENUM column
await queryInterface.removeColumn('table', 'column', { transaction }); await queryInterface.removeColumn('table', 'column', { transaction });
// 4. Rename TEXT column // 4. Rename TEXT column
await queryInterface.renameColumn('table', 'column_text', 'column', { transaction }); await queryInterface.renameColumn('table', 'column_text', 'column', {
transaction,
});
// 5. Add NOT NULL constraint // 5. Add NOT NULL constraint
await queryInterface.changeColumn('table', 'column', { await queryInterface.changeColumn(
type: Sequelize.TEXT, 'table',
allowNull: false, 'column',
}, { transaction }); {
type: Sequelize.TEXT,
allowNull: false,
},
{ transaction },
);
// 6. Drop ENUM type // 6. Drop ENUM type
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
`DROP TYPE IF EXISTS "enum_table_column"`, `DROP TYPE IF EXISTS "enum_table_column"`,
{ transaction } { transaction },
); );
await transaction.commit(); await transaction.commit();
@ -330,6 +365,7 @@ module.exports = {
--- ---
### 6. Data Backfill Pattern ### 6. Data Backfill Pattern
**Purpose:** Populate new tables/columns with data from existing records. **Purpose:** Populate new tables/columns with data from existing records.
```javascript ```javascript
@ -383,6 +419,7 @@ module.exports = {
--- ---
### 7. Cross-Environment Data Copy Pattern ### 7. Cross-Environment Data Copy Pattern
**Purpose:** Copy content between environments (dev → stage → production). **Purpose:** Copy content between environments (dev → stage → production).
```javascript ```javascript
@ -390,14 +427,14 @@ module.exports = {
async up(queryInterface, Sequelize) { async up(queryInterface, Sequelize) {
const projects = await queryInterface.sequelize.query( const projects = await queryInterface.sequelize.query(
`SELECT id FROM projects WHERE "deletedAt" IS NULL`, `SELECT id FROM projects WHERE "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT } { type: Sequelize.QueryTypes.SELECT },
); );
for (const project of projects) { for (const project of projects) {
// Check if target environment already has content // Check if target environment already has content
const [stageCheck] = await queryInterface.sequelize.query( const [stageCheck] = await queryInterface.sequelize.query(
`SELECT COUNT(*)::int as count FROM tour_pages `SELECT COUNT(*)::int as count FROM tour_pages
WHERE "projectId" = '${project.id}' AND environment = 'stage'` WHERE "projectId" = '${project.id}' AND environment = 'stage'`,
); );
if (stageCheck?.count > 0) continue; if (stageCheck?.count > 0) continue;
@ -433,7 +470,7 @@ module.exports = {
async down(queryInterface) { async down(queryInterface) {
// Delete records with source_key (created by migration) // Delete records with source_key (created by migration)
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
`DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL` `DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL`,
); );
}, },
}; };
@ -444,6 +481,7 @@ module.exports = {
--- ---
### 8. JSON Field Transformation Pattern ### 8. JSON Field Transformation Pattern
**Purpose:** Transform data stored in JSON columns. **Purpose:** Transform data stored in JSON columns.
```javascript ```javascript
@ -456,24 +494,27 @@ module.exports = {
const [records] = await queryInterface.sequelize.query( const [records] = await queryInterface.sequelize.query(
`SELECT id, "projectId", environment, slug, json_column `SELECT id, "projectId", environment, slug, json_column
FROM table_name WHERE json_column IS NOT NULL`, FROM table_name WHERE json_column IS NOT NULL`,
{ transaction } { transaction },
); );
// Build lookup maps for ID → slug transformations // Build lookup maps for ID → slug transformations
const slugById = new Map(); const slugById = new Map();
records.forEach(r => slugById.set(r.id, { projectId: r.projectId, slug: r.slug })); records.forEach((r) =>
slugById.set(r.id, { projectId: r.projectId, slug: r.slug }),
);
// Transform each record // Transform each record
for (const record of records) { for (const record of records) {
const jsonData = typeof record.json_column === 'string' const jsonData =
? JSON.parse(record.json_column) typeof record.json_column === 'string'
: record.json_column; ? JSON.parse(record.json_column)
: record.json_column;
let hasChanges = false; let hasChanges = false;
// Transform JSON structure // Transform JSON structure
if (jsonData.elements) { if (jsonData.elements) {
jsonData.elements.forEach(element => { jsonData.elements.forEach((element) => {
if (element.targetPageId) { if (element.targetPageId) {
const target = slugById.get(element.targetPageId); const target = slugById.get(element.targetPageId);
if (target) { if (target) {
@ -490,8 +531,8 @@ module.exports = {
`UPDATE table_name SET json_column = :json WHERE id = :id`, `UPDATE table_name SET json_column = :json WHERE id = :id`,
{ {
replacements: { json: JSON.stringify(jsonData), id: record.id }, replacements: { json: JSON.stringify(jsonData), id: record.id },
transaction transaction,
} },
); );
} }
} }
@ -510,6 +551,7 @@ module.exports = {
--- ---
### 9. Constraint Enforcement Pattern ### 9. Constraint Enforcement Pattern
**Purpose:** Add NOT NULL constraints after fixing existing NULL values. **Purpose:** Add NOT NULL constraints after fixing existing NULL values.
```javascript ```javascript
@ -517,7 +559,7 @@ module.exports = {
async up(queryInterface) { async up(queryInterface) {
// First, fix any NULL values // First, fix any NULL values
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
`UPDATE table_name SET column = 'default' WHERE column IS NULL` `UPDATE table_name SET column = 'default' WHERE column IS NULL`,
); );
// Then add NOT NULL constraint with default // Then add NOT NULL constraint with default
@ -543,6 +585,7 @@ module.exports = {
--- ---
### 10. Safe Down Migration Pattern ### 10. Safe Down Migration Pattern
**Purpose:** Handle cases where down migration isn't meaningful. **Purpose:** Handle cases where down migration isn't meaningful.
```javascript ```javascript
@ -554,7 +597,9 @@ module.exports = {
async down(_queryInterface, _Sequelize) { async down(_queryInterface, _Sequelize) {
// This migration only adds missing data, not destructive // This migration only adds missing data, not destructive
console.log('No down migration needed - this migration only adds missing data.'); console.log(
'No down migration needed - this migration only adds missing data.',
);
}, },
}; };
``` ```
@ -566,60 +611,74 @@ module.exports = {
## Migration Categories ## Migration Categories
### Schema Changes ### Schema Changes
| Migration | Description |
|-----------|-------------| | Migration | Description |
| `add-foreign-key-constraints` | Add FK constraints to all model associations | | --------------------------------- | -------------------------------------------- |
| `create-project-element-defaults` | Create new table with indexes | | `add-foreign-key-constraints` | Add FK constraints to all model associations |
| `drop-page-elements-table` | Drop unused table | | `create-project-element-defaults` | Create new table with indexes |
| `drop-page-links-table` | Drop unused table | | `drop-page-elements-table` | Drop unused table |
| `drop-transitions-table` | Drop unused table | | `drop-page-links-table` | Drop unused table |
| `drop-transitions-table` | Drop unused table |
### Column Modifications ### Column Modifications
| Migration | Description |
|-----------|-------------| | Migration | Description |
| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` | | ------------------------------------------- | --------------------------------------------------------------------------------- |
| `remove-project-phase-column` | Remove redundant `phase` column | | `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` |
| `remove-entry-page-slug-column` | Remove unused column | | `remove-project-phase-column` | Remove redundant `phase` column |
| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility | | `remove-entry-page-slug-column` | Remove unused column |
| `enforce-environment-not-null` | Add NOT NULL constraint | | `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility |
| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` | | `enforce-environment-not-null` | Add NOT NULL constraint |
| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages | | `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` |
| `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table | | `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages |
| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table | | `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table |
| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table |
### Table Renames ### Table Renames
| Migration | Description |
|-----------|-------------| | Migration | Description |
| --------------------------------------------- | ------------------ |
| `rename-ui-elements-to-element-type-defaults` | Rename for clarity | | `rename-ui-elements-to-element-type-defaults` | Rename for clarity |
### Data Migrations ### Data Migrations
| Migration | Description |
|-----------|-------------| | Migration | Description |
| `backfill-project-element-defaults` | Populate new table for existing projects | | ---------------------------------------- | ---------------------------------------------------------- |
| `copy-dev-to-stage` | Initialize stage environment | | `backfill-project-element-defaults` | Populate new table for existing projects |
| `convert-targetpageid-to-slug` | Transform JSON navigation references | | `copy-dev-to-stage` | Initialize stage environment |
| `fix-project-audio-tracks-environment` | Fix environment values | | `convert-targetpageid-to-slug` | Transform JSON navigation references |
| `add-missing-element-type-defaults` | Insert missing default rows | | `fix-project-audio-tracks-environment` | Fix environment values |
| `sync-all-element-type-defaults` | Full sync of all 11 element types | | `add-missing-element-type-defaults` | Insert missing default rows |
| `sync-all-element-type-defaults` | Full sync of all 11 element types |
| `remove-duplicate-element-type-defaults` | Remove duplicate records created during earlier migrations | | `remove-duplicate-element-type-defaults` | Remove duplicate records created during earlier migrations |
| `cleanup-invalid-element-type-defaults` | Clean up invalid entries and ensure data integrity | | `cleanup-invalid-element-type-defaults` | Clean up invalid entries and ensure data integrity |
--- ---
## Foreign Key Strategies ## Foreign Key Strategies
| Strategy | When to Use | Example | | Strategy | When to Use | Example |
|----------|-------------|---------| | ------------------------------ | -------------------------------- | ------------------------------------------------ |
| `CASCADE` | Delete child when parent deleted | `assets.projectId → projects.id` | | `CASCADE` | Delete child when parent deleted | `assets.projectId → projects.id` |
| `SET NULL` | Preserve record, nullify FK | `publish_events.userId → users.id` (audit trail) | | `SET NULL` | Preserve record, nullify FK | `publish_events.userId → users.id` (audit trail) |
| `SET NULL` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` | | `SET NULL` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` |
```javascript ```javascript
// CASCADE - delete assets when project is deleted // CASCADE - delete assets when project is deleted
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE'); await addForeignKey(
'assets',
'projectId',
{ table: 'projects', field: 'id' },
'CASCADE',
);
// SET NULL - preserve audit log when user is deleted // SET NULL - preserve audit log when user is deleted
await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'SET NULL'); await addForeignKey(
'access_logs',
'userId',
{ table: 'users', field: 'id' },
'SET NULL',
);
``` ```
--- ---
@ -627,6 +686,7 @@ await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'S
## Best Practices ## Best Practices
### 1. Always Use Transactions ### 1. Always Use Transactions
```javascript ```javascript
const transaction = await queryInterface.sequelize.transaction(); const transaction = await queryInterface.sequelize.transaction();
try { try {
@ -639,20 +699,23 @@ try {
``` ```
### 2. Check Before Modify ### 2. Check Before Modify
```javascript ```javascript
// Always check existence before adding/removing // Always check existence before adding/removing
const tableExists = await queryInterface.sequelize.query( const tableExists = await queryInterface.sequelize.query(
`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')` `SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')`,
); );
``` ```
### 3. Log Progress ### 3. Log Progress
```javascript ```javascript
console.log(`Migrating project ${projectId}: ${addedCount} records added`); console.log(`Migrating project ${projectId}: ${addedCount} records added`);
console.log('Migration complete: All foreign keys added'); console.log('Migration complete: All foreign keys added');
``` ```
### 4. Safe Drops ### 4. Safe Drops
```javascript ```javascript
// Never drop non-empty tables silently // Never drop non-empty tables silently
if (count > 0) { if (count > 0) {
@ -661,6 +724,7 @@ if (count > 0) {
``` ```
### 5. Reversible Operations ### 5. Reversible Operations
```javascript ```javascript
// Down migration should restore previous state // Down migration should restore previous state
async down(queryInterface, Sequelize) { async down(queryInterface, Sequelize) {
@ -671,16 +735,17 @@ async down(queryInterface, Sequelize) {
``` ```
### 6. Use Parameterized Queries ### 6. Use Parameterized Queries
```javascript ```javascript
// Good - prevents SQL injection // Good - prevents SQL injection
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
`UPDATE table SET column = :value WHERE id = :id`, `UPDATE table SET column = :value WHERE id = :id`,
{ replacements: { value: 'safe', id: record.id } } { replacements: { value: 'safe', id: record.id } },
); );
// Avoid - SQL injection risk // Avoid - SQL injection risk
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
`UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'` `UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'`,
); );
``` ```
@ -689,13 +754,16 @@ await queryInterface.sequelize.query(
## Running Migrations ## Running Migrations
### Development ### Development
```bash ```bash
cd backend cd backend
npm run db:migrate npm run db:migrate
``` ```
### Server Startup ### Server Startup
Migrations run automatically via `npm start`: Migrations run automatically via `npm start`:
```json ```json
{ {
"scripts": { "scripts": {
@ -705,11 +773,13 @@ Migrations run automatically via `npm start`:
``` ```
### Migration Status ### Migration Status
```bash ```bash
npm run db:migrate:status npm run db:migrate:status
``` ```
### Undo Migrations ### Undo Migrations
```bash ```bash
# Undo last migration # Undo last migration
npm run db:migrate:undo npm run db:migrate:undo
@ -728,32 +798,32 @@ explicit rollback/backup plan.
## Current Migration Inventory ## Current Migration Inventory
| # | Timestamp | Name | Type | | # | Timestamp | Name | Type |
|---|-----------|------|------| | --- | -------------- | ------------------------------------------- | ------ |
| 1 | 20260319000001 | add-foreign-key-constraints | Schema | | 1 | 20260319000001 | add-foreign-key-constraints | Schema |
| 2 | 20260319000002 | remove-redundant-deletion-columns | Column | | 2 | 20260319000002 | remove-redundant-deletion-columns | Column |
| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename | | 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename |
| 4 | 20260326000002 | convert-element-type-enum-to-text | Column | | 4 | 20260326000002 | convert-element-type-enum-to-text | Column |
| 5 | 20260326000003 | create-project-element-defaults | Schema | | 5 | 20260326000003 | create-project-element-defaults | Schema |
| 6 | 20260326000004 | backfill-project-element-defaults | Data | | 6 | 20260326000004 | backfill-project-element-defaults | Data |
| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data | | 7 | 20260326000005 | fix-project-audio-tracks-environment | Data |
| 8 | 20260326000006 | copy-dev-to-stage | Data | | 8 | 20260326000006 | copy-dev-to-stage | Data |
| 9 | 20260326043002 | enforce-environment-not-null | Column | | 9 | 20260326043002 | enforce-environment-not-null | Column |
| 10 | 20260326050442 | remove-project-phase-column | Column | | 10 | 20260326050442 | remove-project-phase-column | Column |
| 11 | 20260326054410 | remove-entry-page-slug-column | Column | | 11 | 20260326054410 | remove-entry-page-slug-column | Column |
| 12 | 20260326060000 | convert-targetpageid-to-slug | Data | | 12 | 20260326060000 | convert-targetpageid-to-slug | Data |
| 13 | 20260326060001 | drop-page-elements-table | Schema | | 13 | 20260326060001 | drop-page-elements-table | Schema |
| 14 | 20260326060002 | drop-page-links-table | Schema | | 14 | 20260326060002 | drop-page-links-table | Schema |
| 15 | 20260326060003 | drop-transitions-table | Schema | | 15 | 20260326060003 | drop-transitions-table | Schema |
| 16 | 20260326171017 | add-missing-element-type-defaults | Data | | 16 | 20260326171017 | add-missing-element-type-defaults | Data |
| 17 | 20260327000001 | sync-all-element-type-defaults | Data | | 17 | 20260327000001 | sync-all-element-type-defaults | Data |
| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column | | 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column |
| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data | | 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data |
| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data | | 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data |
| 21 | 20260403000001 | add-background-video-settings | Column | | 21 | 20260403000001 | add-background-video-settings | Column |
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column | | 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column | | 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column |
| 24 | 20260605000001 | add-background-audio-settings | Column | | 24 | 20260605000001 | add-background-audio-settings | Column |
--- ---

View File

@ -10,31 +10,31 @@ The DB Models module defines the Sequelize ORM models that map to PostgreSQL dat
the backend TS/ESM migration, model entries have a typed `.ts` source plus a the backend TS/ESM migration, model entries have a typed `.ts` source plus a
typed ESM source file. There is no model-level CommonJS compatibility facade. typed ESM source file. There is no model-level CommonJS compatibility facade.
| File | Model | Purpose | LOC | | File | Model | Purpose | LOC |
|------|-------|---------|-----| | -------------------------------------------------- | -------------------------------- | ------------------------------------------------------------- | --- |
| `index.ts` | - | ESM entrypoint re-exporting `loader.ts` | 1 | | `index.ts` | - | ESM entrypoint re-exporting `loader.ts` | 1 |
| `loader.ts` | - | Typed model registry and Sequelize initialization | 128 | | `loader.ts` | - | Typed model registry and Sequelize initialization | 128 |
| `users.ts` + `.js` bridge | `users` | User accounts with authentication | 246 | | `users.ts` + `.js` bridge | `users` | User accounts with authentication | 246 |
| `projects.ts` + `.js` bridge | `projects` | Virtual tour projects | 211 | | `projects.ts` + `.js` bridge | `projects` | Virtual tour projects | 211 |
| `production_presentation_access.ts` + `.js` bridge | `production_presentation_access` | Customer grants for private production presentations | 67 | | `production_presentation_access.ts` + `.js` bridge | `production_presentation_access` | Customer grants for private production presentations | 67 |
| `tour_pages.ts` + `.js` bridge | `tour_pages` | Individual tour pages with UI schema | 131 | | `tour_pages.ts` + `.js` bridge | `tour_pages` | Individual tour pages with UI schema | 131 |
| `assets.ts` + `.js` bridge | `assets` | Uploaded media files | 169 | | `assets.ts` + `.js` bridge | `assets` | Uploaded media files | 169 |
| `asset_variants.ts` + `.js` bridge | `asset_variants` | Asset size/format variants | 103 | | `asset_variants.ts` + `.js` bridge | `asset_variants` | Asset size/format variants | 103 |
| `roles.ts` + `roles.js` bridge | `roles` | RBAC roles | 85 | | `roles.ts` + `roles.js` bridge | `roles` | RBAC roles | 85 |
| `permissions.ts` + `permissions.js` bridge | `permissions` | RBAC permissions | 52 | | `permissions.ts` + `permissions.js` bridge | `permissions` | RBAC permissions | 52 |
| `project_memberships.ts` + `.js` bridge | `project_memberships` | User-project access | 89 | | `project_memberships.ts` + `.js` bridge | `project_memberships` | User-project access | 89 |
| `publish_events.ts` + `.js` bridge | `publish_events` | Publishing history | 148 | | `publish_events.ts` + `.js` bridge | `publish_events` | Publishing history | 148 |
| `pwa_caches.ts` + `.js` bridge | `pwa_caches` | PWA offline cache manifests | 84 | | `pwa_caches.ts` + `.js` bridge | `pwa_caches` | PWA offline cache manifests | 84 |
| `access_logs.ts` + `.js` bridge | `access_logs` | Activity audit trail | 105 | | `access_logs.ts` + `.js` bridge | `access_logs` | Activity audit trail | 105 |
| `element_type_defaults.ts` + `.js` bridge | `element_type_defaults` | Global UI element defaults | 91 | | `element_type_defaults.ts` + `.js` bridge | `element_type_defaults` | Global UI element defaults | 91 |
| `project_element_defaults.ts` + `.js` bridge | `project_element_defaults` | Project-specific element defaults | 101 | | `project_element_defaults.ts` + `.js` bridge | `project_element_defaults` | Project-specific element defaults | 101 |
| `project_audio_tracks.ts` + `.js` bridge | `project_audio_tracks` | Background audio tracks | 103 | | `project_audio_tracks.ts` + `.js` bridge | `project_audio_tracks` | Background audio tracks | 103 |
| `project_transition_settings.ts` + `.js` bridge | `project_transition_settings` | Environment-aware CSS transition settings | 95 | | `project_transition_settings.ts` + `.js` bridge | `project_transition_settings` | Environment-aware CSS transition settings | 95 |
| `global_transition_defaults.ts` + `.js` bridge | `global_transition_defaults` | Platform defaults for CSS page transitions | 65 | | `global_transition_defaults.ts` + `.js` bridge | `global_transition_defaults` | Platform defaults for CSS page transitions | 65 |
| `global_ui_control_defaults.ts` + `.js` bridge | `global_ui_control_defaults` | Platform defaults for fullscreen, sound, and offline controls | 33 | | `global_ui_control_defaults.ts` + `.js` bridge | `global_ui_control_defaults` | Platform defaults for fullscreen, sound, and offline controls | 33 |
| `project_ui_control_settings.ts` + `.js` bridge | `project_ui_control_settings` | Project/environment overrides for global UI controls | 61 | | `project_ui_control_settings.ts` + `.js` bridge | `project_ui_control_settings` | Project/environment overrides for global UI controls | 61 |
| `presigned_url_requests.ts` + `.js` bridge | `presigned_url_requests` | S3 presigned URL audit | 118 | | `presigned_url_requests.ts` + `.js` bridge | `presigned_url_requests` | S3 presigned URL audit | 118 |
| `file.ts` + `.js` bridge | `file` | Generic file attachments | 53 | | `file.ts` + `.js` bridge | `file` | Generic file attachments | 53 |
--- ---
@ -128,6 +128,7 @@ bridge or immutable migration.
## Model Loader ## Model Loader
**Locations:** **Locations:**
- `backend/src/db/models/loader.ts` - `backend/src/db/models/loader.ts`
- `backend/src/db/models/index.ts` - `backend/src/db/models/index.ts`
- `backend/src/types/db-models.ts` - `backend/src/types/db-models.ts`
@ -143,11 +144,11 @@ for service-specific model calls.
**Location:** `backend/src/db/db-config.ts` **Location:** `backend/src/db/db-config.ts`
| Environment | Database | Logging | Notes | | Environment | Database | Logging | Notes |
|-------------|----------|---------|-------| | ------------- | -------------------------- | -------- | --------------- |
| `production` | From env vars | Disabled | Live production | | `production` | From env vars | Disabled | Live production |
| `development` | `db_tour_builder_platform` | Console | Local dev | | `development` | `db_tour_builder_platform` | Console | Local dev |
| `dev_stage` | From env vars | Console | Staging server | | `dev_stage` | From env vars | Console | Staging server |
--- ---
@ -167,13 +168,13 @@ All models share these Sequelize options:
### Common Fields ### Common Fields
| Field | Type | Description | | Field | Type | Description |
|-------|------|-------------| | ------------ | ------------- | ----------------------------------------- |
| `id` | `UUID` | Primary key (auto-generated UUIDv4) | | `id` | `UUID` | Primary key (auto-generated UUIDv4) |
| `importHash` | `STRING(255)` | Unique hash for bulk import deduplication | | `importHash` | `STRING(255)` | Unique hash for bulk import deduplication |
| `createdAt` | `DATE` | Auto-managed creation timestamp | | `createdAt` | `DATE` | Auto-managed creation timestamp |
| `updatedAt` | `DATE` | Auto-managed update timestamp | | `updatedAt` | `DATE` | Auto-managed update timestamp |
| `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) | | `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) |
### Common Associations ### Common Associations
@ -193,40 +194,49 @@ db.MODEL.belongsTo(db.users, { as: 'updatedBy' });
**Purpose:** User accounts for authentication and authorization. **Purpose:** User accounts for authentication and authorization.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | --------------------------------- | ------- | -------- | ------- | ------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `firstName` | TEXT | Yes | - | Trimmed | | `firstName` | TEXT | Yes | - | Trimmed |
| `lastName` | TEXT | Yes | - | Trimmed | | `lastName` | TEXT | Yes | - | Trimmed |
| `phoneNumber` | TEXT | Yes | - | - | | `phoneNumber` | TEXT | Yes | - | - |
| `email` | TEXT | No | - | isEmail, notEmpty, unique | | `email` | TEXT | No | - | isEmail, notEmpty, unique |
| `password` | TEXT | No | - | Hashed with bcrypt | | `password` | TEXT | No | - | Hashed with bcrypt |
| `disabled` | BOOLEAN | No | false | - | | `disabled` | BOOLEAN | No | false | - |
| `emailVerified` | BOOLEAN | No | false | - | | `emailVerified` | BOOLEAN | No | false | - |
| `emailVerificationToken` | TEXT | Yes | - | - | | `emailVerificationToken` | TEXT | Yes | - | - |
| `emailVerificationTokenExpiresAt` | DATE | Yes | - | - | | `emailVerificationTokenExpiresAt` | DATE | Yes | - | - |
| `passwordResetToken` | TEXT | Yes | - | - | | `passwordResetToken` | TEXT | Yes | - | - |
| `passwordResetTokenExpiresAt` | DATE | Yes | - | - | | `passwordResetTokenExpiresAt` | DATE | Yes | - | - |
| `provider` | TEXT | No | 'local' | OAuth provider | | `provider` | TEXT | No | 'local' | OAuth provider |
| `app_roleId` | UUID | Yes | - | FK to roles | | `app_roleId` | UUID | Yes | - | FK to roles |
**Indexes:** **Indexes:**
- `email` (unique) - `email` (unique)
- `app_roleId` - `app_roleId`
- `deletedAt` - `deletedAt`
**Associations:** **Associations:**
```javascript ```javascript
users.belongsTo(roles, { as: 'app_role' }); users.belongsTo(roles, { as: 'app_role' });
users.belongsToMany(permissions, { as: 'custom_permissions', through: 'usersCustom_permissionsPermissions' }); users.belongsToMany(permissions, {
as: 'custom_permissions',
through: 'usersCustom_permissionsPermissions',
});
users.hasMany(project_memberships, { as: 'project_memberships_user' }); users.hasMany(project_memberships, { as: 'project_memberships_user' });
users.hasMany(presigned_url_requests, { as: 'presigned_url_requests_user' }); users.hasMany(presigned_url_requests, { as: 'presigned_url_requests_user' });
users.hasMany(publish_events, { as: 'publish_events_user' }); users.hasMany(publish_events, { as: 'publish_events_user' });
users.hasMany(access_logs, { as: 'access_logs_user' }); users.hasMany(access_logs, { as: 'access_logs_user' });
users.hasMany(file, { as: 'avatar', scope: { belongsTo: 'users', belongsToColumn: 'avatar' } }); users.hasMany(file, {
as: 'avatar',
scope: { belongsTo: 'users', belongsToColumn: 'avatar' },
});
``` ```
**Hooks:** **Hooks:**
```javascript ```javascript
users.beforeCreate((user) => { users.beforeCreate((user) => {
// Trim string fields // Trim string fields
@ -245,34 +255,60 @@ users.beforeUpdate((user) => {
**Purpose:** Virtual tour projects container. **Purpose:** Virtual tour projects container.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ------------------------------------ | ---- | -------- | ------- | --------------------------------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, len[1,255] | | `name` | TEXT | No | - | notEmpty, len[1,255] |
| `slug` | TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores | | `slug` | TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores |
| `description` | TEXT | Yes | - | - | | `description` | TEXT | Yes | - | - |
| `logo_url` | TEXT | Yes | - | - | | `logo_url` | TEXT | Yes | - | - |
| `favicon_url` | TEXT | Yes | - | - | | `favicon_url` | TEXT | Yes | - | - |
| `og_image_url` | TEXT | Yes | - | - | | `og_image_url` | TEXT | Yes | - | - |
| `production_presentation_visibility` | ENUM | No | public | public, private | | `production_presentation_visibility` | ENUM | No | public | public, private |
**Indexes:** **Indexes:**
- `slug` (unique) - `slug` (unique)
- `deletedAt` - `deletedAt`
**Associations:** **Associations:**
```javascript ```javascript
projects.hasMany(project_memberships, { as: 'project_memberships_project', onDelete: 'CASCADE' }); projects.hasMany(project_memberships, {
as: 'project_memberships_project',
onDelete: 'CASCADE',
});
projects.hasMany(assets, { as: 'assets_project', onDelete: 'CASCADE' }); projects.hasMany(assets, { as: 'assets_project', onDelete: 'CASCADE' });
projects.hasMany(presigned_url_requests, { as: 'presigned_url_requests_project', onDelete: 'CASCADE' }); projects.hasMany(presigned_url_requests, {
as: 'presigned_url_requests_project',
onDelete: 'CASCADE',
});
projects.hasMany(tour_pages, { as: 'tour_pages_project', onDelete: 'CASCADE' }); projects.hasMany(tour_pages, { as: 'tour_pages_project', onDelete: 'CASCADE' });
projects.hasMany(project_audio_tracks, { as: 'project_audio_tracks_project', onDelete: 'CASCADE' }); projects.hasMany(project_audio_tracks, {
projects.hasMany(project_transition_settings, { as: 'project_transition_settings_project', onDelete: 'CASCADE' }); as: 'project_audio_tracks_project',
projects.hasMany(publish_events, { as: 'publish_events_project', onDelete: 'CASCADE' }); onDelete: 'CASCADE',
});
projects.hasMany(project_transition_settings, {
as: 'project_transition_settings_project',
onDelete: 'CASCADE',
});
projects.hasMany(publish_events, {
as: 'publish_events_project',
onDelete: 'CASCADE',
});
projects.hasMany(pwa_caches, { as: 'pwa_caches_project', onDelete: 'CASCADE' }); projects.hasMany(pwa_caches, { as: 'pwa_caches_project', onDelete: 'CASCADE' });
projects.hasMany(access_logs, { as: 'access_logs_project', onDelete: 'CASCADE' }); projects.hasMany(access_logs, {
projects.hasMany(project_element_defaults, { as: 'project_element_defaults_project', onDelete: 'CASCADE' }); as: 'access_logs_project',
projects.hasMany(production_presentation_access, { as: 'production_presentation_access_project', onDelete: 'CASCADE' }); onDelete: 'CASCADE',
});
projects.hasMany(project_element_defaults, {
as: 'project_element_defaults_project',
onDelete: 'CASCADE',
});
projects.hasMany(production_presentation_access, {
as: 'production_presentation_access_project',
onDelete: 'CASCADE',
});
``` ```
--- ---
@ -282,26 +318,40 @@ projects.hasMany(production_presentation_access, { as: 'production_presentation_
**Purpose:** Grants Public-role customer users access to selected private **Purpose:** Grants Public-role customer users access to selected private
production presentations. production presentations.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ------------- | ----------- | -------- | ------- | -------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `projectId` | UUID | No | - | FK to projects | | `projectId` | UUID | No | - | FK to projects |
| `userId` | UUID | No | - | FK to users | | `userId` | UUID | No | - | FK to users |
| `createdById` | UUID | Yes | - | FK to users | | `createdById` | UUID | Yes | - | FK to users |
| `updatedById` | UUID | Yes | - | FK to users | | `updatedById` | UUID | Yes | - | FK to users |
| `importHash` | STRING(255) | Yes | - | unique | | `importHash` | STRING(255) | Yes | - | unique |
**Indexes:** **Indexes:**
- `projectId` - `projectId`
- `userId` - `userId`
- `projectId, userId` unique for active rows - `projectId, userId` unique for active rows
**Associations:** **Associations:**
```javascript ```javascript
production_presentation_access.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' }); production_presentation_access.belongsTo(projects, {
production_presentation_access.belongsTo(users, { as: 'user', onDelete: 'CASCADE' }); as: 'project',
production_presentation_access.belongsTo(users, { as: 'createdBy', onDelete: 'SET NULL' }); onDelete: 'CASCADE',
production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SET NULL' }); });
production_presentation_access.belongsTo(users, {
as: 'user',
onDelete: 'CASCADE',
});
production_presentation_access.belongsTo(users, {
as: 'createdBy',
onDelete: 'SET NULL',
});
production_presentation_access.belongsTo(users, {
as: 'updatedBy',
onDelete: 'SET NULL',
});
``` ```
--- ---
@ -310,23 +360,24 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE
**Purpose:** Individual pages within a tour with UI elements schema. **Purpose:** Individual pages within a tour with UI elements schema.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ---------------------- | ------- | -------- | ------- | --------------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | 'dev' | dev, stage, production | | `environment` | ENUM | No | 'dev' | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original page ID for cloning | | `source_key` | TEXT | Yes | - | Original page ID for cloning |
| `name` | TEXT | No | - | notEmpty, len[1,255] | | `name` | TEXT | No | - | notEmpty, len[1,255] |
| `slug` | TEXT | No | - | notEmpty, alphanumeric + dashes | | `slug` | TEXT | No | - | notEmpty, alphanumeric + dashes |
| `sort_order` | INTEGER | No | 0 | - | | `sort_order` | INTEGER | No | 0 | - |
| `background_image_url` | TEXT | Yes | - | - | | `background_image_url` | TEXT | Yes | - | - |
| `background_video_url` | TEXT | Yes | - | - | | `background_video_url` | TEXT | Yes | - | - |
| `background_audio_url` | TEXT | Yes | - | - | | `background_audio_url` | TEXT | Yes | - | - |
| `background_loop` | BOOLEAN | No | false | - | | `background_loop` | BOOLEAN | No | false | - |
| `requires_auth` | BOOLEAN | No | false | - | | `requires_auth` | BOOLEAN | No | false | - |
| `ui_schema_json` | JSON | Yes | - | Page elements, links, transitions | | `ui_schema_json` | JSON | Yes | - | Page elements, links, transitions |
| `projectId` | UUID | Yes | - | FK to projects | | `projectId` | UUID | Yes | - | FK to projects |
**Indexes:** **Indexes:**
- `projectId` - `projectId`
- `[projectId, environment, slug]` (unique) - Composite unique per project+environment - `[projectId, environment, slug]` (unique) - Composite unique per project+environment
- `[projectId, environment, sort_order]` - For ordering queries - `[projectId, environment, sort_order]` - For ordering queries
@ -340,15 +391,19 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE
**Purpose:** RBAC role definitions. **Purpose:** RBAC role definitions.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | -------------------- | ---- | -------- | ------- | -------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, len[1,100] | | `name` | TEXT | No | - | notEmpty, len[1,100] |
| `role_customization` | TEXT | Yes | - | Custom role metadata | | `role_customization` | TEXT | Yes | - | Custom role metadata |
**Associations:** **Associations:**
```javascript ```javascript
roles.belongsToMany(permissions, { as: 'permissions', through: 'rolesPermissionsPermissions' }); roles.belongsToMany(permissions, {
as: 'permissions',
through: 'rolesPermissionsPermissions',
});
roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' }); roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
``` ```
@ -358,10 +413,10 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
**Purpose:** Individual permission definitions. **Purpose:** Individual permission definitions.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ------ | ---- | -------- | ------- | ---------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, unique, len[1,100] | | `name` | TEXT | No | - | notEmpty, unique, len[1,100] |
**Permission Naming Convention:** `{ACTION}_{ENTITY}` (e.g., `READ_USERS`, `CREATE_ASSETS`) **Permission Naming Convention:** `{ACTION}_{ENTITY}` (e.g., `READ_USERS`, `CREATE_ASSETS`)
@ -373,24 +428,25 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
**Purpose:** Uploaded media files (images, videos, audio, documents). **Purpose:** Uploaded media files (images, videos, audio, documents).
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | -------------- | ------- | -------- | --------- | ---------------------------------------------------------------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | Yes | - | len[0,255] | | `name` | TEXT | Yes | - | len[0,255] |
| `asset_type` | ENUM | No | - | image, video, audio, file | | `asset_type` | ENUM | No | - | image, video, audio, file |
| `type` | ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general | | `type` | ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general |
| `cdn_url` | TEXT | Yes | - | - | | `cdn_url` | TEXT | Yes | - | - |
| `storage_key` | TEXT | Yes | - | S3/storage path | | `storage_key` | TEXT | Yes | - | S3/storage path |
| `mime_type` | TEXT | Yes | - | MIME type format | | `mime_type` | TEXT | Yes | - | MIME type format |
| `size_mb` | DECIMAL | Yes | - | - | | `size_mb` | DECIMAL | Yes | - | - |
| `width_px` | INTEGER | Yes | - | Image/video width | | `width_px` | INTEGER | Yes | - | Image/video width |
| `height_px` | INTEGER | Yes | - | Image/video height | | `height_px` | INTEGER | Yes | - | Image/video height |
| `duration_sec` | DECIMAL | Yes | - | Audio/video duration | | `duration_sec` | DECIMAL | Yes | - | Audio/video duration |
| `checksum` | TEXT | Yes | - | File hash | | `checksum` | TEXT | Yes | - | File hash |
| `is_public` | BOOLEAN | No | false | - | | `is_public` | BOOLEAN | No | false | - |
| `projectId` | UUID | Yes | - | FK to projects | | `projectId` | UUID | Yes | - | FK to projects |
**Indexes:** **Indexes:**
- `projectId` - `projectId`
- `asset_type` - `asset_type`
- `type` - `type`
@ -398,8 +454,12 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
- `deletedAt` - `deletedAt`
**Associations:** **Associations:**
```javascript ```javascript
assets.hasMany(asset_variants, { as: 'asset_variants_asset', onDelete: 'CASCADE' }); assets.hasMany(asset_variants, {
as: 'asset_variants_asset',
onDelete: 'CASCADE',
});
assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' }); assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
``` ```
@ -409,17 +469,18 @@ assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
**Purpose:** Optimized versions of assets (thumbnails, different formats). **Purpose:** Optimized versions of assets (thumbnails, different formats).
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | -------------- | ------- | -------- | ------- | ----------------------------------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `variant_type` | ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original | | `variant_type` | ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original |
| `cdn_url` | TEXT | Yes | - | len[0,2048], URL format | | `cdn_url` | TEXT | Yes | - | len[0,2048], URL format |
| `width_px` | INTEGER | Yes | - | min: 0 | | `width_px` | INTEGER | Yes | - | min: 0 |
| `height_px` | INTEGER | Yes | - | min: 0 | | `height_px` | INTEGER | Yes | - | min: 0 |
| `size_mb` | DECIMAL | Yes | - | min: 0 | | `size_mb` | DECIMAL | Yes | - | min: 0 |
| `assetId` | UUID | Yes | - | FK to assets | | `assetId` | UUID | Yes | - | FK to assets |
**Associations:** **Associations:**
```javascript ```javascript
asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' }); asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' });
``` ```
@ -432,26 +493,28 @@ asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' });
**Purpose:** Global platform-wide default settings for UI element types. **Purpose:** Global platform-wide default settings for UI element types.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ----------------------- | ------- | -------- | ------- | ---------------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `element_type` | TEXT | No | - | notEmpty, unique, len[1,100] | | `element_type` | TEXT | No | - | notEmpty, unique, len[1,100] |
| `name` | TEXT | No | - | notEmpty, len[1,255] | | `name` | TEXT | No | - | notEmpty, len[1,255] |
| `sort_order` | INTEGER | No | 0 | - | | `sort_order` | INTEGER | No | 0 | - |
| `is_active` | VIRTUAL | - | true | Always returns true | | `is_active` | VIRTUAL | - | true | Always returns true |
| `default_settings_json` | TEXT | Yes | - | Mapped from `settings_json` column | | `default_settings_json` | TEXT | Yes | - | Mapped from `settings_json` column |
**Indexes:** **Indexes:**
- `element_type` - `element_type`
- `sort_order` - `sort_order`
- `deletedAt` - `deletedAt`
**Associations:** **Associations:**
```javascript ```javascript
element_type_defaults.hasMany(project_element_defaults, { element_type_defaults.hasMany(project_element_defaults, {
as: 'project_defaults', as: 'project_defaults',
foreignKey: 'source_element_id', foreignKey: 'source_element_id',
onDelete: 'SET NULL' onDelete: 'SET NULL',
}); });
``` ```
@ -463,18 +526,19 @@ element_type_defaults.hasMany(project_element_defaults, {
**Purpose:** Project-specific overrides for element defaults. **Purpose:** Project-specific overrides for element defaults.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ------------------- | ------- | -------- | ------- | --------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `element_type` | TEXT | No | - | notEmpty, len[1,100] | | `element_type` | TEXT | No | - | notEmpty, len[1,100] |
| `name` | TEXT | Yes | - | len[0,255] | | `name` | TEXT | Yes | - | len[0,255] |
| `sort_order` | INTEGER | No | 0 | - | | `sort_order` | INTEGER | No | 0 | - |
| `settings_json` | TEXT | Yes | - | Element configuration | | `settings_json` | TEXT | Yes | - | Element configuration |
| `source_element_id` | UUID | Yes | - | FK to element_type_defaults | | `source_element_id` | UUID | Yes | - | FK to element_type_defaults |
| `snapshot_version` | INTEGER | No | 1 | Version tracking | | `snapshot_version` | INTEGER | No | 1 | Version tracking |
| `projectId` | UUID | No | - | FK to projects | | `projectId` | UUID | No | - | FK to projects |
**Indexes:** **Indexes:**
- `projectId` - `projectId`
- `[projectId, element_type]` (unique) - `[projectId, element_type]` (unique)
- `element_type` - `element_type`
@ -482,11 +546,15 @@ element_type_defaults.hasMany(project_element_defaults, {
- `deletedAt` - `deletedAt`
**Associations:** **Associations:**
```javascript ```javascript
project_element_defaults.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' }); project_element_defaults.belongsTo(projects, {
as: 'project',
onDelete: 'CASCADE',
});
project_element_defaults.belongsTo(element_type_defaults, { project_element_defaults.belongsTo(element_type_defaults, {
as: 'source_element', as: 'source_element',
onDelete: 'SET NULL' onDelete: 'SET NULL',
}); });
``` ```
@ -498,24 +566,25 @@ project_element_defaults.belongsTo(element_type_defaults, {
**Purpose:** Track publishing actions between environments. **Purpose:** Track publishing actions between environments.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | -------------------- | ------- | -------- | -------- | -------------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `title` | STRING | Yes | - | len[0,255] | | `title` | STRING | Yes | - | len[0,255] |
| `description` | TEXT | Yes | - | len[0,5000] | | `description` | TEXT | Yes | - | len[0,5000] |
| `from_environment` | ENUM | No | - | dev, stage, production | | `from_environment` | ENUM | No | - | dev, stage, production |
| `to_environment` | ENUM | No | - | dev, stage, production | | `to_environment` | ENUM | No | - | dev, stage, production |
| `started_at` | DATE | Yes | - | - | | `started_at` | DATE | Yes | - | - |
| `finished_at` | DATE | Yes | - | - | | `finished_at` | DATE | Yes | - | - |
| `status` | ENUM | No | 'queued' | queued, running, success, failed | | `status` | ENUM | No | 'queued' | queued, running, success, failed |
| `error_message` | TEXT | Yes | - | - | | `error_message` | TEXT | Yes | - | - |
| `pages_copied` | INTEGER | Yes | - | min: 0 | | `pages_copied` | INTEGER | Yes | - | min: 0 |
| `transitions_copied` | INTEGER | Yes | - | min: 0 | | `transitions_copied` | INTEGER | Yes | - | min: 0 |
| `audios_copied` | INTEGER | Yes | - | min: 0 | | `audios_copied` | INTEGER | Yes | - | min: 0 |
| `projectId` | UUID | Yes | - | FK to projects | | `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users | | `userId` | UUID | Yes | - | FK to users |
**Indexes:** **Indexes:**
- `projectId` - `projectId`
- `userId` - `userId`
- `status` - `status`
@ -527,18 +596,19 @@ project_element_defaults.belongsTo(element_type_defaults, {
**Purpose:** Audit trail for user activity. **Purpose:** Audit trail for user activity.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ------------- | ---- | -------- | ------- | ------------------------ |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | - | admin, stage, production | | `environment` | ENUM | No | - | admin, stage, production |
| `path` | TEXT | Yes | - | len[0,2048] | | `path` | TEXT | Yes | - | len[0,2048] |
| `ip_address` | TEXT | Yes | - | len[0,45] (IPv6 max) | | `ip_address` | TEXT | Yes | - | len[0,45] (IPv6 max) |
| `user_agent` | TEXT | Yes | - | len[0,1024] | | `user_agent` | TEXT | Yes | - | len[0,1024] |
| `accessed_at` | DATE | No | NOW | - | | `accessed_at` | DATE | No | NOW | - |
| `projectId` | UUID | Yes | - | FK to projects | | `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users | | `userId` | UUID | Yes | - | FK to users |
**Indexes:** **Indexes:**
- `projectId` - `projectId`
- `environment` - `environment`
- `userId` - `userId`
@ -552,17 +622,18 @@ project_element_defaults.belongsTo(element_type_defaults, {
**Purpose:** User access to projects with role-based permissions. **Purpose:** User access to projects with role-based permissions.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | -------------- | ------- | -------- | -------- | ------------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `access_level` | ENUM | No | 'viewer' | owner, editor, reviewer, viewer | | `access_level` | ENUM | No | 'viewer' | owner, editor, reviewer, viewer |
| `is_active` | BOOLEAN | No | false | - | | `is_active` | BOOLEAN | No | false | - |
| `invited_at` | DATE | Yes | - | - | | `invited_at` | DATE | Yes | - | - |
| `accepted_at` | DATE | Yes | - | - | | `accepted_at` | DATE | Yes | - | - |
| `projectId` | UUID | Yes | - | FK to projects | | `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users | | `userId` | UUID | Yes | - | FK to users |
**Indexes:** **Indexes:**
- `projectId` - `projectId`
- `userId` - `userId`
- `[projectId, userId]` (unique) - One membership per user per project - `[projectId, userId]` (unique) - One membership per user per project
@ -575,19 +646,19 @@ project_element_defaults.belongsTo(element_type_defaults, {
**Purpose:** Background audio tracks for projects. **Purpose:** Background audio tracks for projects.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ------------- | ------- | -------- | ------- | ----------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | Yes | - | dev, stage, production | | `environment` | ENUM | Yes | - | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original track ID for cloning | | `source_key` | TEXT | Yes | - | Original track ID for cloning |
| `name` | TEXT | Yes | - | len[0,255] | | `name` | TEXT | Yes | - | len[0,255] |
| `slug` | TEXT | Yes | - | - | | `slug` | TEXT | Yes | - | - |
| `url` | TEXT | Yes | - | - | | `url` | TEXT | Yes | - | - |
| `loop` | BOOLEAN | No | false | - | | `loop` | BOOLEAN | No | false | - |
| `volume` | DECIMAL | Yes | - | min: 0, max: 1 | | `volume` | DECIMAL | Yes | - | min: 0, max: 1 |
| `sort_order` | INTEGER | Yes | - | - | | `sort_order` | INTEGER | Yes | - | - |
| `is_enabled` | BOOLEAN | No | false | - | | `is_enabled` | BOOLEAN | No | false | - |
| `projectId` | UUID | Yes | - | FK to projects | | `projectId` | UUID | Yes | - | FK to projects |
--- ---
@ -595,27 +666,32 @@ project_element_defaults.belongsTo(element_type_defaults, {
**Purpose:** Environment-aware CSS transition settings for page navigation. **Purpose:** Environment-aware CSS transition settings for page navigation.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ----------------- | ------- | -------- | ------------- | -------------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | - | dev, stage, production | | `environment` | ENUM | No | - | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original settings ID for cloning | | `source_key` | TEXT | Yes | - | Original settings ID for cloning |
| `transition_type` | TEXT | No | 'fade' | CSS transition type | | `transition_type` | TEXT | No | 'fade' | CSS transition type |
| `duration_ms` | INTEGER | No | 700 | Transition duration in ms | | `duration_ms` | INTEGER | No | 700 | Transition duration in ms |
| `easing` | TEXT | No | 'ease-in-out' | CSS easing function | | `easing` | TEXT | No | 'ease-in-out' | CSS easing function |
| `overlay_color` | TEXT | No | '#000000' | Transition overlay color | | `overlay_color` | TEXT | No | '#000000' | Transition overlay color |
| `projectId` | UUID | No | - | FK to projects | | `projectId` | UUID | No | - | FK to projects |
| `createdById` | UUID | Yes | - | FK to users | | `createdById` | UUID | Yes | - | FK to users |
| `updatedById` | UUID | Yes | - | FK to users | | `updatedById` | UUID | Yes | - | FK to users |
**Indexes:** **Indexes:**
- `[projectId, environment]` (unique where deletedAt IS NULL) - `[projectId, environment]` (unique where deletedAt IS NULL)
- `projectId` - `projectId`
- `deletedAt` - `deletedAt`
**Associations:** **Associations:**
```javascript ```javascript
project_transition_settings.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' }); project_transition_settings.belongsTo(projects, {
as: 'project',
onDelete: 'CASCADE',
});
project_transition_settings.belongsTo(users, { as: 'createdBy' }); project_transition_settings.belongsTo(users, { as: 'createdBy' });
project_transition_settings.belongsTo(users, { as: 'updatedBy' }); project_transition_settings.belongsTo(users, { as: 'updatedBy' });
``` ```
@ -628,16 +704,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
**Purpose:** PWA offline cache manifest tracking. **Purpose:** PWA offline cache manifest tracking.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ----------------- | ------- | -------- | ------- | ---------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | Yes | - | dev, stage, production | | `environment` | ENUM | Yes | - | dev, stage, production |
| `cache_version` | TEXT | Yes | - | len[0,255] | | `cache_version` | TEXT | Yes | - | len[0,255] |
| `manifest_json` | JSON | Yes | - | PWA manifest | | `manifest_json` | JSON | Yes | - | PWA manifest |
| `asset_list_json` | JSON | Yes | - | Cached asset URLs | | `asset_list_json` | JSON | Yes | - | Cached asset URLs |
| `generated_at` | DATE | Yes | - | - | | `generated_at` | DATE | Yes | - | - |
| `is_active` | BOOLEAN | No | false | - | | `is_active` | BOOLEAN | No | false | - |
| `projectId` | UUID | Yes | - | FK to projects | | `projectId` | UUID | Yes | - | FK to projects |
--- ---
@ -645,18 +721,18 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
**Purpose:** Audit log for S3 presigned URL requests. **Purpose:** Audit log for S3 presigned URL requests.
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ------------------- | ------- | -------- | ------- | ------------------------- |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `purpose` | ENUM | Yes | - | upload, download | | `purpose` | ENUM | Yes | - | upload, download |
| `asset_type` | ENUM | Yes | - | image, video, audio, file | | `asset_type` | ENUM | Yes | - | image, video, audio, file |
| `requested_key` | TEXT | Yes | - | len[0,1024] | | `requested_key` | TEXT | Yes | - | len[0,1024] |
| `mime_type` | TEXT | Yes | - | MIME format, len[0,255] | | `mime_type` | TEXT | Yes | - | MIME format, len[0,255] |
| `requested_size_mb` | DECIMAL | Yes | - | min: 0 | | `requested_size_mb` | DECIMAL | Yes | - | min: 0 |
| `expires_at` | DATE | Yes | - | - | | `expires_at` | DATE | Yes | - | - |
| `status` | TEXT | Yes | - | - | | `status` | TEXT | Yes | - | - |
| `projectId` | UUID | Yes | - | FK to projects | | `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users | | `userId` | UUID | Yes | - | FK to users |
--- ---
@ -664,16 +740,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
**Purpose:** Generic file attachments (user avatars, etc.). **Purpose:** Generic file attachments (user avatars, etc.).
| Field | Type | Nullable | Default | Validation | | Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------| | ----------------- | ------------ | -------- | ------- | ------------------ |
| `id` | UUID | No | UUIDv4 | - | | `id` | UUID | No | UUIDv4 | - |
| `belongsTo` | STRING(255) | Yes | - | Parent table name | | `belongsTo` | STRING(255) | Yes | - | Parent table name |
| `belongsToId` | UUID | Yes | - | Parent record ID | | `belongsToId` | UUID | Yes | - | Parent record ID |
| `belongsToColumn` | STRING(255) | Yes | - | Parent column name | | `belongsToColumn` | STRING(255) | Yes | - | Parent column name |
| `name` | STRING(2083) | No | - | notEmpty | | `name` | STRING(2083) | No | - | notEmpty |
| `sizeInBytes` | INTEGER | Yes | - | - | | `sizeInBytes` | INTEGER | Yes | - | - |
| `privateUrl` | STRING(2083) | Yes | - | - | | `privateUrl` | STRING(2083) | Yes | - | - |
| `publicUrl` | STRING(2083) | No | - | notEmpty | | `publicUrl` | STRING(2083) | No | - | notEmpty |
**Usage Pattern:** Polymorphic association via `belongsTo`, `belongsToId`, `belongsToColumn` fields and scoped `hasMany` on parent models: **Usage Pattern:** Polymorphic association via `belongsTo`, `belongsToId`, `belongsToColumn` fields and scoped `hasMany` on parent models:
@ -811,19 +887,17 @@ importHash: {
### 4. Cascade Delete Patterns ### 4. Cascade Delete Patterns
| Relationship | onDelete | Use Case | | Relationship | onDelete | Use Case |
|--------------|----------|----------| | ------------ | ----------------------------------- | --------------------- |
| `CASCADE` | Delete children when parent deleted | Projects → tour_pages | | `CASCADE` | Delete children when parent deleted | Projects → tour_pages |
| `SET NULL` | Keep children, null the FK | Roles → users | | `SET NULL` | Keep children, null the FK | Roles → users |
### 5. Composite Unique Constraints ### 5. Composite Unique Constraints
For scoped uniqueness: For scoped uniqueness:
```javascript ```javascript
indexes: [ indexes: [{ fields: ['projectId', 'environment', 'slug'], unique: true }];
{ fields: ['projectId', 'environment', 'slug'], unique: true },
]
``` ```
### 6. JSON Fields ### 6. JSON Fields
@ -831,8 +905,12 @@ indexes: [
Complex configurations stored as JSON: Complex configurations stored as JSON:
```javascript ```javascript
ui_schema_json: { type: DataTypes.JSON } // Parsed JSON column ui_schema_json: {
settings_json: { type: DataTypes.TEXT } // Stringified JSON text type: DataTypes.JSON;
} // Parsed JSON column
settings_json: {
type: DataTypes.TEXT;
} // Stringified JSON text
``` ```
### 7. Virtual Fields ### 7. Virtual Fields
@ -933,7 +1011,7 @@ const transaction = await db.sequelize.transaction();
// Access Sequelize operators // Access Sequelize operators
const { Op } = db.Sequelize; const { Op } = db.Sequelize;
const users = await db.users.findAll({ const users = await db.users.findAll({
where: { email: { [Op.like]: '%@example.com' } } where: { email: { [Op.like]: '%@example.com' } },
}); });
``` ```

View File

@ -30,6 +30,7 @@ backend/src/db/seeders/
## Configuration ## Configuration
### NPM Scripts ### NPM Scripts
```bash ```bash
# Run pending seeders # Run pending seeders
npm run db:seed npm run db:seed
@ -42,7 +43,9 @@ npm run db:reset
``` ```
### Server Startup ### Server Startup
Seeders run automatically via `npm start`: Seeders run automatically via `npm start`:
```json ```json
{ {
"scripts": { "scripts": {
@ -65,13 +68,14 @@ development and the compiled JavaScript file from `dist/` in production builds.
**Users Created:** **Users Created:**
| User | Email | Role Assignment | | User | Email | Role Assignment |
|------|-------|-----------------| | ------ | -------------------- | --------------- |
| Admin | `config.admin_email` | Administrator | | Admin | `config.admin_email` | Administrator |
| John | john@doe.com | Account Manager | | John | john@doe.com | Account Manager |
| Client | client@hello.com | Platform Owner | | Client | client@hello.com | Platform Owner |
**Key Features:** **Key Features:**
- Uses bcrypt for password hashing with configured salt rounds - Uses bcrypt for password hashing with configured salt rounds
- Hardcoded UUIDs for consistent user IDs - Hardcoded UUIDs for consistent user IDs
- Reads credentials from `config.ts` (environment variables) - Reads credentials from `config.ts` (environment variables)
@ -112,14 +116,15 @@ data.
**Data Created:** **Data Created:**
| Data Type | Count | Description | | Data Type | Count | Description |
|-----------|-------|-------------| | --------------------- | ----- | ------------------------------------------ |
| Roles | 7 | User role definitions | | Roles | 7 | User role definitions |
| Permissions | 54 | CRUD permissions for 13 entities + special | | Permissions | 54 | CRUD permissions for 13 entities + special |
| Role-Permission Links | 200+ | M:N relationships | | Role-Permission Links | 200+ | M:N relationships |
| Join Table | 1 | `rolesPermissionsPermissions` table | | Join Table | 1 | `rolesPermissionsPermissions` table |
**Key Features:** **Key Features:**
- Uses stable named role and permission definitions, then reuses existing DB IDs - Uses stable named role and permission definitions, then reuses existing DB IDs
when the same role/permission name is already present. when the same role/permission name is already present.
- Inserts only missing roles, permissions, and role-permission links. This keeps - Inserts only missing roles, permissions, and role-permission links. This keeps
@ -128,19 +133,21 @@ data.
by email after RBAC data exists. by email after RBAC data exists.
#### Roles #### Roles
```javascript ```javascript
const roles = [ const roles = [
'Administrator', // Full system access 'Administrator', // Full system access
'PlatformOwner', // Full project/content access 'PlatformOwner', // Full project/content access
'AccountManager', // User and project management 'AccountManager', // User and project management
'TourDesigner', // Content creation and editing 'TourDesigner', // Content creation and editing
'ContentReviewer', // Read + limited update access 'ContentReviewer', // Read + limited update access
'AnalyticsViewer', // Read-only access 'AnalyticsViewer', // Read-only access
'Public', // Public/unauthenticated access 'Public', // Public/unauthenticated access
]; ];
``` ```
#### Permission Generation Pattern #### Permission Generation Pattern
```javascript ```javascript
// Generates CREATE, READ, UPDATE, DELETE permissions per entity // Generates CREATE, READ, UPDATE, DELETE permissions per entity
function createPermissions(name) { function createPermissions(name) {
@ -153,13 +160,26 @@ function createPermissions(name) {
} }
const entities = [ const entities = [
'users', 'roles', 'permissions', 'projects', 'project_memberships', 'users',
'assets', 'asset_variants', 'presigned_url_requests', 'tour_pages', 'roles',
'project_audio_tracks', 'publish_events', 'pwa_caches', 'access_logs' 'permissions',
'projects',
'project_memberships',
'assets',
'asset_variants',
'presigned_url_requests',
'tour_pages',
'project_audio_tracks',
'publish_events',
'pwa_caches',
'access_logs',
]; ];
// Creates 52 permissions (13 entities × 4 CRUD operations) // Creates 52 permissions (13 entities × 4 CRUD operations)
await queryInterface.bulkInsert('permissions', entities.flatMap(createPermissions)); await queryInterface.bulkInsert(
'permissions',
entities.flatMap(createPermissions),
);
// Plus special permissions // Plus special permissions
await queryInterface.bulkInsert('permissions', [ await queryInterface.bulkInsert('permissions', [
@ -169,6 +189,7 @@ await queryInterface.bulkInsert('permissions', [
``` ```
#### ID Map Pattern #### ID Map Pattern
```javascript ```javascript
// Consistent UUID generation using key-based map // Consistent UUID generation using key-based map
const idMap = new Map(); const idMap = new Map();
@ -183,25 +204,26 @@ function getId(key) {
} }
// Usage - same key always returns same UUID within seeder run // Usage - same key always returns same UUID within seeder run
getId('Administrator') // Returns consistent UUID getId('Administrator'); // Returns consistent UUID
getId('CREATE_USERS') // Returns consistent UUID getId('CREATE_USERS'); // Returns consistent UUID
``` ```
#### Permission Matrix #### Permission Matrix
| Role | Users | Projects | Assets | Tour Pages | Access Logs | | Role | Users | Projects | Assets | Tour Pages | Access Logs |
|------|-------|----------|--------|------------|-------------| | ------------------- | ----- | -------- | ------ | ---------- | ----------- |
| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD | | **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD |
| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD | | **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD |
| **AccountManager** | RU | CRU | CRU | CRU | R | | **AccountManager** | RU | CRU | CRU | CRU | R |
| **TourDesigner** | R | RU | CRU | CRU | R | | **TourDesigner** | R | RU | CRU | CRU | R |
| **ContentReviewer** | R | RU | RU | RU | R | | **ContentReviewer** | R | RU | RU | RU | R |
| **AnalyticsViewer** | R | R | R | R | R | | **AnalyticsViewer** | R | R | R | R | R |
| **Public** | - | - | - | - | - | | **Public** | - | - | - | - | - |
**Legend:** C=Create, R=Read, U=Update, D=Delete **Legend:** C=Create, R=Read, U=Update, D=Delete
#### Join Table Creation #### Join Table Creation
```javascript ```javascript
// Creates M:N relationship table directly in seeder // Creates M:N relationship table directly in seeder
await queryInterface.sequelize.query(` await queryInterface.sequelize.query(`
@ -235,6 +257,7 @@ separate. Umzug records the seeder under its legacy `.js` name for storage
compatibility only. compatibility only.
**Opt-In Activation:** **Opt-In Activation:**
```bash ```bash
# Enable sample data seeding # Enable sample data seeding
export ENABLE_SAMPLE_DATA=true export ENABLE_SAMPLE_DATA=true
@ -242,6 +265,7 @@ npm run db:seed
``` ```
**Check in Code:** **Check in Code:**
```typescript ```typescript
const sampleDataSeeder: SequelizeSeeder = { const sampleDataSeeder: SequelizeSeeder = {
async up() { async up() {
@ -253,20 +277,21 @@ const sampleDataSeeder: SequelizeSeeder = {
**Data Created:** **Data Created:**
| Entity | Records | Description | | Entity | Records | Description |
|--------|---------|-------------| | ---------------------- | ------- | -------------------------- |
| Projects | 3 | Sample tour projects | | Projects | 3 | Sample tour projects |
| Project Memberships | 3 | User-project associations | | Project Memberships | 3 | User-project associations |
| Assets | 3 | Images, videos, audio | | Assets | 3 | Images, videos, audio |
| Asset Variants | 3 | Thumbnail/preview variants | | Asset Variants | 3 | Thumbnail/preview variants |
| Presigned URL Requests | 3 | Upload/download requests | | Presigned URL Requests | 3 | Upload/download requests |
| Tour Pages | 3 | Sample tour pages | | Tour Pages | 3 | Sample tour pages |
| Project Audio Tracks | 3 | Background audio | | Project Audio Tracks | 3 | Background audio |
| Publish Events | 3 | Deployment history | | Publish Events | 3 | Deployment history |
| PWA Caches | 3 | Offline cache configs | | PWA Caches | 3 | Offline cache configs |
| Access Logs | 3 | Visitor tracking | | Access Logs | 3 | Visitor tracking |
#### Sample Projects #### Sample Projects
```javascript ```javascript
const ProjectsData = [ const ProjectsData = [
{ {
@ -293,6 +318,7 @@ const ProjectsData = [
``` ```
#### Association Helper Pattern #### Association Helper Pattern
```javascript ```javascript
// Associates records after bulk creation using Sequelize model methods // Associates records after bulk creation using Sequelize model methods
async function associateAssetWithProject() { async function associateAssetWithProject() {
@ -314,6 +340,7 @@ async function associateAssetWithProject() {
## Seeder Patterns ## Seeder Patterns
### 1. bulkInsert Pattern ### 1. bulkInsert Pattern
**Purpose:** Insert multiple records efficiently. **Purpose:** Insert multiple records efficiently.
```javascript ```javascript
@ -329,6 +356,7 @@ await queryInterface.bulkInsert('tableName', [
``` ```
### 2. bulkDelete Pattern ### 2. bulkDelete Pattern
**Purpose:** Remove seeded data during rollback. **Purpose:** Remove seeded data during rollback.
```javascript ```javascript
@ -340,6 +368,7 @@ async down(queryInterface, Sequelize) {
``` ```
### 3. Conditional Execution Pattern ### 3. Conditional Execution Pattern
**Purpose:** Enable/disable seeders based on environment. **Purpose:** Enable/disable seeders based on environment.
Seeder-only environment gates are intentionally allowed to read `process.env` Seeder-only environment gates are intentionally allowed to read `process.env`
@ -354,13 +383,14 @@ records, or sample-data entities.
```javascript ```javascript
up: async () => { up: async () => {
if (process.env.ENABLE_SAMPLE_DATA !== 'true') { if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
return; // Skip seeding return; // Skip seeding
} }
// ... proceed with seeding // ... proceed with seeding
} };
``` ```
### 4. ID Consistency Pattern ### 4. ID Consistency Pattern
**Purpose:** Use deterministic IDs for reliable down() migrations. **Purpose:** Use deterministic IDs for reliable down() migrations.
```javascript ```javascript
@ -381,6 +411,7 @@ function getId(key) {
``` ```
### 5. Model-Based Association Pattern ### 5. Model-Based Association Pattern
**Purpose:** Create relationships using Sequelize models after bulk insert. **Purpose:** Create relationships using Sequelize models after bulk insert.
```javascript ```javascript
@ -391,6 +422,7 @@ await asset.setProject(project);
``` ```
### 6. Raw SQL Pattern ### 6. Raw SQL Pattern
**Purpose:** Create structures not managed by Sequelize models. **Purpose:** Create structures not managed by Sequelize models.
```javascript ```javascript
@ -403,7 +435,7 @@ await queryInterface.sequelize.query(`
// Create indexes // Create indexes
await queryInterface.sequelize.query( await queryInterface.sequelize.query(
'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");' 'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");',
); );
``` ```
@ -448,16 +480,21 @@ npm run db:seed
## Best Practices ## Best Practices
### 1. Use Consistent IDs ### 1. Use Consistent IDs
```javascript ```javascript
// Good - allows rollback // Good - allows rollback
const ids = ['uuid-1', 'uuid-2']; const ids = ['uuid-1', 'uuid-2'];
await queryInterface.bulkInsert('table', records.map((r, i) => ({ id: ids[i], ...r }))); await queryInterface.bulkInsert(
'table',
records.map((r, i) => ({ id: ids[i], ...r })),
);
// Down migration can target specific IDs // Down migration can target specific IDs
await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } }); await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
``` ```
### 2. Always Include Timestamps ### 2. Always Include Timestamps
```javascript ```javascript
{ {
field: 'value', field: 'value',
@ -467,6 +504,7 @@ await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
``` ```
### 3. Handle Errors ### 3. Handle Errors
```javascript ```javascript
try { try {
await queryInterface.bulkInsert('users', [...]); await queryInterface.bulkInsert('users', [...]);
@ -477,6 +515,7 @@ try {
``` ```
### 4. Environment-Aware Seeding ### 4. Environment-Aware Seeding
```javascript ```javascript
// Production - only essential data // Production - only essential data
// Development - include sample data // Development - include sample data
@ -486,6 +525,7 @@ if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
``` ```
### 5. Idempotent Where Possible ### 5. Idempotent Where Possible
```javascript ```javascript
// Use IF NOT EXISTS for table/index creation // Use IF NOT EXISTS for table/index creation
await queryInterface.sequelize.query(` await queryInterface.sequelize.query(`
@ -523,23 +563,27 @@ sample-data.ts
## Running Seeders ## Running Seeders
### Development Setup ### Development Setup
```bash ```bash
cd backend cd backend
npm run db:seed npm run db:seed
``` ```
### With Sample Data ### With Sample Data
```bash ```bash
export ENABLE_SAMPLE_DATA=true export ENABLE_SAMPLE_DATA=true
npm run db:seed npm run db:seed
``` ```
### Fresh Database ### Fresh Database
```bash ```bash
npm run db:reset # drop, create, migrate, seed npm run db:reset # drop, create, migrate, seed
``` ```
### Undo Seeders ### Undo Seeders
```bash ```bash
npm run db:seed:undo # Runs all down() methods in reverse order npm run db:seed:undo # Runs all down() methods in reverse order
``` ```
@ -548,22 +592,22 @@ npm run db:seed:undo # Runs all down() methods in reverse order
## Seeder Inventory ## Seeder Inventory
| # | Timestamp | Name | Records | Required | | # | Timestamp | Name | Records | Required |
|---|-----------|------|---------|----------| | --- | -------------- | ----------- | ----------------------------------- | ----------- |
| 1 | 20200430130759 | admin-user | 3 users | Yes | | 1 | 20200430130759 | admin-user | 3 users | Yes |
| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes | | 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes |
| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) | | 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) |
--- ---
## Environment Variables ## Environment Variables
| Variable | Purpose | Default | | Variable | Purpose | Default |
|----------|---------|---------| | -------------------- | ------------------------- | ------------- |
| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` | | `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` |
| `ADMIN_EMAIL` | Admin user email | (from config) | | `ADMIN_EMAIL` | Admin user email | (from config) |
| `ADMIN_PASS` | Admin user password | (from config) | | `ADMIN_PASS` | Admin user password | (from config) |
| `USER_PASS` | Default user password | (from config) | | `USER_PASS` | Default user password | (from config) |
--- ---

View File

@ -129,13 +129,13 @@ export default class EmailSender {
**Key Methods:** **Key Methods:**
| Method | Type | Description | | Method | Type | Description |
|--------|------|-------------| | -------------------- | ------------- | -------------------------------- |
| `constructor(email)` | Instance | Accepts email template object | | `constructor(email)` | Instance | Accepts email template object |
| `send()` | Async | Sends email via Nodemailer | | `send()` | Async | Sends email via Nodemailer |
| `isConfigured` | Static getter | Checks if SMTP credentials exist | | `isConfigured` | Static getter | Checks if SMTP credentials exist |
| `transportConfig` | Getter | Returns SMTP config | | `transportConfig` | Getter | Returns SMTP config |
| `from` | Getter | Returns sender address | | `from` | Getter | Returns sender address |
--- ---
@ -167,7 +167,7 @@ export default class PasswordResetEmail implements EmailTemplate {
get subject() { get subject() {
return getNotification( return getNotification(
'emails.passwordReset.subject', 'emails.passwordReset.subject',
getNotification('app.title') getNotification('app.title'),
); );
// → "Reset your password for Tour Builder Platform" // → "Reset your password for Tour Builder Platform"
} }
@ -179,10 +179,11 @@ export default class PasswordResetEmail implements EmailTemplate {
.replace(/{resetUrl}/g, this.link) .replace(/{resetUrl}/g, this.link)
.replace(/{accountName}/g, this.to); .replace(/{accountName}/g, this.to);
} }
}; }
``` ```
**Template Variables:** **Template Variables:**
- `{appTitle}` - Application name - `{appTitle}` - Application name
- `{resetUrl}` - Password reset link - `{resetUrl}` - Password reset link
- `{accountName}` - User email address - `{accountName}` - User email address
@ -201,7 +202,7 @@ export default class EmailAddressVerificationEmail implements EmailTemplate {
get subject() { get subject() {
return getNotification( return getNotification(
'emails.emailAddressVerification.subject', 'emails.emailAddressVerification.subject',
getNotification('app.title') getNotification('app.title'),
); );
// → "Verify your email for Tour Builder Platform" // → "Verify your email for Tour Builder Platform"
} }
@ -213,10 +214,11 @@ export default class EmailAddressVerificationEmail implements EmailTemplate {
.replace(/{signupUrl}/g, this.link) .replace(/{signupUrl}/g, this.link)
.replace(/{to}/g, this.to); .replace(/{to}/g, this.to);
} }
}; }
``` ```
**Template Variables:** **Template Variables:**
- `{appTitle}` - Application name - `{appTitle}` - Application name
- `{signupUrl}` - Email verification link - `{signupUrl}` - Email verification link
- `{to}` - User email address - `{to}` - User email address
@ -235,7 +237,7 @@ export default class InvitationEmail implements EmailTemplate {
get subject() { get subject() {
return getNotification( return getNotification(
'emails.invitation.subject', 'emails.invitation.subject',
getNotification('app.title') getNotification('app.title'),
); );
// → "You've been invited to Tour Builder Platform" // → "You've been invited to Tour Builder Platform"
} }
@ -248,10 +250,11 @@ export default class InvitationEmail implements EmailTemplate {
.replace(/{signupUrl}/g, signupUrl) .replace(/{signupUrl}/g, signupUrl)
.replace(/{to}/g, this.to); .replace(/{to}/g, this.to);
} }
}; }
``` ```
**Template Variables:** **Template Variables:**
- `{appTitle}` - Application name - `{appTitle}` - Application name
- `{signupUrl}` - Account setup link with `&invitation=true` - `{signupUrl}` - Account setup link with `&invitation=true`
- `{to}` - User email address - `{to}` - User email address
@ -267,66 +270,66 @@ All HTML templates follow consistent styling:
```html ```html
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<style> <style>
.email-container { .email-container {
max-width: 600px; max-width: 600px;
margin: auto; margin: auto;
background-color: #ffffff; background-color: #ffffff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
} }
.email-header { .email-header {
background-color: #3498db; /* Primary blue */ background-color: #3498db; /* Primary blue */
color: #fff; color: #fff;
padding: 16px; padding: 16px;
text-align: center; text-align: center;
} }
.email-body { .email-body {
padding: 16px; padding: 16px;
} }
.email-footer { .email-footer {
padding: 16px; padding: 16px;
background-color: #f7fafc; background-color: #f7fafc;
text-align: center; text-align: center;
color: #4a5568; color: #4a5568;
font-size: 14px; font-size: 14px;
} }
.link-primary { .link-primary {
color: #3498db; color: #3498db;
text-decoration: none; text-decoration: none;
} }
.btn-primary { .btn-primary {
background-color: #3498db; background-color: #3498db;
color: #fff !important; color: #fff !important;
padding: 8px 16px; padding: 8px 16px;
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="email-header">...</div> <div class="email-header">...</div>
<div class="email-body">...</div> <div class="email-body">...</div>
<div class="email-footer"> <div class="email-footer">
Thanks,<br/> Thanks,<br />
The {appTitle} Team The {appTitle} Team
</div> </div>
</div> </div>
</body> </body>
</html> </html>
``` ```
### Template Comparison ### Template Comparison
| Template | Header Text | Call-to-Action | Button Style | | Template | Header Text | Call-to-Action | Button Style |
|----------|-------------|----------------|--------------| | ------------------ | ------------------------------------ | -------------- | -------------- |
| Password Reset | "Reset your password for {appTitle}" | Link | Text link | | Password Reset | "Reset your password for {appTitle}" | Link | Text link |
| Email Verification | "Verify your email for {appTitle}!" | Link | Text link | | Email Verification | "Verify your email for {appTitle}!" | Link | Text link |
| Invitation | "Welcome to {appTitle}!" | Button | Primary button | | Invitation | "Welcome to {appTitle}!" | Button | Primary button |
--- ---
@ -351,11 +354,11 @@ email: {
### Environment Variables ### Environment Variables
| Variable | Required | Description | | Variable | Required | Description |
|----------|----------|-------------| | ------------------------------- | -------- | ----------------------------------------- |
| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) | | `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) |
| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) | | `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification | | `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification |
### AWS SES Configuration ### AWS SES Configuration
@ -397,9 +400,10 @@ class Auth {
const token = await UsersDBApi.generatePasswordResetToken(email); const token = await UsersDBApi.generatePasswordResetToken(email);
const link = `${host}/password-reset?token=${token}`; const link = `${host}/password-reset?token=${token}`;
const emailObj = type === 'invitation' const emailObj =
? new InvitationEmail({ to: email, host: link }) type === 'invitation'
: new PasswordResetEmail({ to: email, link }); ? new InvitationEmail({ to: email, host: link })
: new PasswordResetEmail({ to: email, link });
return new EmailSender(emailObj).send(); return new EmailSender(emailObj).send();
} }
@ -445,10 +449,14 @@ router.get('/email-configured', (req, res) => {
}); });
// Resend verification email (authenticated) // Resend verification email (authenticated)
router.put('/send-email-address-verification-email', jwtAuth, async (req, res) => { router.put(
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email); '/send-email-address-verification-email',
res.status(200).send(true); jwtAuth,
}); async (req, res) => {
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
res.status(200).send(true);
},
);
// Request password reset (public) // Request password reset (public)
router.put('/send-password-reset-email', async (req, res) => { router.put('/send-password-reset-email', async (req, res) => {
@ -542,10 +550,10 @@ static async markEmailVerified(id, options) {
### Token Properties ### Token Properties
| Token Type | Field | Expiry Field | TTL | | Token Type | Field | Expiry Field | TTL |
|------------|-------|--------------|-----| | ------------------ | ------------------------ | --------------------------------- | -------- |
| Email Verification | `emailVerificationToken` | `emailVerificationTokenExpiresAt` | 24 hours | | Email Verification | `emailVerificationToken` | `emailVerificationTokenExpiresAt` | 24 hours |
| Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours | | Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours |
--- ---
@ -670,6 +678,7 @@ static async markEmailVerified(id, options) {
### Email Configured Mode ### Email Configured Mode
When `EMAIL_USER` and `EMAIL_PASS` are set: When `EMAIL_USER` and `EMAIL_PASS` are set:
- Email verification required before login - Email verification required before login
- Password reset emails sent on request - Password reset emails sent on request
- User invitations include email - User invitations include email
@ -677,6 +686,7 @@ When `EMAIL_USER` and `EMAIL_PASS` are set:
### Email Not Configured Mode ### Email Not Configured Mode
When credentials are missing: When credentials are missing:
- `EmailSender.isConfigured` returns `false` - `EmailSender.isConfigured` returns `false`
- Users auto-verified on signin: `user.emailVerified = true` - Users auto-verified on signin: `user.emailVerified = true`
- Password reset/invitation silently skipped - Password reset/invitation silently skipped
@ -746,13 +756,13 @@ emails: {
### Email-Related Errors ### Email-Related Errors
| Error Code | Message | When Thrown | | Error Code | Message | When Thrown |
|------------|---------|-------------| | ------------------------------------------------- | --------------------------------------------------- | ------------------------------------- |
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | Token generation fails | | `auth.emailAddressVerificationEmail.error` | "Email not recognized" | Token generation fails |
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | Invalid/expired verification token | | `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | Invalid/expired verification token |
| `auth.passwordReset.error` | "Email not recognized" | Password reset token generation fails | | `auth.passwordReset.error` | "Email not recognized" | Password reset token generation fails |
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | Invalid/expired reset token | | `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | Invalid/expired reset token |
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification | | `auth.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification |
### Error Flow ### Error Flow
@ -827,7 +837,7 @@ describe('EmailSender', () => {
expect.objectContaining({ expect.objectContaining({
to: 'test@example.com', to: 'test@example.com',
subject: expect.stringContaining('Reset your password'), subject: expect.stringContaining('Reset your password'),
}) }),
); );
}); });
}); });
@ -843,6 +853,7 @@ EMAIL_PASS=
``` ```
Result: Result:
- `EmailSender.isConfigured` returns `false` - `EmailSender.isConfigured` returns `false`
- Users auto-verified on login - Users auto-verified on login
- No emails sent - No emails sent
@ -859,19 +870,21 @@ Result:
<!-- services/email/htmlTemplates/welcome/welcomeEmail.html --> <!-- services/email/htmlTemplates/welcome/welcomeEmail.html -->
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<style>/* Same styles as other templates */</style> <style>
</head> /* Same styles as other templates */
<body> </style>
<div class="email-container"> </head>
<div class="email-header">Welcome to {appTitle}!</div> <body>
<div class="email-body"> <div class="email-container">
<div class="email-header">Welcome to {appTitle}!</div>
<div class="email-body">
<p>Hello {userName},</p> <p>Hello {userName},</p>
<p>Your account has been activated.</p> <p>Your account has been activated.</p>
</div>
<div class="email-footer">Thanks,<br />The {appTitle} Team</div>
</div> </div>
<div class="email-footer">Thanks,<br/>The {appTitle} Team</div> </body>
</div>
</body>
</html> </html>
``` ```
@ -944,12 +957,12 @@ await new EmailSender(email).send();
## Dependencies ## Dependencies
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| | ------------- | -------- | ------------------------ |
| `nodemailer` | ^6.x | SMTP transport | | `nodemailer` | ^6.x | SMTP transport |
| `assert` | built-in | Input validation | | `assert` | built-in | Input validation |
| `fs.promises` | built-in | Template file reading | | `fs.promises` | built-in | Template file reading |
| `path` | built-in | Template path resolution | | `path` | built-in | Template path resolution |
--- ---

View File

@ -7,9 +7,10 @@ The Factories module provides code generation patterns that eliminate boilerplat
**Location:** `backend/src/factories/` **Location:** `backend/src/factories/`
**Files:** **Files:**
| File | Purpose | LOC |
|------|---------|-----| | File | Purpose | LOC |
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 | | -------------------- | --------------------------------------------------- | --- |
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 |
| `service.factory.ts` | Generates service classes with transaction handling | 350 | | `service.factory.ts` | Generates service classes with transaction handling | 350 |
--- ---
@ -63,34 +64,34 @@ function createEntityRouter(entityName, Service, DBApi, options = {})
#### Parameters #### Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
|-----------|------|-------------| | ------------ | -------- | ----------------------------------------- |
| `entityName` | `string` | Entity name for routes and permissions | | `entityName` | `string` | Entity name for routes and permissions |
| `Service` | `class` | Service class with CRUD methods | | `Service` | `class` | Service class with CRUD methods |
| `DBApi` | `class` | Database API class extending GenericDBApi | | `DBApi` | `class` | Database API class extending GenericDBApi |
| `options` | `object` | Configuration options | | `options` | `object` | Configuration options |
#### Options #### Options
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| | ------------------ | ---------- | ------------------ | ------------------------------- |
| `permissionEntity` | `string` | `entityName` | Override permission entity name | | `permissionEntity` | `string` | `entityName` | Override permission entity name |
| `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export | | `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export |
| `customRoutes` | `function` | `null` | Callback to add custom routes | | `customRoutes` | `function` | `null` | Callback to add custom routes |
#### Generated Endpoints #### Generated Endpoints
| Method | Endpoint | Description | | Method | Endpoint | Description |
|--------|----------|-------------| | -------- | --------------- | ------------------------------------------- |
| `POST` | `/` | Create new record | | `POST` | `/` | Create new record |
| `POST` | `/bulk-import` | Bulk import from CSV | | `POST` | `/bulk-import` | Bulk import from CSV |
| `PUT` | `/:id` | Update record by ID | | `PUT` | `/:id` | Update record by ID |
| `DELETE` | `/:id` | Delete record by ID | | `DELETE` | `/:id` | Delete record by ID |
| `POST` | `/deleteByIds` | Delete multiple records | | `POST` | `/deleteByIds` | Delete multiple records |
| `GET` | `/` | List all records (with filters, pagination) | | `GET` | `/` | List all records (with filters, pagination) |
| `GET` | `/count` | Get record count | | `GET` | `/count` | Get record count |
| `GET` | `/autocomplete` | Get autocomplete suggestions | | `GET` | `/autocomplete` | Get autocomplete suggestions |
| `GET` | `/:id` | Get single record by ID | | `GET` | `/:id` | Get single record by ID |
#### Implementation #### Implementation
@ -108,111 +109,153 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
router.use(checkCrudPermissions(permissionEntity)); router.use(checkCrudPermissions(permissionEntity));
// POST / - Create // POST / - Create
router.post('/', wrapAsync(async (req, res) => { router.post(
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; '/',
const link = new URL(referer); wrapAsync(async (req, res) => {
const payload = await Service.create({ const referer =
data: req.body.data, req.headers.referer ||
currentUser: req.currentUser, `${req.protocol}://${req.hostname}${req.originalUrl}`;
runtimeContext: req.runtimeContext, const link = new URL(referer);
sendInvitationEmails: true, const payload = await Service.create({
host: link.host, data: req.body.data,
}); currentUser: req.currentUser,
res.status(200).send(payload); runtimeContext: req.runtimeContext,
})); sendInvitationEmails: true,
host: link.host,
});
res.status(200).send(payload);
}),
);
// POST /bulk-import - Bulk CSV import // POST /bulk-import - Bulk CSV import
router.post('/bulk-import', wrapAsync(async (req, res) => { router.post(
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`; '/bulk-import',
const link = new URL(referer); wrapAsync(async (req, res) => {
await Service.bulkImport(req, res, true, link.host); const referer =
res.status(200).send(true); req.headers.referer ||
})); `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
await Service.bulkImport(req, res, true, link.host);
res.status(200).send(true);
}),
);
// PUT /:id - Update // PUT /:id - Update
router.put('/:id', wrapAsync(async (req, res) => { router.put(
assertRouteIdMatchesBody(req); '/:id',
await Service.update({ wrapAsync(async (req, res) => {
id: req.params.id, assertRouteIdMatchesBody(req);
data: req.body.data, await Service.update({
currentUser: req.currentUser, id: req.params.id,
runtimeContext: req.runtimeContext, data: req.body.data,
}); currentUser: req.currentUser,
res.status(200).send(true); runtimeContext: req.runtimeContext,
})); });
res.status(200).send(true);
}),
);
// DELETE /:id - Delete single // DELETE /:id - Delete single
router.delete('/:id', wrapAsync(async (req, res) => { router.delete(
await Service.remove({ '/:id',
id: req.params.id, wrapAsync(async (req, res) => {
currentUser: req.currentUser, await Service.remove({
runtimeContext: req.runtimeContext, id: req.params.id,
}); currentUser: req.currentUser,
res.status(200).send(true); runtimeContext: req.runtimeContext,
})); });
res.status(200).send(true);
}),
);
// POST /deleteByIds - Delete multiple // POST /deleteByIds - Delete multiple
router.post('/deleteByIds', wrapAsync(async (req, res) => { router.post(
await Service.deleteByIds({ '/deleteByIds',
ids: req.body.data, wrapAsync(async (req, res) => {
currentUser: req.currentUser, await Service.deleteByIds({
runtimeContext: req.runtimeContext, ids: req.body.data,
}); currentUser: req.currentUser,
res.status(200).send(true); runtimeContext: req.runtimeContext,
})); });
res.status(200).send(true);
}),
);
// GET / - List all with optional CSV export // GET / - List all with optional CSV export
router.get('/', wrapAsync(async (req, res) => { router.get(
const filetype = req.query.filetype; '/',
const currentUser = req.currentUser; wrapAsync(async (req, res) => {
const runtimeContext = req.runtimeContext; const filetype = req.query.filetype;
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi, { const payload = await DBApi.findAll(
csv: filetype === 'csv', normalizeQuery(req.query, DBApi, {
}), { currentUser, runtimeContext }); csv: filetype === 'csv',
}),
{ currentUser, runtimeContext },
);
if (filetype === 'csv') { if (filetype === 'csv') {
const fields = options.csvFields || DBApi.CSV_FIELDS || ['id', 'createdAt']; const fields = options.csvFields ||
const opts = { fields }; DBApi.CSV_FIELDS || ['id', 'createdAt'];
try { const opts = { fields };
const csv = parse(payload.rows, opts); try {
res.status(200).attachment('export.csv').send(csv); const csv = parse(payload.rows, opts);
} catch (err) { res.status(200).attachment('export.csv').send(csv);
logger.error({ err, entityName }, 'CSV export error'); } catch (err) {
res.status(500).send('CSV export error'); logger.error({ err, entityName }, 'CSV export error');
res.status(500).send('CSV export error');
}
} else {
res.status(200).send(payload);
} }
} else { }),
res.status(200).send(payload); );
}
}));
// GET /count - Count only // GET /count - Count only
router.get('/count', wrapAsync(async (req, res) => { router.get(
const currentUser = req.currentUser; '/count',
const runtimeContext = req.runtimeContext; wrapAsync(async (req, res) => {
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), { countOnly: true, currentUser, runtimeContext }); const currentUser = req.currentUser;
res.status(200).send(payload); const runtimeContext = req.runtimeContext;
})); const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), {
countOnly: true,
currentUser,
runtimeContext,
});
res.status(200).send(payload);
}),
);
// GET /autocomplete - Autocomplete search // GET /autocomplete - Autocomplete search
router.get('/autocomplete', wrapAsync(async (req, res) => { router.get(
const payload = await DBApi.findAllAutocomplete({ '/autocomplete',
query: req.query.query, wrapAsync(async (req, res) => {
limit, const payload = await DBApi.findAllAutocomplete({
offset: req.query.offset, query: req.query.query,
}); limit,
res.status(200).send(payload); offset: req.query.offset,
})); });
res.status(200).send(payload);
}),
);
// GET /:id - Find by ID // GET /:id - Find by ID
router.get('/:id', wrapAsync(async (req, res) => { router.get(
if (!isUuidV4(req.params.id)) { '/:id',
return res.status(400).send(`Invalid ${entityName} id`); wrapAsync(async (req, res) => {
} if (!isUuidV4(req.params.id)) {
const runtimeContext = req.runtimeContext; return res.status(400).send(`Invalid ${entityName} id`);
const payload = await DBApi.findBy({ id: req.params.id }, { runtimeContext }); }
res.status(200).send(payload); const runtimeContext = req.runtimeContext;
})); const payload = await DBApi.findBy(
{ id: req.params.id },
{ runtimeContext },
);
res.status(200).send(payload);
}),
);
// Custom routes hook // Custom routes hook
if (options.customRoutes) { if (options.customRoutes) {
@ -241,10 +284,10 @@ Generic CRUD query safety:
#### Exports #### Exports
| Export | Type | Description | | Export | Type | Description |
|--------|------|-------------| | -------------------- | ---------- | ---------------------- |
| `createEntityRouter` | `function` | Factory function | | `createEntityRouter` | `function` | Factory function |
| `isUuidV4` | `function` | UUID validation helper | | `isUuidV4` | `function` | UUID validation helper |
--- ---
@ -262,26 +305,26 @@ function createEntityService(DBApi, options = {})
#### Parameters #### Parameters
| Parameter | Type | Description | | Parameter | Type | Description |
|-----------|------|-------------| | --------- | -------- | ----------------------------------------- |
| `DBApi` | `class` | Database API class extending GenericDBApi | | `DBApi` | `class` | Database API class extending GenericDBApi |
| `options` | `object` | Configuration options | | `options` | `object` | Configuration options |
#### Options #### Options
| Option | Type | Default | Description | | Option | Type | Default | Description |
|--------|------|---------|-------------| | ------------ | -------- | ---------- | --------------------------- |
| `entityName` | `string` | `'Entity'` | Name used in error messages | | `entityName` | `string` | `'Entity'` | Name used in error messages |
#### Generated Methods #### Generated Methods
| Method | Description | | Method | Description |
|--------|-------------| | ---------------------------------------------------------------- | ------------------------------------- |
| `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction | | `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction |
| `bulkImport(req, res)` | Bulk import from CSV with transaction | | `bulkImport(req, res)` | Bulk import from CSV with transaction |
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction | | `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction | | `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction |
| `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction | | `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction |
#### Implementation #### Implementation
@ -296,11 +339,22 @@ function createEntityService(DBApi, options = {}) {
const entityName = options.entityName || 'Entity'; const entityName = options.entityName || 'Entity';
return class GenericService { return class GenericService {
static async create({ data, currentUser, transaction: externalTransaction, runtimeContext }) { static async create({
const transaction = externalTransaction || await db.sequelize.transaction(); data,
currentUser,
transaction: externalTransaction,
runtimeContext,
}) {
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction; const ownsTransaction = !externalTransaction;
try { try {
const record = await DBApi.create({ data, currentUser, transaction, runtimeContext }); const record = await DBApi.create({
data,
currentUser,
transaction,
runtimeContext,
});
if (ownsTransaction) await transaction.commit(); if (ownsTransaction) await transaction.commit();
return record; return record;
} catch (error) { } catch (error) {
@ -340,17 +394,33 @@ function createEntityService(DBApi, options = {}) {
} }
} }
static async update({ id, data, currentUser, transaction: externalTransaction, runtimeContext }) { static async update({
const transaction = externalTransaction || await db.sequelize.transaction(); id,
data,
currentUser,
transaction: externalTransaction,
runtimeContext,
}) {
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction; const ownsTransaction = !externalTransaction;
try { try {
const record = await DBApi.findBy({ id }, { transaction, runtimeContext }); const record = await DBApi.findBy(
{ id },
{ transaction, runtimeContext },
);
if (!record) { if (!record) {
throw new ValidationError(`${entityName}NotFound`); throw new ValidationError(`${entityName}NotFound`);
} }
const updated = await DBApi.update({ id, data, currentUser, transaction, runtimeContext }); const updated = await DBApi.update({
id,
data,
currentUser,
transaction,
runtimeContext,
});
if (ownsTransaction) await transaction.commit(); if (ownsTransaction) await transaction.commit();
return updated; return updated;
} catch (error) { } catch (error) {
@ -359,11 +429,22 @@ function createEntityService(DBApi, options = {}) {
} }
} }
static async deleteByIds({ ids, currentUser, transaction: externalTransaction, runtimeContext }) { static async deleteByIds({
const transaction = externalTransaction || await db.sequelize.transaction(); ids,
currentUser,
transaction: externalTransaction,
runtimeContext,
}) {
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction; const ownsTransaction = !externalTransaction;
try { try {
await DBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext }); await DBApi.deleteByIds({
ids,
currentUser,
transaction,
runtimeContext,
});
if (ownsTransaction) await transaction.commit(); if (ownsTransaction) await transaction.commit();
} catch (error) { } catch (error) {
if (ownsTransaction) await transaction.rollback(); if (ownsTransaction) await transaction.rollback();
@ -371,8 +452,14 @@ function createEntityService(DBApi, options = {}) {
} }
} }
static async remove({ id, currentUser, transaction: externalTransaction, runtimeContext }) { static async remove({
const transaction = externalTransaction || await db.sequelize.transaction(); id,
currentUser,
transaction: externalTransaction,
runtimeContext,
}) {
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction; const ownsTransaction = !externalTransaction;
try { try {
await DBApi.remove({ id, currentUser, transaction, runtimeContext }); await DBApi.remove({ id, currentUser, transaction, runtimeContext });
@ -444,7 +531,9 @@ module.exports = class Helpers {
// UUID v4 validation // UUID v4 validation
static isUuidV4(value) { static isUuidV4(value) {
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value); return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
value,
);
} }
}; };
``` ```
@ -505,37 +594,37 @@ The DB API base class that all entity APIs extend. Provides declarative configur
### Static Getters (Override in Subclasses) ### Static Getters (Override in Subclasses)
| Getter | Type | Description | | Getter | Type | Description |
|--------|------|-------------| | -------------------- | ---------- | ------------------------------------ |
| `MODEL` | `Model` | Sequelize model reference (required) | | `MODEL` | `Model` | Sequelize model reference (required) |
| `TABLE_NAME` | `string` | Database table name | | `TABLE_NAME` | `string` | Database table name |
| `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) | | `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) |
| `RANGE_FIELDS` | `string[]` | Fields for range filtering | | `RANGE_FIELDS` | `string[]` | Fields for range filtering |
| `ENUM_FIELDS` | `string[]` | Fields for exact match filtering | | `ENUM_FIELDS` | `string[]` | Fields for exact match filtering |
| `RELATION_FILTERS` | `object[]` | Related entity filters | | `RELATION_FILTERS` | `object[]` | Related entity filters |
| `CSV_FIELDS` | `string[]` | Fields for CSV export | | `CSV_FIELDS` | `string[]` | Fields for CSV export |
| `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete | | `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete |
| `ASSOCIATIONS` | `object[]` | Related entity setters | | `ASSOCIATIONS` | `object[]` | Related entity setters |
| `FIND_BY_INCLUDES` | `object[]` | Includes for findBy | | `FIND_BY_INCLUDES` | `object[]` | Includes for findBy |
| `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll | | `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll |
| `JSON_FIELDS` | `string[]` | Fields to auto-stringify | | `JSON_FIELDS` | `string[]` | Fields to auto-stringify |
| `FIELD_TRANSFORMERS` | `object` | Custom field transformers | | `FIELD_TRANSFORMERS` | `object` | Custom field transformers |
| `FIELD_DEFAULTS` | `object` | Default values for fields | | `FIELD_DEFAULTS` | `object` | Default values for fields |
### Methods ### Methods
| Method | Description | | Method | Description |
|--------|-------------| | ---------------------------------------------------------------- | --------------------------------- |
| `getFieldMapping(data)` | Transform input data for database | | `getFieldMapping(data)` | Transform input data for database |
| `create(data, options)` | Create record | | `create(data, options)` | Create record |
| `bulkImport(data, options)` | Bulk create records | | `bulkImport(data, options)` | Bulk create records |
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record | | `update({ id, data, currentUser, transaction, runtimeContext })` | Update record |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple | | `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple |
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single | | `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single |
| `findBy(where, options)` | Find single by criteria | | `findBy(where, options)` | Find single by criteria |
| `findAll(filter, options)` | Find all with pagination/filters | | `findAll(filter, options)` | Find all with pagination/filters |
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search | | `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
| `toCSV(rows)` | Convert to CSV string | | `toCSV(rows)` | Convert to CSV string |
--- ---
@ -544,6 +633,7 @@ The DB API base class that all entity APIs extend. Provides declarative configur
### Basic Entity (Minimal Configuration) ### Basic Entity (Minimal Configuration)
**Route (assets.ts):** **Route (assets.ts):**
```typescript ```typescript
import AssetsDBApi from '../db/api/assets.ts'; import AssetsDBApi from '../db/api/assets.ts';
import { createEntityRouter } from '../factories/router.factory.ts'; import { createEntityRouter } from '../factories/router.factory.ts';
@ -554,6 +644,7 @@ export default createEntityRouter('assets', AssetsService, AssetsDBApi);
``` ```
**Service (assets.ts):** **Service (assets.ts):**
```typescript ```typescript
import AssetsDBApi from '../db/api/assets.ts'; import AssetsDBApi from '../db/api/assets.ts';
import { createEntityService } from '../factories/service.factory.ts'; import { createEntityService } from '../factories/service.factory.ts';
@ -565,6 +656,7 @@ export default createEntityService(AssetsDBApi, {
``` ```
**DB API (assets.js):** **DB API (assets.js):**
```javascript ```javascript
const GenericDBApi = require('./base.api'); const GenericDBApi = require('./base.api');
const db = require('../models'); const db = require('../models');
@ -607,6 +699,7 @@ module.exports = AssetsDBApi;
### Entity with Custom Routes ### Entity with Custom Routes
**Route (project_element_defaults.ts):** **Route (project_element_defaults.ts):**
```typescript ```typescript
import Service from '../services/project_element_defaults.ts'; import Service from '../services/project_element_defaults.ts';
import DBApi from '../db/api/project_element_defaults.ts'; import DBApi from '../db/api/project_element_defaults.ts';
@ -618,22 +711,28 @@ const baseRouter = createEntityRouter(
'project_element_defaults', 'project_element_defaults',
Service, Service,
DBApi, DBApi,
{ permissionEntity: 'page_elements' } // Override permission entity { permissionEntity: 'page_elements' }, // Override permission entity
); );
// Add custom endpoint // Add custom endpoint
baseRouter.post('/:id/reset', wrapAsync(async (req, res) => { baseRouter.post(
const payload = await Service.resetToGlobal(req.params.id, { '/:id/reset',
currentUser: req.currentUser, wrapAsync(async (req, res) => {
}); const payload = await Service.resetToGlobal(req.params.id, {
res.status(200).json(payload); currentUser: req.currentUser,
})); });
res.status(200).json(payload);
}),
);
// Add another custom endpoint // Add another custom endpoint
baseRouter.get('/:id/diff', wrapAsync(async (req, res) => { baseRouter.get(
const payload = await Service.getDiffFromGlobal(req.params.id); '/:id/diff',
res.status(200).json(payload); wrapAsync(async (req, res) => {
})); const payload = await Service.getDiffFromGlobal(req.params.id);
res.status(200).json(payload);
}),
);
export default baseRouter; export default baseRouter;
``` ```
@ -641,6 +740,7 @@ export default baseRouter;
### Service with Extended Methods ### Service with Extended Methods
**Service (project_element_defaults.ts):** **Service (project_element_defaults.ts):**
```typescript ```typescript
import DBApi from '../db/api/project_element_defaults.ts'; import DBApi from '../db/api/project_element_defaults.ts';
import { createEntityService } from '../factories/service.factory.ts'; import { createEntityService } from '../factories/service.factory.ts';
@ -673,16 +773,22 @@ Alternative approach using the options callback:
```javascript ```javascript
module.exports = createEntityRouter('entities', Service, DBApi, { module.exports = createEntityRouter('entities', Service, DBApi, {
customRoutes: (router, Service, DBApi) => { customRoutes: (router, Service, DBApi) => {
router.post('/:id/custom-action', wrapAsync(async (req, res) => { router.post(
const result = await Service.customAction(req.params.id); '/:id/custom-action',
res.status(200).json(result); wrapAsync(async (req, res) => {
})); const result = await Service.customAction(req.params.id);
res.status(200).json(result);
}),
);
router.get('/stats', wrapAsync(async (req, res) => { router.get(
const stats = await DBApi.getStatistics(); '/stats',
res.status(200).json(stats); wrapAsync(async (req, res) => {
})); const stats = await DBApi.getStatistics();
} res.status(200).json(stats);
}),
);
},
}); });
``` ```
@ -692,50 +798,50 @@ module.exports = createEntityRouter('entities', Service, DBApi, {
### Entities Using Router Factory (13) ### Entities Using Router Factory (13)
| Entity | Permission Override | Custom Routes | | Entity | Permission Override | Custom Routes |
|--------|--------------------|--------------| | -------------------------- | ------------------- | ----------------- |
| `access_logs` | - | No | | `access_logs` | - | No |
| `asset_variants` | - | No | | `asset_variants` | - | No |
| `assets` | - | No | | `assets` | - | No |
| `element_type_defaults` | - | No | | `element_type_defaults` | - | No |
| `permissions` | - | No | | `permissions` | - | No |
| `presigned_url_requests` | - | No | | `presigned_url_requests` | - | No |
| `project_audio_tracks` | - | No | | `project_audio_tracks` | - | No |
| `project_element_defaults` | `page_elements` | Yes (reset, diff) | | `project_element_defaults` | `page_elements` | Yes (reset, diff) |
| `project_memberships` | - | No | | `project_memberships` | - | No |
| `publish_events` | - | No | | `publish_events` | - | No |
| `pwa_caches` | - | No | | `pwa_caches` | - | No |
| `roles` | - | No | | `roles` | - | No |
| `tour_pages` | - | No | | `tour_pages` | - | No |
### Entities Using Service Factory (11) ### Entities Using Service Factory (11)
| Entity | Custom Methods | | Entity | Custom Methods |
|--------|---------------| | -------------------------- | -------------------------------------------------------------- |
| `access_logs` | No | | `access_logs` | No |
| `asset_variants` | No | | `asset_variants` | No |
| `assets` | No | | `assets` | No |
| `element_type_defaults` | No | | `element_type_defaults` | No |
| `permissions` | No | | `permissions` | No |
| `presigned_url_requests` | No | | `presigned_url_requests` | No |
| `pwa_caches` | No | | `pwa_caches` | No |
| `publish_events` | No | | `publish_events` | No |
| `tour_pages` | No | | `tour_pages` | No |
| `project_element_defaults` | Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults) | | `project_element_defaults` | Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults) |
| `project_memberships` | No | | `project_memberships` | No |
### Entities NOT Using Factories ### Entities NOT Using Factories
Some entities have custom implementations due to specialized requirements: Some entities have custom implementations due to specialized requirements:
| Entity | Reason | | Entity | Reason |
|--------|--------| | ---------- | --------------------------------------------------- |
| `users` | Complex auth, password hashing, token management | | `users` | Complex auth, password hashing, token management |
| `projects` | Publishing workflow, complex business logic | | `projects` | Publishing workflow, complex business logic |
| `auth` | Authentication flows (login, OAuth, password reset) | | `auth` | Authentication flows (login, OAuth, password reset) |
| `file` | File upload/download, S3/GCloud/Local storage | | `file` | File upload/download, S3/GCloud/Local storage |
| `search` | Full-text search across multiple entities | | `search` | Full-text search across multiple entities |
| `publish` | Multi-step publishing workflow | | `publish` | Multi-step publishing workflow |
--- ---
@ -797,15 +903,19 @@ HTTP Response
## Design Patterns ## Design Patterns
### Factory Pattern ### Factory Pattern
Both `createEntityRouter` and `createEntityService` implement the Factory pattern, creating objects (router, service class) without specifying their exact classes. Both `createEntityRouter` and `createEntityService` implement the Factory pattern, creating objects (router, service class) without specifying their exact classes.
### Template Method Pattern ### Template Method Pattern
`GenericDBApi.getFieldMapping()` uses the Template Method pattern - the base class defines the algorithm skeleton, while subclasses can override specific steps via static getters (`JSON_FIELDS`, `FIELD_TRANSFORMERS`, `FIELD_DEFAULTS`). `GenericDBApi.getFieldMapping()` uses the Template Method pattern - the base class defines the algorithm skeleton, while subclasses can override specific steps via static getters (`JSON_FIELDS`, `FIELD_TRANSFORMERS`, `FIELD_DEFAULTS`).
### Strategy Pattern ### Strategy Pattern
The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding `permissionEntity` option. The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding `permissionEntity` option.
### Decorator Pattern ### Decorator Pattern
The router factory decorates Express routers with permission middleware and error handling. The router factory decorates Express routers with permission middleware and error handling.
--- ---
@ -815,6 +925,7 @@ The router factory decorates Express routers with permission middleware and erro
### When to Use Factories ### When to Use Factories
Use factories when: Use factories when:
- Entity requires standard CRUD operations - Entity requires standard CRUD operations
- No complex business logic beyond data transformation - No complex business logic beyond data transformation
- Permissions follow standard READ/CREATE/UPDATE/DELETE pattern - Permissions follow standard READ/CREATE/UPDATE/DELETE pattern
@ -823,6 +934,7 @@ Use factories when:
### When NOT to Use Factories ### When NOT to Use Factories
Don't use factories when: Don't use factories when:
- Complex multi-step workflows (use custom service) - Complex multi-step workflows (use custom service)
- Special authentication (OAuth flows, password reset) - Special authentication (OAuth flows, password reset)
- External API integration (file storage, AI) - External API integration (file storage, AI)

View File

@ -5,13 +5,14 @@
The Middleware module provides cross-cutting concerns for the Express application including rate limiting, permission checking, runtime context management, file uploads, and public access control. The Middleware module provides cross-cutting concerns for the Express application including rate limiting, permission checking, runtime context management, file uploads, and public access control.
**Files:** **Files:**
| File | Lines | Purpose |
|------|-------|---------| | File | Lines | Purpose |
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store | | -------------------------------------- | --------------------------------------------- | ------------------------------------------------------- |
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
| `src/middlewares/check-permissions.ts` | RBAC permission checking through AccessPolicy | | `src/middlewares/check-permissions.ts` | RBAC permission checking through AccessPolicy |
| `src/middlewares/runtime-context.ts` | 34 | Runtime environment context from headers | | `src/middlewares/runtime-context.ts` | 34 | Runtime environment context from headers |
| `src/middlewares/runtime-public.ts` | 200 | Public runtime access control and response sanitization | | `src/middlewares/runtime-public.ts` | 200 | Public runtime access control and response sanitization |
| `src/middlewares/upload.ts` | 34 | Multer-based file upload handling | | `src/middlewares/upload.ts` | 34 | Multer-based file upload handling |
--- ---
@ -126,19 +127,21 @@ setInterval(() => {
Creates a configurable rate limiter middleware. Creates a configurable rate limiter middleware.
**Parameters:** **Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------| | Parameter | Type | Default | Description |
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys | | -------------------- | -------- | ------------------------ | -------------------------------------- |
| `windowMs` | number | `900000` (15min) | Time window in milliseconds | | `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
| `max` | number | `100` | Maximum requests per window | | `windowMs` | number | `900000` (15min) | Time window in milliseconds |
| `message` | string | `'Too many requests...'` | Error message on limit | | `max` | number | `100` | Maximum requests per window |
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses | | `message` | string | `'Too many requests...'` | Error message on limit |
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` | | `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` | | `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
**Returns:** Express middleware function **Returns:** Express middleware function
**Response Headers:** **Response Headers:**
``` ```
X-RateLimit-Limit: 100 X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99 X-RateLimit-Remaining: 99
@ -147,6 +150,7 @@ Retry-After: 300 (only when limit exceeded)
``` ```
**Rate Limit Exceeded Response (429):** **Rate Limit Exceeded Response (429):**
```json ```json
{ {
"error": "Too Many Requests", "error": "Too Many Requests",
@ -174,14 +178,14 @@ const createAuthenticatedRateLimiter = (options = {}) => {
#### Pre-configured Limiters #### Pre-configured Limiters
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case | | Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
|---------|------------|--------|-----|-------------|----------| | ---------------------- | ---------------- | ------ | --- | ----------- | -------------- |
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts | | `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset | | `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API | | `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads | | `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads | | `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries | | `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
#### Route Mapping #### Route Mapping
@ -238,6 +242,7 @@ fetchAndCachePublicRole();
Creates middleware that checks if user has specific permission. Creates middleware that checks if user has specific permission.
**Permission Check Flow:** **Permission Check Flow:**
``` ```
1. AccessPolicy.hasPermission(user, permission) 1. AccessPolicy.hasPermission(user, permission)
├── Public users are always denied admin API permissions ├── Public users are always denied admin API permissions
@ -254,6 +259,7 @@ to `GET`, `PUT`, and `PATCH` on the authenticated user's own `/api/users/:id`
route in `checkCrudPermissions`. route in `checkCrudPermissions`.
**Usage:** **Usage:**
```javascript ```javascript
const { checkPermissions } = require('./middlewares/check-permissions'); const { checkPermissions } = require('./middlewares/check-permissions');
@ -265,6 +271,7 @@ router.get('/users', checkPermissions('READ_USERS'), handler);
``` ```
**Error Response (403):** **Error Response (403):**
```json ```json
{ {
"message": "Forbidden" "message": "Forbidden"
@ -276,17 +283,19 @@ router.get('/users', checkPermissions('READ_USERS'), handler);
Creates middleware that maps HTTP method to CRUD permission. Creates middleware that maps HTTP method to CRUD permission.
**Method Mapping:** **Method Mapping:**
| HTTP Method | Permission Prefix | | HTTP Method | Permission Prefix |
|-------------|-------------------| | ----------- | ----------------- |
| `POST` | `CREATE_` | | `POST` | `CREATE_` |
| `GET` | `READ_` | | `GET` | `READ_` |
| `PUT` | `UPDATE_` | | `PUT` | `UPDATE_` |
| `PATCH` | `UPDATE_` | | `PATCH` | `UPDATE_` |
| `DELETE` | `DELETE_` | | `DELETE` | `DELETE_` |
**Permission Name Format:** `{METHOD}_{ENTITY}` **Permission Name Format:** `{METHOD}_{ENTITY}`
Examples: Examples:
- `GET /api/users``READ_USERS` - `GET /api/users``READ_USERS`
- `POST /api/projects``CREATE_PROJECTS` - `POST /api/projects``CREATE_PROJECTS`
- `DELETE /api/assets/123``DELETE_ASSETS` - `DELETE /api/assets/123``DELETE_ASSETS`
@ -300,6 +309,7 @@ is "use inherited defaults", so those routes require `UPDATE_PAGE_ELEMENTS`
rather than `DELETE_PAGE_ELEMENTS`. rather than `DELETE_PAGE_ELEMENTS`.
**Usage:** **Usage:**
```javascript ```javascript
const { checkCrudPermissions } = require('./middlewares/check-permissions'); const { checkCrudPermissions } = require('./middlewares/check-permissions');
@ -352,7 +362,7 @@ For public read bypass to work, the middleware that sets `req.isRuntimePublicReq
```javascript ```javascript
// ❌ WRONG - allowPublicRead runs AFTER checkCrudPermissions // ❌ WRONG - allowPublicRead runs AFTER checkCrudPermissions
router.use(checkCrudPermissions('entity')); router.use(checkCrudPermissions('entity'));
router.get('/', allowPublicRead, handler); // Too late! router.get('/', allowPublicRead, handler); // Too late!
// ✅ CORRECT - allowPublicRead runs BEFORE checkCrudPermissions // ✅ CORRECT - allowPublicRead runs BEFORE checkCrudPermissions
router.use(allowPublicRead); router.use(allowPublicRead);
@ -373,22 +383,25 @@ Middleware that extracts runtime environment context from request headers.
Reads environment and project slug from headers for route-based access. Reads environment and project slug from headers for route-based access.
**Headers:** **Headers:**
| Header | Values | Description |
|--------|--------|-------------| | Header | Values | Description |
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment | | ------------------------ | ---------------------------- | ------------------- |
| `X-Runtime-Project-Slug` | string | Project identifier | | `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
| `X-Runtime-Project-Slug` | string | Project identifier |
**Context Object:** **Context Object:**
```javascript ```javascript
req.runtimeContext = { req.runtimeContext = {
mode: 'admin', // Default mode mode: 'admin', // Default mode
projectSlug: null, // Extracted from path or header projectSlug: null, // Extracted from path or header
headerEnvironment: 'production', // From X-Runtime-Environment headerEnvironment: 'production', // From X-Runtime-Environment
headerProjectSlug: 'my-tour' // From X-Runtime-Project-Slug headerProjectSlug: 'my-tour', // From X-Runtime-Project-Slug
}; };
``` ```
**Usage in Routes:** **Usage in Routes:**
```javascript ```javascript
// index.js // index.js
app.use(runtimeContextMiddleware); app.use(runtimeContextMiddleware);
@ -401,11 +414,12 @@ if (env === 'production') {
``` ```
**Route-Based Environment Access:** **Route-Based Environment Access:**
| Route | Environment | Access |
|-------|-------------|--------| | Route | Environment | Access |
| `/p/[slug]` | `production` | Public (no auth) | | ------------------------- | ------------ | ------------------ |
| `/p/[slug]/stage` | `stage` | Authenticated only | | `/p/[slug]` | `production` | Public (no auth) |
| `/constructor?projectId=` | `dev` | Authenticated only | | `/p/[slug]/stage` | `stage` | Authenticated only |
| `/constructor?projectId=` | `dev` | Authenticated only |
--- ---
@ -420,16 +434,41 @@ Only these fields are returned for public runtime requests:
```javascript ```javascript
const PUBLIC_RUNTIME_ENTITY_FIELDS = { const PUBLIC_RUNTIME_ENTITY_FIELDS = {
projects: [ projects: [
'id', 'name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url', 'id',
'name',
'slug',
'description',
'logo_url',
'favicon_url',
'og_image_url',
], ],
tour_pages: [ tour_pages: [
'id', 'projectId', 'environment', 'source_key', 'name', 'slug', 'id',
'sort_order', 'background_image_url', 'background_video_url', 'projectId',
'background_audio_url', 'background_loop', 'requires_auth', 'ui_schema_json', 'environment',
'source_key',
'name',
'slug',
'sort_order',
'background_image_url',
'background_video_url',
'background_audio_url',
'background_loop',
'requires_auth',
'ui_schema_json',
], ],
project_audio_tracks: [ project_audio_tracks: [
'id', 'projectId', 'environment', 'source_key', 'name', 'slug', 'id',
'url', 'loop', 'volume', 'sort_order', 'is_enabled', 'projectId',
'environment',
'source_key',
'name',
'slug',
'url',
'loop',
'volume',
'sort_order',
'is_enabled',
], ],
}; };
``` ```
@ -459,10 +498,12 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
``` ```
**Blocked:** **Blocked:**
- Individual record access: `GET /api/projects/123` → 404 - Individual record access: `GET /api/projects/123` → 404
- CSV exports: `GET /api/projects?filetype=csv` → 404 - CSV exports: `GET /api/projects?filetype=csv` → 404
**Allowed:** **Allowed:**
- List endpoints: `GET /api/projects/` → Continue - List endpoints: `GET /api/projects/` → Continue
#### Function: sanitizePublicRuntimeListResponse(entityName) #### Function: sanitizePublicRuntimeListResponse(entityName)
@ -491,27 +532,33 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
``` ```
**Before Sanitization:** **Before Sanitization:**
```json ```json
{ {
"rows": [{ "rows": [
"id": "123", {
"name": "My Tour", "id": "123",
"slug": "my-tour", "name": "My Tour",
"createdAt": "2024-01-01", "slug": "my-tour",
"createdById": "user-456", "createdAt": "2024-01-01",
"internalNotes": "sensitive data" "createdById": "user-456",
}] "internalNotes": "sensitive data"
}
]
} }
``` ```
**After Sanitization:** **After Sanitization:**
```json ```json
{ {
"rows": [{ "rows": [
"id": "123", {
"name": "My Tour", "id": "123",
"slug": "my-tour" "name": "My Tour",
}] "slug": "my-tour"
}
]
} }
``` ```
@ -552,13 +599,15 @@ module.exports = processFileMiddleware;
``` ```
**Configuration:** **Configuration:**
| Setting | Value |
|---------|-------| | Setting | Value |
| Storage | Memory (Buffer) | | ---------- | --------------- |
| Field Name | `file` | | Storage | Memory (Buffer) |
| Max Files | 1 (single) | | Field Name | `file` |
| Max Files | 1 (single) |
**Usage:** **Usage:**
```javascript ```javascript
const upload = require('./middlewares/upload'); const upload = require('./middlewares/upload');
@ -729,8 +778,8 @@ Content-Type: multipart/form-data
### Environment Variables ### Environment Variables
| Variable | Affects | Description | | Variable | Affects | Description |
|----------|---------|-------------| | ---------- | ------------- | ----------------------------- |
| `NODE_ENV` | Rate limiting | Skip localhost in development | | `NODE_ENV` | Rate limiting | Skip localhost in development |
### Constants ### Constants
@ -749,8 +798,12 @@ const METHOD_MAP = {
}; };
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([ const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS', 'TOUR_PAGES', 'PAGE_ELEMENTS', 'PROJECTS',
'PAGE_LINKS', 'TRANSITIONS', 'PROJECT_AUDIO_TRACKS', 'TOUR_PAGES',
'PAGE_ELEMENTS',
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS',
]); ]);
// runtime-public.ts // runtime-public.ts
@ -761,12 +814,13 @@ const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
## Dependencies ## Dependencies
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| | -------- | -------- | --------------------------- |
| `multer` | ^1.4.5 | Multipart form data parsing | | `multer` | ^1.4.5 | Multipart form data parsing |
| `util` | built-in | Promisify multer | | `util` | built-in | Promisify multer |
**Internal Dependencies:** **Internal Dependencies:**
- `../utils/logger` - Pino logger for rate limit logging - `../utils/logger` - Pino logger for rate limit logging
- `../services/notifications/errors/validation` - ValidationError class - `../services/notifications/errors/validation` - ValidationError class
- `../db/api/roles` - RolesDBApi for Public role - `../db/api/roles` - RolesDBApi for Public role
@ -835,6 +889,7 @@ The Middleware module provides:
5. **upload.ts** - Simple Multer-based file upload 5. **upload.ts** - Simple Multer-based file upload
**Key Features:** **Key Features:**
- Configurable rate limiting per endpoint type - Configurable rate limiting per endpoint type
- Role-based permission checking with method-to-CRUD mapping - Role-based permission checking with method-to-CRUD mapping
- Public runtime access for production presentations - Public runtime access for production presentations

View File

@ -107,7 +107,8 @@ const errors = {
error: 'Email not recognized', error: 'Email not recognized',
}, },
passwordUpdate: { passwordUpdate: {
samePassword: "You can't use the same password. Please create new password", samePassword:
"You can't use the same password. Please create new password",
}, },
userNotVerified: 'Sorry, your email has not been verified yet', userNotVerified: 'Sorry, your email has not been verified yet',
emailAddressVerificationEmail: { emailAddressVerificationEmail: {
@ -133,7 +134,8 @@ const errors = {
errors: { errors: {
invalidFileEmpty: 'The file is empty', invalidFileEmpty: 'The file is empty',
invalidFileExcel: 'Only excel (.xlsx) files are allowed', invalidFileExcel: 'Only excel (.xlsx) files are allowed',
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.', invalidFileUpload:
'Invalid file. Make sure you are using the last version of the template.',
importHashRequired: 'Import hash is required', importHashRequired: 'Import hash is required',
importHashExistent: 'Data has already been imported', importHashExistent: 'Data has already been imported',
userEmailMissing: 'Some items in the CSV do not have an email', userEmailMissing: 'Some items in the CSV do not have an email',
@ -173,14 +175,14 @@ module.exports = errors;
### Message Categories ### Message Categories
| Category | Purpose | Example Key | | Category | Purpose | Example Key |
|----------|---------|-------------| | ---------- | -------------------------- | ---------------------------------- |
| `app` | Application metadata | `app.title` | | `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userNotFound` | | `auth` | Authentication errors | `auth.userNotFound` |
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` | | `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` | | `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic error messages | `errors.validation.message` | | `errors` | Generic error messages | `errors.validation.message` |
| `emails` | Email subjects/bodies | `emails.invitation.subject` | | `emails` | Email subjects/bodies | `emails.invitation.subject` |
--- ---
@ -228,7 +230,7 @@ const getNotification = (key, ...args) => {
const message = _get(errors, key); const message = _get(errors, key);
if (!message) { if (!message) {
return key; // Return raw key as fallback return key; // Return raw key as fallback
} }
return format(message, args); return format(message, args);
@ -252,8 +254,8 @@ getNotification('emails.invitation.subject', 'Tour Builder Platform');
// → "You've been invited to Tour Builder Platform" // → "You've been invited to Tour Builder Platform"
// Check if key exists // Check if key exists
isNotification('auth.userNotFound'); // → true isNotification('auth.userNotFound'); // → true
isNotification('custom.message'); // → false isNotification('custom.message'); // → false
// Unknown key returns the key itself // Unknown key returns the key itself
getNotification('unknown.key'); getNotification('unknown.key');
@ -290,10 +292,12 @@ module.exports = class ValidationError extends Error {
``` ```
**Properties:** **Properties:**
- `message` - Human-readable error message - `message` - Human-readable error message
- `code` - HTTP status code (400) - `code` - HTTP status code (400)
**Usage:** **Usage:**
```javascript ```javascript
const ValidationError = require('./notifications/errors/validation'); const ValidationError = require('./notifications/errors/validation');
@ -336,10 +340,12 @@ module.exports = class ForbiddenError extends Error {
``` ```
**Properties:** **Properties:**
- `message` - Human-readable error message - `message` - Human-readable error message
- `code` - HTTP status code (403) - `code` - HTTP status code (403)
**Usage:** **Usage:**
```javascript ```javascript
const ForbiddenError = require('./notifications/errors/forbidden'); const ForbiddenError = require('./notifications/errors/forbidden');
@ -358,62 +364,62 @@ throw new ForbiddenError();
### Authentication Messages (`auth.*`) ### Authentication Messages (`auth.*`)
| Key | Message | Used In | | Key | Message | Used In |
|-----|---------|---------| | ------------------------------------------------- | --------------------------------------------------- | ----------------------- |
| `auth.userDisabled` | "Your account is disabled" | signin | | `auth.userDisabled` | "Your account is disabled" | signin |
| `auth.forbidden` | "Forbidden" | authorization failures | | `auth.forbidden` | "Forbidden" | authorization failures |
| `auth.unauthorized` | "Unauthorized" | missing authentication | | `auth.unauthorized` | "Unauthorized" | missing authentication |
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin | | `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update | | `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
| `auth.weakPassword` | "This password is too weak" | signup, password reset | | `auth.weakPassword` | "This password is too weak" | signup, password reset |
| `auth.emailAlreadyInUse` | "Email is already in use" | signup | | `auth.emailAlreadyInUse` | "Email is already in use" | signup |
| `auth.invalidEmail` | "Please provide a valid email" | signup | | `auth.invalidEmail` | "Please provide a valid email" | signup |
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin | | `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin |
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset | | `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset |
| `auth.passwordReset.error` | "Email not recognized" | password reset request | | `auth.passwordReset.error` | "Email not recognized" | password reset request |
| `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update | | `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update |
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification | | `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification |
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification | | `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification |
### IAM Messages (`iam.errors.*`) ### IAM Messages (`iam.errors.*`)
| Key | Message | Used In | | Key | Message | Used In |
|-----|---------|---------| | ---------------------------------- | ------------------------------------------------ | --------------------- |
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation | | `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
| `iam.errors.userNotFound` | "User not found" | user update/delete | | `iam.errors.userNotFound` | "User not found" | user update/delete |
| `iam.errors.disablingHimself` | "You can't disable yourself" | user disable | | `iam.errors.disablingHimself` | "You can't disable yourself" | user disable |
| `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke | | `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke |
| `iam.errors.deletingHimself` | "You can't delete yourself" | user delete | | `iam.errors.deletingHimself` | "You can't delete yourself" | user delete |
| `iam.errors.emailRequired` | "Email is required" | user creation | | `iam.errors.emailRequired` | "Email is required" | user creation |
| `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update | | `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update |
| `iam.errors.searchQueryRequired` | "Search query is required" | search | | `iam.errors.searchQueryRequired` | "Search query is required" | search |
### Importer Messages (`importer.errors.*`) ### Importer Messages (`importer.errors.*`)
| Key | Message | Used In | | Key | Message | Used In |
|-----|---------|---------| | ------------------------------------ | -------------------------------------------- | ---------------- |
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import | | `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import | | `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import | | `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
| `importer.errors.importHashRequired` | "Import hash is required" | bulk import | | `importer.errors.importHashRequired` | "Import hash is required" | bulk import |
| `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import | | `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import |
| `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import | | `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import |
### Generic Messages (`errors.*`) ### Generic Messages (`errors.*`)
| Key | Message | Used In | | Key | Message | Used In |
|-----|---------|---------| | ------------------------------------ | -------------------------- | ----------------------- |
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default | | `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
| `errors.validation.message` | "An error occurred" | ValidationError default | | `errors.validation.message` | "An error occurred" | ValidationError default |
| `errors.searchQueryRequired.message` | "Search query is required" | search validation | | `errors.searchQueryRequired.message` | "Search query is required" | search validation |
### Email Messages (`emails.*`) ### Email Messages (`emails.*`)
| Key | Message | Used In | | Key | Message | Used In |
|-----|---------|---------| | ----------------------------------------- | ----------------------------- | ------------------ |
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation | | `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification | | `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset | | `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
--- ---
@ -421,27 +427,27 @@ throw new ForbiddenError();
### Services Using Notifications ### Services Using Notifications
| Service | Errors Used | Common Keys | | Service | Errors Used | Common Keys |
|---------|-------------|-------------| | ------------------------- | ------------------------------- | ------------------------------------------------ |
| `auth.js` | ValidationError, ForbiddenError | auth.*, iam.* | | `auth.js` | ValidationError, ForbiddenError | auth._, iam._ |
| `users.ts` | ValidationError | iam.errors.* | | `users.ts` | ValidationError | iam.errors.* |
| `projects.ts` | ValidationError | projectsNotFound | | `projects.ts` | ValidationError | projectsNotFound |
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation | | `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden | | `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound | | `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
### Email Templates Using Notifications ### Email Templates Using Notifications
| Template | Helper Usage | | Template | Helper Usage |
|----------|--------------| | ------------------------ | ------------------------------------------------------------ |
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` | | `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
| `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` | | `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` |
| `invitation.js` | `getNotification('emails.invitation.subject')` | | `invitation.js` | `getNotification('emails.invitation.subject')` |
### Middleware Using Notifications ### Middleware Using Notifications
| Middleware | Error Used | Purpose | | Middleware | Error Used | Purpose |
|------------|------------|---------| | ---------------------- | --------------- | --------------------------- |
| `check-permissions.ts` | ValidationError | Permission denied responses | | `check-permissions.ts` | ValidationError | Permission denied responses |
### Service Factory Using Notifications ### Service Factory Using Notifications
@ -596,13 +602,13 @@ class ValidationError extends AppError {
### When to Use Which ### When to Use Which
| Scenario | Use | | Scenario | Use |
|----------|-----| | ----------------------------- | -------------------------------------- |
| Service business logic errors | `notifications/errors/ValidationError` | | Service business logic errors | `notifications/errors/ValidationError` |
| Authorization failures | `notifications/errors/ForbiddenError` | | Authorization failures | `notifications/errors/ForbiddenError` |
| Email subject/body text | `getNotification()` | | Email subject/body text | `getNotification()` |
| Low-level utility errors | `utils/errors.js` | | Low-level utility errors | `utils/errors.js` |
| Errors needing details object | `utils/errors.js` | | Errors needing details object | `utils/errors.js` |
--- ---
@ -619,7 +625,8 @@ const notifications = {
errors: { errors: {
notFound: 'Project not found', notFound: 'Project not found',
slugTaken: 'A project with this slug already exists', slugTaken: 'A project with this slug already exists',
invalidSlug: 'Project slug must contain only letters, numbers, and hyphens', invalidSlug:
'Project slug must contain only letters, numbers, and hyphens',
}, },
}, },
}; };
@ -704,13 +711,13 @@ const { getNotification, isNotification } = require('./helpers');
describe('getNotification', () => { describe('getNotification', () => {
it('should return message for valid key', () => { it('should return message for valid key', () => {
expect(getNotification('auth.userNotFound')).toBe( expect(getNotification('auth.userNotFound')).toBe(
"Sorry, we don't recognize your credentials" "Sorry, we don't recognize your credentials",
); );
}); });
it('should substitute parameters', () => { it('should substitute parameters', () => {
expect(getNotification('emails.invitation.subject', 'My App')).toBe( expect(getNotification('emails.invitation.subject', 'My App')).toBe(
"You've been invited to My App" "You've been invited to My App",
); );
}); });
@ -734,9 +741,9 @@ describe('isNotification', () => {
## Dependencies ## Dependencies
| Package | Version | Purpose | | Package | Version | Purpose |
|---------|---------|---------| | ------------ | ------- | --------------------------- |
| `lodash/get` | ^4.x | Deep object property access | | `lodash/get` | ^4.x | Deep object property access |
--- ---

View File

@ -7,32 +7,33 @@ The Routes module defines all HTTP endpoints for the application. It uses a fact
**Directory:** `src/routes/` **Directory:** `src/routes/`
**Files (25 total):** **Files (25 total):**
| File | Lines | Pattern | Description |
|------|-------|---------|-------------| | File | Lines | Pattern | Description |
| `auth.ts` | 327 | Custom | Authentication endpoints | | -------------------------------- | ----- | ----------- | ------------------------------------------------------------- |
| `file.ts` | 150 | Custom | File upload/download endpoints | | `auth.ts` | 327 | Custom | Authentication endpoints |
| `publish.ts` | 107 | Custom | Publishing workflow endpoints | | `file.ts` | 150 | Custom | File upload/download endpoints |
| `search.ts` | 64 | Custom | Global search endpoint | | `publish.ts` | 107 | Custom | Publishing workflow endpoints |
| `runtime-context.ts` | 16 | Custom | Runtime context inspection | | `search.ts` | 64 | Custom | Global search endpoint |
| `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint | | `runtime-context.ts` | 16 | Custom | Runtime context inspection |
| `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID | | `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint |
| `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status | | `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID |
| `roles.ts` | 141 | Factory | Roles CRUD via factory | | `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status |
| `permissions.ts` | 188 | Factory | Permissions CRUD via factory | | `roles.ts` | 141 | Factory | Roles CRUD via factory |
| `assets.ts` | 155 | Factory | Assets CRUD via factory | | `permissions.ts` | 188 | Factory | Permissions CRUD via factory |
| `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory | | `assets.ts` | 155 | Factory | Assets CRUD via factory |
| `access_logs.ts` | 145 | Factory | Access logs CRUD via factory | | `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory |
| `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory | | `access_logs.ts` | 145 | Factory | Access logs CRUD via factory |
| `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory | | `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory |
| `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults | | `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory |
| `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults | | `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults |
| `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides | | `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults |
| `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides | | `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides |
| `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory | | `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides |
| `publish_events.ts` | 157 | Factory | Publish events CRUD via factory | | `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory |
| `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory | | `publish_events.ts` | 157 | Factory | Publish events CRUD via factory |
| `element_type_defaults.ts` | 12 | Factory | Element type defaults via factory | | `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory |
| `project_element_defaults.ts` | 92 | Hybrid | Project element defaults CRUD + custom (reset, diff) | | `element_type_defaults.ts` | 12 | Factory | Element type defaults via factory |
| `project_element_defaults.ts` | 92 | Hybrid | Project element defaults CRUD + custom (reset, diff) |
--- ---
@ -98,40 +99,43 @@ Generates standardized CRUD routes for entities.
```javascript ```javascript
const router = createEntityRouter( const router = createEntityRouter(
'tour_pages', // Entity name 'tour_pages', // Entity name
Tour_pagesService, // Service class Tour_pagesService, // Service class
Tour_pagesDBApi, // Database API class Tour_pagesDBApi, // Database API class
{ {
permissionEntity: 'tour_pages', // Permission entity name (optional) permissionEntity: 'tour_pages', // Permission entity name (optional)
csvFields: ['id', 'name'], // CSV export fields (optional) csvFields: ['id', 'name'], // CSV export fields (optional)
validation: { // Request validation overrides (optional) validation: {
// Request validation overrides (optional)
create: customCreateSchema, create: customCreateSchema,
update: customUpdateSchema, update: customUpdateSchema,
}, },
customRoutes: (router, Service, DBApi) => { // Custom routes (optional) customRoutes: (router, Service, DBApi) => {
// Custom routes (optional)
router.post('/custom', handler); router.post('/custom', handler);
} },
} },
); );
``` ```
#### Generated Endpoints #### Generated Endpoints
| Method | Path | Description | | Method | Path | Description |
|--------|------|-------------| | -------- | --------------- | ------------------------------------- |
| `POST` | `/` | Create new item | | `POST` | `/` | Create new item |
| `POST` | `/bulk-import` | Bulk import items | | `POST` | `/bulk-import` | Bulk import items |
| `PUT` | `/:id` | Update item by ID | | `PUT` | `/:id` | Update item by ID |
| `DELETE` | `/:id` | Delete item by ID | | `DELETE` | `/:id` | Delete item by ID |
| `POST` | `/deleteByIds` | Delete multiple items | | `POST` | `/deleteByIds` | Delete multiple items |
| `GET` | `/` | List items (with pagination, filters) | | `GET` | `/` | List items (with pagination, filters) |
| `GET` | `/count` | Count items matching filters | | `GET` | `/count` | Count items matching filters |
| `GET` | `/autocomplete` | Autocomplete search | | `GET` | `/autocomplete` | Autocomplete search |
| `GET` | `/:id` | Get single item by ID | | `GET` | `/:id` | Get single item by ID |
#### Factory Features #### Factory Features
**Permission Checking:** **Permission Checking:**
```javascript ```javascript
router.use(checkCrudPermissions(permissionEntity)); router.use(checkCrudPermissions(permissionEntity));
// Maps HTTP methods to permissions: // Maps HTTP methods to permissions:
@ -142,6 +146,7 @@ router.use(checkCrudPermissions(permissionEntity));
``` ```
**Request Validation:** **Request Validation:**
```typescript ```typescript
import { validateRequest } from '../middlewares/validate-request.ts'; import { validateRequest } from '../middlewares/validate-request.ts';
import { crud as crudSchemas } from '../validators/request-schemas.ts'; import { crud as crudSchemas } from '../validators/request-schemas.ts';
@ -159,6 +164,7 @@ router.get(
Factory CRUD routes validate request bodies, params, and common query controls before service/DB calls. List and count routes validate `limit`, `page`, `field`, `sort`, and `filetype` while keeping existing entity filter query parameters. Entity routers can pass `validation` overrides for stricter contracts, as `users` and `projects` do. Factory CRUD routes validate request bodies, params, and common query controls before service/DB calls. List and count routes validate `limit`, `page`, `field`, `sort`, and `filetype` while keeping existing entity filter query parameters. Entity routers can pass `validation` overrides for stricter contracts, as `users` and `projects` do.
**CSV Export:** **CSV Export:**
```javascript ```javascript
// GET /?filetype=csv // GET /?filetype=csv
if (filetype === 'csv') { if (filetype === 'csv') {
@ -168,6 +174,7 @@ if (filetype === 'csv') {
``` ```
**Runtime Context:** **Runtime Context:**
```javascript ```javascript
const runtimeContext = req.runtimeContext; const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(req.query, { const payload = await DBApi.findAll(req.query, {
@ -226,22 +233,22 @@ mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', ...
Authentication and account management. Authentication and account management.
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| | ------ | ---------------------------------------- | ---- | ------------------------- |
| POST | `/signin/local` | No | Email/password login | | POST | `/signin/local` | No | Email/password login |
| POST | `/signup` | No | Register new user | | POST | `/signup` | No | Register new user |
| GET | `/me` | JWT | Get current user | | GET | `/me` | JWT | Get current user |
| PUT | `/password-reset` | No | Reset password with token | | PUT | `/password-reset` | No | Reset password with token |
| PUT | `/password-update` | JWT | Change password | | PUT | `/password-update` | JWT | Change password |
| PUT | `/profile` | JWT | Update user profile | | PUT | `/profile` | JWT | Update user profile |
| PUT | `/verify-email` | No | Verify email with token | | PUT | `/verify-email` | No | Verify email with token |
| POST | `/send-email-address-verification-email` | JWT | Resend verification | | POST | `/send-email-address-verification-email` | JWT | Resend verification |
| POST | `/send-password-reset-email` | No | Send reset email | | POST | `/send-password-reset-email` | No | Send reset email |
| GET | `/email-configured` | No | Check email config | | GET | `/email-configured` | No | Check email config |
| GET | `/signin/google` | No | Google OAuth start | | GET | `/signin/google` | No | Google OAuth start |
| GET | `/signin/google/callback` | No | Google OAuth callback | | GET | `/signin/google/callback` | No | Google OAuth callback |
| GET | `/signin/microsoft` | No | Microsoft OAuth start | | GET | `/signin/microsoft` | No | Microsoft OAuth start |
| GET | `/signin/microsoft/callback` | No | Microsoft OAuth callback | | GET | `/signin/microsoft/callback` | No | Microsoft OAuth callback |
`auth.ts` is a typed ESM route boundary. It uses reusable request body/query contracts from `backend/src/types/auth-routes.ts`, the typed `backend/src/services/auth.ts` service, and the shared Passport helpers in `backend/src/auth/passport-middleware.ts`. `auth.ts` is a typed ESM route boundary. It uses reusable request body/query contracts from `backend/src/types/auth-routes.ts`, the typed `backend/src/services/auth.ts` service, and the shared Passport helpers in `backend/src/auth/passport-middleware.ts`.
@ -253,17 +260,18 @@ File upload and download operations.
`file.ts` is a typed ESM route boundary. It calls the typed unified storage facade in `backend/src/services/file.ts`, while provider contracts and request/response shapes live in reusable `backend/src/types/file.ts` contracts. S3 and GCloud use official SDK-provided types instead of local SDK declarations. `file.ts` is a typed ESM route boundary. It calls the typed unified storage facade in `backend/src/services/file.ts`, while provider contracts and request/response shapes live in reusable `backend/src/types/file.ts` contracts. S3 and GCloud use official SDK-provided types instead of local SDK declarations.
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| | ------ | ------------------------------------------------ | ---- | -------------------------------- |
| GET | `/download` | No | Download file by privateUrl | | GET | `/download` | No | Download file by privateUrl |
| POST | `/presign` | No | Generate presigned URLs (max 50) | | POST | `/presign` | No | Generate presigned URLs (max 50) |
| POST | `/upload/:table/:field` | JWT | Legacy single file upload | | POST | `/upload/:table/:field` | JWT | Legacy single file upload |
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload | | POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status | | GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
| PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk | | PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk |
| POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload | | POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload |
**Presigned URLs Request:** **Presigned URLs Request:**
```json ```json
{ {
"urls": ["assets/image.jpg", "assets/video.mp4"] "urls": ["assets/image.jpg", "assets/video.mp4"]
@ -271,6 +279,7 @@ File upload and download operations.
``` ```
**Presigned URLs Response:** **Presigned URLs Response:**
```json ```json
{ {
"presignedUrls": { "presignedUrls": {
@ -286,13 +295,14 @@ File upload and download operations.
Publishing workflow for Dev → Stage → Production. Publishing workflow for Dev → Stage → Production.
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| | ------ | ---------------- | ---- | --------------------------- |
| POST | `/` | JWT | Publish stage to production | | POST | `/` | JWT | Publish stage to production |
| POST | `/publish` | JWT | Alias for publish | | POST | `/publish` | JWT | Alias for publish |
| POST | `/save-to-stage` | JWT | Save dev to stage | | POST | `/save-to-stage` | JWT | Save dev to stage |
**Publish Request:** **Publish Request:**
```json ```json
{ {
"projectId": "uuid", "projectId": "uuid",
@ -302,6 +312,7 @@ Publishing workflow for Dev → Stage → Production.
``` ```
**Save to Stage Request:** **Save to Stage Request:**
```json ```json
{ {
"projectId": "uuid" "projectId": "uuid"
@ -314,11 +325,12 @@ Publishing workflow for Dev → Stage → Production.
Global full-text search across entities. Global full-text search across entities.
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| | ------ | ---- | ---- | -------------------------- |
| POST | `/` | JWT | Search across all entities | | POST | `/` | JWT | Search across all entities |
**Request:** **Request:**
```json ```json
{ {
"searchQuery": "my search term" "searchQuery": "my search term"
@ -326,6 +338,7 @@ Global full-text search across entities.
``` ```
**Response:** **Response:**
```json ```json
{ {
"users": [...], "users": [...],
@ -338,11 +351,12 @@ Global full-text search across entities.
Runtime context inspection for debugging. Runtime context inspection for debugging.
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
|--------|------|------|-------------| | ------ | ---- | ---- | --------------------------- |
| GET | `/` | No | Get current runtime context | | GET | `/` | No | Get current runtime context |
**Response:** **Response:**
```json ```json
{ {
"mode": "admin", "mode": "admin",
@ -361,22 +375,25 @@ Runtime context inspection for debugging.
Projects with factory CRUD plus a clone endpoint. Projects with factory CRUD plus a clone endpoint.
**Standard CRUD Endpoints:** **Standard CRUD Endpoints:**
| Method | Path | Description |
|--------|------|-------------| | Method | Path | Description |
| POST | `/` | Create project | | ------ | --------------- | ------------------------ |
| POST | `/bulk-import` | Bulk import projects | | POST | `/` | Create project |
| PUT | `/:id` | Update project | | POST | `/bulk-import` | Bulk import projects |
| DELETE | `/:id` | Delete project | | PUT | `/:id` | Update project |
| POST | `/deleteByIds` | Delete multiple projects | | DELETE | `/:id` | Delete project |
| GET | `/` | List projects | | POST | `/deleteByIds` | Delete multiple projects |
| GET | `/count` | Count projects | | GET | `/` | List projects |
| GET | `/autocomplete` | Autocomplete search | | GET | `/count` | Count projects |
| GET | `/:id` | Get project by ID | | GET | `/autocomplete` | Autocomplete search |
| GET | `/:id` | Get project by ID |
**Custom Endpoints:** **Custom Endpoints:**
| Method | Path | Description |
|--------|------|-------------| | Method | Path | Description |
| POST | `/:id/clone` | Clone project with all pages | | ------ | ------------ | ---------------------------- |
| POST | `/:id/clone` | Clone project with all pages |
--- ---
### tour_pages.ts (380 lines) ### tour_pages.ts (380 lines)
@ -384,30 +401,33 @@ Projects with factory CRUD plus a clone endpoint.
Typed manual CRUD route for tour pages. In addition to standard create/update/delete/list/count/autocomplete/find-by-id operations, it owns page reorder, dev-page duplication, CSV export, and reverse-video status endpoints. The route uses reusable contracts from `backend/src/types/tour-pages.ts`. Typed manual CRUD route for tour pages. In addition to standard create/update/delete/list/count/autocomplete/find-by-id operations, it owns page reorder, dev-page duplication, CSV export, and reverse-video status endpoints. The route uses reusable contracts from `backend/src/types/tour-pages.ts`.
**Schema Fields:** **Schema Fields:**
- `source_key`, `name`, `slug` - `source_key`, `name`, `slug`
- `background_image_url`, `background_video_url`, `background_audio_url` - `background_image_url`, `background_video_url`, `background_audio_url`
- `ui_schema_json`, `sort_order` - `ui_schema_json`, `sort_order`
**Endpoints:** **Endpoints:**
| Method | Path | Description |
|--------|------|-------------| | Method | Path | Description |
| POST | `/` | Create page | | ------ | ----------------------- | -------------------------------------------------------------------- |
| POST | `/bulk-import` | Bulk import pages | | POST | `/` | Create page |
| POST | `/reorder` | Reorder dev pages in a project/environment | | POST | `/bulk-import` | Bulk import pages |
| POST | `/:id/duplicate` | Duplicate a dev page | | POST | `/reorder` | Reorder dev pages in a project/environment |
| PUT | `/:id` | Update page | | POST | `/:id/duplicate` | Duplicate a dev page |
| DELETE | `/:id` | Remove page | | PUT | `/:id` | Update page |
| POST | `/deleteByIds` | Remove multiple pages | | DELETE | `/:id` | Remove page |
| GET | `/` | List pages with reverse video URL population and optional CSV export | | POST | `/deleteByIds` | Remove multiple pages |
| POST | `/reverse-video-status` | Check generated reverse-video variants | | GET | `/` | List pages with reverse video URL population and optional CSV export |
| GET | `/count` | Count pages | | POST | `/reverse-video-status` | Check generated reverse-video variants |
| GET | `/autocomplete` | Page autocomplete | | GET | `/count` | Count pages |
| GET | `/:id` | Get one page with reverse video URL population | | GET | `/autocomplete` | Page autocomplete |
| GET | `/:id` | Get one page with reverse video URL population |
- `POST /api/tour_pages/reorder` accepts `{ data: { projectId, environment, - `POST /api/tour_pages/reorder` accepts `{ data: { projectId, environment,
orderedPageIds } }`, validates a complete page list for the project/dev orderedPageIds } }`, validates a complete page list for the project/dev
environment, and updates only `sort_order`. environment, and updates only `sort_order`.
- `POST /api/tour_pages/:id/duplicate` accepts `{ data: { projectId, - `POST /api/tour_pages/:id/duplicate` accepts `{ data: { projectId,
environment, name, slug } }`, duplicates a dev page into a new independent dev environment, name, slug } }`, duplicates a dev page into a new independent dev
page, appends it to the project order, deep-copies `ui_schema_json`, and page, appends it to the project order, deep-copies `ui_schema_json`, and
regenerates inline element IDs. regenerates inline element IDs.
- Constructor page deletion uses the standard `DELETE /api/tour_pages/:id` - Constructor page deletion uses the standard `DELETE /api/tour_pages/:id`
@ -431,12 +451,13 @@ module.exports = createEntityRouter(
Element_type_defaultsService, Element_type_defaultsService,
Element_type_defaultsDBApi, Element_type_defaultsDBApi,
{ {
permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions
}, },
); );
``` ```
**URL Aliases:** **URL Aliases:**
- `/api/element-type-defaults` (primary) - `/api/element-type-defaults` (primary)
- `/api/ui-elements` (backwards compatibility) - `/api/ui-elements` (backwards compatibility)
@ -462,21 +483,24 @@ baseRouter.get('/:id/diff', ...);
``` ```
**Standard CRUD Endpoints:** (via factory) **Standard CRUD Endpoints:** (via factory)
| Method | Path | Description |
|--------|------|-------------| | Method | Path | Description |
| POST | `/` | Create project element default | | ------ | ------ | --------------------------------- |
| PUT | `/:id` | Update project element default | | POST | `/` | Create project element default |
| DELETE | `/:id` | Delete project element default | | PUT | `/:id` | Update project element default |
| GET | `/` | List project element defaults | | DELETE | `/:id` | Delete project element default |
| GET | `/:id` | Get project element default by ID | | GET | `/` | List project element defaults |
| GET | `/:id` | Get project element default by ID |
**Custom Endpoints:** **Custom Endpoints:**
| Method | Path | Description |
|--------|------|-------------| | Method | Path | Description |
| POST | `/:id/reset` | Reset project element default to global | | ------ | ------------ | ----------------------------------------- |
| GET | `/:id/diff` | Get diff from global element type default | | POST | `/:id/reset` | Reset project element default to global |
| GET | `/:id/diff` | Get diff from global element type default |
**URL Alias:** **URL Alias:**
- `/api/project-element-defaults` (primary) - `/api/project-element-defaults` (primary)
--- ---
@ -486,17 +510,19 @@ baseRouter.get('/:id/diff', ...);
### List Endpoint (GET /) ### List Endpoint (GET /)
**Query Parameters:** **Query Parameters:**
| Param | Type | Description |
|-------|------|-------------| | Param | Type | Description |
| `page` | number | Page number (0-indexed) | | ------------------ | ------ | ------------------------------ |
| `limit` | number | Items per page | | `page` | number | Page number (0-indexed) |
| `field` | string | Sort field | | `limit` | number | Items per page |
| `sort` | string | Sort direction (`asc`/`desc`) | | `field` | string | Sort field |
| `filetype` | string | Export format (`csv`) | | `sort` | string | Sort direction (`asc`/`desc`) |
| `[fieldName]` | string | Filter by field value | | `filetype` | string | Export format (`csv`) |
| `[fieldName]Range` | array | Filter by range `[start, end]` | | `[fieldName]` | string | Filter by field value |
| `[fieldName]Range` | array | Filter by range `[start, end]` |
**Response:** **Response:**
```json ```json
{ {
"rows": [...], "rows": [...],
@ -507,6 +533,7 @@ baseRouter.get('/:id/diff', ...);
### Create Endpoint (POST /) ### Create Endpoint (POST /)
**Request:** **Request:**
```json ```json
{ {
"data": { "data": {
@ -517,6 +544,7 @@ baseRouter.get('/:id/diff', ...);
``` ```
**Response:** **Response:**
```json ```json
{ {
"id": "uuid", "id": "uuid",
@ -529,6 +557,7 @@ baseRouter.get('/:id/diff', ...);
### Update Endpoint (PUT /:id) ### Update Endpoint (PUT /:id)
**Request:** **Request:**
```json ```json
{ {
"id": "uuid", "id": "uuid",
@ -539,6 +568,7 @@ baseRouter.get('/:id/diff', ...);
``` ```
**Response:** **Response:**
```json ```json
true true
``` ```
@ -546,11 +576,13 @@ true
### Delete Endpoints ### Delete Endpoints
**Single Delete (DELETE /:id):** **Single Delete (DELETE /:id):**
```json ```json
true true
``` ```
**Bulk Delete (POST /deleteByIds):** **Bulk Delete (POST /deleteByIds):**
```json ```json
{ {
"data": ["uuid1", "uuid2", "uuid3"] "data": ["uuid1", "uuid2", "uuid3"]
@ -625,15 +657,16 @@ router.use('/', require('../helpers').commonErrorHandler);
``` ```
**Error Response Mapping:** **Error Response Mapping:**
| Status Code | Description |
|-------------|-------------| | Status Code | Description |
| 400 | Bad Request | | ----------- | --------------------- |
| 401 | Unauthorized | | 400 | Bad Request |
| 403 | Forbidden | | 401 | Unauthorized |
| 404 | Not Found | | 403 | Forbidden |
| 409 | Conflict | | 404 | Not Found |
| 422 | Unprocessable Entity | | 409 | Conflict |
| 500 | Internal Server Error | | 422 | Unprocessable Entity |
| 500 | Internal Server Error |
--- ---
@ -641,49 +674,50 @@ router.use('/', require('../helpers').commonErrorHandler);
### Public Routes (No Auth) ### Public Routes (No Auth)
| Route | Description | | Route | Description |
|-------|-------------| | -------------------------------- | ----------------------- |
| `GET /api/health` | Health check | | `GET /api/health` | Health check |
| `POST /api/auth/signin/local` | Login | | `POST /api/auth/signin/local` | Login |
| `GET /api/auth/signin/google` | Google OAuth | | `GET /api/auth/signin/google` | Google OAuth |
| `GET /api/auth/signin/microsoft` | Microsoft OAuth | | `GET /api/auth/signin/microsoft` | Microsoft OAuth |
| `GET /api/file/download` | File download | | `GET /api/file/download` | File download |
| `POST /api/file/presign` | Generate presigned URLs | | `POST /api/file/presign` | Generate presigned URLs |
| `GET /api/runtime-context` | Runtime context | | `GET /api/runtime-context` | Runtime context |
### Runtime Public Routes (Production Environment) ### Runtime Public Routes (Production Environment)
| Route | Description | | Route | Description |
|-------|-------------| | ------------------------------- | ----------------------------- |
| `GET /api/projects` | List projects (sanitized) | | `GET /api/projects` | List projects (sanitized) |
| `GET /api/tour_pages` | List tour pages (sanitized) | | `GET /api/tour_pages` | List tour pages (sanitized) |
| `GET /api/project_audio_tracks` | List audio tracks (sanitized) | | `GET /api/project_audio_tracks` | List audio tracks (sanitized) |
### Authenticated Routes (JWT Required) ### Authenticated Routes (JWT Required)
All other routes require JWT authentication via: All other routes require JWT authentication via:
```javascript ```javascript
app.use('/api/users', jwtAuth, usersRoutes); app.use('/api/users', jwtAuth, usersRoutes);
``` ```
### Rate Limited Routes ### Rate Limited Routes
| Route | Limiter | Config | | Route | Limiter | Config |
|-------|---------|--------| | ------------------------------------- | -------------------- | -------- |
| `/api/auth/signin/local` | authLimiter | 10/15min | | `/api/auth/signin/local` | authLimiter | 10/15min |
| `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour | | `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour |
| `/api/file/upload*` | uploadLimiter | 10/min | | `/api/file/upload*` | uploadLimiter | 10/min |
| `/api/file/download`, `/presign` | downloadLimiter | 200/min | | `/api/file/download`, `/presign` | downloadLimiter | 200/min |
| `/api/search` | searchLimiter | 30/min | | `/api/search` | searchLimiter | 30/min |
## Dependencies ## Dependencies
| Package | Purpose | | Package | Purpose |
|---------|---------| | ------------- | ------------------------ |
| `express` | Router and middleware | | `express` | Router and middleware |
| `json2csv` | CSV export functionality | | `json2csv` | CSV export functionality |
| `passport` | JWT authentication | | `passport` | JWT authentication |
| `body-parser` | JSON body parsing | | `body-parser` | JSON body parsing |
--- ---
@ -750,6 +784,7 @@ The Routes module provides:
10. **Error Handling** - Centralized via wrapAsync and commonErrorHandler 10. **Error Handling** - Centralized via wrapAsync and commonErrorHandler
**Route Statistics:** **Route Statistics:**
- 26 route files - 26 route files
- ~50+ unique endpoints - ~50+ unique endpoints
- 11 factory-generated entity routers - 11 factory-generated entity routers

View File

@ -46,18 +46,18 @@ The Services module implements the **business logic layer** of the backend appli
Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling. Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling.
| Service | File | Entity | LOC | | Service | File | Entity | LOC |
| -------------------------- | ------------------------------- | ---------------------------------------------- | ------ | | -------------------------- | ------------------------------- | --------------------------------------------------------------------------- | ------ |
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 | | tour_pages | `tour_pages.ts` | Tour Pages (reverse video generation plus targeted back-transition refresh) | ~1,500 |
| permissions | `permissions.ts` | Permissions | 6 | | permissions | `permissions.ts` | Permissions | 6 |
| asset_variants | `asset_variants.ts` | Asset Variants | 6 | | asset_variants | `asset_variants.ts` | Asset Variants | 6 |
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 | | presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
| publish_events | `publish_events.ts` | Publish Events | 6 | | publish_events | `publish_events.ts` | Publish Events | 6 |
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 | | pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
| access_logs | `access_logs.ts` | Access Logs | 6 | | access_logs | `access_logs.ts` | Access Logs | 6 |
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 | | element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
| project_memberships | `project_memberships.ts` | Project Memberships | 6 | | project_memberships | `project_memberships.ts` | Project Memberships | 6 |
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 | | global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
**Example - Factory Service:** **Example - Factory Service:**
@ -565,6 +565,16 @@ Phase G: Clone tour_pages, audio_tracks, element_defaults
- Primary assets: `assets/{projectId}/{uuid}.ext` - Primary assets: `assets/{projectId}/{uuid}.ext`
- Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID) - Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID)
**Tour page transition safeguards:**
- Before save-time auto-reverse validation, targeted back buttons refresh their
transition fields from the current incoming forward element.
- If the source page's forward transition was removed, stale back-button
`transitionVideoUrl`, `transitionReverseMode`, and `reverseVideoUrl` values
are cleared before validation so old heavy videos do not block unrelated saves.
- Project-wide reverse regeneration performs the same refresh before queuing
missing reversed videos.
**Error Handling:** **Error Handling:**
- Failed file copies fall back to original storage path (cloned project still functional, shares assets with source) - Failed file copies fall back to original storage path (cloned project still functional, shares assets with source)

View File

@ -5,6 +5,7 @@
The Utilities module provides centralized helper functions, error handling, logging, environment validation, and i18n message management. Utilities are organized across several locations based on their domain. The Utilities module provides centralized helper functions, error handling, logging, environment validation, and i18n message management. Utilities are organized across several locations based on their domain.
**Locations:** **Locations:**
- `backend/src/utils/` - Core utilities (errors, logging, env validation, request context) - `backend/src/utils/` - Core utilities (errors, logging, env validation, request context)
- `backend/src/helpers.ts` - Request helpers (async wrapper, error handler, JWT) - `backend/src/helpers.ts` - Request helpers (async wrapper, error handler, JWT)
- `backend/src/db/utils.ts` - Database utilities - `backend/src/db/utils.ts` - Database utilities
@ -47,7 +48,7 @@ class AppError extends Error {
super(message); super(message);
this.statusCode = statusCode; this.statusCode = statusCode;
this.details = details; this.details = details;
this.isOperational = true; // Distinguishes from programming errors this.isOperational = true; // Distinguishes from programming errors
Error.captureStackTrace(this, this.constructor); Error.captureStackTrace(this, this.constructor);
} }
} }
@ -55,16 +56,17 @@ class AppError extends Error {
**Error Types:** **Error Types:**
| Class | Status Code | Default Message | Usage | | Class | Status Code | Default Message | Usage |
|-------|-------------|-----------------|-------| | ------------------- | ----------- | ---------------------- | ------------------- |
| `AppError` | 500 | (custom) | Base class | | `AppError` | 500 | (custom) | Base class |
| `NotFoundError` | 404 | `{resource} not found` | Missing resources | | `NotFoundError` | 404 | `{resource} not found` | Missing resources |
| `ValidationError` | 400 | (custom) | Invalid input | | `ValidationError` | 400 | (custom) | Invalid input |
| `ForbiddenError` | 403 | `Access denied` | Permission denied | | `ForbiddenError` | 403 | `Access denied` | Permission denied |
| `UnauthorizedError` | 401 | `Unauthorized` | Auth required | | `UnauthorizedError` | 401 | `Unauthorized` | Auth required |
| `ConflictError` | 409 | `Resource conflict` | Duplicate resources | | `ConflictError` | 409 | `Resource conflict` | Duplicate resources |
**Usage:** **Usage:**
```javascript ```javascript
const { NotFoundError, ValidationError, ForbiddenError } = require('./utils'); const { NotFoundError, ValidationError, ForbiddenError } = require('./utils');
@ -110,6 +112,7 @@ const logger = pino({
``` ```
**Log Levels:** **Log Levels:**
- `fatal` - Unrecoverable errors - `fatal` - Unrecoverable errors
- `error` - Errors requiring attention - `error` - Errors requiring attention
- `warn` - Warning conditions (400-499 responses) - `warn` - Warning conditions (400-499 responses)
@ -139,6 +142,7 @@ process.on('unhandledRejection', (reason) => {
``` ```
**Request Logger Middleware:** **Request Logger Middleware:**
```javascript ```javascript
function requestLogger(req, res, next) { function requestLogger(req, res, next) {
// Generate or use existing request ID // Generate or use existing request ID
@ -162,9 +166,15 @@ function requestLogger(req, res, next) {
// Log level based on status code // Log level based on status code
if (res.statusCode >= 500) { if (res.statusCode >= 500) {
getRequestLogger(req)?.error(logData, 'Request completed with server error'); getRequestLogger(req)?.error(
logData,
'Request completed with server error',
);
} else if (res.statusCode >= 400) { } else if (res.statusCode >= 400) {
getRequestLogger(req)?.warn(logData, 'Request completed with client error'); getRequestLogger(req)?.warn(
logData,
'Request completed with client error',
);
} else { } else {
getRequestLogger(req)?.info(logData, 'Request completed'); getRequestLogger(req)?.info(logData, 'Request completed');
} }
@ -177,6 +187,7 @@ function requestLogger(req, res, next) {
**Log Output Examples:** **Log Output Examples:**
Development (pino-pretty): Development (pino-pretty):
``` ```
[12:34:56.789] INFO (tour-builder-api): Request completed [12:34:56.789] INFO (tour-builder-api): Request completed
requestId: "abc-123" requestId: "abc-123"
@ -187,11 +198,24 @@ Development (pino-pretty):
``` ```
Production (JSON): Production (JSON):
```json ```json
{"level":30,"time":1711723456789,"service":"tour-builder-api","env":"production","requestId":"abc-123","method":"GET","url":"/api/users","status":200,"duration":45,"msg":"Request completed"} {
"level": 30,
"time": 1711723456789,
"service": "tour-builder-api",
"env": "production",
"requestId": "abc-123",
"method": "GET",
"url": "/api/users",
"status": 200,
"duration": 45,
"msg": "Request completed"
}
``` ```
**Usage:** **Usage:**
```javascript ```javascript
const { logger, requestLogger } = require('./utils/logger'); const { logger, requestLogger } = require('./utils/logger');
@ -223,6 +247,7 @@ routes/services should read through `getCurrentUser`, `getRuntimeContext`,
Joi-based validation ensuring all required environment variables are present with correct types. Joi-based validation ensuring all required environment variables are present with correct types.
**Schema Definition:** **Schema Definition:**
```javascript ```javascript
const Joi = require('joi'); const Joi = require('joi');
@ -275,15 +300,16 @@ const envSchema = Joi.object({
LOG_LEVEL: Joi.string() LOG_LEVEL: Joi.string()
.valid('fatal', 'error', 'warn', 'info', 'debug', 'trace') .valid('fatal', 'error', 'warn', 'info', 'debug', 'trace')
.default('info'), .default('info'),
}).unknown(true); // Allow additional env vars }).unknown(true); // Allow additional env vars
``` ```
**Validation Function:** **Validation Function:**
```javascript ```javascript
function validateEnv() { function validateEnv() {
const { error, value } = envSchema.validate(process.env, { const { error, value } = envSchema.validate(process.env, {
abortEarly: false, // Report all errors abortEarly: false, // Report all errors
stripUnknown: false, // Keep unknown vars stripUnknown: false, // Keep unknown vars
}); });
if (error) { if (error) {
@ -298,22 +324,22 @@ function validateEnv() {
} }
} }
return value; // Returns validated/defaulted values return value; // Returns validated/defaulted values
} }
``` ```
**Environment Variable Categories:** **Environment Variable Categories:**
| Category | Variables | Required | | Category | Variables | Required |
|----------|-----------|----------| | ----------------- | ----------------------------------------------------------------------------------------------- | -------- |
| **Server** | `NODE_ENV`, `PORT` | Defaults | | **Server** | `NODE_ENV`, `PORT` | Defaults |
| **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | Defaults | | **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | Defaults |
| **Auth** | `SECRET_KEY`, `ADMIN_PASS`, `USER_PASS`, `ADMIN_EMAIL` | Defaults | | **Auth** | `SECRET_KEY`, `ADMIN_PASS`, `USER_PASS`, `ADMIN_EMAIL` | Defaults |
| **OAuth** | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | Optional | | **OAuth** | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | Optional |
| **AWS S3** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`, `AWS_S3_REGION`, `AWS_S3_PREFIX` | Optional | | **AWS S3** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`, `AWS_S3_REGION`, `AWS_S3_PREFIX` | Optional |
| **Email** | `EMAIL_USER`, `EMAIL_PASS`, `EMAIL_TLS_REJECT_UNAUTHORIZED` | Optional | | **Email** | `EMAIL_USER`, `EMAIL_PASS`, `EMAIL_TLS_REJECT_UNAUTHORIZED` | Optional |
| **External APIs** | `PEXELS_KEY` | Optional | | **External APIs** | `PEXELS_KEY` | Optional |
| **Logging** | `LOG_LEVEL` | Defaults | | **Logging** | `LOG_LEVEL` | Defaults |
--- ---
@ -330,6 +356,7 @@ module.exports = {
``` ```
**Exported:** **Exported:**
- `AppError`, `NotFoundError`, `ValidationError`, `ForbiddenError`, `UnauthorizedError`, `ConflictError` - `AppError`, `NotFoundError`, `ValidationError`, `ForbiddenError`, `UnauthorizedError`, `ConflictError`
- `logger`, `requestLogger`, `registerProcessErrorHandlers`, - `logger`, `requestLogger`, `registerProcessErrorHandlers`,
`exitAfterLogging`, `exitAfterLogging`,
@ -382,12 +409,12 @@ module.exports = class Helpers {
**Functions:** **Functions:**
| Function | Purpose | Usage | | Function | Purpose | Usage |
|----------|---------|-------| | ----------------------------------------- | ---------------------------------------- | -------------------------- |
| `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers | | `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers |
| `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware | | `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware |
| `jwtSign(data)` | Creates JWT with 6h expiry | Auth service | | `jwtSign(data)` | Creates JWT with 6h expiry | Auth service |
| `isUuidV4(value)` | Validates UUID v4 format | Route parameter validation | | `isUuidV4(value)` | Validates UUID v4 format | Route parameter validation |
## Request Validation ## Request Validation
@ -433,18 +460,22 @@ Request validation errors return JSON:
Service/domain `ValidationError` responses keep the legacy plain-text format unless they are raised by request validation middleware. Service/domain `ValidationError` responses keep the legacy plain-text format unless they are raised by request validation middleware.
**Usage Pattern:** **Usage Pattern:**
```javascript ```javascript
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers'); const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
// Async route handler // Async route handler
router.get('/users/:id', wrapAsync(async (req, res) => { router.get(
if (!isUuidV4(req.params.id)) { '/users/:id',
return res.status(400).send('Invalid ID format'); wrapAsync(async (req, res) => {
} if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid ID format');
}
const user = await UserService.findOne(req.params.id); const user = await UserService.findOne(req.params.id);
res.json(user); res.json(user);
})); }),
);
// Register error handler at end of router // Register error handler at end of router
router.use('/', commonErrorHandler); router.use('/', commonErrorHandler);
@ -489,19 +520,21 @@ module.exports = class Utils {
**Functions:** **Functions:**
| Function | Purpose | Returns | | Function | Purpose | Returns |
|----------|---------|---------| | ----------------------------- | -------------------------------- | ---------------------- |
| `isValidUuid(value)` | Check if value is a valid UUID | `boolean` | | `isValidUuid(value)` | Check if value is a valid UUID | `boolean` |
| `generateUuid()` | Generate a new UUID v4 | `string` | | `generateUuid()` | Generate a new UUID v4 | `string` |
| `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` | | `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` |
| `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause | | `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause |
**UUID Validation Behavior:** **UUID Validation Behavior:**
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately - Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
- Invalid UUIDs in relation filters (`?project=uuid|name`) → filtered out for ID search, kept for text search - Invalid UUIDs in relation filters (`?project=uuid|name`) → filtered out for ID search, kept for text search
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately - Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
**Usage in DB API:** **Usage in DB API:**
```javascript ```javascript
const Utils = require('../utils'); const Utils = require('../utils');
@ -596,7 +629,8 @@ const errors = {
errors: { errors: {
invalidFileEmpty: 'The file is empty', invalidFileEmpty: 'The file is empty',
invalidFileExcel: 'Only excel (.xlsx) files are allowed', invalidFileExcel: 'Only excel (.xlsx) files are allowed',
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.', invalidFileUpload:
'Invalid file. Make sure you are using the last version of the template.',
importHashRequired: 'Import hash is required', importHashRequired: 'Import hash is required',
importHashExistent: 'Data has already been imported', importHashExistent: 'Data has already been imported',
userEmailMissing: 'Some items in the CSV do not have an email', userEmailMissing: 'Some items in the CSV do not have an email',
@ -628,14 +662,14 @@ const errors = {
**Message Categories:** **Message Categories:**
| Category | Purpose | Examples | | Category | Purpose | Examples |
|----------|---------|----------| | ---------- | ---------------------- | ----------------------------------------- |
| `app` | Application metadata | `app.title` | | `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` | | `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` |
| `iam` | User management errors | `iam.errors.userAlreadyExists` | | `iam` | User management errors | `iam.errors.userAlreadyExists` |
| `importer` | Import/export errors | `importer.errors.invalidFileEmpty` | | `importer` | Import/export errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic errors | `errors.forbidden.message` | | `errors` | Generic errors | `errors.forbidden.message` |
| `emails` | Email templates | `emails.invitation.subject` | | `emails` | Email templates | `emails.invitation.subject` |
--- ---
@ -670,6 +704,7 @@ const getNotification = (key, ...args) => {
``` ```
**Usage:** **Usage:**
```javascript ```javascript
const { getNotification, isNotification } = require('./helpers'); const { getNotification, isNotification } = require('./helpers');
@ -682,8 +717,8 @@ getNotification('emails.invitation.subject', 'Tour Builder');
// → "You've been invited to Tour Builder" // → "You've been invited to Tour Builder"
// Check existence // Check existence
isNotification('auth.userDisabled'); // → true isNotification('auth.userDisabled'); // → true
isNotification('unknown.key'); // → false isNotification('unknown.key'); // → false
``` ```
--- ---
@ -693,6 +728,7 @@ isNotification('unknown.key'); // → false
i18n-aware error classes (legacy pattern, prefer `utils/errors.js`): i18n-aware error classes (legacy pattern, prefer `utils/errors.js`):
**ForbiddenError:** **ForbiddenError:**
```javascript ```javascript
const { getNotification, isNotification } = require('../helpers'); const { getNotification, isNotification } = require('../helpers');
@ -713,6 +749,7 @@ module.exports = class ForbiddenError extends Error {
``` ```
**ValidationError:** **ValidationError:**
```javascript ```javascript
module.exports = class ValidationError extends Error { module.exports = class ValidationError extends Error {
constructor(messageCode) { constructor(messageCode) {
@ -731,6 +768,7 @@ module.exports = class ValidationError extends Error {
``` ```
**Usage:** **Usage:**
```javascript ```javascript
const ForbiddenError = require('./services/notifications/errors/forbidden'); const ForbiddenError = require('./services/notifications/errors/forbidden');
const ValidationError = require('./services/notifications/errors/validation'); const ValidationError = require('./services/notifications/errors/validation');
@ -740,7 +778,7 @@ throw new ForbiddenError('auth.forbidden');
throw new ValidationError('iam.errors.emailRequired'); throw new ValidationError('iam.errors.emailRequired');
// With default message // With default message
throw new ForbiddenError(); // → 'Forbidden' throw new ForbiddenError(); // → 'Forbidden'
throw new ValidationError(); // → 'An error occurred' throw new ValidationError(); // → 'An error occurred'
``` ```
@ -804,46 +842,46 @@ const { getNotification } = require('./services/notifications/helpers');
### Error Class Selection ### Error Class Selection
| Scenario | Recommended Class | | Scenario | Recommended Class |
|----------|-------------------| | ------------------ | ------------------------------------------- |
| Resource not found | `NotFoundError` from `utils/errors.js` | | Resource not found | `NotFoundError` from `utils/errors.js` |
| Invalid input | `ValidationError` from `utils/errors.js` | | Invalid input | `ValidationError` from `utils/errors.js` |
| Permission denied | `ForbiddenError` from `utils/errors.js` | | Permission denied | `ForbiddenError` from `utils/errors.js` |
| Auth required | `UnauthorizedError` from `utils/errors.js` | | Auth required | `UnauthorizedError` from `utils/errors.js` |
| Duplicate resource | `ConflictError` from `utils/errors.js` | | Duplicate resource | `ConflictError` from `utils/errors.js` |
| i18n error message | Legacy classes from `notifications/errors/` | | i18n error message | Legacy classes from `notifications/errors/` |
--- ---
## Environment Variables Reference ## Environment Variables Reference
| Variable | Type | Default | Description | | Variable | Type | Default | Description |
|----------|------|---------|-------------| | ------------------------------- | ------ | -------------------------- | ------------------------------------------------ |
| `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` | | `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` |
| `PORT` | number | `8080` | Server port | | `PORT` | number | `8080` | Server port |
| `DB_HOST` | string | `localhost` | PostgreSQL host | | `DB_HOST` | string | `localhost` | PostgreSQL host |
| `DB_PORT` | number | `5432` | PostgreSQL port | | `DB_PORT` | number | `5432` | PostgreSQL port |
| `DB_NAME` | string | `db_tour_builder_platform` | Database name | | `DB_NAME` | string | `db_tour_builder_platform` | Database name |
| `DB_USER` | string | `postgres` | Database user | | `DB_USER` | string | `postgres` | Database user |
| `DB_PASS` | string | `` | Database password | | `DB_PASS` | string | `` | Database password |
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) | | `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
| `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email | | `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email |
| `ADMIN_PASS` | string | `88dbeaf8` | Admin account password | | `ADMIN_PASS` | string | `88dbeaf8` | Admin account password |
| `USER_PASS` | string | `c3baadeda5c6` | Default user password | | `USER_PASS` | string | `c3baadeda5c6` | Default user password |
| `GOOGLE_CLIENT_ID` | string | `` | Google OAuth client ID | | `GOOGLE_CLIENT_ID` | string | `` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | string | `` | Google OAuth client secret | | `GOOGLE_CLIENT_SECRET` | string | `` | Google OAuth client secret |
| `MS_CLIENT_ID` | string | `` | Microsoft OAuth client ID | | `MS_CLIENT_ID` | string | `` | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | string | `` | Microsoft OAuth client secret | | `MS_CLIENT_SECRET` | string | `` | Microsoft OAuth client secret |
| `AWS_ACCESS_KEY_ID` | string | `` | AWS access key | | `AWS_ACCESS_KEY_ID` | string | `` | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key | | `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key |
| `AWS_S3_BUCKET` | string | `` | S3 bucket name | | `AWS_S3_BUCKET` | string | `` | S3 bucket name |
| `AWS_S3_REGION` | string | `us-east-1` | S3 region | | `AWS_S3_REGION` | string | `us-east-1` | S3 region |
| `AWS_S3_PREFIX` | string | UUID | S3 key prefix | | `AWS_S3_PREFIX` | string | UUID | S3 key prefix |
| `EMAIL_USER` | string | `` | SMTP username | | `EMAIL_USER` | string | `` | SMTP username |
| `EMAIL_PASS` | string | `` | SMTP password | | `EMAIL_PASS` | string | `` | SMTP password |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation | | `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation |
| `PEXELS_KEY` | string | `` | Pexels API key | | `PEXELS_KEY` | string | `` | Pexels API key |
| `LOG_LEVEL` | string | `info` | Pino log level | | `LOG_LEVEL` | string | `info` | Pino log level |
--- ---

View File

@ -5,13 +5,13 @@
Backend tests use Node.js 24's built-in test runner and TypeScript test files. Backend tests use Node.js 24's built-in test runner and TypeScript test files.
The suite is split into three layers: The suite is split into three layers:
| Layer | Command | Location | Purpose | | Layer | Command | Location | Purpose |
|-------|---------|----------|---------| | ------------ | -------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
| Unit | `npm run test` | `backend/tests/*.test.ts` | Pure helpers, validators, policy decisions, service contracts, and file/session utilities | | Unit | `npm run test` | `backend/tests/*.test.ts` | Pure helpers, validators, policy decisions, service contracts, and file/session utilities |
| Integration | `npm run test:integration` | `backend/tests/integration/*.test.ts` | Cross-module behavior such as DB-backed access policy and Express router factory contracts | | Integration | `npm run test:integration` | `backend/tests/integration/*.test.ts` | Cross-module behavior such as DB-backed access policy and Express router factory contracts |
| E2E | `npm run test:e2e` | `backend/tests/e2e/*.test.ts` | Real HTTP request/response checks against local Express test apps | | E2E | `npm run test:e2e` | `backend/tests/e2e/*.test.ts` | Real HTTP request/response checks against local Express test apps |
| Full suite | `npm run test:all` | all test folders | Runs unit, integration, and e2e in sequence | | Full suite | `npm run test:all` | all test folders | Runs unit, integration, and e2e in sequence |
| Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite | | Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite |
## Current Coverage ## Current Coverage

View File

@ -32,7 +32,9 @@ function toProjectPath(filePath: string): string {
} }
function isHistoricalMigration(projectPath: string): boolean { function isHistoricalMigration(projectPath: string): boolean {
return projectPath.startsWith('src/db/migrations/') && projectPath.endsWith('.js'); return (
projectPath.startsWith('src/db/migrations/') && projectPath.endsWith('.js')
);
} }
function checkJsBoundary(projectPath: string): BoundaryViolation | null { function checkJsBoundary(projectPath: string): BoundaryViolation | null {
@ -44,7 +46,10 @@ function checkJsBoundary(projectPath: string): BoundaryViolation | null {
}; };
} }
function checkTsBoundary(projectPath: string, source: string): BoundaryViolation | null { function checkTsBoundary(
projectPath: string,
source: string,
): BoundaryViolation | null {
if (!commonJsPattern.test(source)) return null; if (!commonJsPattern.test(source)) return null;
return { return {
@ -70,7 +75,9 @@ async function checkFile(filePath: string): Promise<BoundaryViolation | null> {
async function main(): Promise<void> { async function main(): Promise<void> {
const files = ( const files = (
await Promise.all(sourceRoots.map((root) => collectFiles(path.join(process.cwd(), root)))) await Promise.all(
sourceRoots.map((root) => collectFiles(path.join(process.cwd(), root))),
)
).flat(); ).flat();
const checks = await Promise.all(files.map((file) => checkFile(file))); const checks = await Promise.all(files.map((file) => checkFile(file)));
const violations = checks.filter((violation) => violation !== null); const violations = checks.filter((violation) => violation !== null);

View File

@ -10,7 +10,10 @@ import config from '../config.ts';
import db from '../db/models/index.ts'; import db from '../db/models/index.ts';
import UsersDBApi from '../db/api/users.ts'; import UsersDBApi from '../db/api/users.ts';
import { jwtSign } from '../helpers.ts'; import { jwtSign } from '../helpers.ts';
import { setCurrentUser, setSocialAuthToken } from '../utils/request-context.ts'; import {
setCurrentUser,
setSocialAuthToken,
} from '../utils/request-context.ts';
import type { import type {
AuthTokenPayload, AuthTokenPayload,
CurrentUser, CurrentUser,
@ -111,9 +114,11 @@ async function socialStrategy(
} }
try { try {
const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate({ const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate(
where: { email, provider }, {
}); where: { email, provider },
},
);
const body: AuthTokenPayload['user'] = { const body: AuthTokenPayload['user'] = {
id: user.id, id: user.id,
email: user.email, email: user.email,
@ -165,11 +170,7 @@ passport.use(
secretOrKey: config.secret_key, secretOrKey: config.secret_key,
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(), jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
}, },
( (req: Request, token: unknown, done: VerifiedCallback) => {
req: Request,
token: unknown,
done: VerifiedCallback,
) => {
void verifyJwt(req, token, done); void verifyJwt(req, token, done);
}, },
), ),

View File

@ -47,7 +47,9 @@ function authenticatePassport(
const middleware: unknown = passport.authenticate(strategy, options); const middleware: unknown = passport.authenticate(strategy, options);
if (!isRequestHandler(middleware)) { if (!isRequestHandler(middleware)) {
throw new Error(`Passport ${strategy} authentication middleware is unavailable.`); throw new Error(
`Passport ${strategy} authentication middleware is unavailable.`,
);
} }
return middleware; return middleware;

View File

@ -73,7 +73,9 @@ class Asset_variantsDBApi extends GenericDBApi {
]; ];
} }
static override getFieldMapping(data: AssetVariantData): AssetVariantFieldMapping { static override getFieldMapping(
data: AssetVariantData,
): AssetVariantFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
assetId: data.assetId || null, assetId: data.assetId || null,

View File

@ -88,15 +88,12 @@ function isGenericDbModel(value: unknown): value is GenericDbModel {
return false; return false;
} }
return ( return 'getTableName' in value && typeof value.getTableName === 'function';
'getTableName' in value &&
typeof value.getTableName === 'function'
);
} }
function buildTransactionOptions( function buildTransactionOptions(transaction: Transaction | undefined): {
transaction: Transaction | undefined, transaction?: Transaction | undefined;
): { transaction?: Transaction | undefined } { } {
const options: { transaction?: Transaction | undefined } = {}; const options: { transaction?: Transaction | undefined } = {};
if (transaction !== undefined) { if (transaction !== undefined) {
options.transaction = transaction; options.transaction = transaction;
@ -139,12 +136,17 @@ function addRangeFilter(where: DbData, field: string, range: unknown): void {
addRangeBoundary(where, field, Op.lte, end); addRangeBoundary(where, field, Op.lte, end);
} }
function getFilterString(filter: GenericDbListFilter, field: string): string | null { function getFilterString(
filter: GenericDbListFilter,
field: string,
): string | null {
const value = filter[field]; const value = filter[field];
return typeof value === 'string' ? value : null; return typeof value === 'string' ? value : null;
} }
function isDbFindAllOptions(value: unknown): value is DbFindAllOptions<unknown> { function isDbFindAllOptions(
value: unknown,
): value is DbFindAllOptions<unknown> {
return ( return (
isRecord(value) && isRecord(value) &&
('filter' in value || ('filter' in value ||
@ -495,9 +497,7 @@ class GenericDBApi {
return record; return record;
} }
static async findBy( static async findBy(options: DbFindByOptions): Promise<EntityRecord | null>;
options: DbFindByOptions,
): Promise<EntityRecord | null>;
static async findBy( static async findBy(
where: unknown, where: unknown,
options?: ServiceOptions & { include?: unknown[] }, options?: ServiceOptions & { include?: unknown[] },
@ -514,7 +514,9 @@ class GenericDBApi {
const rawTransaction = hasWhereOption const rawTransaction = hasWhereOption
? maybeOptions.transaction ? maybeOptions.transaction
: options.transaction; : options.transaction;
const transaction = isTransaction(rawTransaction) ? rawTransaction : undefined; const transaction = isTransaction(rawTransaction)
? rawTransaction
: undefined;
const include = const include =
hasWhereOption && Array.isArray(maybeOptions.include) hasWhereOption && Array.isArray(maybeOptions.include)
? maybeOptions.include ? maybeOptions.include
@ -681,7 +683,8 @@ class GenericDBApi {
queryOptions.transaction = options.transaction; queryOptions.transaction = options.transaction;
} }
const { rows, count } = await this.getModel().findAndCountAll(queryOptions); const { rows, count } =
await this.getModel().findAndCountAll(queryOptions);
return { return {
rows, rows,
count, count,

View File

@ -33,7 +33,9 @@ function isMissingTableError(error: unknown): boolean {
return original.code === '42P01'; return original.code === '42P01';
} }
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null { function stringifySettings(
value: ElementSettingsJson | string | null | undefined,
): string | null {
if (value === undefined || value === null) return null; if (value === undefined || value === null) return null;
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
return JSON.stringify(value); return JSON.stringify(value);
@ -437,7 +439,9 @@ class Element_type_defaultsDBApi extends GenericDBApi {
return super.deleteByIds(options); return super.deleteByIds(options);
} }
static override async remove(options: EntityIdOptions): Promise<EntityRecord> { static override async remove(
options: EntityIdOptions,
): Promise<EntityRecord> {
await this.ensureInitialized(); await this.ensureInitialized();
return super.remove(options); return super.remove(options);
} }

View File

@ -11,7 +11,9 @@ import type {
RelationFileRecord, RelationFileRecord,
} from '../../types/index.ts'; } from '../../types/index.ts';
function normalizeRelationFiles(rawFiles: RelationFileInput): RelationFileRecord[] { function normalizeRelationFiles(
rawFiles: RelationFileInput,
): RelationFileRecord[] {
if (Array.isArray(rawFiles)) return rawFiles; if (Array.isArray(rawFiles)) return rawFiles;
return rawFiles ? [rawFiles] : []; return rawFiles ? [rawFiles] : [];
} }

View File

@ -34,7 +34,9 @@ function isGlobalTransitionListFilter(
return Boolean(value) && typeof value === 'object' && !Array.isArray(value); return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
} }
function isDbFindAllOptions(value: unknown): value is DbFindAllOptions<unknown> { function isDbFindAllOptions(
value: unknown,
): value is DbFindAllOptions<unknown> {
return ( return (
value !== null && value !== null &&
typeof value === 'object' && typeof value === 'object' &&

View File

@ -28,13 +28,17 @@ import type {
const { Op } = db.Sequelize; const { Op } = db.Sequelize;
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null { function stringifySettings(
value: ElementSettingsJson | string | null | undefined,
): string | null {
if (value === undefined || value === null) return null; if (value === undefined || value === null) return null;
if (typeof value === 'string') return value; if (typeof value === 'string') return value;
return JSON.stringify(value); return JSON.stringify(value);
} }
function parseSettings(value: ElementSettingsJson | string | null | undefined): ElementSettingsJson { function parseSettings(
value: ElementSettingsJson | string | null | undefined,
): ElementSettingsJson {
if (!value) return {}; if (!value) return {};
if (typeof value !== 'string') return value; if (typeof value !== 'string') return value;
@ -84,7 +88,9 @@ function isQueryWhere(value: unknown): value is QueryWhere {
return Boolean(value) && typeof value === 'object' && !Array.isArray(value); return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
} }
function isRangeFilter(value: unknown): value is ProjectElementDefaultsRangeFilter { function isRangeFilter(
value: unknown,
): value is ProjectElementDefaultsRangeFilter {
return Array.isArray(value) && value.length === 2; return Array.isArray(value) && value.length === 2;
} }
@ -229,7 +235,8 @@ class Project_element_defaultsDBApi extends GenericDBApi {
whereOrOptions: { id: string } | DbFindByOptions, whereOrOptions: { id: string } | DbFindByOptions,
options: ProjectElementDefaultsOptions = {}, options: ProjectElementDefaultsOptions = {},
): Promise<ProjectElementDefaultRecord | null> { ): Promise<ProjectElementDefaultRecord | null> {
const where = 'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions; const where =
'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
const findOptions: { const findOptions: {
where: QueryWhere; where: QueryWhere;
include?: unknown[]; include?: unknown[];
@ -271,7 +278,8 @@ class Project_element_defaultsDBApi extends GenericDBApi {
const offset = Math.max(currentPage - 1, 0) * limit; const offset = Math.max(currentPage - 1, 0) * limit;
const where: QueryWhere = {}; const where: QueryWhere = {};
const projectFilter = normalizedFilter.project || normalizedFilter.projectId; const projectFilter =
normalizedFilter.project || normalizedFilter.projectId;
const terms = projectFilter ? projectFilter.split('|') : []; const terms = projectFilter ? projectFilter.split('|') : [];
const validUuids = Utils.filterValidUuids(terms); const validUuids = Utils.filterValidUuids(terms);
const include: RuntimeProjectInclude[] = [ const include: RuntimeProjectInclude[] = [
@ -398,17 +406,19 @@ class Project_element_defaultsDBApi extends GenericDBApi {
} }
const seenTypes = new Set<string>(); const seenTypes = new Set<string>();
const dedupedDefaults = globalDefaults.rows.filter(isGlobalElementDefaultRecord).filter((row) => { const dedupedDefaults = globalDefaults.rows
if (seenTypes.has(row.element_type)) { .filter(isGlobalElementDefaultRecord)
logger.warn( .filter((row) => {
{ elementType: row.element_type }, if (seenTypes.has(row.element_type)) {
'Duplicate element_type in global defaults skipped', logger.warn(
); { elementType: row.element_type },
return false; 'Duplicate element_type in global defaults skipped',
} );
seenTypes.add(row.element_type); return false;
return true; }
}); seenTypes.add(row.element_type);
return true;
});
const now = new Date(); const now = new Date();
const currentUserId = options.currentUser?.id || null; const currentUserId = options.currentUser?.id || null;

View File

@ -49,7 +49,10 @@ function normalizeFilter(
return isProjectListFilter(filter.filter) ? filter.filter : {}; return isProjectListFilter(filter.filter) ? filter.filter : {};
} }
function getFilterString(filter: ProjectListFilter, field: string): string | null { function getFilterString(
filter: ProjectListFilter,
field: string,
): string | null {
const value = filter[field]; const value = filter[field];
return typeof value === 'string' ? value : null; return typeof value === 'string' ? value : null;
} }
@ -69,7 +72,11 @@ function addRangeBoundary(
}; };
} }
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void { function addRangeFilter(
where: QueryWhere,
field: string,
range: unknown,
): void {
if (!isRangeFilter(range)) return; if (!isRangeFilter(range)) return;
const [start, end] = range; const [start, end] = range;
@ -85,7 +92,8 @@ function getDefinedProjectFields(data: ProjectData): ProjectFieldMapping {
description: 'description' in data ? data.description || null : undefined, description: 'description' in data ? data.description || null : undefined,
logo_url: 'logo_url' in data ? data.logo_url || null : undefined, logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
favicon_url: 'favicon_url' in data ? data.favicon_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, 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_width: 'design_width' in data ? data.design_width : undefined,
design_height: 'design_height' in data ? data.design_height : undefined, design_height: 'design_height' in data ? data.design_height : undefined,
production_presentation_visibility: production_presentation_visibility:

View File

@ -90,7 +90,9 @@ class Publish_eventsDBApi extends GenericDBApi {
]; ];
} }
static override getFieldMapping(data: PublishEventData): PublishEventFieldMapping { static override getFieldMapping(
data: PublishEventData,
): PublishEventFieldMapping {
return { return {
id: data.id || undefined, id: data.id || undefined,
title: data.title || null, title: data.title || null,

View File

@ -28,7 +28,9 @@ function getRuntimeEnvironment(
return null; return null;
} }
function getRuntimeProjectSlug(options: RuntimeFilterOptions = {}): string | null { function getRuntimeProjectSlug(
options: RuntimeFilterOptions = {},
): string | null {
const runtimeContext = getRuntimeContext(options); const runtimeContext = getRuntimeContext(options);
return runtimeContext?.headerProjectSlug ?? null; return runtimeContext?.headerProjectSlug ?? null;
} }

View File

@ -52,7 +52,10 @@ function normalizeFilter(
return isTourPageListQuery(filter.filter) ? filter.filter : {}; return isTourPageListQuery(filter.filter) ? filter.filter : {};
} }
function getFilterString(filter: TourPageListQuery, field: string): string | null { function getFilterString(
filter: TourPageListQuery,
field: string,
): string | null {
const value = filter[field]; const value = filter[field];
return typeof value === 'string' ? value : null; return typeof value === 'string' ? value : null;
} }
@ -72,7 +75,11 @@ function addRangeBoundary(
}; };
} }
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void { function addRangeFilter(
where: QueryWhere,
field: string,
range: unknown,
): void {
if (!isRangeFilter(range)) return; if (!isRangeFilter(range)) return;
const [start, end] = range; const [start, end] = range;
@ -83,7 +90,8 @@ function addRangeFilter(where: QueryWhere, field: string, range: unknown): void
function getProjectId(data: TourPageData): string | null { function getProjectId(data: TourPageData): string | null {
if (typeof data.projectId === 'string') return data.projectId; if (typeof data.projectId === 'string') return data.projectId;
if (typeof data.project === 'string') return data.project; if (typeof data.project === 'string') return data.project;
if (data.project && typeof data.project.id === 'string') return data.project.id; if (data.project && typeof data.project.id === 'string')
return data.project.id;
return null; return null;
} }

View File

@ -116,7 +116,11 @@ function addRangeBoundary(
}; };
} }
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void { function addRangeFilter(
where: QueryWhere,
field: string,
range: unknown,
): void {
if (!isRangeFilter(range)) return; if (!isRangeFilter(range)) return;
const [start, end] = range; const [start, end] = range;
@ -128,9 +132,9 @@ function getCurrentUserId(options?: ServiceOptions): string | null {
return options?.currentUser?.id ?? null; return options?.currentUser?.id ?? null;
} }
function buildTransactionOptions( function buildTransactionOptions(transaction: Transaction | undefined): {
transaction: Transaction | undefined, transaction?: Transaction | undefined;
): { transaction?: Transaction | undefined } { } {
const options: { transaction?: Transaction | undefined } = {}; const options: { transaction?: Transaction | undefined } = {};
if (transaction !== undefined) { if (transaction !== undefined) {
options.transaction = transaction; options.transaction = transaction;
@ -173,7 +177,8 @@ function buildUserUpdatePayload(
if (data.firstName !== undefined) updatePayload.firstName = data.firstName; if (data.firstName !== undefined) updatePayload.firstName = data.firstName;
if (data.lastName !== undefined) updatePayload.lastName = data.lastName; if (data.lastName !== undefined) updatePayload.lastName = data.lastName;
if (data.phoneNumber !== undefined) updatePayload.phoneNumber = data.phoneNumber; if (data.phoneNumber !== undefined)
updatePayload.phoneNumber = data.phoneNumber;
if (data.email !== undefined) updatePayload.email = data.email; if (data.email !== undefined) updatePayload.email = data.email;
if (data.disabled !== undefined) updatePayload.disabled = data.disabled; if (data.disabled !== undefined) updatePayload.disabled = data.disabled;
if (data.password !== undefined && data.password !== null) { if (data.password !== undefined && data.password !== null) {
@ -195,7 +200,8 @@ function buildUserUpdatePayload(
updatePayload.passwordResetToken = data.passwordResetToken; updatePayload.passwordResetToken = data.passwordResetToken;
} }
if (data.passwordResetTokenExpiresAt !== undefined) { if (data.passwordResetTokenExpiresAt !== undefined) {
updatePayload.passwordResetTokenExpiresAt = data.passwordResetTokenExpiresAt; updatePayload.passwordResetTokenExpiresAt =
data.passwordResetTokenExpiresAt;
} }
if (data.provider !== undefined) updatePayload.provider = data.provider; if (data.provider !== undefined) updatePayload.provider = data.provider;
@ -255,7 +261,9 @@ class UsersDBApi {
]; ];
} }
static async create(options: Parameters<UsersDbApi['create']>[0]): Promise<UserRecord> { static async create(
options: Parameters<UsersDbApi['create']>[0],
): Promise<UserRecord> {
assertCreateOptions(options, 'DBApi'); assertCreateOptions(options, 'DBApi');
const { data: userData, transaction } = options; const { data: userData, transaction } = options;
@ -342,12 +350,7 @@ class UsersDBApi {
updateOptions: Parameters<UsersDbApi['update']>[0], updateOptions: Parameters<UsersDbApi['update']>[0],
): Promise<UserRecord> { ): Promise<UserRecord> {
assertUpdateOptions(updateOptions, 'DBApi'); assertUpdateOptions(updateOptions, 'DBApi');
const { const { id, data, transaction, runtimeContext } = updateOptions;
id,
data,
transaction,
runtimeContext,
} = updateOptions;
const dbOptions: ServiceOptions = {}; const dbOptions: ServiceOptions = {};
if (transaction !== undefined) { if (transaction !== undefined) {
dbOptions.transaction = transaction; dbOptions.transaction = transaction;
@ -359,7 +362,10 @@ class UsersDBApi {
dbOptions.runtimeContext = runtimeContext; dbOptions.runtimeContext = runtimeContext;
} }
const users = await db.users.findByPk(id, buildTransactionOptions(transaction)); const users = await db.users.findByPk(
id,
buildTransactionOptions(transaction),
);
if (!users) { if (!users) {
throw new Error('UsersNotFound'); throw new Error('UsersNotFound');
} }
@ -379,9 +385,10 @@ class UsersDBApi {
} }
} }
if (!data?.custom_permissions) { if (!data?.custom_permissions) {
const existingPermissionIds = users.custom_permissions?.flatMap((item) => const existingPermissionIds =
item.id ? [item.id] : [], users.custom_permissions?.flatMap((item) =>
) || []; item.id ? [item.id] : [],
) || [];
if (existingPermissionIds.length) { if (existingPermissionIds.length) {
data.custom_permissions = existingPermissionIds; data.custom_permissions = existingPermissionIds;
} }
@ -410,9 +417,12 @@ class UsersDBApi {
} }
if (data.custom_permissions !== undefined) { if (data.custom_permissions !== undefined) {
await users.setCustom_permissions(normalizePermissionIds(data.custom_permissions) || [], { await users.setCustom_permissions(
transaction, normalizePermissionIds(data.custom_permissions) || [],
}); {
transaction,
},
);
} }
await FileDBApi.replaceRelationFiles( await FileDBApi.replaceRelationFiles(
@ -461,7 +471,10 @@ class UsersDBApi {
const { id, transaction } = options; const { id, transaction } = options;
const currentUserId = getCurrentUserId(options); const currentUserId = getCurrentUserId(options);
const users = await db.users.findByPk(id, buildTransactionOptions(transaction)); const users = await db.users.findByPk(
id,
buildTransactionOptions(transaction),
);
if (!users) { if (!users) {
throw new Error('UsersNotFound'); throw new Error('UsersNotFound');
} }
@ -539,19 +552,21 @@ class UsersDBApi {
...buildTransactionOptions(transaction), ...buildTransactionOptions(transaction),
}); });
output.allowed_private_production_project_ids = productionPresentationAccess output.allowed_private_production_project_ids =
.flatMap((row: UserProductionPresentationAccessRecord) => { productionPresentationAccess.flatMap(
const plain = row.get({ plain: true }); (row: UserProductionPresentationAccessRecord) => {
const project = plain.project; const plain = row.get({ plain: true });
if (!project?.id || !project.name || !project.slug) return []; const project = plain.project;
if (!project?.id || !project.name || !project.slug) return [];
return { return {
id: project.id, id: project.id,
label: `${project.name} (${project.slug})`, label: `${project.name} (${project.slug})`,
name: project.name, name: project.name,
slug: project.slug, slug: project.slug,
}; };
}); },
);
return output; return output;
} }
@ -669,7 +684,11 @@ class UsersDBApi {
'emailVerificationToken', 'emailVerificationToken',
normalizedFilter.emailVerificationToken, normalizedFilter.emailVerificationToken,
); );
addTextFilter(where, 'passwordResetToken', normalizedFilter.passwordResetToken); addTextFilter(
where,
'passwordResetToken',
normalizedFilter.passwordResetToken,
);
addTextFilter(where, 'provider', normalizedFilter.provider); addTextFilter(where, 'provider', normalizedFilter.provider);
addRangeFilter( addRangeFilter(
where, where,
@ -684,7 +703,8 @@ class UsersDBApi {
if (normalizedFilter.active !== undefined) { if (normalizedFilter.active !== undefined) {
where.active = where.active =
normalizedFilter.active === true || normalizedFilter.active === 'true'; normalizedFilter.active === true ||
normalizedFilter.active === 'true';
} }
if (normalizedFilter.disabled) { if (normalizedFilter.disabled) {
@ -733,7 +753,7 @@ class UsersDBApi {
typeof normalizedFilter.field === 'string' && typeof normalizedFilter.field === 'string' &&
this.SORTABLE_FIELDS.includes(normalizedFilter.field) this.SORTABLE_FIELDS.includes(normalizedFilter.field)
? normalizedFilter.field ? normalizedFilter.field
: 'createdAt'; : 'createdAt';
const sortDirection = const sortDirection =
String(normalizedFilter.sort || 'desc').toUpperCase() === 'ASC' String(normalizedFilter.sort || 'desc').toUpperCase() === 'ASC'
? 'ASC' ? 'ASC'
@ -787,7 +807,9 @@ class UsersDBApi {
const where: QueryWhere = {}; const where: QueryWhere = {};
if (query) { if (query) {
const orConditions: unknown[] = [Utils.ilike('users', 'firstName', query)]; const orConditions: unknown[] = [
Utils.ilike('users', 'firstName', query),
];
if (Utils.isValidUuid(query)) { if (Utils.isValidUuid(query)) {
orConditions.unshift({ id: query }); orConditions.unshift({ id: query });
@ -849,7 +871,10 @@ class UsersDBApi {
const currentUserId = getCurrentUserId(options); const currentUserId = getCurrentUserId(options);
const transaction = options.transaction; const transaction = options.transaction;
const users = await db.users.findByPk(id, buildTransactionOptions(transaction)); const users = await db.users.findByPk(
id,
buildTransactionOptions(transaction),
);
if (!users) { if (!users) {
throw new Error('UsersNotFound'); throw new Error('UsersNotFound');
} }
@ -928,7 +953,10 @@ class UsersDBApi {
const currentUserId = getCurrentUserId(options); const currentUserId = getCurrentUserId(options);
const transaction = options.transaction; const transaction = options.transaction;
const users = await db.users.findByPk(id, buildTransactionOptions(transaction)); const users = await db.users.findByPk(
id,
buildTransactionOptions(transaction),
);
if (!users) { if (!users) {
return false; return false;
} }

View File

@ -66,7 +66,9 @@ type FunctionPropertyName =
function isDatabaseConfigEnvironment( function isDatabaseConfigEnvironment(
value: string, value: string,
): value is keyof typeof dbConfig { ): value is keyof typeof dbConfig {
return value === 'development' || value === 'production' || value === 'dev_stage'; return (
value === 'development' || value === 'production' || value === 'dev_stage'
);
} }
function getDatabaseConfig(): DatabaseEnvironmentConfig { function getDatabaseConfig(): DatabaseEnvironmentConfig {
@ -115,7 +117,9 @@ function isSampleDataModel(value: object): value is SampleDataModel {
return hasFunctionProperties(value, ['bulkCreate', 'count', 'findOne']); return hasFunctionProperties(value, ['bulkCreate', 'count', 'findOne']);
} }
function isProjectModel(value: object): value is ProjectModel & SampleDataModel { function isProjectModel(
value: object,
): value is ProjectModel & SampleDataModel {
return hasFunctionProperties(value, [ return hasFunctionProperties(value, [
'bulkCreate', 'bulkCreate',
'count', 'count',
@ -127,9 +131,7 @@ function isProjectModel(value: object): value is ProjectModel & SampleDataModel
]); ]);
} }
function isProjectCloneAssetModel( function isProjectCloneAssetModel(value: object): value is DbModels['assets'] {
value: object,
): value is DbModels['assets'] {
return hasFunctionProperties(value, [ return hasFunctionProperties(value, [
'bulkCreate', 'bulkCreate',
'count', 'count',
@ -149,7 +151,9 @@ function isProjectCloneVariantModel(
]); ]);
} }
function isPublishEventModel(value: object): value is DbModels['publish_events'] { function isPublishEventModel(
value: object,
): value is DbModels['publish_events'] {
return hasFunctionProperties(value, [ return hasFunctionProperties(value, [
'bulkCreate', 'bulkCreate',
'count', 'count',
@ -158,7 +162,9 @@ function isPublishEventModel(value: object): value is DbModels['publish_events']
]); ]);
} }
function isTourPageModel(value: object): value is TourPageModel & SampleDataModel { function isTourPageModel(
value: object,
): value is TourPageModel & SampleDataModel {
return hasFunctionProperties(value, [ return hasFunctionProperties(value, [
'bulkCreate', 'bulkCreate',
'count', 'count',
@ -244,7 +250,12 @@ function isProductionPresentationAccessModel(
} }
function isRoleModel(value: object): value is RoleModel { function isRoleModel(value: object): value is RoleModel {
return hasFunctionProperties(value, ['create', 'findAll', 'findByPk', 'findOne']); return hasFunctionProperties(value, [
'create',
'findAll',
'findByPk',
'findOne',
]);
} }
function isPermissionModel(value: object): value is PermissionModel { function isPermissionModel(value: object): value is PermissionModel {

View File

@ -3,7 +3,10 @@ import type {
SequelizeModelFactory, SequelizeModelFactory,
} from '../../types/index.ts'; } from '../../types/index.ts';
const definePermissionsModel: SequelizeModelFactory = (sequelize, DataTypes) => { const definePermissionsModel: SequelizeModelFactory = (
sequelize,
DataTypes,
) => {
const permissions: SequelizeModel = sequelize.define( const permissions: SequelizeModel = sequelize.define(
'permissions', 'permissions',
{ {

View File

@ -25,7 +25,9 @@ interface UsersSequelizeModel extends ModelStatic<UserModelInstance> {
} }
function isKnownExternalProvider(provider: string): boolean { function isKnownExternalProvider(provider: string): boolean {
return provider !== providers.LOCAL && Object.values(providers).includes(provider); return (
provider !== providers.LOCAL && Object.values(providers).includes(provider)
);
} }
function trimStringFields(user: UserModelInstance): UserModelInstance { function trimStringFields(user: UserModelInstance): UserModelInstance {

View File

@ -6,11 +6,7 @@ import { Sequelize } from 'sequelize';
import * as SequelizeModule from 'sequelize'; import * as SequelizeModule from 'sequelize';
import type { QueryInterface } from 'sequelize'; import type { QueryInterface } from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug'; import { SequelizeStorage, Umzug } from 'umzug';
import type { import type { MigrationMeta, MigrationParams, RunnableMigration } from 'umzug';
MigrationMeta,
MigrationParams,
RunnableMigration,
} from 'umzug';
import '../load-env.ts'; import '../load-env.ts';
import dbConfig from './db-config.ts'; import dbConfig from './db-config.ts';
@ -120,7 +116,9 @@ function getModuleDefault(value: unknown): unknown {
return value; return value;
} }
function isLegacyMigrationModule(value: unknown): value is LegacyMigrationModule { function isLegacyMigrationModule(
value: unknown,
): value is LegacyMigrationModule {
return ( return (
isRecord(value) && isRecord(value) &&
typeof value.up === 'function' && typeof value.up === 'function' &&
@ -266,7 +264,10 @@ function createMigrators(sequelize: Sequelize): DbMigrators {
}; };
} }
function printMigrationList(title: string, migrations: readonly MigrationMeta[]): void { function printMigrationList(
title: string,
migrations: readonly MigrationMeta[],
): void {
console.log(`${title}: ${migrations.length}`); console.log(`${title}: ${migrations.length}`);
for (const migration of migrations) { for (const migration of migrations) {
console.log(`- ${migration.name}`); console.log(`- ${migration.name}`);
@ -370,9 +371,7 @@ function selectMigrator(
command: DbUmzugCommand, command: DbUmzugCommand,
migrators: DbMigrators, migrators: DbMigrators,
): Umzug<DbUmzugContext> { ): Umzug<DbUmzugContext> {
return command.startsWith('seed:') return command.startsWith('seed:') ? migrators.seeders : migrators.migrations;
? migrators.seeders
: migrators.migrations;
} }
async function runCommand(command: DbUmzugCommand): Promise<void> { async function runCommand(command: DbUmzugCommand): Promise<void> {
@ -410,7 +409,9 @@ async function main(): Promise<void> {
} }
const mainScriptUrl = const mainScriptUrl =
process.argv[1] === undefined ? undefined : pathToFileURL(process.argv[1]).href; process.argv[1] === undefined
? undefined
: pathToFileURL(process.argv[1]).href;
if (import.meta.url === mainScriptUrl) { if (import.meta.url === mainScriptUrl) {
void main(); void main();

View File

@ -19,9 +19,8 @@ export default class Utils {
} }
static ilike(model: string, column: string, value: string): Where { static ilike(model: string, column: string, value: string): Where {
return where( return where(fn('lower', col(`${model}.${column}`)), {
fn('lower', col(`${model}.${column}`)), [Op.like]: `%${value}%`.toLowerCase(),
{ [Op.like]: `%${value}%`.toLowerCase() }, });
);
} }
} }

View File

@ -12,10 +12,7 @@ import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
import { validateRequest } from '../middlewares/validate-request.ts'; import { validateRequest } from '../middlewares/validate-request.ts';
import { crud as crudSchemas } from '../validators/request-schemas.ts'; import { crud as crudSchemas } from '../validators/request-schemas.ts';
import { logger } from '../utils/logger.ts'; import { logger } from '../utils/logger.ts';
import { import { getCurrentUser, getRuntimeContext } from '../utils/request-context.ts';
getCurrentUser,
getRuntimeContext,
} from '../utils/request-context.ts';
import type { import type {
EntityRouterDbApi, EntityRouterDbApi,
EntityRouterOptions, EntityRouterOptions,
@ -69,7 +66,9 @@ function hasRawAttributes(value: unknown): value is {
} }
function isStringArray(value: unknown): value is string[] { function isStringArray(value: unknown): value is string[] {
return Array.isArray(value) && value.every((item) => typeof item === 'string'); return (
Array.isArray(value) && value.every((item) => typeof item === 'string')
);
} }
function clampLimit(value: unknown, options: ClampLimitOptions): number { function clampLimit(value: unknown, options: ClampLimitOptions): number {
@ -83,7 +82,8 @@ function clampLimit(value: unknown, options: ClampLimitOptions): number {
function getSortableFields(DBApi: EntityRouterDbApi): readonly string[] { function getSortableFields(DBApi: EntityRouterDbApi): readonly string[] {
if (isStringArray(DBApi.SORTABLE_FIELDS)) return DBApi.SORTABLE_FIELDS; if (isStringArray(DBApi.SORTABLE_FIELDS)) return DBApi.SORTABLE_FIELDS;
if (hasRawAttributes(DBApi.MODEL)) return Object.keys(DBApi.MODEL.rawAttributes); if (hasRawAttributes(DBApi.MODEL))
return Object.keys(DBApi.MODEL.rawAttributes);
return []; return [];
} }
@ -115,10 +115,7 @@ function normalizeQuery(
} }
const sortableFields = getSortableFields(DBApi); const sortableFields = getSortableFields(DBApi);
if ( if (typeof query.field === 'string' && sortableFields.includes(query.field)) {
typeof query.field === 'string' &&
sortableFields.includes(query.field)
) {
normalized.field = query.field; normalized.field = query.field;
} }
@ -278,20 +275,22 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
router.post( router.post(
'/', '/',
validateRequest(schemaFor(options.validation, 'create')), validateRequest(schemaFor(options.validation, 'create')),
wrapAsync<never, unknown, EntityDataRequestBody<TCreate>>(async (req, res) => { wrapAsync<never, unknown, EntityDataRequestBody<TCreate>>(
const referer = async (req, res) => {
req.headers.referer || const referer =
`${req.protocol}://${req.hostname}${req.originalUrl}`; req.headers.referer ||
const link = new URL(referer); `${req.protocol}://${req.hostname}${req.originalUrl}`;
const payload = await Service.create( const link = new URL(referer);
buildCreateOptions(req.body.data, { const payload = await Service.create(
currentUser: getCurrentUser(req), buildCreateOptions(req.body.data, {
runtimeContext: getRuntimeContext(req), currentUser: getCurrentUser(req),
host: link.origin, runtimeContext: getRuntimeContext(req),
}), host: link.origin,
); }),
res.status(200).send(payload); );
}), res.status(200).send(payload);
},
),
); );
router.post( router.post(
@ -307,14 +306,14 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
validateRequest(schemaFor(options.validation, 'update')), validateRequest(schemaFor(options.validation, 'update')),
wrapAsync<{ id: string }, unknown, RouteEntityDataRequestBody<TUpdate>>( wrapAsync<{ id: string }, unknown, RouteEntityDataRequestBody<TUpdate>>(
async (req, res) => { async (req, res) => {
assertRouteIdMatchesBody(req); assertRouteIdMatchesBody(req);
await Service.update( await Service.update(
buildUpdateOptions(req.params.id, req.body.data, { buildUpdateOptions(req.params.id, req.body.data, {
currentUser: getCurrentUser(req), currentUser: getCurrentUser(req),
runtimeContext: getRuntimeContext(req), runtimeContext: getRuntimeContext(req),
}), }),
); );
res.status(200).send(true); res.status(200).send(true);
}, },
), ),
); );
@ -336,15 +335,17 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
router.post( router.post(
'/deleteByIds', '/deleteByIds',
validateRequest(schemaFor(options.validation, 'deleteByIds')), validateRequest(schemaFor(options.validation, 'deleteByIds')),
wrapAsync<never, unknown, EntityDeleteByIdsRequestBody>(async (req, res) => { wrapAsync<never, unknown, EntityDeleteByIdsRequestBody>(
await Service.deleteByIds( async (req, res) => {
buildDeleteByIdsOptions(req.body.data, { await Service.deleteByIds(
currentUser: getCurrentUser(req), buildDeleteByIdsOptions(req.body.data, {
runtimeContext: getRuntimeContext(req), currentUser: getCurrentUser(req),
}), runtimeContext: getRuntimeContext(req),
); }),
res.status(200).send(true); );
}), res.status(200).send(true);
},
),
); );
router.get( router.get(
@ -367,10 +368,8 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
return; return;
} }
const fields = options.csvFields || DBApi.CSV_FIELDS || [ const fields = options.csvFields ||
'id', DBApi.CSV_FIELDS || ['id', 'createdAt'];
'createdAt',
];
try { try {
const csv = parse(payload.rows, { fields: [...fields] }); const csv = parse(payload.rows, { fields: [...fields] });
res.status(200).attachment('export.csv').send(csv); res.status(200).attachment('export.csv').send(csv);

View File

@ -5,10 +5,7 @@ import type {
RequestHandler, RequestHandler,
Response, Response,
} from 'express'; } from 'express';
import type { import type { ParamsDictionary, Query } from 'express-serve-static-core';
ParamsDictionary,
Query,
} from 'express-serve-static-core';
import jwt from 'jsonwebtoken'; import jwt from 'jsonwebtoken';
import config from './config.ts'; import config from './config.ts';

View File

@ -7,7 +7,10 @@ import helmet from 'helmet';
import * as swaggerUI from 'swagger-ui-express'; import * as swaggerUI from 'swagger-ui-express';
import './auth/auth.ts'; import './auth/auth.ts';
import { authenticateJwt, authenticateJwtWithCallback } from './auth/passport-middleware.ts'; import {
authenticateJwt,
authenticateJwtWithCallback,
} from './auth/passport-middleware.ts';
import config from './config.ts'; import config from './config.ts';
import db from './db/models/index.ts'; import db from './db/models/index.ts';
import { wrapAsync } from './helpers.ts'; import { wrapAsync } from './helpers.ts';
@ -86,7 +89,10 @@ function getExpressRouter(name: string, router: unknown): ExpressRouter {
return router; return router;
} }
const accessLogsRoutes = getExpressRouter('access_logs', accessLogsRoutesModule); const accessLogsRoutes = getExpressRouter(
'access_logs',
accessLogsRoutesModule,
);
const assetVariantsRoutes = getExpressRouter( const assetVariantsRoutes = getExpressRouter(
'asset_variants', 'asset_variants',
assetVariantsRoutesModule, assetVariantsRoutesModule,
@ -106,7 +112,10 @@ const globalUiControlDefaultsRoutes = getExpressRouter(
'global_ui_control_defaults', 'global_ui_control_defaults',
globalUiControlDefaultsRoutesModule, globalUiControlDefaultsRoutesModule,
); );
const permissionsRoutes = getExpressRouter('permissions', permissionsRoutesModule); const permissionsRoutes = getExpressRouter(
'permissions',
permissionsRoutesModule,
);
const presignedUrlRequestsRoutes = getExpressRouter( const presignedUrlRequestsRoutes = getExpressRouter(
'presigned_url_requests', 'presigned_url_requests',
presignedUrlRequestsRoutesModule, presignedUrlRequestsRoutesModule,
@ -133,10 +142,16 @@ const projectUiControlSettingsRoutes = getExpressRouter(
); );
const projectsRoutes = getExpressRouter('projects', projectsRoutesModule); const projectsRoutes = getExpressRouter('projects', projectsRoutesModule);
const publishRoutes = getExpressRouter('publish', publishRoutesModule); const publishRoutes = getExpressRouter('publish', publishRoutesModule);
const publishEventsRoutes = getExpressRouter('publish_events', publishEventsRoutesModule); const publishEventsRoutes = getExpressRouter(
'publish_events',
publishEventsRoutesModule,
);
const pwaCachesRoutes = getExpressRouter('pwa_caches', pwaCachesRoutesModule); const pwaCachesRoutes = getExpressRouter('pwa_caches', pwaCachesRoutesModule);
const rolesRoutes = getExpressRouter('roles', rolesRoutesModule); const rolesRoutes = getExpressRouter('roles', rolesRoutesModule);
const runtimeAccessRoutes = getExpressRouter('runtime-access', runtimeAccessRoutesModule); const runtimeAccessRoutes = getExpressRouter(
'runtime-access',
runtimeAccessRoutesModule,
);
const runtimeContextRoutes = getExpressRouter( const runtimeContextRoutes = getExpressRouter(
'runtime-context', 'runtime-context',
runtimeContextRoutesModule, runtimeContextRoutesModule,
@ -149,11 +164,7 @@ const specs = createOpenApiDocument({
serverUrl: config.server.swaggerServerUrl, serverUrl: config.server.swaggerServerUrl,
}); });
app.use( app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs));
'/api-docs',
swaggerUI.serve,
swaggerUI.setup(specs),
);
app.enable('trust proxy'); app.enable('trust proxy');
app.use( app.use(
@ -184,104 +195,105 @@ app.use(bodyParser.json({ limit: '50mb' }));
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' })); app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
app.use(runtimeContextMiddleware); app.use(runtimeContextMiddleware);
const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(async ( const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(
req, async (req, res, next) => {
res, try {
next, const runtimeContext = getRuntimeContext(req);
) => { const headerEnvironment = runtimeContext?.headerEnvironment;
try { const headerProjectSlug = runtimeContext?.headerProjectSlug;
const runtimeContext = getRuntimeContext(req); const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
const headerEnvironment = runtimeContext?.headerEnvironment; const hasAuthHeader = Boolean(req.headers.authorization);
const headerProjectSlug = runtimeContext?.headerProjectSlug;
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
const hasAuthHeader = Boolean(req.headers.authorization);
// Only production is public. Stage requires authentication (workspace for review). // Only production is public. Stage requires authentication (workspace for review).
const isPublicEnvironment = headerEnvironment === 'production'; const isPublicEnvironment = headerEnvironment === 'production';
if (!isPublicEnvironment || !isReadOnlyRequest) { if (!isPublicEnvironment || !isReadOnlyRequest) {
setRuntimePublicRequest(req, false); setRuntimePublicRequest(req, false);
jwtAuth(req, res, next); jwtAuth(req, res, next);
return; return;
} }
const isPrivateProductionPresentation = const isPrivateProductionPresentation =
await RuntimePresentationAccessService.isPrivateProductionPresentation( await RuntimePresentationAccessService.isPrivateProductionPresentation(
headerProjectSlug, headerProjectSlug,
); );
if (!isPrivateProductionPresentation) { if (!isPrivateProductionPresentation) {
setRuntimePublicRequest(req, true); setRuntimePublicRequest(req, true);
return next(); return next();
} }
if (!hasAuthHeader) { if (!hasAuthHeader) {
setRuntimePublicRequest(req, false); setRuntimePublicRequest(req, false);
res.status(401).send({ message: 'Authentication required' }); res.status(401).send({ message: 'Authentication required' });
return; return;
} }
const privatePresentationAuth = authenticateJwtWithCallback( const privatePresentationAuth = authenticateJwtWithCallback(
async (error, user) => { async (error, user) => {
if (error) return next(error); if (error) return next(error);
if (!user) { if (!user) {
setRuntimePublicRequest(req, false);
res.status(401).send({ message: 'Authentication required' });
return;
}
setCurrentUser(req, user);
try {
const canAccess =
await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation(
user,
headerProjectSlug,
);
if (!canAccess) {
setRuntimePublicRequest(req, false); setRuntimePublicRequest(req, false);
res.status(403).send({ message: 'Presentation access denied' }); res.status(401).send({ message: 'Authentication required' });
return; return;
} }
setRuntimePublicRequest(req, true); setCurrentUser(req, user);
return next();
} catch (accessError) {
return next(accessError);
}
},
);
privatePresentationAuth(req, res, next); try {
} catch (error) { const canAccess =
return next(error); await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation(
} user,
}); headerProjectSlug,
);
if (!canAccess) {
setRuntimePublicRequest(req, false);
res.status(403).send({ message: 'Presentation access denied' });
return;
}
setRuntimePublicRequest(req, true);
return next();
} catch (accessError) {
return next(accessError);
}
},
);
privatePresentationAuth(req, res, next);
} catch (error) {
return next(error);
}
},
);
// Health check endpoint (no auth required) // Health check endpoint (no auth required)
app.get('/api/health', wrapAsync(async (_req, res) => { app.get(
const health: HealthResponse = { '/api/health',
status: 'ok', wrapAsync(async (_req, res) => {
timestamp: new Date().toISOString(), const health: HealthResponse = {
uptime: process.uptime(), status: 'ok',
environment: config.server.env, timestamp: new Date().toISOString(),
}; uptime: process.uptime(),
environment: config.server.env,
};
try { try {
await db.sequelize.authenticate(); await db.sequelize.authenticate();
health.database = 'connected'; health.database = 'connected';
} catch (error) { } catch (error) {
health.status = 'degraded'; health.status = 'degraded';
health.database = 'disconnected'; health.database = 'disconnected';
health.databaseError = health.databaseError =
error instanceof Error ? error.message : 'Unknown database error'; error instanceof Error ? error.message : 'Unknown database error';
} }
const statusCode = health.status === 'ok' ? 200 : 503; const statusCode = health.status === 'ok' ? 200 : 503;
res.status(statusCode).json(health); res.status(statusCode).json(health);
})); }),
);
app.use('/api/auth', authRoutes); app.use('/api/auth', authRoutes);
app.use('/api/runtime-context', runtimeContextRoutes); app.use('/api/runtime-context', runtimeContextRoutes);
@ -333,11 +345,7 @@ app.use('/api/access_logs', jwtAuth, accessLogsRoutes);
app.use('/api/element-type-defaults', jwtAuth, elementTypeDefaultsRoutes); app.use('/api/element-type-defaults', jwtAuth, elementTypeDefaultsRoutes);
// Backwards compatibility alias for old API endpoint // Backwards compatibility alias for old API endpoint
app.use('/api/ui-elements', jwtAuth, elementTypeDefaultsRoutes); app.use('/api/ui-elements', jwtAuth, elementTypeDefaultsRoutes);
app.use( app.use('/api/project-element-defaults', jwtAuth, projectElementDefaultsRoutes);
'/api/project-element-defaults',
jwtAuth,
projectElementDefaultsRoutes,
);
// Global transition defaults - routes handle their own auth (GET public, PUT protected) // Global transition defaults - routes handle their own auth (GET public, PUT protected)
app.use('/api/global-transition-defaults', globalTransitionDefaultsRoutes); app.use('/api/global-transition-defaults', globalTransitionDefaultsRoutes);
@ -402,10 +410,7 @@ app.use(appErrorHandler);
const PORT = config.server.port; const PORT = config.server.port;
const server = app.listen(PORT, () => { const server = app.listen(PORT, () => {
logger.info( logger.info({ port: PORT, env: config.server.env }, 'Server started');
{ port: PORT, env: config.server.env },
'Server started',
);
}); });
server.on('error', (err: NodeJS.ErrnoException) => { server.on('error', (err: NodeJS.ErrnoException) => {

View File

@ -82,9 +82,7 @@ async function checkPermissionRequest(
if (!effectiveRole) { if (!effectiveRole) {
return next( return next(
new Error( new Error('Internal Server Error: Could not determine effective role.'),
'Internal Server Error: Could not determine effective role.',
),
); );
} }
@ -152,8 +150,7 @@ function getCrudPermissionName(
permissionNameOverride?: string, permissionNameOverride?: string,
): string { ): string {
return ( return (
permissionNameOverride || permissionNameOverride || `${METHOD_MAP[method]}_${name.toUpperCase()}`
`${METHOD_MAP[method]}_${name.toUpperCase()}`
); );
} }

View File

@ -143,9 +143,13 @@ async function handleProjectSettingsReadOrAuth(
return; return;
} }
handlePrivateProductionReadAuth(req, res, next, user, runtimeProjectSlug).catch( handlePrivateProductionReadAuth(
req,
res,
next, next,
); user,
runtimeProjectSlug,
).catch(next);
}); });
privateReadAuth(req, res, next); privateReadAuth(req, res, next);

View File

@ -54,7 +54,9 @@ setInterval(() => {
* @param {Function} [options.skip] - Skip rate limiting for certain requests (req) => boolean * @param {Function} [options.skip] - Skip rate limiting for certain requests (req) => boolean
* @returns {Function} Express middleware * @returns {Function} Express middleware
*/ */
const createRateLimiter = (options: RateLimiterOptions = {}): RequestHandler => { const createRateLimiter = (
options: RateLimiterOptions = {},
): RequestHandler => {
const { const {
keyPrefix = 'rate-limit', keyPrefix = 'rate-limit',
windowMs = 15 * 60 * 1000, // 15 minutes windowMs = 15 * 60 * 1000, // 15 minutes

View File

@ -88,7 +88,9 @@ function hasPlainGetter(
return typeof value.get === 'function'; return typeof value.get === 'function';
} }
function toPlainRecord(value: RuntimePublicPlainRecord): RuntimePublicPlainRecord { function toPlainRecord(
value: RuntimePublicPlainRecord,
): RuntimePublicPlainRecord {
return hasPlainGetter(value) ? value.get({ plain: true }) : value; return hasPlainGetter(value) ? value.get({ plain: true }) : value;
} }
@ -107,7 +109,9 @@ function matchesPublicRuntimePath(
: requestPath === pattern; : requestPath === pattern;
} }
function getAllowedPaths(entityName: string): readonly RuntimePublicPathPattern[] { function getAllowedPaths(
entityName: string,
): readonly RuntimePublicPathPattern[] {
return PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] ?? ['/']; return PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] ?? ['/'];
} }
@ -198,4 +202,7 @@ const sanitizePublicRuntimeListResponse =
next(); next();
}; };
export { blockNonPublicRuntimeListEndpoints, sanitizePublicRuntimeListResponse }; export {
blockNonPublicRuntimeListEndpoints,
sanitizePublicRuntimeListResponse,
};

View File

@ -8,7 +8,11 @@ import type {
RequestSchemaMap, RequestSchemaMap,
} from '../types/index.ts'; } from '../types/index.ts';
const VALID_REQUEST_PARTS: RequestValidationPart[] = ['params', 'query', 'body']; const VALID_REQUEST_PARTS: RequestValidationPart[] = [
'params',
'query',
'body',
];
interface RequestPartsTarget { interface RequestPartsTarget {
params: unknown; params: unknown;

View File

@ -233,7 +233,10 @@ const booleanResponse = jsonResponse('Boolean success response', {
type: 'boolean', type: 'boolean',
}); });
const successObjectResponse = jsonResponse('Success response', ref('SuccessResponse')); const successObjectResponse = jsonResponse(
'Success response',
ref('SuccessResponse'),
);
const errorResponses = { const errorResponses = {
400: { $ref: '#/components/responses/BadRequestError' }, 400: { $ref: '#/components/responses/BadRequestError' },
@ -293,8 +296,7 @@ const crudPaths = (resource: CrudResource): OpenApiPaths => {
get: { get: {
tags: [resource.tag], tags: [resource.tag],
summary: `List ${resource.tag} items`, summary: `List ${resource.tag} items`,
description: description: `${readDescription}. Supports pagination, sorting, entity filters, and CSV export through filetype=csv.`,
`${readDescription}. Supports pagination, sorting, entity filters, and CSV export through filetype=csv.`,
security: readSecurity, security: readSecurity,
parameters: [...commonReadParameters, ...listParameters], parameters: [...commonReadParameters, ...listParameters],
responses: { responses: {
@ -634,7 +636,10 @@ const schemas: Record<string, OpenApiSchema> = {
properties: { properties: {
id: uuidSchema, id: uuidSchema,
name: nullable({ type: 'string' }), name: nullable({ type: 'string' }),
asset_type: { type: 'string', enum: ['image', 'video', 'audio', 'embed'] }, asset_type: {
type: 'string',
enum: ['image', 'video', 'audio', 'embed'],
},
type: { type: 'string', enum: ['general', 'background', 'transition'] }, type: { type: 'string', enum: ['general', 'background', 'transition'] },
cdn_url: nullable({ type: 'string' }), cdn_url: nullable({ type: 'string' }),
storage_key: nullable({ type: 'string' }), storage_key: nullable({ type: 'string' }),
@ -774,7 +779,10 @@ const schemas: Record<string, OpenApiSchema> = {
target_environment: { type: 'string', enum: runtimeEnvironmentValues }, target_environment: { type: 'string', enum: runtimeEnvironmentValues },
started_at: nullable(dateTimeSchema), started_at: nullable(dateTimeSchema),
finished_at: nullable(dateTimeSchema), finished_at: nullable(dateTimeSchema),
status: { type: 'string', enum: ['queued', 'running', 'success', 'failed'] }, status: {
type: 'string',
enum: ['queued', 'running', 'success', 'failed'],
},
error_message: nullable({ type: 'string' }), error_message: nullable({ type: 'string' }),
pages_count: nullable({ type: 'integer' }), pages_count: nullable({ type: 'integer' }),
assets_count: nullable({ type: 'integer' }), assets_count: nullable({ type: 'integer' }),
@ -809,7 +817,10 @@ const schemas: Record<string, OpenApiSchema> = {
id: uuidSchema, id: uuidSchema,
project: uuidSchema, project: uuidSchema,
environment: { type: 'string', enum: runtimeEnvironmentValues }, environment: { type: 'string', enum: runtimeEnvironmentValues },
asset_type: { type: 'string', enum: ['image', 'video', 'audio', 'embed'] }, asset_type: {
type: 'string',
enum: ['image', 'video', 'audio', 'embed'],
},
url: nullable({ type: 'string' }), url: nullable({ type: 'string' }),
mime_type: nullable({ type: 'string' }), mime_type: nullable({ type: 'string' }),
size_mb: nullable({ type: 'number' }), size_mb: nullable({ type: 'number' }),
@ -1530,7 +1541,10 @@ const customPaths: OpenApiPaths = {
security: bearerSecurity, security: bearerSecurity,
requestBody: jsonRequest(ref('ReverseVideoStatusRequest')), requestBody: jsonRequest(ref('ReverseVideoStatusRequest')),
responses: { responses: {
200: jsonResponse('Reverse video status map', ref('ReverseVideoStatusResponse')), 200: jsonResponse(
'Reverse video status map',
ref('ReverseVideoStatusResponse'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1542,7 +1556,10 @@ const customPaths: OpenApiPaths = {
security: bearerSecurity, security: bearerSecurity,
parameters: [idParameter], parameters: [idParameter],
responses: { responses: {
200: jsonResponse('Reset project element default', ref('ProjectElementDefault')), 200: jsonResponse(
'Reset project element default',
ref('ProjectElementDefault'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1554,7 +1571,10 @@ const customPaths: OpenApiPaths = {
security: bearerSecurity, security: bearerSecurity,
parameters: [idParameter], parameters: [idParameter],
responses: { responses: {
200: jsonResponse('Project element default diff', ref('ProjectElementDefaultDiff')), 200: jsonResponse(
'Project element default diff',
ref('ProjectElementDefaultDiff'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1565,7 +1585,10 @@ const customPaths: OpenApiPaths = {
summary: 'Get singleton global transition defaults', summary: 'Get singleton global transition defaults',
security: [], security: [],
responses: { responses: {
200: jsonResponse('Global transition defaults', ref('GlobalTransitionDefault')), 200: jsonResponse(
'Global transition defaults',
ref('GlobalTransitionDefault'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1577,7 +1600,10 @@ const customPaths: OpenApiPaths = {
security: [], security: [],
parameters: [idParameter], parameters: [idParameter],
responses: { responses: {
200: jsonResponse('Global transition defaults', ref('GlobalTransitionDefault')), 200: jsonResponse(
'Global transition defaults',
ref('GlobalTransitionDefault'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1600,7 +1626,10 @@ const customPaths: OpenApiPaths = {
security: bearerSecurity, security: bearerSecurity,
parameters: listParameters, parameters: listParameters,
responses: { responses: {
200: jsonResponse('Paginated list', paginatedSchema('ProjectTransitionSetting')), 200: jsonResponse(
'Paginated list',
paginatedSchema('ProjectTransitionSetting'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1608,7 +1637,9 @@ const customPaths: OpenApiPaths = {
tags: ['ProjectTransitionSettings'], tags: ['ProjectTransitionSettings'],
summary: 'Create project transition settings', summary: 'Create project transition settings',
security: bearerSecurity, security: bearerSecurity,
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')), requestBody: jsonRequest(
entityEnvelopeSchema('ProjectTransitionSetting'),
),
responses: { responses: {
200: jsonResponse('Created settings', ref('ProjectTransitionSetting')), 200: jsonResponse('Created settings', ref('ProjectTransitionSetting')),
...errorResponses, ...errorResponses,
@ -1631,7 +1662,9 @@ const customPaths: OpenApiPaths = {
summary: 'Update project transition settings by ID', summary: 'Update project transition settings by ID',
security: bearerSecurity, security: bearerSecurity,
parameters: [idParameter], parameters: [idParameter],
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')), requestBody: jsonRequest(
entityEnvelopeSchema('ProjectTransitionSetting'),
),
responses: { responses: {
200: booleanResponse, 200: booleanResponse,
...errorResponses, ...errorResponses,
@ -1664,7 +1697,9 @@ const customPaths: OpenApiPaths = {
summary: 'Upsert project transition settings for project environment', summary: 'Upsert project transition settings for project environment',
security: bearerSecurity, security: bearerSecurity,
parameters: [projectIdParameter, environmentParameter], parameters: [projectIdParameter, environmentParameter],
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')), requestBody: jsonRequest(
entityEnvelopeSchema('ProjectTransitionSetting'),
),
responses: { responses: {
200: jsonResponse('Settings', ref('ProjectTransitionSetting')), 200: jsonResponse('Settings', ref('ProjectTransitionSetting')),
...errorResponses, ...errorResponses,
@ -1687,7 +1722,10 @@ const customPaths: OpenApiPaths = {
summary: 'Get singleton global UI-control defaults', summary: 'Get singleton global UI-control defaults',
security: [], security: [],
responses: { responses: {
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')), 200: jsonResponse(
'Global UI-control defaults',
ref('GlobalUiControlDefaults'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1699,7 +1737,10 @@ const customPaths: OpenApiPaths = {
security: [], security: [],
parameters: [idParameter], parameters: [idParameter],
responses: { responses: {
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')), 200: jsonResponse(
'Global UI-control defaults',
ref('GlobalUiControlDefaults'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1710,7 +1751,10 @@ const customPaths: OpenApiPaths = {
parameters: [idParameter], parameters: [idParameter],
requestBody: jsonRequest(entityEnvelopeSchema('GlobalUiControlDefaults')), requestBody: jsonRequest(entityEnvelopeSchema('GlobalUiControlDefaults')),
responses: { responses: {
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')), 200: jsonResponse(
'Global UI-control defaults',
ref('GlobalUiControlDefaults'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1722,7 +1766,10 @@ const customPaths: OpenApiPaths = {
security: bearerSecurity, security: bearerSecurity,
parameters: listParameters, parameters: listParameters,
responses: { responses: {
200: jsonResponse('Paginated list', paginatedSchema('ProjectUiControlSettings')), 200: jsonResponse(
'Paginated list',
paginatedSchema('ProjectUiControlSettings'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1743,7 +1790,9 @@ const customPaths: OpenApiPaths = {
summary: 'Upsert project UI-control settings for project environment', summary: 'Upsert project UI-control settings for project environment',
security: bearerSecurity, security: bearerSecurity,
parameters: [projectIdParameter, environmentParameter], parameters: [projectIdParameter, environmentParameter],
requestBody: jsonRequest(entityEnvelopeSchema('ProjectUiControlSettings')), requestBody: jsonRequest(
entityEnvelopeSchema('ProjectUiControlSettings'),
),
responses: { responses: {
200: jsonResponse('Settings', ref('ProjectUiControlSettings')), 200: jsonResponse('Settings', ref('ProjectUiControlSettings')),
...errorResponses, ...errorResponses,
@ -1822,7 +1871,10 @@ const customPaths: OpenApiPaths = {
}, },
], ],
responses: { responses: {
200: jsonResponse('Presentation access metadata', ref('RuntimePresentationAccess')), 200: jsonResponse(
'Presentation access metadata',
ref('RuntimePresentationAccess'),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1830,10 +1882,14 @@ const customPaths: OpenApiPaths = {
'/api/runtime-access/private-production-presentations': { '/api/runtime-access/private-production-presentations': {
get: { get: {
tags: ['RuntimeAccess'], tags: ['RuntimeAccess'],
summary: 'List private production presentations for user-management grants', summary:
'List private production presentations for user-management grants',
security: bearerSecurity, security: bearerSecurity,
responses: { responses: {
200: jsonResponse('Private production presentations', arrayOf(ref('Project'))), 200: jsonResponse(
'Private production presentations',
arrayOf(ref('Project')),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1841,10 +1897,14 @@ const customPaths: OpenApiPaths = {
'/api/runtime-access/private-production-presentations/autocomplete': { '/api/runtime-access/private-production-presentations/autocomplete': {
get: { get: {
tags: ['RuntimeAccess'], tags: ['RuntimeAccess'],
summary: 'Autocomplete private production presentations for user-management grants', summary:
'Autocomplete private production presentations for user-management grants',
security: bearerSecurity, security: bearerSecurity,
responses: { responses: {
200: jsonResponse('Private production presentations', arrayOf(ref('Project'))), 200: jsonResponse(
'Private production presentations',
arrayOf(ref('Project')),
),
...errorResponses, ...errorResponses,
}, },
}, },
@ -1904,7 +1964,9 @@ function buildCrudPaths(): OpenApiPaths {
); );
} }
function createOpenApiDocument(options: OpenApiDocumentOptions): OpenApiDocument { function createOpenApiDocument(
options: OpenApiDocumentOptions,
): OpenApiDocument {
return { return {
openapi: '3.0.0', openapi: '3.0.0',
info: { info: {
@ -1942,7 +2004,10 @@ function createOpenApiDocument(options: OpenApiDocumentOptions): OpenApiDocument
), ),
ForbiddenError: jsonResponse('Permission denied', ref('ErrorResponse')), ForbiddenError: jsonResponse('Permission denied', ref('ErrorResponse')),
NotFoundError: jsonResponse('Resource not found', ref('ErrorResponse')), NotFoundError: jsonResponse('Resource not found', ref('ErrorResponse')),
RateLimitError: jsonResponse('Rate limit exceeded', ref('ErrorResponse')), RateLimitError: jsonResponse(
'Rate limit exceeded',
ref('ErrorResponse'),
),
ServerError: jsonResponse('Server error', ref('ErrorResponse')), ServerError: jsonResponse('Server error', ref('ErrorResponse')),
}, },
}, },

View File

@ -56,7 +56,7 @@ function getRequestHost(req: Request): string {
const uiUrl = safeParseUrl(config.uiUrl); const uiUrl = safeParseUrl(config.uiUrl);
const fallbackHost = uiUrl const fallbackHost = uiUrl
? uiUrl.origin ? uiUrl.origin
: config.backUrl ?? 'http://localhost:3000'; : (config.backUrl ?? 'http://localhost:3000');
const origin = safeParseUrl(req.headers.origin); const origin = safeParseUrl(req.headers.origin);
const referer = safeParseUrl(req.headers.referer); const referer = safeParseUrl(req.headers.referer);
@ -159,10 +159,7 @@ router.post(
signinLimiter, signinLimiter,
validateRequest(authSchemas.signinLocal), validateRequest(authSchemas.signinLocal),
wrapAsync(async (req: Request<never, string, SigninLocalBody>, res) => { wrapAsync(async (req: Request<never, string, SigninLocalBody>, res) => {
const payload = await AuthService.signin( const payload = await AuthService.signin(req.body.email, req.body.password);
req.body.email,
req.body.password,
);
res.status(200).send(payload); res.status(200).send(payload);
}), }),
); );
@ -253,10 +250,14 @@ router.post(
validateRequest(authSchemas.sendPasswordResetEmail), validateRequest(authSchemas.sendPasswordResetEmail),
wrapAsync( wrapAsync(
async (req: Request<never, boolean, SendPasswordResetEmailBody>, res) => { async (req: Request<never, boolean, SendPasswordResetEmailBody>, res) => {
const host = getRequestHost(req); const host = getRequestHost(req);
await AuthService.sendPasswordResetEmail(req.body.email, 'register', host); await AuthService.sendPasswordResetEmail(
const payload = true; req.body.email,
res.status(200).send(payload); 'register',
host,
);
const payload = true;
res.status(200).send(payload);
}, },
), ),
); );

View File

@ -12,7 +12,10 @@ import {
useUpdatePermissionForProjectEnvironmentReset, useUpdatePermissionForProjectEnvironmentReset,
} from '../middlewares/project-settings-runtime-auth.ts'; } from '../middlewares/project-settings-runtime-auth.ts';
import ProjectTransitionSettingsService from '../services/project_transition_settings.ts'; import ProjectTransitionSettingsService from '../services/project_transition_settings.ts';
import type { RouteMessageResponse, RouteSuccessResponse } from '../types/index.ts'; import type {
RouteMessageResponse,
RouteSuccessResponse,
} from '../types/index.ts';
import { import {
assertBodyIdMatchesRouteId, assertBodyIdMatchesRouteId,
isEntityDataRequestBody, isEntityDataRequestBody,
@ -41,7 +44,9 @@ router.get(
requireProductionProjectSettingsReadOrAuth, requireProductionProjectSettingsReadOrAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isProjectEnvironmentRouteParams(req.params)) { if (!isProjectEnvironmentRouteParams(req.params)) {
const response: RouteMessageResponse = { message: 'Invalid route params' }; const response: RouteMessageResponse = {
message: 'Invalid route params',
};
return res.status(400).send(response); return res.status(400).send(response);
} }
@ -61,7 +66,9 @@ router.put(
'/project/:projectId/env/:environment', '/project/:projectId/env/:environment',
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isProjectEnvironmentRouteParams(req.params)) { if (!isProjectEnvironmentRouteParams(req.params)) {
const response: RouteMessageResponse = { message: 'Invalid route params' }; const response: RouteMessageResponse = {
message: 'Invalid route params',
};
return res.status(400).send(response); return res.status(400).send(response);
} }
@ -91,7 +98,9 @@ router.delete(
'/project/:projectId/env/:environment', '/project/:projectId/env/:environment',
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isProjectEnvironmentRouteParams(req.params)) { if (!isProjectEnvironmentRouteParams(req.params)) {
const response: RouteMessageResponse = { message: 'Invalid route params' }; const response: RouteMessageResponse = {
message: 'Invalid route params',
};
return res.status(400).send(response); return res.status(400).send(response);
} }

View File

@ -35,7 +35,9 @@ router.get(
requireProductionProjectSettingsReadOrAuth, requireProductionProjectSettingsReadOrAuth,
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isProjectEnvironmentRouteParams(req.params)) { if (!isProjectEnvironmentRouteParams(req.params)) {
const response: RouteMessageResponse = { message: 'Invalid route params' }; const response: RouteMessageResponse = {
message: 'Invalid route params',
};
return res.status(400).send(response); return res.status(400).send(response);
} }
@ -55,14 +57,14 @@ router.put(
'/project/:projectId/env/:environment', '/project/:projectId/env/:environment',
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isProjectEnvironmentRouteParams(req.params)) { if (!isProjectEnvironmentRouteParams(req.params)) {
const response: RouteMessageResponse = { message: 'Invalid route params' }; const response: RouteMessageResponse = {
message: 'Invalid route params',
};
return res.status(400).send(response); return res.status(400).send(response);
} }
const body: unknown = req.body; const body: unknown = req.body;
const data: unknown = isEntityDataRequestBody(body) const data: unknown = isEntityDataRequestBody(body) ? body.data : {};
? body.data
: {};
if (!isProjectUiControlSettingsData(data)) { if (!isProjectUiControlSettingsData(data)) {
const response: RouteMessageResponse = { const response: RouteMessageResponse = {
@ -87,7 +89,9 @@ router.delete(
'/project/:projectId/env/:environment', '/project/:projectId/env/:environment',
wrapAsync(async (req, res) => { wrapAsync(async (req, res) => {
if (!isProjectEnvironmentRouteParams(req.params)) { if (!isProjectEnvironmentRouteParams(req.params)) {
const response: RouteMessageResponse = { message: 'Invalid route params' }; const response: RouteMessageResponse = {
message: 'Invalid route params',
};
return res.status(400).send(response); return res.status(400).send(response);
} }

View File

@ -2,10 +2,7 @@ import express from 'express';
import { parse } from 'json2csv'; import { parse } from 'json2csv';
import Tour_pagesDBApi from '../db/api/tour_pages.ts'; import Tour_pagesDBApi from '../db/api/tour_pages.ts';
import { import { wrapAsync, commonErrorHandler } from '../helpers.ts';
wrapAsync,
commonErrorHandler,
} from '../helpers.ts';
import { checkCrudPermissions } from '../middlewares/check-permissions.ts'; import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
import { validateRequest } from '../middlewares/validate-request.ts'; import { validateRequest } from '../middlewares/validate-request.ts';
import Tour_pagesService from '../services/tour_pages.ts'; import Tour_pagesService from '../services/tour_pages.ts';

View File

@ -51,12 +51,12 @@ const originalGetByIdHandler = originalGetById?.route.stack[0];
if (originalGetByIdHandler) { if (originalGetByIdHandler) {
originalGetByIdHandler.handle = wrapAsync( originalGetByIdHandler.handle = wrapAsync(
async (req: Request<{ id: string }>, res) => { async (req: Request<{ id: string }>, res) => {
// Call original handler with a custom response // Call original handler with a custom response
const payload = await UsersDBApi.findBy({ id: req.params.id }); const payload = await UsersDBApi.findBy({ id: req.params.id });
if (payload) { if (payload) {
delete payload.password; delete payload.password;
} }
res.status(200).send(payload); res.status(200).send(payload);
}, },
); );
} }

View File

@ -33,7 +33,9 @@ export default class AccessPolicy {
return user?.app_role?.name || user?.role?.name || null; return user?.app_role?.name || user?.role?.name || null;
} }
static getStandaloneRoleName(role: RoleRecord | null | undefined): string | null { static getStandaloneRoleName(
role: RoleRecord | null | undefined,
): string | null {
return role?.name || null; return role?.name || null;
} }
@ -105,7 +107,9 @@ export default class AccessPolicy {
): Promise<boolean> { ): Promise<boolean> {
if (!user || !permission) return Promise.resolve(false); if (!user || !permission) return Promise.resolve(false);
if (this.isPublicUser(user)) return Promise.resolve(false); if (this.isPublicUser(user)) return Promise.resolve(false);
return Promise.resolve(this.getEffectivePermissionNames(user).has(permission)); return Promise.resolve(
this.getEffectivePermissionNames(user).has(permission),
);
} }
static isPublicUser(user: AccessPolicyUser): boolean { static isPublicUser(user: AccessPolicyUser): boolean {

View File

@ -61,7 +61,9 @@ const ALLOWED_EMBED_DOMAINS = [
'360stories.com', '360stories.com',
]; ];
function extractEmbedUrl(embedCode: string | null | undefined): AssetEmbedUrlResult { function extractEmbedUrl(
embedCode: string | null | undefined,
): AssetEmbedUrlResult {
if (!embedCode?.trim()) { if (!embedCode?.trim()) {
throw new ValidationError('Embed code is required'); throw new ValidationError('Embed code is required');
} }
@ -175,7 +177,9 @@ function buildUpdateServiceOptions(
return serviceOptions; return serviceOptions;
} }
function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOptions { function buildAssetFindByOptions(
options: AssetUpdateOptions,
): AssetFindByOptions {
const findByOptions: AssetFindByOptions = {}; const findByOptions: AssetFindByOptions = {};
if (options.transaction !== undefined) { if (options.transaction !== undefined) {
@ -188,9 +192,7 @@ function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOption
return findByOptions; return findByOptions;
} }
function getCurrentUserId( function getCurrentUserId(options: ServiceOptions): string | null {
options: ServiceOptions,
): string | null {
return options.currentUser?.id ?? null; return options.currentUser?.id ?? null;
} }

View File

@ -1,52 +1,52 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<style> <style>
.email-container { .email-container {
max-width: 600px; max-width: 600px;
margin: auto; margin: auto;
background-color: #ffffff; background-color: #ffffff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
} }
.email-header { .email-header {
background-color: #3498db; background-color: #3498db;
color: #fff; color: #fff;
padding: 16px; padding: 16px;
text-align: center; text-align: center;
} }
.email-body { .email-body {
padding: 16px; padding: 16px;
} }
.email-footer { .email-footer {
padding: 16px; padding: 16px;
background-color: #f7fafc; background-color: #f7fafc;
text-align: center; text-align: center;
color: #4a5568; color: #4a5568;
font-size: 14px; font-size: 14px;
} }
.link-primary { .link-primary {
color: #3498db; color: #3498db;
text-decoration: none; text-decoration: none;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="email-header"> <div class="email-header">Verify your email for {appTitle}!</div>
Verify your email for {appTitle}! <div class="email-body">
</div>
<div class="email-body">
<p>Hello,</p> <p>Hello,</p>
<p>Follow this link to verify your email address.</p> <p>Follow this link to verify your email address.</p>
<p>If you didn't ask to verify this address, you can ignore this email.</p> <p>
If you didn't ask to verify this address, you can ignore this email.
</p>
<p><a href="{signupUrl}" class="link-primary">{signupUrl}</a></p> <p><a href="{signupUrl}" class="link-primary">{signupUrl}</a></p>
</div> </div>
<div class="email-footer"> <div class="email-footer">
Thanks,<br/> Thanks,<br />
The {appTitle} Team The {appTitle} Team
</div>
</div> </div>
</div> </body>
</body> </html>
</html>

View File

@ -1,55 +1,56 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<style> <style>
.email-container { .email-container {
max-width: 600px; max-width: 600px;
margin: auto; margin: auto;
background-color: #ffffff; background-color: #ffffff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
} }
.email-header { .email-header {
background-color: #3498db; background-color: #3498db;
color: #fff; color: #fff;
padding: 16px; padding: 16px;
text-align: center; text-align: center;
} }
.email-body { .email-body {
padding: 16px; padding: 16px;
} }
.email-footer { .email-footer {
padding: 16px; padding: 16px;
background-color: #f7fafc; background-color: #f7fafc;
text-align: center; text-align: center;
color: #4a5568; color: #4a5568;
font-size: 14px; font-size: 14px;
} }
.btn-primary { .btn-primary {
background-color: #3498db; background-color: #3498db;
color: #fff!important; color: #fff !important;
padding: 8px 16px; padding: 8px 16px;
border-radius: 4px; border-radius: 4px;
text-decoration: none; text-decoration: none;
display: inline-block; display: inline-block;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="email-header"> <div class="email-header">Welcome to {appTitle}!</div>
Welcome to {appTitle}! <div class="email-body">
</div>
<div class="email-body">
<p>Hello,</p> <p>Hello,</p>
<p>You've been invited to join {appTitle}. Please click the button below to set up your account.</p> <p>
You've been invited to join {appTitle}. Please click the button below
to set up your account.
</p>
<a href="{signupUrl}" class="btn-primary">Set up account</a> <a href="{signupUrl}" class="btn-primary">Set up account</a>
</div> </div>
<div class="email-footer"> <div class="email-footer">
Thanks,<br/> Thanks,<br />
The {appTitle} Team The {appTitle} Team
</div>
</div> </div>
</div> </body>
</body> </html>
</html>

View File

@ -1,52 +1,55 @@
<!DOCTYPE html> <!doctype html>
<html> <html>
<head> <head>
<style> <style>
.email-container { .email-container {
max-width: 600px; max-width: 600px;
margin: auto; margin: auto;
background-color: #ffffff; background-color: #ffffff;
border: 1px solid #e2e8f0; border: 1px solid #e2e8f0;
border-radius: 4px; border-radius: 4px;
overflow: hidden; overflow: hidden;
} }
.email-header { .email-header {
background-color: #3498db; background-color: #3498db;
color: #fff; color: #fff;
padding: 16px; padding: 16px;
text-align: center; text-align: center;
} }
.email-body { .email-body {
padding: 16px; padding: 16px;
} }
.email-footer { .email-footer {
padding: 16px; padding: 16px;
background-color: #f7fafc; background-color: #f7fafc;
text-align: center; text-align: center;
color: #4a5568; color: #4a5568;
font-size: 14px; font-size: 14px;
} }
.link-primary { .link-primary {
color: #3498db; color: #3498db;
text-decoration: none; text-decoration: none;
} }
</style> </style>
</head> </head>
<body> <body>
<div class="email-container"> <div class="email-container">
<div class="email-header"> <div class="email-header">Reset your password for {appTitle}</div>
Reset your password for {appTitle} <div class="email-body">
</div>
<div class="email-body">
<p>Hello,</p> <p>Hello,</p>
<p>Follow this link to reset your {appTitle} password for your {accountName} account.</p> <p>
Follow this link to reset your {appTitle} password for your
{accountName} account.
</p>
<p><a href="{resetUrl}" class="link-primary">{resetUrl}</a></p> <p><a href="{resetUrl}" class="link-primary">{resetUrl}</a></p>
<p>If you didn't ask to reset your password, you can ignore this email.</p> <p>
</div> If you didn't ask to reset your password, you can ignore this email.
<div class="email-footer"> </p>
Thanks,<br/> </div>
<div class="email-footer">
Thanks,<br />
The {appTitle} Team The {appTitle} Team
</div>
</div> </div>
</div> </body>
</body> </html>
</html>

View File

@ -178,10 +178,7 @@ export default class LocalStorageProvider extends BaseStorageProvider {
return { key: destinationKey }; return { key: destinationKey };
} }
override getSignedUrl( override getSignedUrl(key: string, _expiresIn: number): Promise<string> {
key: string,
_expiresIn: number,
): Promise<string> {
return Promise.resolve(`/uploads/${key}`); return Promise.resolve(`/uploads/${key}`);
} }

View File

@ -177,10 +177,7 @@ export default class S3StorageProvider extends BaseStorageProvider {
return 503; return 503;
} }
if ( if (error instanceof S3ServiceException && error.$metadata.httpStatusCode) {
error instanceof S3ServiceException &&
error.$metadata.httpStatusCode
) {
return error.$metadata.httpStatusCode; return error.$metadata.httpStatusCode;
} }
@ -193,11 +190,11 @@ export default class S3StorageProvider extends BaseStorageProvider {
return Boolean( return Boolean(
(errorName && RETRYABLE_ERRORS.has(errorName)) || (errorName && RETRYABLE_ERRORS.has(errorName)) ||
(errorCode && RETRYABLE_ERRORS.has(errorCode)) || (errorCode && RETRYABLE_ERRORS.has(errorCode)) ||
(error instanceof S3ServiceException && (error instanceof S3ServiceException &&
error.$metadata.httpStatusCode !== undefined && error.$metadata.httpStatusCode !== undefined &&
error.$metadata.httpStatusCode >= 500 && error.$metadata.httpStatusCode >= 500 &&
error.$metadata.httpStatusCode < 600), error.$metadata.httpStatusCode < 600),
); );
} }
@ -411,10 +408,7 @@ export default class S3StorageProvider extends BaseStorageProvider {
return keys; return keys;
} }
override async getSignedUrl( override async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
key: string,
expiresIn = 3600,
): Promise<string> {
const fullKey = this.buildKey(key); const fullKey = this.buildKey(key);
const command = new GetObjectCommand({ const command = new GetObjectCommand({

View File

@ -61,7 +61,9 @@ const isUploadSessionChunkMeta = (
const isUploadSessionUploadedChunks = ( const isUploadSessionUploadedChunks = (
value: unknown, value: unknown,
): value is UploadSessionUploadedChunks => { ): value is UploadSessionUploadedChunks => {
return isRecord(value) && Object.values(value).every(isUploadSessionChunkMeta); return (
isRecord(value) && Object.values(value).every(isUploadSessionChunkMeta)
);
}; };
const isNullableString = (value: unknown): value is string | null => { const isNullableString = (value: unknown): value is string | null => {
@ -160,7 +162,11 @@ export default class UploadSessionManager {
return sessionId; return sessionId;
} }
saveChunk(sessionId: string, chunkIndex: number, data: Buffer): Promise<void> { saveChunk(
sessionId: string,
chunkIndex: number,
data: Buffer,
): Promise<void> {
const chunkPath = this.getChunkPath(sessionId, chunkIndex); const chunkPath = this.getChunkPath(sessionId, chunkIndex);
ensureDirectoryExistence(chunkPath); ensureDirectoryExistence(chunkPath);
fs.writeFileSync(chunkPath, data); fs.writeFileSync(chunkPath, data);

View File

@ -147,10 +147,7 @@ export default class ProjectAudioTracksService {
const results = await parseCsvRows(req.file.buffer); const results = await parseCsvRows(req.file.buffer);
logger.debug( logger.debug({ rows: results.length }, 'Project audio tracks CSV parsed');
{ rows: results.length },
'Project audio tracks CSV parsed',
);
const bulkImportOptions = buildContextOptions({ const bulkImportOptions = buildContextOptions({
currentUser: getCurrentUser(req), currentUser: getCurrentUser(req),

View File

@ -12,9 +12,9 @@ import type {
} from '../types/index.ts'; } from '../types/index.ts';
import type { Transaction } from 'sequelize'; import type { Transaction } from 'sequelize';
function buildTransactionOptions( function buildTransactionOptions(transaction: Transaction | undefined): {
transaction: Transaction | undefined, transaction?: Transaction;
): { transaction?: Transaction } { } {
return transaction ? { transaction } : {}; return transaction ? { transaction } : {};
} }

View File

@ -536,7 +536,8 @@ export default class ProjectsService extends BaseProjectsService {
if (sourceAsset.mime_type !== undefined) { if (sourceAsset.mime_type !== undefined) {
payload.mime_type = sourceAsset.mime_type; payload.mime_type = sourceAsset.mime_type;
} }
if (sourceAsset.size_mb !== undefined) payload.size_mb = sourceAsset.size_mb; if (sourceAsset.size_mb !== undefined)
payload.size_mb = sourceAsset.size_mb;
if (sourceAsset.width_px !== undefined) { if (sourceAsset.width_px !== undefined) {
payload.width_px = sourceAsset.width_px; payload.width_px = sourceAsset.width_px;
} }

View File

@ -162,7 +162,11 @@ export default class PublishService {
{ transaction }, { transaction },
); );
return this.copyStageToProduction(projectId, currentUser, transaction); return this.copyStageToProduction(
projectId,
currentUser,
transaction,
);
}, },
); );

View File

@ -102,7 +102,9 @@ const columnsInt: Partial<Record<SearchTableName, readonly string[]>> = {
const SEARCH_LIMIT_PER_TABLE = 50; const SEARCH_LIMIT_PER_TABLE = 50;
function hasPermissionMethod(value: unknown): value is RoleWithPermissionMethod { function hasPermissionMethod(
value: unknown,
): value is RoleWithPermissionMethod {
return ( return (
value !== null && value !== null &&
typeof value === 'object' && typeof value === 'object' &&

View File

@ -165,69 +165,79 @@ const regenerateElementInstanceIds = (
if (!uiSchema || typeof uiSchema !== 'object') return uiSchema; if (!uiSchema || typeof uiSchema !== 'object') return uiSchema;
const parsedClone = parseUnknownJson(JSON.stringify(uiSchema)); const parsedClone = parseUnknownJson(JSON.stringify(uiSchema));
const clonedSchema = isStructuredUiSchema(parsedClone) ? parsedClone : uiSchema; const clonedSchema = isStructuredUiSchema(parsedClone)
? parsedClone
: uiSchema;
if (!Array.isArray(clonedSchema.elements)) return clonedSchema; if (!Array.isArray(clonedSchema.elements)) return clonedSchema;
clonedSchema.elements = clonedSchema.elements.map((element): TourPageElement => { clonedSchema.elements = clonedSchema.elements.map(
const clonedElement: TourPageElement = { (element): TourPageElement => {
...element, const clonedElement: TourPageElement = {
id: createLocalElementId(), ...element,
}; id: createLocalElementId(),
};
const galleryCards = regenerateNestedItemIds(clonedElement.galleryCards); const galleryCards = regenerateNestedItemIds(clonedElement.galleryCards);
if (galleryCards !== undefined) { if (galleryCards !== undefined) {
clonedElement.galleryCards = galleryCards; clonedElement.galleryCards = galleryCards;
} }
const galleryInfoSpans = regenerateNestedItemIds( const galleryInfoSpans = regenerateNestedItemIds(
clonedElement.galleryInfoSpans, clonedElement.galleryInfoSpans,
);
if (galleryInfoSpans !== undefined) {
clonedElement.galleryInfoSpans = galleryInfoSpans;
}
const carouselSlides = regenerateNestedItemIds(clonedElement.carouselSlides);
if (carouselSlides !== undefined) {
clonedElement.carouselSlides = carouselSlides;
}
if (Array.isArray(clonedElement.infoPanelSections)) {
clonedElement.infoPanelSections = clonedElement.infoPanelSections.map(
(section) => {
const clonedSection = {
...section,
id: `section-${createLocalElementId()}`,
};
const spans = regenerateNestedItemIds(section.spans);
if (spans !== undefined) {
clonedSection.spans = spans;
}
const images = regenerateNestedItemIds(section.images);
if (images !== undefined) {
clonedSection.images = images;
}
return clonedSection;
},
); );
} if (galleryInfoSpans !== undefined) {
clonedElement.galleryInfoSpans = galleryInfoSpans;
}
return clonedElement; const carouselSlides = regenerateNestedItemIds(
}); clonedElement.carouselSlides,
);
if (carouselSlides !== undefined) {
clonedElement.carouselSlides = carouselSlides;
}
if (Array.isArray(clonedElement.infoPanelSections)) {
clonedElement.infoPanelSections = clonedElement.infoPanelSections.map(
(section) => {
const clonedSection = {
...section,
id: `section-${createLocalElementId()}`,
};
const spans = regenerateNestedItemIds(section.spans);
if (spans !== undefined) {
clonedSection.spans = spans;
}
const images = regenerateNestedItemIds(section.images);
if (images !== undefined) {
clonedSection.images = images;
}
return clonedSection;
},
);
}
return clonedElement;
},
);
return clonedSchema; return clonedSchema;
}; };
// Create base service from factory // Create base service from factory
const BaseService = createEntityService<TourPageRecord, TourPageData, TourPageData>( const BaseService = createEntityService<
Tour_pagesDBApi, TourPageRecord,
{ TourPageData,
TourPageData
>(Tour_pagesDBApi, {
entityName: 'tour_pages', entityName: 'tour_pages',
}, });
);
const buildCreateServiceOptions = ( const buildCreateServiceOptions = (
options: TourPageCreateOptions, options: TourPageCreateOptions,
): Pick<TourPageCreateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> => { ): Pick<
TourPageCreateOptions,
'currentUser' | 'transaction' | 'runtimeContext'
> => {
const serviceOptions: Pick< const serviceOptions: Pick<
TourPageCreateOptions, TourPageCreateOptions,
'currentUser' | 'transaction' | 'runtimeContext' 'currentUser' | 'transaction' | 'runtimeContext'
@ -248,7 +258,10 @@ const buildCreateServiceOptions = (
const buildUpdateServiceOptions = ( const buildUpdateServiceOptions = (
options: TourPageUpdateOptions, options: TourPageUpdateOptions,
): Pick<TourPageUpdateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> => { ): Pick<
TourPageUpdateOptions,
'currentUser' | 'transaction' | 'runtimeContext'
> => {
const serviceOptions: Pick< const serviceOptions: Pick<
TourPageUpdateOptions, TourPageUpdateOptions,
'currentUser' | 'transaction' | 'runtimeContext' 'currentUser' | 'transaction' | 'runtimeContext'
@ -270,8 +283,10 @@ const buildUpdateServiceOptions = (
const buildFindByOptions = ( const buildFindByOptions = (
options: TourPageUpdateOptions, options: TourPageUpdateOptions,
): { transaction?: Transaction; runtimeContext?: RuntimeContext } => { ): { transaction?: Transaction; runtimeContext?: RuntimeContext } => {
const findByOptions: { transaction?: Transaction; runtimeContext?: RuntimeContext } = const findByOptions: {
{}; transaction?: Transaction;
runtimeContext?: RuntimeContext;
} = {};
if (options.transaction !== undefined) { if (options.transaction !== undefined) {
findByOptions.transaction = options.transaction; findByOptions.transaction = options.transaction;
@ -317,6 +332,10 @@ const toPlainTourPage = (
return page; return page;
}; };
const getStringValue = (value: unknown): string => {
return typeof value === 'string' ? value.trim() : '';
};
/** /**
* Tour Pages Service with reversed video generation * Tour Pages Service with reversed video generation
*/ */
@ -325,7 +344,9 @@ class TourPagesService extends BaseService {
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`; return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
} }
static getAssetSizeBytes(asset: AssetRecord | null | undefined): number | null { static getAssetSizeBytes(
asset: AssetRecord | null | undefined,
): number | null {
if (!asset || asset.size_mb == null) return null; if (!asset || asset.size_mb == null) return null;
const sizeMb = Number(asset.size_mb); const sizeMb = Number(asset.size_mb);
@ -740,7 +761,16 @@ class TourPagesService extends BaseService {
static isBackElement(element: TourPageElement): boolean { static isBackElement(element: TourPageElement): boolean {
return Boolean( return Boolean(
element.type === 'navigation_prev' || element.type === 'navigation_prev' ||
(element.type?.startsWith?.('navigation') && element.navType === 'back'), (element.type?.startsWith?.('navigation') && element.navType === 'back'),
);
}
static isForwardNavigationElement(element: TourPageElement): boolean {
return Boolean(
element.type === 'navigation_next' ||
(element.type?.startsWith?.('navigation') &&
element.navType !== 'back' &&
element.type !== 'navigation_prev'),
); );
} }
@ -753,14 +783,183 @@ class TourPagesService extends BaseService {
return false; return false;
} }
const isForward =
element.type === 'navigation_next' ||
(element.type?.startsWith?.('navigation') &&
element.navType !== 'back' &&
element.type !== 'navigation_prev');
// Check for target (slug or legacy ID) and transition video // Check for target (slug or legacy ID) and transition video
const hasTarget = element.targetPageSlug || element.targetPageId; const hasTarget = element.targetPageSlug || element.targetPageId;
return Boolean(isForward && hasTarget && element.transitionVideoUrl); return Boolean(
TourPagesService.isForwardNavigationElement(element) &&
hasTarget &&
element.transitionVideoUrl,
);
}
static isForwardElementTargetingPage(
element: TourPageElement,
page: Pick<TourPageData, 'id' | 'slug'>,
): boolean {
if (element.navigationTargetMode === 'external_url') {
return false;
}
if (!TourPagesService.isForwardNavigationElement(element)) return false;
const targetSlug = getStringValue(element.targetPageSlug);
const targetId = getStringValue(element.targetPageId);
const pageSlug = getStringValue(page.slug);
const pageId = getStringValue(page.id);
return Boolean(
(pageSlug && targetSlug === pageSlug) || (pageId && targetId === pageId),
);
}
static findBestIncomingForwardElement(
sourcePage: TourPageRecord,
currentPage: Pick<TourPageData, 'id' | 'slug'>,
): TourPageElement | null {
const uiSchema = parseStructuredUiSchema(sourcePage.ui_schema_json);
if (!uiSchema?.elements || !Array.isArray(uiSchema.elements)) {
return null;
}
const candidates = uiSchema.elements.filter((element) =>
TourPagesService.isForwardElementTargetingPage(element, currentPage),
);
const scoreCandidate = (element: TourPageElement): number => {
const hasTransition = Boolean(getStringValue(element.transitionVideoUrl));
const hasReverse = Boolean(getStringValue(element.reverseVideoUrl));
if (hasTransition && hasReverse) return 3;
if (hasTransition) return 2;
if (hasReverse) return 1;
return 0;
};
return candidates.reduce<TourPageElement | null>((best, candidate) => {
if (!best) return candidate;
return scoreCandidate(candidate) > scoreCandidate(best)
? candidate
: best;
}, null);
}
static applyIncomingTransitionToBackElement(
backElement: TourPageElement,
incomingElement: TourPageElement | null,
): boolean {
const nextTransitionVideoUrl = getStringValue(
incomingElement?.transitionVideoUrl,
);
const nextReverseMode = nextTransitionVideoUrl
? incomingElement?.transitionReverseMode === 'separate_video'
? 'separate_video'
: 'auto_reverse'
: '';
const nextReverseVideoUrl = nextTransitionVideoUrl
? getStringValue(incomingElement?.reverseVideoUrl)
: '';
const currentTransitionVideoUrl = getStringValue(
backElement.transitionVideoUrl,
);
const currentReverseMode = getStringValue(
backElement.transitionReverseMode,
);
const currentReverseVideoUrl = getStringValue(backElement.reverseVideoUrl);
if (
currentTransitionVideoUrl === nextTransitionVideoUrl &&
currentReverseMode === nextReverseMode &&
currentReverseVideoUrl === nextReverseVideoUrl
) {
return false;
}
backElement.transitionVideoUrl = nextTransitionVideoUrl;
backElement.transitionReverseMode = nextReverseMode;
backElement.reverseVideoUrl = nextReverseVideoUrl;
return true;
}
static refreshBackNavigationTransitionSources(
uiSchema: TourPageStructuredUiSchema,
pages: TourPageRecord[],
currentPage: Pick<TourPageData, 'id' | 'slug'>,
): boolean {
if (!uiSchema.elements || !Array.isArray(uiSchema.elements)) return false;
let wasModified = false;
for (const element of uiSchema.elements) {
if (!TourPagesService.isBackElement(element)) continue;
const sourcePageSlug = getStringValue(element.targetPageSlug);
const sourcePageId = getStringValue(element.targetPageId);
if (!sourcePageSlug && !sourcePageId) {
continue;
}
const sourcePage = pages.find(
(page) =>
(sourcePageSlug && page.slug === sourcePageSlug) ||
(sourcePageId && page.id === sourcePageId),
);
const incomingElement = sourcePage
? TourPagesService.findBestIncomingForwardElement(
sourcePage,
currentPage,
)
: null;
if (
TourPagesService.applyIncomingTransitionToBackElement(
element,
incomingElement,
)
) {
wasModified = true;
}
}
return wasModified;
}
static async refreshBackNavigationTransitionSourcesForPage(
data: TourPageData,
uiSchema: TourPageStructuredUiSchema,
projectId: string | null | undefined,
): Promise<boolean> {
const currentPageSlug = getStringValue(data.slug);
const currentPageId = getStringValue(data.id);
if (!projectId || (!currentPageSlug && !currentPageId)) {
return false;
}
const hasTargetedBackNavigation = uiSchema.elements?.some(
(element) =>
TourPagesService.isBackElement(element) &&
(getStringValue(element.targetPageSlug) ||
getStringValue(element.targetPageId)),
);
if (!hasTargetedBackNavigation) {
return false;
}
const { rows: pages } = await Tour_pagesDBApi.findAll(
{
projectId,
environment: data.environment || 'dev',
},
{},
);
return TourPagesService.refreshBackNavigationTransitionSources(
uiSchema,
pages,
{ id: currentPageId, slug: currentPageSlug },
);
} }
/** /**
@ -802,8 +1001,14 @@ class TourPagesService extends BaseService {
'Processing reversed videos for navigation elements', 'Processing reversed videos for navigation elements',
); );
let wasModified =
await TourPagesService.refreshBackNavigationTransitionSourcesForPage(
data,
uiSchema,
projectId,
);
const storageKeysToValidate = new Set<string>(); const storageKeysToValidate = new Set<string>();
let wasModified = false;
for (const element of uiSchema.elements) { for (const element of uiSchema.elements) {
const isBack = TourPagesService.isBackElement(element); const isBack = TourPagesService.isBackElement(element);
@ -972,38 +1177,39 @@ class TourPagesService extends BaseService {
setImmediate(() => { setImmediate(() => {
void (async () => { void (async () => {
const log = logger.child({ const log = logger.child({
projectId,
storageKey,
pageId,
operation: 'singleReverseGeneration',
});
try {
log.info('Starting background reversed variant generation');
const reversedUrl = await TourPagesService.getOrGenerateReversedVariant(
storageKey,
currentUser,
);
if (!reversedUrl) {
log.warn(
'Background reversed variant generation finished without result',
);
return;
}
await TourPagesService.applyReversedUrlToProjectElements(
projectId, projectId,
storageKey, storageKey,
reversedUrl, pageId,
currentUser, operation: 'singleReverseGeneration',
); });
} catch (err) {
log.error({ err }, 'Background reversed generation failed'); try {
} finally { log.info('Starting background reversed variant generation');
singleReverseGenerationInProgress.delete(taskKey); const reversedUrl =
} await TourPagesService.getOrGenerateReversedVariant(
storageKey,
currentUser,
);
if (!reversedUrl) {
log.warn(
'Background reversed variant generation finished without result',
);
return;
}
await TourPagesService.applyReversedUrlToProjectElements(
projectId,
storageKey,
reversedUrl,
currentUser,
);
} catch (err) {
log.error({ err }, 'Background reversed generation failed');
} finally {
singleReverseGenerationInProgress.delete(taskKey);
}
})(); })();
}); });
} }
@ -1027,20 +1233,20 @@ class TourPagesService extends BaseService {
setImmediate(() => { setImmediate(() => {
void (async () => { void (async () => {
try { try {
await TourPagesService.regenerateProjectReversedVideos( await TourPagesService.regenerateProjectReversedVideos(
projectId, projectId,
currentUser, currentUser,
excludePageId, excludePageId,
); );
} catch (err) { } catch (err) {
logger.error( logger.error(
{ err, projectId }, { err, projectId },
'Background project regeneration failed', 'Background project regeneration failed',
); );
} finally { } finally {
projectRegenInProgress.delete(projectId); projectRegenInProgress.delete(projectId);
} }
})(); })();
}); });
} }
@ -1197,9 +1403,13 @@ class TourPagesService extends BaseService {
// Upload reversed video to storage // Upload reversed video to storage
const reversedKey = `assets/${asset.id}/reversed.mp4`; const reversedKey = `assets/${asset.id}/reversed.mp4`;
const result = await FileService.uploadBuffer(reversedKey, reversedBuffer, { const result = await FileService.uploadBuffer(
contentType: 'video/mp4', reversedKey,
}); reversedBuffer,
{
contentType: 'video/mp4',
},
);
// Create variant record // Create variant record
await Asset_variantsDBApi.create({ await Asset_variantsDBApi.create({
@ -1266,7 +1476,12 @@ class TourPagesService extends BaseService {
continue; continue;
} }
let pageModified = false; let pageModified =
TourPagesService.refreshBackNavigationTransitionSources(
uiSchema,
pages,
{ id: page.id, slug: page.slug },
);
for (const element of uiSchema.elements) { for (const element of uiSchema.elements) {
// Process both forward elements AND back elements with their own transition // Process both forward elements AND back elements with their own transition
@ -1274,7 +1489,7 @@ class TourPagesService extends BaseService {
TourPagesService.isForwardElementWithTarget(element); TourPagesService.isForwardElementWithTarget(element);
const isBackWithTransition = Boolean( const isBackWithTransition = Boolean(
TourPagesService.isBackElement(element) && TourPagesService.isBackElement(element) &&
element.transitionVideoUrl, element.transitionVideoUrl,
); );
log.debug( log.debug(

View File

@ -33,12 +33,9 @@ const BaseUsersService = createEntityService<
UserData, UserData,
UserListFilter, UserListFilter,
UserAutocompleteOption UserAutocompleteOption
>( >(UsersDBApi, {
UsersDBApi, entityName: 'Users',
{ });
entityName: 'Users',
},
);
const buildRuntimeOptions = ( const buildRuntimeOptions = (
transaction: Transaction, transaction: Transaction,
@ -230,7 +227,9 @@ export default class UsersService extends BaseUsersService {
await this.createProductionPresentationAccessForPublicUser(options); await this.createProductionPresentationAccessForPublicUser(options);
} }
static override async create(options: UserCreateOptions): Promise<UserRecord> { static override async create(
options: UserCreateOptions,
): Promise<UserRecord> {
assertCreateOptions(options, 'Service'); assertCreateOptions(options, 'Service');
const { const {
data, data,
@ -319,7 +318,9 @@ export default class UsersService extends BaseUsersService {
} }
} }
static override async update(options: UserUpdateOptions): Promise<UserRecord> { static override async update(
options: UserUpdateOptions,
): Promise<UserRecord> {
assertUpdateOptions(options, 'Service'); assertUpdateOptions(options, 'Service');
const { const {
id, id,
@ -405,7 +406,9 @@ export default class UsersService extends BaseUsersService {
} }
} }
static override async remove(options: UserRemoveOptions): Promise<UserRecord> { static override async remove(
options: UserRemoveOptions,
): Promise<UserRecord> {
assertIdOptions(options, 'Service', 'remove'); assertIdOptions(options, 'Service', 'remove');
const { id, currentUser, transaction, runtimeContext } = options; const { id, currentUser, transaction, runtimeContext } = options;

View File

@ -20,7 +20,8 @@ import { logger } from '../utils/logger.ts';
const loadCommonJsModule = createRequire(import.meta.url); const loadCommonJsModule = createRequire(import.meta.url);
const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static'); const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static');
const ffmpegPath = typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null; const ffmpegPath =
typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null;
let ffmpegQueueTail: Promise<unknown> = Promise.resolve(); let ffmpegQueueTail: Promise<unknown> = Promise.resolve();
let queuedFfmpegJobs = 0; let queuedFfmpegJobs = 0;
@ -47,9 +48,11 @@ interface MediaProbeStream {
interface MediaProbeOutput { interface MediaProbeOutput {
streams?: MediaProbeStream[] | undefined; streams?: MediaProbeStream[] | undefined;
format?: { format?:
duration?: string | number | undefined; | {
} | undefined; duration?: string | number | undefined;
}
| undefined;
} }
interface ProcessResult { interface ProcessResult {
@ -360,7 +363,9 @@ async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> {
} }
metadata = parsedMetadata; metadata = parsedMetadata;
} catch (error) { } catch (error) {
throw new Error(`Failed to parse FFprobe metadata: ${toError(error).message}`); throw new Error(
`Failed to parse FFprobe metadata: ${toError(error).message}`,
);
} }
const streams = metadata.streams ?? []; const streams = metadata.streams ?? [];

View File

@ -21,7 +21,9 @@ export interface ProductionPresentationProject {
} }
export interface RoleWithPermissionLoader extends RoleRecord { export interface RoleWithPermissionLoader extends RoleRecord {
getPermissions?: () => Promise<ReadonlyArray<PermissionRecord | PermissionName>>; getPermissions?: () => Promise<
ReadonlyArray<PermissionRecord | PermissionName>
>;
} }
export type AccessPolicyUser = CurrentUser | null | undefined; export type AccessPolicyUser = CurrentUser | null | undefined;

View File

@ -145,16 +145,19 @@ export interface InvalidAssetMimeValidationResult {
} }
export type AssetMimeValidationResult = export type AssetMimeValidationResult =
| ValidAssetMimeValidationResult ValidAssetMimeValidationResult | InvalidAssetMimeValidationResult;
| InvalidAssetMimeValidationResult;
export type AssetFindByOptions = Pick< export type AssetFindByOptions = Pick<
UpdateOptions<AssetData>, UpdateOptions<AssetData>,
'transaction' | 'runtimeContext' 'transaction' | 'runtimeContext'
>; >;
export interface AssetsDbApi export interface AssetsDbApi extends EntityDbApi<
extends EntityDbApi<AssetRecord, AssetData, AssetData, AssetListFilter> { AssetRecord,
AssetData,
AssetData,
AssetListFilter
> {
findBy(options: DbFindByOptions): Promise<AssetRecord | null>; findBy(options: DbFindByOptions): Promise<AssetRecord | null>;
findBy( findBy(
where: { id?: string; storage_key?: string }, where: { id?: string; storage_key?: string },

View File

@ -1,8 +1,10 @@
import type SMTPConnection from 'nodemailer/lib/smtp-connection/index.js'; import type SMTPConnection from 'nodemailer/lib/smtp-connection/index.js';
import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js'; import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js';
export interface BackendEmailConfig export interface BackendEmailConfig extends Omit<
extends Omit<SMTPTransport.Options, 'auth'> { SMTPTransport.Options,
'auth'
> {
from: string; from: string;
auth: SMTPConnection.Credentials; auth: SMTPConnection.Credentials;
} }

View File

@ -103,8 +103,9 @@ export interface DbFindAllOptions<TFilter> extends ServiceOptions {
offset?: number; offset?: number;
} }
export interface DbFindByOptions<TWhere extends WhereOptions = WhereOptions> export interface DbFindByOptions<
extends ServiceOptions { TWhere extends WhereOptions = WhereOptions,
> extends ServiceOptions {
where: TWhere; where: TWhere;
include?: Includeable[]; include?: Includeable[];
} }
@ -135,13 +136,20 @@ export interface EntityDbApi<
remove(options: EntityIdOptions): Promise<TEntity>; remove(options: EntityIdOptions): Promise<TEntity>;
deleteByIds(options: DeleteByIdsOptions): Promise<TEntity[]>; deleteByIds(options: DeleteByIdsOptions): Promise<TEntity[]>;
findBy(options: DbFindByOptions): Promise<TEntity | null>; findBy(options: DbFindByOptions): Promise<TEntity | null>;
findAll(options: DbFindAllOptions<TFilter>): Promise<PaginatedResult<TEntity>>; findAll(
options: DbFindAllOptions<TFilter>,
): Promise<PaginatedResult<TEntity>>;
findAllAutocomplete(options: AutocompleteOptions): Promise<TAutocomplete[]>; findAllAutocomplete(options: AutocompleteOptions): Promise<TAutocomplete[]>;
} }
export interface SingletonDbApi<TEntity extends EntityRecord, TUpdate> export interface SingletonDbApi<
extends EntityDbApi<TEntity, TUpdate, TUpdate, unknown> { TEntity extends EntityRecord,
TUpdate,
> extends EntityDbApi<TEntity, TUpdate, TUpdate, unknown> {
findOne(options?: ServiceOptions): Promise<TEntity | null>; findOne(options?: ServiceOptions): Promise<TEntity | null>;
findBy(options: DbFindByOptions): Promise<TEntity | null>; findBy(options: DbFindByOptions): Promise<TEntity | null>;
findBy(where: { id: string }, options?: ServiceOptions): Promise<TEntity | null>; findBy(
where: { id: string },
options?: ServiceOptions,
): Promise<TEntity | null>;
} }

View File

@ -11,8 +11,7 @@ export interface SequelizeCliStorageOptions {
} }
export interface DatabaseEnvironmentConfig export interface DatabaseEnvironmentConfig
extends Options, extends Options, SequelizeCliStorageOptions {
SequelizeCliStorageOptions {
dialect: 'postgres'; dialect: 'postgres';
use_env_variable?: string; use_env_variable?: string;
} }

View File

@ -461,9 +461,7 @@ export interface RoleModel {
data: { name: string }, data: { name: string },
options: { transaction: Transaction }, options: { transaction: Transaction },
): Promise<RoleModelRecord>; ): Promise<RoleModelRecord>;
findOne(options: { findOne(options: { where: { name: string } }): Promise<UserPublicRole | null>;
where: { name: string };
}): Promise<UserPublicRole | null>;
findAll(options: { findAll(options: {
where: { name: 'Public' }; where: { name: 'Public' };
include: readonly [{ association: 'permissions' }]; include: readonly [{ association: 'permissions' }];

View File

@ -76,13 +76,12 @@ export interface ElementTypeDefaultsModel {
}): Promise<ElementTypeDefaultsModelRecord | null>; }): Promise<ElementTypeDefaultsModelRecord | null>;
} }
export interface ElementTypeDefaultsDbApi export interface ElementTypeDefaultsDbApi extends EntityDbApi<
extends EntityDbApi< ElementTypeDefaultsRecord,
ElementTypeDefaultsRecord, ElementTypeDefaultsData,
ElementTypeDefaultsData, ElementTypeDefaultsData,
ElementTypeDefaultsData, unknown
unknown > {
> {
ensureInitialized(): Promise<void>; ensureInitialized(): Promise<void>;
bulkImport( bulkImport(
data: ElementTypeDefaultsData[], data: ElementTypeDefaultsData[],

View File

@ -47,8 +47,10 @@ export interface EntityRouterQuery {
offset?: unknown; offset?: unknown;
} }
export interface NormalizedEntityRouterQuery export interface NormalizedEntityRouterQuery extends Omit<
extends Omit<EntityRouterQuery, 'limit' | 'page' | 'sort' | 'field'> { EntityRouterQuery,
'limit' | 'page' | 'sort' | 'field'
> {
limit: number; limit: number;
page: number; page: number;
sort?: EntityRouterSortDirection; sort?: EntityRouterSortDirection;

View File

@ -34,12 +34,12 @@ export interface EntityServiceConstructor<
TFilter = unknown, TFilter = unknown,
TAutocomplete extends EntityRecord | { id: string } = TEntity, TAutocomplete extends EntityRecord | { id: string } = TEntity,
> extends EntityServiceClass< > extends EntityServiceClass<
TEntity, TEntity,
TCreate, TCreate,
TUpdate, TUpdate,
TFilter, TFilter,
TAutocomplete TAutocomplete
> { > {
new (): object; new (): object;
} }
@ -58,7 +58,10 @@ export interface EntityServiceDbApi<
readonly __filterType?: TFilter; readonly __filterType?: TFilter;
readonly __autocompleteType?: TAutocomplete; readonly __autocompleteType?: TAutocomplete;
create(options: CreateOptions<unknown>): Promise<TEntity>; create(options: CreateOptions<unknown>): Promise<TEntity>;
bulkImport(rows: readonly unknown[], options: BulkImportOptions): Promise<unknown>; bulkImport(
rows: readonly unknown[],
options: BulkImportOptions,
): Promise<unknown>;
update(options: UpdateOptions<unknown>): Promise<TEntity>; update(options: UpdateOptions<unknown>): Promise<TEntity>;
findBy( findBy(
where: { id: string }, where: { id: string },

View File

@ -1,8 +1,5 @@
export type NodeEnvironment = export type NodeEnvironment =
| 'development' 'development' | 'test' | 'production' | 'dev_stage';
| 'test'
| 'production'
| 'dev_stage';
export interface ValidatedEnvironment { export interface ValidatedEnvironment {
NODE_ENV: NodeEnvironment; NODE_ENV: NodeEnvironment;

View File

@ -231,7 +231,9 @@ export interface FileServiceFacade {
req: FileServiceRequest, req: FileServiceRequest,
res: FileServiceResponse, res: FileServiceResponse,
): Promise<unknown>; ): Promise<unknown>;
generatePresignedUrls(urls: readonly string[]): Promise<Record<string, string>>; generatePresignedUrls(
urls: readonly string[],
): Promise<Record<string, string>>;
isValidPath(urlPath: unknown): urlPath is string; isValidPath(urlPath: unknown): urlPath is string;
createErrorResponse( createErrorResponse(
message: string, message: string,
@ -266,10 +268,7 @@ export interface FileDbOptions {
} }
export type RelationFileInput = export type RelationFileInput =
| RelationFileRecord RelationFileRecord | RelationFileRecord[] | null | undefined;
| RelationFileRecord[]
| null
| undefined;
export interface FileModelCreatePayload { export interface FileModelCreatePayload {
belongsTo: string; belongsTo: string;
@ -364,7 +363,10 @@ export interface UploadSessionChunkMeta {
uploadedAt: string; uploadedAt: string;
} }
export type UploadSessionUploadedChunks = Record<string, UploadSessionChunkMeta>; export type UploadSessionUploadedChunks = Record<
string,
UploadSessionChunkMeta
>;
export interface UploadSessionMeta { export interface UploadSessionMeta {
sessionId: string; sessionId: string;

View File

@ -1,15 +1,8 @@
export type TransitionType = export type TransitionType =
| 'fade' 'fade' | 'slide-left' | 'slide-right' | 'zoom' | 'none';
| 'slide-left'
| 'slide-right'
| 'zoom'
| 'none';
export type TransitionEasing = export type TransitionEasing =
| 'ease-in-out' 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
| 'ease-in'
| 'ease-out'
| 'linear';
export interface GlobalTransitionDefaultsData { export interface GlobalTransitionDefaultsData {
id?: string; id?: string;
@ -19,8 +12,7 @@ export interface GlobalTransitionDefaultsData {
overlay_color?: string; overlay_color?: string;
} }
export interface GlobalTransitionDefaultsRecord export interface GlobalTransitionDefaultsRecord extends Required<GlobalTransitionDefaultsData> {
extends Required<GlobalTransitionDefaultsData> {
id: string; id: string;
createdAt?: Date; createdAt?: Date;
updatedAt?: Date; updatedAt?: Date;
@ -86,13 +78,11 @@ export interface GlobalUiControlDefaultsModelRecord {
export interface GlobalUiControlDefaultsModel { export interface GlobalUiControlDefaultsModel {
count(): Promise<number>; count(): Promise<number>;
sync(): Promise<unknown>; sync(): Promise<unknown>;
create( create(data: {
data: { settings_json: GlobalUiControlSettingsJson;
settings_json: GlobalUiControlSettingsJson; createdAt: Date;
createdAt: Date; updatedAt: Date;
updatedAt: Date; }): Promise<GlobalUiControlDefaultsModelRecord>;
},
): Promise<GlobalUiControlDefaultsModelRecord>;
findOne(options: { findOne(options: {
transaction?: unknown; transaction?: unknown;
}): Promise<GlobalUiControlDefaultsModelRecord | null>; }): Promise<GlobalUiControlDefaultsModelRecord | null>;

View File

@ -1,8 +1,5 @@
import type { NextFunction, Request, Response } from 'express'; import type { NextFunction, Request, Response } from 'express';
import type { import type { ParamsDictionary, Query } from 'express-serve-static-core';
ParamsDictionary,
Query,
} from 'express-serve-static-core';
import type { RequestValidationDetail } from './validation.ts'; import type { RequestValidationDetail } from './validation.ts';
@ -32,7 +29,11 @@ export interface RouteIdRequestLike {
body: unknown; body: unknown;
} }
export type RouteIdRequest = Request<{ id: string }, unknown, RouteIdRequestBody>; export type RouteIdRequest = Request<
{ id: string },
unknown,
RouteIdRequestBody
>;
export interface RouteIdRequestBody { export interface RouteIdRequestBody {
id?: string; id?: string;

View File

@ -12,8 +12,7 @@ export interface SortOptions<TSortField extends string = string> {
} }
export interface ListQueryOptions<TFilter, TSortField extends string = string> export interface ListQueryOptions<TFilter, TSortField extends string = string>
extends PaginationOptions, extends PaginationOptions, SortOptions<TSortField> {
SortOptions<TSortField> {
filter?: TFilter; filter?: TFilter;
query?: string; query?: string;
} }

View File

@ -124,9 +124,7 @@ export interface ProjectAudioTracksDbApi {
where: { id: string }, where: { id: string },
options?: ProjectAudioTrackRuntimeOptions, options?: ProjectAudioTrackRuntimeOptions,
): Promise<ProjectAudioTrackRecord | null>; ): Promise<ProjectAudioTrackRecord | null>;
findBy( findBy(options: DbFindByOptions): Promise<ProjectAudioTrackRecord | null>;
options: DbFindByOptions,
): Promise<ProjectAudioTrackRecord | null>;
findAll( findAll(
filter?: ProjectAudioTrackListFilter, filter?: ProjectAudioTrackListFilter,
options?: ProjectAudioTrackRuntimeOptions, options?: ProjectAudioTrackRuntimeOptions,

View File

@ -103,8 +103,7 @@ export interface ProjectElementDefaultsOptions extends ServiceOptions {
countOnly?: boolean; countOnly?: boolean;
} }
export interface ProjectElementDefaultsModelRecord export interface ProjectElementDefaultsModelRecord extends ProjectElementDefaultRecord {
extends ProjectElementDefaultRecord {
update( update(
data: Partial<ProjectElementDefaultRecord> & { data: Partial<ProjectElementDefaultRecord> & {
updatedById?: string | null; updatedById?: string | null;
@ -155,13 +154,12 @@ export interface ProjectElementDefaultsModel {
}): Promise<PaginatedResult<ProjectElementDefaultRecord>>; }): Promise<PaginatedResult<ProjectElementDefaultRecord>>;
} }
export interface ProjectElementDefaultsDbApi export interface ProjectElementDefaultsDbApi extends EntityDbApi<
extends EntityDbApi< ProjectElementDefaultRecord,
ProjectElementDefaultRecord, ProjectElementDefaultsData,
ProjectElementDefaultsData, ProjectElementDefaultsData,
ProjectElementDefaultsData, ProjectElementDefaultsListFilter
ProjectElementDefaultsListFilter > {
> {
findByElementType( findByElementType(
projectId: string, projectId: string,
elementType: string, elementType: string,

View File

@ -6,10 +6,7 @@ import type {
} from './index.ts'; } from './index.ts';
export type ProjectMembershipAccessLevel = export type ProjectMembershipAccessLevel =
| 'owner' 'owner' | 'editor' | 'reviewer' | 'viewer';
| 'editor'
| 'reviewer'
| 'viewer';
export interface ProjectMembershipData { export interface ProjectMembershipData {
id?: string; id?: string;

View File

@ -18,10 +18,7 @@ import type { Transaction } from 'sequelize';
export type ProjectTransitionType = 'fade' | 'none' | 'video'; export type ProjectTransitionType = 'fade' | 'none' | 'video';
export type ProjectTransitionEasing = export type ProjectTransitionEasing =
| 'ease-in-out' 'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
| 'ease-in'
| 'ease-out'
| 'linear';
export interface ProjectTransitionSettingsData { export interface ProjectTransitionSettingsData {
id?: string; id?: string;

View File

@ -39,8 +39,7 @@ export interface ProjectUiControlSettingsRuntimeOptions {
transaction?: Transaction; transaction?: Transaction;
} }
export interface ProjectUiControlSettingsUpsertOptions export interface ProjectUiControlSettingsUpsertOptions extends ProjectUiControlSettingsRuntimeOptions {
extends ProjectUiControlSettingsRuntimeOptions {
currentUser?: CurrentUser | null; currentUser?: CurrentUser | null;
} }

View File

@ -1,10 +1,18 @@
import type { Transaction } from 'sequelize'; import type { Transaction } from 'sequelize';
import type { CurrentUser } from './auth.ts'; import type { CurrentUser } from './auth.ts';
import type { DbFindAllOptions, DbFindByOptions, EntityDbApi } from './db-api.ts'; import type {
DbFindAllOptions,
DbFindByOptions,
EntityDbApi,
} from './db-api.ts';
import type { PaginatedResult } from './pagination.ts'; import type { PaginatedResult } from './pagination.ts';
import type { QueryWhere } from './runtime.ts'; import type { QueryWhere } from './runtime.ts';
import type { CreateOptions, ServiceOptions, UpdateOptions } from './service-options.ts'; import type {
CreateOptions,
ServiceOptions,
UpdateOptions,
} from './service-options.ts';
export type ProjectProductionPresentationVisibility = 'public' | 'private'; export type ProjectProductionPresentationVisibility = 'public' | 'private';
@ -38,8 +46,7 @@ export interface ProjectFieldMapping {
design_width: number | null | undefined; design_width: number | null | undefined;
design_height: number | null | undefined; design_height: number | null | undefined;
production_presentation_visibility: production_presentation_visibility:
| ProjectProductionPresentationVisibility ProjectProductionPresentationVisibility | undefined;
| undefined;
} }
export interface ProjectCreatePayload extends ProjectFieldMapping { export interface ProjectCreatePayload extends ProjectFieldMapping {
@ -233,14 +240,20 @@ export interface ProjectCloneCreateOptions extends ProjectCloneTransactionOption
export type ProjectCloneCurrentUser = Pick<CurrentUser, 'id'>; export type ProjectCloneCurrentUser = Pick<CurrentUser, 'id'>;
export interface ProjectsDbApi export interface ProjectsDbApi extends EntityDbApi<
extends EntityDbApi<ProjectRecord, ProjectData, ProjectData, ProjectListFilter> { ProjectRecord,
ProjectData,
ProjectData,
ProjectListFilter
> {
findBy(options: DbFindByOptions): Promise<ProjectRecord | null>; findBy(options: DbFindByOptions): Promise<ProjectRecord | null>;
findBy( findBy(
where: { id: string }, where: { id: string },
options?: ProjectFindAllOptions, options?: ProjectFindAllOptions,
): Promise<ProjectRecord | null>; ): Promise<ProjectRecord | null>;
findAll(options: DbFindAllOptions<unknown>): Promise<PaginatedResult<ProjectRecord>>; findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<ProjectRecord>>;
findAll( findAll(
filter?: ProjectListFilter, filter?: ProjectListFilter,
options?: ProjectFindAllOptions, options?: ProjectFindAllOptions,

View File

@ -33,7 +33,10 @@ export interface SaveToStageResult {
export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed'; export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed';
export type PublishSourceEnvironment = Extract<RuntimeEnvironment, 'dev' | 'stage'>; export type PublishSourceEnvironment = Extract<
RuntimeEnvironment,
'dev' | 'stage'
>;
export type PublishTargetEnvironment = Extract< export type PublishTargetEnvironment = Extract<
RuntimeEnvironment, RuntimeEnvironment,

View File

@ -2,6 +2,8 @@ import type Joi from 'joi';
import type { RequestValidationPart } from './validation.ts'; import type { RequestValidationPart } from './validation.ts';
export type RequestSchemaMap = Partial<Record<RequestValidationPart, Joi.Schema>>; export type RequestSchemaMap = Partial<
Record<RequestValidationPart, Joi.Schema>
>;
export type RequestSchemaGroup = Record<string, RequestSchemaMap>; export type RequestSchemaGroup = Record<string, RequestSchemaMap>;
export type RequestSchemaCatalog = Record<string, RequestSchemaGroup>; export type RequestSchemaCatalog = Record<string, RequestSchemaGroup>;

View File

@ -1,4 +1,7 @@
import type { AccessPolicyOptions, ProductionPresentationProject } from './access-policy.ts'; import type {
AccessPolicyOptions,
ProductionPresentationProject,
} from './access-policy.ts';
export interface PrivateProductionPresentationOption { export interface PrivateProductionPresentationOption {
id: string; id: string;
@ -16,16 +19,17 @@ export interface ProductionPresentationAccessGrantPlain {
project?: ProductionPresentationAccessProject | null; project?: ProductionPresentationAccessProject | null;
} }
export interface ProductionPresentationAccessGrantRow export interface ProductionPresentationAccessGrantRow extends ProductionPresentationAccessGrantPlain {
extends ProductionPresentationAccessGrantPlain {
get?: (options: { plain: true }) => ProductionPresentationAccessGrantPlain; get?: (options: { plain: true }) => ProductionPresentationAccessGrantPlain;
} }
export interface PrivateProductionProjectRow export interface PrivateProductionProjectRow extends Pick<
extends Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'> { ProductionPresentationProject,
get?: ( 'id' | 'name' | 'slug'
options: { plain: true }, > {
) => Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'>; get?: (options: {
plain: true;
}) => Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'>;
} }
export type RuntimePresentationAccessOptions = AccessPolicyOptions; export type RuntimePresentationAccessOptions = AccessPolicyOptions;

View File

@ -15,8 +15,7 @@ export interface UnknownRuntimeContext {
} }
export type RuntimeContextInspectionResponse = export type RuntimeContextInspectionResponse =
| RuntimeContext RuntimeContext | UnknownRuntimeContext;
| UnknownRuntimeContext;
export interface RuntimeFilterOptions { export interface RuntimeFilterOptions {
runtimeContext?: RuntimeContext | null; runtimeContext?: RuntimeContext | null;

View File

@ -68,8 +68,7 @@ export interface TourPageSequelizeRecord extends TourPageRecord {
} }
export type TourPageMaybeSequelizeRecord = export type TourPageMaybeSequelizeRecord =
| TourPageRecord TourPageRecord | TourPageSequelizeRecord;
| TourPageSequelizeRecord;
export interface TourPageProjectRef { export interface TourPageProjectRef {
id: string; id: string;
@ -121,8 +120,7 @@ export interface TourPageRecord extends TourPageData {
export type TourPageCreateBody = EntityDataRequestBody<TourPageData>; export type TourPageCreateBody = EntityDataRequestBody<TourPageData>;
export interface TourPageUpdateBody export interface TourPageUpdateBody extends EntityDataRequestBody<TourPageData> {
extends EntityDataRequestBody<TourPageData> {
id?: string; id?: string;
} }
@ -194,12 +192,11 @@ export interface TourPageReverseGenerationTask {
pageId?: string | null | undefined; pageId?: string | null | undefined;
} }
export interface TourPagesDbApi export interface TourPagesDbApi extends EntityDbApi<
extends EntityDbApi< TourPageRecord,
TourPageRecord, TourPageData,
TourPageData, TourPageData,
TourPageData, TourPageListQuery
TourPageListQuery
> { > {
readonly CSV_FIELDS: readonly string[]; readonly CSV_FIELDS: readonly string[];
findBy(options: DbFindByOptions): Promise<TourPageRecord | null>; findBy(options: DbFindByOptions): Promise<TourPageRecord | null>;
@ -207,12 +204,16 @@ export interface TourPagesDbApi
where: { id: string }, where: { id: string },
options?: ServiceOptions, options?: ServiceOptions,
): Promise<TourPageRecord | null>; ): Promise<TourPageRecord | null>;
findAll(options: DbFindAllOptions<TourPageListQuery>): Promise<TourPageListResult>; findAll(
options: DbFindAllOptions<TourPageListQuery>,
): Promise<TourPageListResult>;
findAll( findAll(
filter?: TourPageListQuery, filter?: TourPageListQuery,
options?: TourPageFindAllOptions, options?: TourPageFindAllOptions,
): Promise<TourPageListResult>; ): Promise<TourPageListResult>;
findAllAutocomplete(options: TourPageAutocompleteOptions): Promise<TourPageRecord[]>; findAllAutocomplete(
options: TourPageAutocompleteOptions,
): Promise<TourPageRecord[]>;
findAllAutocomplete(options: AutocompleteOptions): Promise<TourPageRecord[]>; findAllAutocomplete(options: AutocompleteOptions): Promise<TourPageRecord[]>;
} }

View File

@ -2,8 +2,15 @@ import type { RequestHandler } from 'express';
import type { Transaction } from 'sequelize'; import type { Transaction } from 'sequelize';
import type { PermissionRecord, RoleRecord } from './auth.ts'; import type { PermissionRecord, RoleRecord } from './auth.ts';
import type { AutocompleteOptions, DeleteByIdsOptions } from './service-options.ts'; import type {
import type { DbFindAllOptions, DbFindByOptions, EntityDbApi } from './db-api.ts'; AutocompleteOptions,
DeleteByIdsOptions,
} from './service-options.ts';
import type {
DbFindAllOptions,
DbFindByOptions,
EntityDbApi,
} from './db-api.ts';
import type { EntityDataRequestBody } from './http.ts'; import type { EntityDataRequestBody } from './http.ts';
import type { PaginatedResult } from './pagination.ts'; import type { PaginatedResult } from './pagination.ts';
import type { QueryWhere } from './runtime.ts'; import type { QueryWhere } from './runtime.ts';
@ -157,15 +164,10 @@ export interface UserFindAllOptions extends ServiceOptions {
} }
export type UserSelectableIdInput = export type UserSelectableIdInput =
| string string | { id?: string | null; value?: string | null } | null | undefined;
| { id?: string | null; value?: string | null }
| null
| undefined;
export type UserSelectableIdArrayInput = export type UserSelectableIdArrayInput =
| Array<UserSelectableIdInput> Array<UserSelectableIdInput> | null | undefined;
| null
| undefined;
export interface UserAccessMutationOptions { export interface UserAccessMutationOptions {
user: Pick<UserRecord, 'id'> | null | undefined; user: Pick<UserRecord, 'id'> | null | undefined;
@ -287,19 +289,15 @@ export interface UserModelApi {
): Promise<PaginatedResult<UserModelRecord>>; ): Promise<PaginatedResult<UserModelRecord>>;
} }
export interface UsersDbApi export interface UsersDbApi extends EntityDbApi<
extends EntityDbApi< UserRecord,
UserRecord, UserData,
UserData, UserData,
UserData, UserListFilter,
UserListFilter, UserAutocompleteOption
UserAutocompleteOption > {
> {
create(options: CreateOptions<UserData>): Promise<UserRecord>; create(options: CreateOptions<UserData>): Promise<UserRecord>;
bulkImport( bulkImport(data: UserData[], options?: ServiceOptions): Promise<UserRecord[]>;
data: UserData[],
options?: ServiceOptions,
): Promise<UserRecord[]>;
update(options: UpdateOptions<UserData>): Promise<UserRecord>; update(options: UpdateOptions<UserData>): Promise<UserRecord>;
deleteByIds(options: DeleteByIdsOptions): Promise<UserRecord[]>; deleteByIds(options: DeleteByIdsOptions): Promise<UserRecord[]>;
remove(options: EntityIdOptions): Promise<UserRecord>; remove(options: EntityIdOptions): Promise<UserRecord>;
@ -312,7 +310,9 @@ export interface UsersDbApi
where: UserFindByWhere, where: UserFindByWhere,
options?: ServiceOptions, options?: ServiceOptions,
): Promise<UserRecord | null>; ): Promise<UserRecord | null>;
findAll(options: DbFindAllOptions<unknown>): Promise<PaginatedResult<UserRecord>>; findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<UserRecord>>;
findAll( findAll(
filter?: UserListFilter, filter?: UserListFilter,
options?: UserFindAllOptions, options?: UserFindAllOptions,

View File

@ -64,10 +64,7 @@ const envSchema = Joi.object({
.integer() .integer()
.positive() .positive()
.default(3), .default(3),
FFMPEG_BREAKER_COOLDOWN_MS: Joi.number() FFMPEG_BREAKER_COOLDOWN_MS: Joi.number().integer().positive().default(120000),
.integer()
.positive()
.default(120000),
FFMPEG_BREAKER_SUCCESS_THRESHOLD: Joi.number() FFMPEG_BREAKER_SUCCESS_THRESHOLD: Joi.number()
.integer() .integer()
.positive() .positive()
@ -191,19 +188,11 @@ function toValidatedEnvironment(
'AWS_S3_CONNECTION_TIMEOUT', 'AWS_S3_CONNECTION_TIMEOUT',
5000, 5000,
), ),
AWS_S3_REQUEST_TIMEOUT: readNumber( AWS_S3_REQUEST_TIMEOUT: readNumber(values, 'AWS_S3_REQUEST_TIMEOUT', 30000),
values,
'AWS_S3_REQUEST_TIMEOUT',
30000,
),
AWS_S3_MAX_ATTEMPTS: readNumber(values, 'AWS_S3_MAX_ATTEMPTS', 3), AWS_S3_MAX_ATTEMPTS: readNumber(values, 'AWS_S3_MAX_ATTEMPTS', 3),
AWS_S3_MAX_SOCKETS: readNumber(values, 'AWS_S3_MAX_SOCKETS', 50), AWS_S3_MAX_SOCKETS: readNumber(values, 'AWS_S3_MAX_SOCKETS', 50),
AWS_S3_KEEP_ALIVE: isEnvBooleanString(s3KeepAlive) ? s3KeepAlive : 'true', AWS_S3_KEEP_ALIVE: isEnvBooleanString(s3KeepAlive) ? s3KeepAlive : 'true',
AWS_S3_PRESIGN_EXPIRY: readNumber( AWS_S3_PRESIGN_EXPIRY: readNumber(values, 'AWS_S3_PRESIGN_EXPIRY', 3600),
values,
'AWS_S3_PRESIGN_EXPIRY',
3600,
),
FILE_STORAGE_PROVIDER: FILE_STORAGE_PROVIDER:
fileStorageProvider === 's3' || fileStorageProvider === 's3' ||
fileStorageProvider === 'gcloud' || fileStorageProvider === 'gcloud' ||
@ -268,9 +257,9 @@ function toValidatedEnvironment(
function validateEnv(): ValidatedEnvironment { function validateEnv(): ValidatedEnvironment {
const result: Joi.ValidationResult<Record<string, unknown>> = const result: Joi.ValidationResult<Record<string, unknown>> =
envSchema.validate(process.env, { envSchema.validate(process.env, {
abortEarly: false, abortEarly: false,
stripUnknown: false, stripUnknown: false,
}); });
if (result.error) { if (result.error) {
const messages = result.error.details.map( const messages = result.error.details.map(

View File

@ -44,7 +44,8 @@ function isGlobalTransitionDefaultsData(
transitionTypes.has(value.transition_type))) && transitionTypes.has(value.transition_type))) &&
isOptionalNumber(value.duration_ms) && isOptionalNumber(value.duration_ms) &&
(value.easing === undefined || (value.easing === undefined ||
(typeof value.easing === 'string' && transitionEasings.has(value.easing))) && (typeof value.easing === 'string' &&
transitionEasings.has(value.easing))) &&
isOptionalString(value.overlay_color) isOptionalString(value.overlay_color)
); );
} }

View File

@ -45,9 +45,7 @@ function normalizeLoggedError(reason: unknown): Error {
if (reason instanceof Error) return reason; if (reason instanceof Error) return reason;
const message = const message =
typeof reason === 'string' typeof reason === 'string' ? reason : 'Non-Error value thrown or rejected';
? reason
: 'Non-Error value thrown or rejected';
return new Error(message, { cause: reason }); return new Error(message, { cause: reason });
} }

View File

@ -25,7 +25,9 @@ function readQueryString(query: unknown, key: string): string | undefined {
return undefined; return undefined;
} }
function getProjectSettingsListFilter(query: unknown): ProjectSettingsListFilter { function getProjectSettingsListFilter(
query: unknown,
): ProjectSettingsListFilter {
const filter: ProjectSettingsListFilter = {}; const filter: ProjectSettingsListFilter = {};
const id = readQueryString(query, 'id'); const id = readQueryString(query, 'id');
const project = readQueryString(query, 'project'); const project = readQueryString(query, 'project');
@ -66,9 +68,7 @@ function readQueryRange(
if (Array.isArray(value)) { if (Array.isArray(value)) {
const first = value.find((item) => typeof item === 'string'); const first = value.find((item) => typeof item === 'string');
const second = value const second = value.slice(1).find((item) => typeof item === 'string');
.slice(1)
.find((item) => typeof item === 'string');
return [first, second]; return [first, second];
} }

View File

@ -10,9 +10,7 @@ function hasStringId(value: unknown): value is { id: string } {
); );
} }
function isEntityDataRequestBody( function isEntityDataRequestBody(body: unknown): body is EntityDataRequestBody {
body: unknown,
): body is EntityDataRequestBody {
return body !== null && typeof body === 'object' && 'data' in body; return body !== null && typeof body === 'object' && 'data' in body;
} }

View File

@ -71,9 +71,7 @@ export function setPermissionNameOverride(
permissionNameOverride; permissionNameOverride;
} }
export function getPermissionNameOverride( export function getPermissionNameOverride(req: Request): string | undefined {
req: Request,
): string | undefined {
return getRequestContext(req).permissionNameOverride; return getRequestContext(req).permissionNameOverride;
} }

View File

@ -63,11 +63,15 @@ void test('internal users with permissions can use admin api', () => {
void test('platform-wide roles are explicit', () => { void test('platform-wide roles are explicit', () => {
assert.equal( assert.equal(
AccessPolicy.isPlatformWideRole(userWithRole('admin-1', { name: 'Administrator' })), AccessPolicy.isPlatformWideRole(
userWithRole('admin-1', { name: 'Administrator' }),
),
true, true,
); );
assert.equal( assert.equal(
AccessPolicy.isPlatformWideRole(userWithRole('designer-1', { name: 'Tour Designer' })), AccessPolicy.isPlatformWideRole(
userWithRole('designer-1', { name: 'Tour Designer' }),
),
false, false,
); );
}); });

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