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
class AssetsDBApi extends GenericDBApi {
// Required: Define the Sequelize model
static get MODEL() { return db.assets; }
static get MODEL() {
return db.assets;
}
// Configurable behavior via static getters
static get SEARCHABLE_FIELDS() { return ['name', 'cdn_url']; }
static get RANGE_FIELDS() { return ['size_mb', 'width_px']; }
static get ENUM_FIELDS() { 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' }]; }
static get SEARCHABLE_FIELDS() {
return ['name', 'cdn_url'];
}
static get RANGE_FIELDS() {
return ['size_mb', 'width_px'];
}
static get ENUM_FIELDS() {
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
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`.
Interface:
- `upload(key, data, options)``{ key, url }`
- `download(key)``{ body, contentType }`
- `delete(key)``void`
@ -292,16 +311,17 @@ Application bootstrap:
```javascript
// Key route mounting patterns
app.use('/api/auth', authRoutes); // No JWT required
app.use('/api/users', jwtAuth, usersRoutes); // JWT required
app.use('/api/auth', authRoutes); // No JWT required
app.use('/api/users', jwtAuth, usersRoutes); // JWT required
// Runtime public routes (production content accessible without auth)
const mountRuntimeEntityRoute = (path, entityName, router) => {
app.use(path,
requireRuntimeReadOrAuth, // JWT or public production
app.use(
path,
requireRuntimeReadOrAuth, // JWT or public production
blockNonPublicRuntimeListEndpoints, // Block non-list endpoints
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
router
router,
);
};
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
@ -312,27 +332,27 @@ mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
**Factory-Generated Routes** provide standard CRUD:
| Method | Path | Description |
|--------|------|-------------|
| POST | `/` | Create record |
| POST | `/bulk-import` | Bulk import from CSV |
| PUT | `/:id` | Update record |
| DELETE | `/:id` | Delete record |
| POST | `/deleteByIds` | Bulk delete |
| GET | `/` | List with pagination & filters |
| GET | `/count` | Count only |
| GET | `/autocomplete` | Autocomplete search |
| GET | `/:id` | Get single record |
| Method | Path | Description |
| ------ | --------------- | ------------------------------ |
| POST | `/` | Create record |
| POST | `/bulk-import` | Bulk import from CSV |
| PUT | `/:id` | Update record |
| DELETE | `/:id` | Delete record |
| POST | `/deleteByIds` | Bulk delete |
| GET | `/` | List with pagination & filters |
| GET | `/count` | Count only |
| GET | `/autocomplete` | Autocomplete search |
| GET | `/:id` | Get single record |
**Custom Routes** (auth, file, publish, search, runtime-context):
| Route | Endpoints |
|-------|-----------|
| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth |
| `/api/file` | upload, download, presign, upload-sessions (chunked) |
| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) |
| `/api/search` | Global full-text search |
| `/api/runtime-context` | Runtime environment detection |
| Route | Endpoints |
| ---------------------- | ------------------------------------------------------------------------ |
| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth |
| `/api/file` | upload, download, presign, upload-sessions (chunked) |
| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) |
| `/api/search` | Global full-text search |
| `/api/runtime-context` | Runtime environment detection |
### Service Layer
@ -356,6 +376,7 @@ static async create({ data, currentUser, transaction: externalTransaction, runti
**Publish Service** (`services/publish.ts`):
Implements the dev→stage→production workflow with:
- Transaction locking to prevent concurrent publishes
- Source key tracking for content lineage
- Bulk copy operations for pages and audio tracks
@ -364,14 +385,14 @@ Implements the dev→stage→production workflow with:
**Query Building** in `findAll()`:
| Filter Type | Example | SQL |
|-------------|---------|-----|
| Text search | `?name=foo` | `name ILIKE '%foo%'` |
| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` |
| Enum | `?asset_type=image` | `asset_type = 'image'` |
| Relation | `?project=uuid` | JOIN with projects table |
| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` |
| Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` |
| Filter Type | Example | SQL |
| ----------- | ----------------------- | --------------------------------- |
| Text search | `?name=foo` | `name ILIKE '%foo%'` |
| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` |
| Enum | `?asset_type=image` | `asset_type = 'image'` |
| Relation | `?project=uuid` | JOIN with projects table |
| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` |
| 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
**Permission Naming Convention**:
- `CREATE_<ENTITY>` - Create records
- `READ_<ENTITY>` - Read records
- `UPDATE_<ENTITY>` - Modify records
@ -420,15 +442,16 @@ For production content accessible without authentication:
```javascript
const requireRuntimeReadOrAuth = (req, res, next) => {
const isPublicEnvironment = req.runtimeContext?.headerEnvironment === 'production';
const isPublicEnvironment =
req.runtimeContext?.headerEnvironment === 'production';
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
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`):
| Limiter | Window | Max Requests | Use Case |
|---------|--------|--------------|----------|
| `authLimiter` | 15 min | 10 | Authentication endpoints |
| `passwordResetLimiter` | 1 hour | 5 | Password reset |
| `apiLimiter` | 1 min | 100 | General API |
| `uploadLimiter` | 1 min | 10 | File uploads |
| `downloadLimiter` | 1 min | 200 | File downloads |
| `searchLimiter` | 1 min | 30 | Search queries |
| Limiter | Window | Max Requests | Use Case |
| ---------------------- | ------ | ------------ | ------------------------ |
| `authLimiter` | 15 min | 10 | Authentication endpoints |
| `passwordResetLimiter` | 1 hour | 5 | Password reset |
| `apiLimiter` | 1 min | 100 | General API |
| `uploadLimiter` | 1 min | 10 | File uploads |
| `downloadLimiter` | 1 min | 200 | File downloads |
| `searchLimiter` | 1 min | 30 | Search queries |
Headers returned:
- `X-RateLimit-Limit`: Maximum requests
- `X-RateLimit-Remaining`: Remaining requests
- `X-RateLimit-Reset`: Reset time (ISO timestamp)
@ -460,24 +484,26 @@ Headers returned:
**Storage Provider Selection**:
```javascript
const provider = config.fileStorage.provider ||
const provider =
config.fileStorage.provider ||
(hasS3Credentials ? 's3' : hasGCloudCredentials ? 'gcloud' : 'local');
```
**S3 Operations**:
| Operation | Method | Description |
|-----------|--------|-------------|
| Upload | `upload(key, data, options)` | Put object with metadata |
| Download | `download(key)` | Get object stream |
| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL |
| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects |
| Check | `exists(key)` | Head object |
| List | `list(prefix)` | List objects with prefix |
| Operation | Method | Description |
| --------- | ---------------------------------- | ------------------------ |
| Upload | `upload(key, data, options)` | Put object with metadata |
| Download | `download(key)` | Get object stream |
| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL |
| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects |
| Check | `exists(key)` | Head object |
| List | `list(prefix)` | List objects with prefix |
**Chunked Uploads** (`UploadSessionManager`):
For large files, supports multipart upload sessions:
1. `POST /upload-sessions/init` - Create session
2. `POST /upload-sessions/:id/chunk` - Upload chunk
3. `POST /upload-sessions/:id/finalize` - Complete upload
@ -498,11 +524,21 @@ class AppError extends Error {
}
}
class NotFoundError extends AppError { statusCode = 404 }
class ValidationError extends AppError { statusCode = 400 }
class ForbiddenError extends AppError { statusCode = 403 }
class UnauthorizedError extends AppError { statusCode = 401 }
class ConflictError extends AppError { statusCode = 409 }
class NotFoundError extends AppError {
statusCode = 404;
}
class ValidationError extends AppError {
statusCode = 400;
}
class ForbiddenError extends AppError {
statusCode = 403;
}
class UnauthorizedError extends AppError {
statusCode = 401;
}
class ConflictError extends AppError {
statusCode = 409;
}
```
**Async Handler** (`helpers.ts`):
@ -563,12 +599,15 @@ function requestLogger(req, res, next) {
res.setHeader('X-Request-Id', requestId);
res.on('finish', () => {
req.log.info({
method: req.method,
url: req.originalUrl,
status: res.statusCode,
duration: Date.now() - start,
}, 'Request completed');
req.log.info(
{
method: req.method,
url: req.originalUrl,
status: res.statusCode,
duration: Date.now() - start,
},
'Request completed',
);
});
}
```
@ -579,30 +618,30 @@ function requestLogger(req, res, next) {
**Environment Variables** (`config.ts`):
| Variable | Description | Default |
|----------|-------------|---------|
| `SECRET_KEY` | JWT signing key | UUID-based default |
| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` |
| `ADMIN_PASS` | Admin user password | Generated |
| `AWS_S3_BUCKET` | S3 bucket name | - |
| `AWS_S3_REGION` | S3 region | `us-east-1` |
| `AWS_ACCESS_KEY_ID` | AWS access key | - |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - |
| `MS_CLIENT_ID` | Microsoft OAuth client ID | - |
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - |
| `EMAIL_USER` | SMTP username | - |
| `EMAIL_PASS` | SMTP password | - |
| `LOG_LEVEL` | Logging level | `info` |
| Variable | Description | Default |
| ----------------------- | ----------------------------- | --------------------- |
| `SECRET_KEY` | JWT signing key | UUID-based default |
| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` |
| `ADMIN_PASS` | Admin user password | Generated |
| `AWS_S3_BUCKET` | S3 bucket name | - |
| `AWS_S3_REGION` | S3 region | `us-east-1` |
| `AWS_ACCESS_KEY_ID` | AWS access key | - |
| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - |
| `MS_CLIENT_ID` | Microsoft OAuth client ID | - |
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - |
| `EMAIL_USER` | SMTP username | - |
| `EMAIL_PASS` | SMTP password | - |
| `LOG_LEVEL` | Logging level | `info` |
**Database Configuration** (`db/db-config.ts`):
| Environment | Database | Logging |
|-------------|----------|---------|
| `production` | `DB_*` env vars | Disabled |
| `development` | `db_tour_builder_platform` | Console |
| `dev_stage` | `DB_*` env vars | Console |
| Environment | Database | Logging |
| ------------- | -------------------------- | -------- |
| `production` | `DB_*` env vars | Disabled |
| `development` | `db_tour_builder_platform` | Console |
| `dev_stage` | `DB_*` env vars | Console |
---
@ -647,23 +686,23 @@ GET /api/health
## Key Implementation Files
| File | Purpose |
|------|---------|
| `src/index.ts` | Application entry, middleware setup, route mounting |
| `src/config.ts` | Environment configuration |
| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 |
| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) |
| `src/factories/router.factory.ts` | Route generator for entities |
| `src/factories/service.factory.ts` | Service generator for entities |
| `src/db/api/base.api.ts` | GenericDBApi base class |
| `src/middlewares/check-permissions.ts` | RBAC permission checking |
| `src/middlewares/rateLimiter.ts` | Rate limiting configuration |
| `src/middlewares/runtime-context.ts` | Runtime environment detection |
| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization |
| `src/services/publish.ts` | Publishing workflow service |
| File | Purpose |
| ---------------------------------------- | --------------------------------------------------------- |
| `src/index.ts` | Application entry, middleware setup, route mounting |
| `src/config.ts` | Environment configuration |
| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 |
| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) |
| `src/factories/router.factory.ts` | Route generator for entities |
| `src/factories/service.factory.ts` | Service generator for entities |
| `src/db/api/base.api.ts` | GenericDBApi base class |
| `src/middlewares/check-permissions.ts` | RBAC permission checking |
| `src/middlewares/rateLimiter.ts` | Rate limiting configuration |
| `src/middlewares/runtime-context.ts` | Runtime environment detection |
| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization |
| `src/services/publish.ts` | Publishing workflow service |
| `src/services/file/S3StorageProvider.ts` | S3 storage implementation using official AWS SDK v3 types |
| `src/utils/logger.ts` | Pino logger configuration |
| `src/utils/errors.ts` | Error class definitions |
| `src/utils/logger.ts` | Pino logger configuration |
| `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.
**Files:**
| 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/routes/auth.ts` | REST API endpoints for authentication |
| `src/helpers.ts` | JWT signing utility (`jwtSign`) |
| `src/db/api/users.js` | User database operations (tokens, password updates) |
| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters |
| 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/routes/auth.ts` | REST API endpoints for authentication |
| `src/helpers.ts` | JWT signing utility (`jwtSign`) |
| `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.
**Configuration (auth/auth.ts):**
```javascript
passport.use(
new JWTstrategy({
passReqToCallback: true,
secretOrKey: config.secret_key,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
}, async (req, token, done) => {
const user = await UsersDBApi.findBy({ email: token.user.email });
new JWTstrategy(
{
passReqToCallback: true,
secretOrKey: config.secret_key,
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
},
async (req, token, done) => {
const user = await UsersDBApi.findBy({ email: token.user.email });
if (user && user.disabled) {
return done(new Error(`User '${user.email}' is disabled`));
}
if (user && user.disabled) {
return done(new Error(`User '${user.email}' is disabled`));
}
req.currentUser = user;
return done(null, user);
})
req.currentUser = user;
return done(null, user);
},
),
);
```
**Token Structure:**
```javascript
{
user: {
@ -135,6 +141,7 @@ passport.use(
```
**Usage:**
```javascript
// Protect route with JWT
router.get('/me', passport.authenticate('jwt', { session: false }), handler);
@ -146,23 +153,28 @@ const currentUser = req.currentUser;
### 2. Google OAuth Strategy
**Configuration (auth/auth.ts):**
```javascript
passport.use(
new GoogleStrategy({
clientID: config.google.clientId,
clientSecret: config.google.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/google/callback',
passReqToCallback: true,
}, (request, accessToken, refreshToken, profile, done) => {
socialStrategy(profile.email, profile, providers.GOOGLE, done);
})
new GoogleStrategy(
{
clientID: config.google.clientId,
clientSecret: config.google.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/google/callback',
passReqToCallback: true,
},
(request, accessToken, refreshToken, profile, done) => {
socialStrategy(profile.email, profile, providers.GOOGLE, done);
},
),
);
```
**Environment Variables:**
| Variable | Description |
|----------|-------------|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| Variable | Description |
| ---------------------- | -------------------------- |
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
**OAuth Scopes:** `profile`, `email`
@ -170,24 +182,29 @@ passport.use(
### 3. Microsoft OAuth Strategy
**Configuration (auth/auth.ts):**
```javascript
passport.use(
new MicrosoftStrategy({
clientID: config.microsoft.clientId,
clientSecret: config.microsoft.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
passReqToCallback: true,
}, (request, accessToken, refreshToken, profile, done) => {
const email = profile._json.mail || profile._json.userPrincipalName;
socialStrategy(email, profile, providers.MICROSOFT, done);
})
new MicrosoftStrategy(
{
clientID: config.microsoft.clientId,
clientSecret: config.microsoft.clientSecret,
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
passReqToCallback: true,
},
(request, accessToken, refreshToken, profile, done) => {
const email = profile._json.mail || profile._json.userPrincipalName;
socialStrategy(email, profile, providers.MICROSOFT, done);
},
),
);
```
**Environment Variables:**
| Variable | Description |
|----------|-------------|
| `MS_CLIENT_ID` | Microsoft OAuth client ID |
| Variable | Description |
| ------------------ | ----------------------------- |
| `MS_CLIENT_ID` | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret |
**OAuth Scopes:** `https://graph.microsoft.com/user.read`, `openid`
@ -222,13 +239,13 @@ Core authentication business logic.
```typescript
class Auth {
static async signin(email, password)
static async verifyEmail(token, options)
static async passwordUpdate(currentPassword, newPassword, options)
static async passwordReset(token, password, options)
static async sendEmailAddressVerificationEmail(email, host)
static async sendPasswordResetEmail(email, type, host)
static async updateProfile(data, currentUser)
static async signin(email, password);
static async verifyEmail(token, options);
static async passwordUpdate(currentPassword, newPassword, options);
static async passwordReset(token, password, options);
static async sendEmailAddressVerificationEmail(email, host);
static async sendPasswordResetEmail(email, type, host);
static async updateProfile(data, currentUser);
}
```
@ -237,6 +254,7 @@ class Auth {
Registers a new user or updates password for existing unverified user.
**Flow:**
```
1. Check if user exists by email
├── 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.
**Flow:**
```
1. Find user by email
└── Not found → Error: userNotFound
@ -278,6 +297,7 @@ Authenticates user with email and password.
Verifies user email address using token.
**Flow:**
```
1. Find user by email verification token
└── Not found or expired → Error: invalidToken
@ -290,6 +310,7 @@ Verifies user email address using token.
Updates password for authenticated user.
**Flow:**
```
1. Verify currentUser exists
└── Not authenticated → ForbiddenError
@ -305,6 +326,7 @@ Updates password for authenticated user.
Resets password using reset token.
**Flow:**
```
1. Find user by password reset token
└── Not found or expired → Error: invalidToken
@ -320,27 +342,28 @@ REST API endpoints for authentication.
#### Endpoints Overview
| Method | Path | Auth | Rate Limit | Description |
|--------|------|------|------------|-------------|
| POST | `/signin/local` | No | authLimiter | Login with email/password |
| GET | `/me` | JWT | - | Get current user |
| PUT | `/password-reset` | No | - | Reset password with token |
| PUT | `/password-update` | JWT | - | Change password |
| PUT | `/profile` | JWT | - | Update user profile |
| PUT | `/verify-email` | No | - | Verify email with token |
| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email |
| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email |
| GET | `/email-configured` | No | - | Check if email is configured |
| GET | `/signin/google` | No | - | Initiate Google OAuth |
| GET | `/signin/google/callback` | No | - | Google OAuth callback |
| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth |
| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback |
| Method | Path | Auth | Rate Limit | Description |
| ------ | ---------------------------------------- | ---- | -------------------- | ---------------------------- |
| POST | `/signin/local` | No | authLimiter | Login with email/password |
| GET | `/me` | JWT | - | Get current user |
| PUT | `/password-reset` | No | - | Reset password with token |
| PUT | `/password-update` | JWT | - | Change password |
| PUT | `/profile` | JWT | - | Update user profile |
| PUT | `/verify-email` | No | - | Verify email with token |
| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email |
| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email |
| GET | `/email-configured` | No | - | Check if email is configured |
| GET | `/signin/google` | No | - | Initiate Google OAuth |
| GET | `/signin/google/callback` | No | - | Google OAuth callback |
| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth |
| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback |
#### POST /api/auth/signin/local
Login with email and password.
**Request:**
```json
{
"email": "user@example.com",
@ -349,18 +372,20 @@ Login with email and password.
```
**Response (200):**
```json
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
```
**Errors:**
| Code | Message | Cause |
|------|---------|-------|
| 400 | `auth.userNotFound` | User doesn't exist |
| 400 | `auth.userDisabled` | User account is disabled |
| 400 | `auth.wrongPassword` | Invalid password |
| 400 | `auth.userNotVerified` | Email not verified |
| 429 | Too Many Requests | Rate limit exceeded |
| Code | Message | Cause |
| ---- | ---------------------- | ------------------------ |
| 400 | `auth.userNotFound` | User doesn't exist |
| 400 | `auth.userDisabled` | User account is disabled |
| 400 | `auth.wrongPassword` | Invalid password |
| 400 | `auth.userNotVerified` | Email not verified |
| 429 | Too Many Requests | Rate limit exceeded |
#### Self-Registration
@ -373,11 +398,13 @@ invitation/setup link.
Get current authenticated user.
**Headers:**
```
Authorization: Bearer <JWT_TOKEN>
```
**Response (200):**
```json
{
"id": "uuid",
@ -404,6 +431,7 @@ Authorization: Bearer <JWT_TOKEN>
Reset password using token from email.
**Request:**
```json
{
"token": "abc123...",
@ -412,6 +440,7 @@ Reset password using token from email.
```
**Response (200):**
```json
{ "success": true }
```
@ -421,11 +450,13 @@ Reset password using token from email.
Change password for authenticated user.
**Headers:**
```
Authorization: Bearer <JWT_TOKEN>
```
**Request:**
```json
{
"currentPassword": "oldPassword123",
@ -434,22 +465,25 @@ Authorization: Bearer <JWT_TOKEN>
```
**Errors:**
| Code | Message | Cause |
|------|---------|-------|
| 400 | `auth.wrongPassword` | Current password incorrect |
| 400 | `auth.passwordUpdate.samePassword` | New password same as old |
| 403 | Forbidden | Not authenticated |
| Code | Message | Cause |
| ---- | ---------------------------------- | -------------------------- |
| 400 | `auth.wrongPassword` | Current password incorrect |
| 400 | `auth.passwordUpdate.samePassword` | New password same as old |
| 403 | Forbidden | Not authenticated |
#### PUT /api/auth/profile
Update user profile.
**Headers:**
```
Authorization: Bearer <JWT_TOKEN>
```
**Request:**
```json
{
"profile": {
@ -463,18 +497,22 @@ Authorization: Bearer <JWT_TOKEN>
#### OAuth Endpoints
**GET /api/auth/signin/google**
- Redirects to Google OAuth consent screen
- Query param: `app` (passed as state)
**GET /api/auth/signin/google/callback**
- Handles Google OAuth callback
- Redirects to: `{uiUrl}/login?token={jwt}`
**GET /api/auth/signin/microsoft**
- Redirects to Microsoft OAuth consent screen
- Query param: `app` (passed as state)
**GET /api/auth/signin/microsoft/callback**
- Handles Microsoft OAuth callback
- Redirects to: `{uiUrl}/login?token={jwt}`
@ -495,11 +533,12 @@ static jwtSign(data) {
```
**Configuration:**
| Setting | Value | Description |
|---------|-------|-------------|
| Setting | Value | Description |
| ---------- | ------------------- | ------------------------- |
| Secret Key | `config.secret_key` | From `SECRET_KEY` env var |
| Expiration | `6h` | Token valid for 6 hours |
| Algorithm | `HS256` | Default HMAC SHA-256 |
| Expiration | `6h` | Token valid for 6 hours |
| Algorithm | `HS256` | Default HMAC SHA-256 |
---
@ -549,17 +588,19 @@ static async generateEmailVerificationToken(email, options) {
```
**Token Properties:**
| Property | Value |
|----------|-------|
| Length | 40 hex characters |
| Expiry | 24 hours |
| Storage | `emailVerificationToken` column |
| Property | Value |
| -------- | ------------------------------- |
| Length | 40 hex characters |
| Expiry | 24 hours |
| Storage | `emailVerificationToken` column |
#### Method: generatePasswordResetToken(email)
Generates secure token for password reset.
Same implementation as `generateEmailVerificationToken` but stores in:
- `passwordResetToken`
- `passwordResetTokenExpiresAt`
@ -610,11 +651,11 @@ const authLimiter = createRateLimiter({
});
```
| Setting | Value |
|---------|-------|
| Window | 15 minutes |
| Max Requests | 10 |
| Applied To | `/signin/local` |
| Setting | Value |
| ------------ | --------------- |
| Window | 15 minutes |
| Max Requests | 10 |
| Applied To | `/signin/local` |
### Signup Limiter
@ -631,11 +672,11 @@ const passwordResetLimiter = createRateLimiter({
});
```
| Setting | Value |
|---------|-------|
| Window | 1 hour |
| Max Requests | 5 |
| Applied To | `/send-password-reset-email` |
| Setting | Value |
| ------------ | ---------------------------- |
| Window | 1 hour |
| Max Requests | 5 |
| Applied To | `/send-password-reset-email` |
### Rate Limit Response
@ -648,6 +689,7 @@ const passwordResetLimiter = createRateLimiter({
```
**Headers:**
```
X-RateLimit-Limit: 10
X-RateLimit-Remaining: 0
@ -668,15 +710,16 @@ bcrypt: {
}
```
| Setting | Value | Security Impact |
|---------|-------|-----------------|
| Algorithm | bcrypt | Industry standard |
| Salt Rounds | 12 | ~200ms hash time |
| Salt | Auto-generated | Per-password unique |
| Setting | Value | Security Impact |
| ----------- | -------------- | ------------------- |
| Algorithm | bcrypt | Industry standard |
| Salt Rounds | 12 | ~200ms hash time |
| Salt | Auto-generated | Per-password unique |
### Password Validation
Passwords are:
1. Hashed before storage (never stored in plain text)
2. Compared using `bcrypt.compare()` (timing-attack safe)
3. Required for local authentication
@ -696,6 +739,7 @@ if (EmailSender.isConfigured) {
```
When email is NOT configured:
- Signup succeeds without verification email
- Users are auto-verified on signin
- Password reset emails not sent
@ -705,6 +749,7 @@ When email is NOT configured:
**Email Class:** `EmailAddressVerificationEmail`
**Link Format:**
```
{host}/verify-email?token={token}
```
@ -712,10 +757,12 @@ When email is NOT configured:
### Password Reset Email
**Email Classes:**
- `PasswordResetEmail` - Standard reset
- `InvitationEmail` - New user invitation
**Link Format:**
```
{host}/password-reset?token={token}
```
@ -726,16 +773,16 @@ When email is NOT configured:
### Environment Variables
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret |
| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret |
| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret |
| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email |
| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password |
| `USER_PASS` | No | `c3baadeda5c6` | Default user password |
| Variable | Required | Default | Description |
| ---------------------- | -------- | -------------------------------------- | ----------------------------- |
| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret |
| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret |
| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret |
| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email |
| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password |
| `USER_PASS` | No | `c3baadeda5c6` | Default user password |
### config.ts Settings
@ -862,18 +909,18 @@ When email is NOT configured:
## Error Codes
| Error Key | HTTP Status | Description |
|-----------|-------------|-------------|
| `auth.userNotFound` | 400 | User with email doesn't exist |
| `auth.userDisabled` | 400 | User account is disabled |
| `auth.wrongPassword` | 400 | Password doesn't match |
| `auth.userNotVerified` | 400 | Email not verified |
| `auth.emailAlreadyInUse` | 400 | Email already registered |
| `auth.passwordUpdate.samePassword` | 400 | New password same as current |
| `auth.passwordReset.error` | 400 | Token generation failed |
| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token |
| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed |
| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token |
| Error Key | HTTP Status | Description |
| ------------------------------------------------- | ----------- | ------------------------------ |
| `auth.userNotFound` | 400 | User with email doesn't exist |
| `auth.userDisabled` | 400 | User account is disabled |
| `auth.wrongPassword` | 400 | Password doesn't match |
| `auth.userNotVerified` | 400 | Email not verified |
| `auth.emailAlreadyInUse` | 400 | Email already registered |
| `auth.passwordUpdate.samePassword` | 400 | New password same as current |
| `auth.passwordReset.error` | 400 | Token generation failed |
| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token |
| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed |
| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token |
---
@ -913,18 +960,18 @@ Use the authenticated Users API/UI to create invited users.
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `passport` | ^0.6.0 | Authentication middleware |
| `passport-jwt` | ^4.0.0 | JWT strategy for Passport |
| `passport-google-oauth2` | ^0.2.0 | Google 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-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 |
| `jsonwebtoken` | ^9.0.0 | JWT sign/verify |
| `bcrypt` | ^5.1.0 | Password hashing |
| `crypto` | built-in | Token generation |
| Package | Version | Purpose |
| ------------------------------- | -------- | -------------------------------------------------------------------- |
| `passport` | ^0.6.0 | Authentication middleware |
| `passport-jwt` | ^4.0.0 | JWT strategy for Passport |
| `passport-google-oauth2` | ^0.2.0 | Google 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-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 |
| `jsonwebtoken` | ^9.0.0 | JWT sign/verify |
| `bcrypt` | ^5.1.0 | Password hashing |
| `crypto` | built-in | Token generation |
---

View File

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

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)
| File | Class/Purpose | LOC | Extends GenericDBApi |
|------|---------------|-----|---------------------|
| `base.api.ts` | `GenericDBApi` - Base class | 726 | - |
| `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) |
| `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes |
| `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes |
| `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes |
| `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes |
| `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes |
| `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes |
| `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes |
| `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes |
| `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes |
| `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | 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_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 |
| `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes |
| `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes |
| `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes |
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
| `runtime-context.ts` | Runtime context helpers | 57 | - |
| File | Class/Purpose | LOC | Extends GenericDBApi |
| -------------------------------- | ---------------------------------------------------------------- | ---- | -------------------- |
| `base.api.ts` | `GenericDBApi` - Base class | 726 | - |
| `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) |
| `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes |
| `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes |
| `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes |
| `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes |
| `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes |
| `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes |
| `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes |
| `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes |
| `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes |
| `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | 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_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 |
| `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes |
| `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes |
| `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes |
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
| `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)
| Getter | Type | Default | Description |
|--------|------|---------|-------------|
| `MODEL` | Model | (required) | Sequelize model reference |
| `TABLE_NAME` | string | From MODEL | Database table name |
| `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search |
| `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) |
| `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering |
| `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) |
| `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs |
| `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters |
| `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() |
| `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() |
| `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export |
| `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete |
| `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify |
| `FIELD_DEFAULTS` | object | `{}` | Default values for fields |
| `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations |
| Getter | Type | Default | Description |
| -------------------- | -------- | --------------------- | ------------------------------------------------ |
| `MODEL` | Model | (required) | Sequelize model reference |
| `TABLE_NAME` | string | From MODEL | Database table name |
| `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search |
| `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) |
| `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering |
| `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) |
| `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs |
| `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters |
| `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() |
| `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() |
| `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export |
| `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete |
| `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify |
| `FIELD_DEFAULTS` | object | `{}` | Default values for fields |
| `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations |
### Methods
@ -320,14 +320,14 @@ static async findAll(filter = {}, options = {}) {
#### Other Methods
| Method | Description |
|--------|-------------|
| `bulkImport(data, options)` | Bulk create with timestamps offset |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records |
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record |
| `findBy(where, options)` | Find single record by criteria |
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
| `toCSV(rows)` | Convert rows to CSV string |
| Method | Description |
| ---------------------------------------------------------------- | ---------------------------------- |
| `bulkImport(data, options)` | Bulk create with timestamps offset |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records |
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record |
| `findBy(where, options)` | Find single record by criteria |
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
| `toCSV(rows)` | Convert rows to CSV string |
---
@ -421,13 +421,25 @@ Extend `GenericDBApi` with minimal configuration. Only override static getters a
```typescript
class PermissionsDBApi extends GenericDBApi {
static override get MODEL(): unknown { return db.permissions; }
static override get TABLE_NAME(): string { return 'permissions'; }
static override get SEARCHABLE_FIELDS(): string[] { return ['name']; }
static override get CSV_FIELDS(): string[] { return ['id', 'name', 'createdAt']; }
static override get AUTOCOMPLETE_FIELD(): string { return 'name'; }
static override get MODEL(): unknown {
return db.permissions;
}
static override get TABLE_NAME(): string {
return 'permissions';
}
static override get SEARCHABLE_FIELDS(): string[] {
return ['name'];
}
static override get 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 {
id: data.id || undefined,
name: data.name || null,
@ -437,6 +449,7 @@ class PermissionsDBApi extends GenericDBApi {
```
**Entities using this pattern:**
- `PermissionsDBApi`
- `AssetsDBApi`
- `Asset_variantsDBApi`
@ -542,6 +555,7 @@ class ProjectsDBApi extends GenericDBApi {
```
**Entities using this pattern:**
- `Tour_pagesDBApi` - Environment filtering via `applyRuntimeEnvironment()`
- `ProjectsDBApi` - Slug filtering with ID bypass and auto-snapshot on create
- `Project_audio_tracksDBApi` - Environment filtering
@ -642,6 +656,7 @@ Don't extend `GenericDBApi` due to significantly different requirements.
**Example: UsersDBApi**
Complex user management with:
- Password hashing (bcrypt)
- File avatar handling
- Token generation for email verification and password reset
@ -651,13 +666,16 @@ Complex user management with:
class UsersDBApi {
static async create(options: UserCreateOptions): Promise<UserRecord> {
const { data, currentUser = { id: null }, transaction } = options;
const users = await db.users.create({
firstName: data.firstName || null,
lastName: data.lastName || null,
email: data.email || null,
password: data.password || null, // Already hashed by service
// ...
}, { transaction });
const users = await db.users.create(
{
firstName: data.firstName || null,
lastName: data.lastName || null,
email: data.email || null,
password: data.password || null, // Already hashed by service
// ...
},
{ transaction },
);
// Auto-assign default role
if (!data.app_role) {
@ -698,9 +716,11 @@ class UsersDBApi {
}
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 tokenExpiresAt = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
const tokenExpiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
await users.update({
[keyNames[0]]: token,
@ -719,7 +739,7 @@ class UsersDBApi {
},
});
}
};
}
```
**Example: FileDBApi**
@ -733,7 +753,11 @@ export default class FileDBApi {
assert(relation.belongsToColumn);
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._addFiles(relation, files, options);
@ -743,14 +767,17 @@ export default class FileDBApi {
const inexistentFiles = files.filter((file) => !!file.new);
for (const file of inexistentFiles) {
await db.file.create({
belongsTo: relation.belongsTo,
belongsToColumn: relation.belongsToColumn,
belongsToId: relation.belongsToId,
name: file.name,
publicUrl: file.publicUrl,
privateUrl: file.privateUrl,
}, { transaction: options.transaction });
await db.file.create(
{
belongsTo: relation.belongsTo,
belongsToColumn: relation.belongsToColumn,
belongsToId: relation.belongsToId,
name: file.name,
publicUrl: file.publicUrl,
privateUrl: file.privateUrl,
},
{ transaction: options.transaction },
);
}
}
@ -769,7 +796,7 @@ export default class FileDBApi {
await file.destroy({ transaction: options.transaction });
}
}
};
}
```
---
@ -875,14 +902,15 @@ module.exports = class Utils {
**UUID Utility Functions:**
| Function | Purpose | Returns |
|----------|---------|---------|
| `isValidUuid(value)` | Check if valid UUID | `boolean` |
| `generateUuid()` | Create new UUID v4 | `string` |
| `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` |
| `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause |
| Function | Purpose | Returns |
| ----------------------------- | -------------------------------- | ---------------------- |
| `isValidUuid(value)` | Check if valid UUID | `boolean` |
| `generateUuid()` | Create new UUID v4 | `string` |
| `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` |
| `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause |
**UUID Validation Behavior:**
- 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 field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
@ -895,8 +923,12 @@ module.exports = class Utils {
```javascript
class AssetsDBApi extends GenericDBApi {
static get MODEL() { return db.assets; }
static get TABLE_NAME() { return 'assets'; }
static get MODEL() {
return db.assets;
}
static get TABLE_NAME() {
return 'assets';
}
static get SEARCHABLE_FIELDS() {
return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
@ -928,7 +960,12 @@ class AssetsDBApi extends GenericDBApi {
static get RELATION_FILTERS() {
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:**
1. **Direct field mapping** (preferred for programmatic use):
```javascript
// In getFieldMapping()
static getFieldMapping(data) {
@ -973,6 +1011,7 @@ await Asset_variantsDBApi.create({ assetId: asset.id, ... });
```
2. **Via ASSOCIATIONS setter** (used by frontend forms):
```javascript
// ASSOCIATIONS config uses 'asset' (relation name)
static get ASSOCIATIONS() {
@ -996,28 +1035,30 @@ undefined source instance and fail before the foreign key can be saved.
```typescript
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[] {
return [{ field: 'permissions', setter: 'setPermissions', isArray: true }];
}
static override get FIND_BY_INCLUDES(): unknown[] {
return [
{ association: 'users_app_role' },
{ association: 'permissions' },
];
return [{ association: 'users_app_role' }, { association: 'permissions' }];
}
static override get FIND_ALL_INCLUDES(): unknown[] {
return [
{ model: db.permissions, as: 'permissions', required: false },
];
return [{ model: db.permissions, as: 'permissions', required: false }];
}
static get RELATION_FILTERS() {
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 | Pattern | Getters | Custom Methods | Notes |
|-----|---------|---------|----------------|-------|
| `PermissionsDBApi` | Simple | 6 | 0 | Minimal config |
| `AssetsDBApi` | Simple | 9 | 0 | With associations |
| `RolesDBApi` | Simple | 10 | 0 | M:N permissions |
| `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups |
| `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering |
| `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering |
| `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding |
| `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff |
| `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files |
| `FileDBApi` | Fully custom | - | 3 | Polymorphic files |
| API | Pattern | Getters | Custom Methods | Notes |
| ------------------------------- | ------------- | ------- | -------------- | ----------------------------------------------------------- |
| `PermissionsDBApi` | Simple | 6 | 0 | Minimal config |
| `AssetsDBApi` | Simple | 9 | 0 | With associations |
| `RolesDBApi` | Simple | 10 | 0 | M:N permissions |
| `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups |
| `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering |
| `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering |
| `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding |
| `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff |
| `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, 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/`
**Key Files:**
- `db-config.ts` - Typed ESM database connection settings per environment
- `umzug.ts` - Typed Umzug runner for migrations, seeders, create/drop
- `utils.ts` - Database utility functions
@ -14,6 +15,7 @@ The DB Config module manages database connection settings, environment validatio
- `reset.ts` - Database reset script
**Related Files:**
- `backend/src/config.ts` - Application configuration
- `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
| Setting | Production | Development | Dev Stage |
|---------|------------|-------------|-----------|
| **Dialect** | postgres | postgres | postgres |
| **Credentials** | Env vars | Hardcoded | Env vars |
| **Logging** | Disabled | Pino debug | Pino debug |
| **Host** | Env var | localhost | Env var |
| Setting | Production | Development | Dev Stage |
| --------------------- | ------------- | ------------- | ------------- |
| **Dialect** | postgres | postgres | postgres |
| **Credentials** | Env vars | Hardcoded | Env vars |
| **Logging** | Disabled | Pino debug | Pino debug |
| **Host** | Env var | localhost | Env var |
| **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
| Variable | Required | Default | Description |
|----------|----------|---------|-------------|
| `NODE_ENV` | No | development | Environment selection |
| `DB_HOST` | Prod/Stage | localhost | Database host |
| `DB_PORT` | Prod/Stage | 5432 | Database port |
| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name |
| `DB_USER` | Prod/Stage | postgres | Database username |
| `DB_PASS` | Prod/Stage | (empty) | Database password |
| Variable | Required | Default | Description |
| ---------- | ---------- | ------------------------ | --------------------- |
| `NODE_ENV` | No | development | Environment selection |
| `DB_HOST` | Prod/Stage | localhost | Database host |
| `DB_PORT` | Prod/Stage | 5432 | Database port |
| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name |
| `DB_USER` | Prod/Stage | postgres | Database username |
| `DB_PASS` | Prod/Stage | (empty) | Database password |
### Environment Validation
@ -112,47 +114,47 @@ const envSchema = Joi.object({
### Complete Environment Variable Schema
| Category | Variable | Validation | Default |
|----------|----------|------------|---------|
| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development |
| | PORT | number | 8080 |
| **Database** | DB_HOST | string | localhost |
| | DB_PORT | number | 5432 |
| | DB_NAME | string | db_tour_builder_platform |
| | DB_USER | string | postgres |
| | DB_PASS | string (allow empty) | (empty) |
| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) |
| | ADMIN_PASS | string | 88dbeaf8 |
| | USER_PASS | string | c3baadeda5c6 |
| | ADMIN_EMAIL | email | admin@flatlogic.com |
| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) |
| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) |
| | MS_CLIENT_ID | string (allow empty) | (empty) |
| | MS_CLIENT_SECRET | string (allow empty) | (empty) |
| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) |
| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) |
| | AWS_S3_BUCKET | string (allow empty) | (empty) |
| | AWS_S3_REGION | string | us-east-1 |
| | AWS_S3_PREFIX | string | (default hash) |
| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 |
| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 |
| | AWS_S3_MAX_ATTEMPTS | number | 3 |
| | AWS_S3_MAX_SOCKETS | number | 50 |
| | AWS_S3_KEEP_ALIVE | boolean string | true |
| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 |
| **Email** | EMAIL_USER | string (allow empty) | (empty) |
| | EMAIL_PASS | string (allow empty) | (empty) |
| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true |
| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) |
| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info |
| Category | Variable | Validation | Default |
| ----------------- | ----------------------------- | ---------------------------------------------- | ------------------------ |
| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development |
| | PORT | number | 8080 |
| **Database** | DB_HOST | string | localhost |
| | DB_PORT | number | 5432 |
| | DB_NAME | string | db_tour_builder_platform |
| | DB_USER | string | postgres |
| | DB_PASS | string (allow empty) | (empty) |
| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) |
| | ADMIN_PASS | string | 88dbeaf8 |
| | USER_PASS | string | c3baadeda5c6 |
| | ADMIN_EMAIL | email | admin@flatlogic.com |
| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) |
| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) |
| | MS_CLIENT_ID | string (allow empty) | (empty) |
| | MS_CLIENT_SECRET | string (allow empty) | (empty) |
| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) |
| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) |
| | AWS_S3_BUCKET | string (allow empty) | (empty) |
| | AWS_S3_REGION | string | us-east-1 |
| | AWS_S3_PREFIX | string | (default hash) |
| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 |
| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 |
| | AWS_S3_MAX_ATTEMPTS | number | 3 |
| | AWS_S3_MAX_SOCKETS | number | 50 |
| | AWS_S3_KEEP_ALIVE | boolean string | true |
| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 |
| **Email** | EMAIL_USER | string (allow empty) | (empty) |
| | EMAIL_PASS | string (allow empty) | (empty) |
| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true |
| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) |
| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info |
### Validation Behavior
```javascript
function validateEnv() {
const { error, value } = envSchema.validate(process.env, {
abortEarly: false, // Report all errors, not just first
stripUnknown: false, // Keep unknown env vars
abortEarly: false, // Report all errors, not just first
stripUnknown: false, // Keep unknown env vars
});
if (error) {
@ -160,7 +162,7 @@ function validateEnv() {
logger.error({ errors: messages }, 'Environment validation failed');
if (process.env.NODE_ENV === 'production') {
process.exit(1); // Fatal in production
process.exit(1); // Fatal in production
} else {
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
| Setting | Path |
|---------|------|
| Config | `src/db/db-config.ts` |
| Runner | `src/db/umzug.ts` |
| Models | `src/db/models/` |
| Seeders | `src/db/seeders/` |
| Migrations | `src/db/migrations/` |
| Setting | Path |
| ---------- | --------------------- |
| Config | `src/db/db-config.ts` |
| Runner | `src/db/umzug.ts` |
| Models | `src/db/models/` |
| Seeders | `src/db/seeders/` |
| 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';
// UUID validation
Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true
Utils.isValidUuid('not-a-uuid'); // false
Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true
Utils.isValidUuid('not-a-uuid'); // false
// 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
const validIds = Utils.filterValidUuids(['uuid1', 'invalid', 'uuid2']);
@ -260,7 +262,7 @@ const where = {
Utils.ilike('users', 'firstName', searchTerm),
Utils.ilike('users', 'lastName', searchTerm),
Utils.ilike('users', 'email', searchTerm),
]
],
};
```
@ -276,7 +278,9 @@ Synchronizes models to database schema using Sequelize's `alter` mode.
async function syncDatabase() {
// Safety check - never run in 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);
}
@ -293,17 +297,18 @@ async function syncDatabase() {
```
**Usage:**
```bash
node src/db/sync.ts
```
**Sync Modes:**
| Mode | Description | Use Case |
|------|-------------|----------|
| `{ force: true }` | Drop and recreate all tables | Fresh start |
| `{ alter: true }` | Modify tables to match models | Development |
| (none) | Create only missing tables | Safe default |
| Mode | Description | Use Case |
| ----------------- | ----------------------------- | ------------ |
| `{ force: true }` | Drop and recreate all tables | Fresh start |
| `{ alter: true }` | Modify tables to match models | Development |
| (none) | Create only missing tables | Safe default |
### reset.ts
@ -324,6 +329,7 @@ db.sequelize
```
**Usage:**
```bash
node src/db/reset.ts
```
@ -397,45 +403,46 @@ const config = {
### Storage Configuration
| Provider | Variables | Purpose |
|----------|-----------|---------|
| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage |
| **GCloud** | (hardcoded bucket) | Legacy support |
| **Local** | uploadDir (os.tmpdir()) | Development fallback |
| Provider | Variables | Purpose |
| ---------- | ---------------------------------------------------------------------- | -------------------- |
| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage |
| **GCloud** | (hardcoded bucket) | Legacy support |
| **Local** | uploadDir (os.tmpdir()) | Development fallback |
### S3 Performance Tuning
| Variable | Default | Description |
|----------|---------|-------------|
| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout |
| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout |
| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure |
| AWS_S3_MAX_SOCKETS | 50 | Connection pool size |
| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections |
| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) |
| Variable | Default | Description |
| ------------------------- | ------- | ------------------------------- |
| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout |
| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout |
| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure |
| AWS_S3_MAX_SOCKETS | 50 | Connection pool size |
| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections |
| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) |
### Security Configuration
| Setting | Value | Purpose |
|---------|-------|---------|
| bcrypt.saltRounds | 12 | Password hashing strength |
| SECRET_KEY | 16+ char string | JWT signing key |
| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation |
| Setting | Value | Purpose |
| ----------------------------- | --------------- | -------------------------- |
| bcrypt.saltRounds | 12 | Password hashing strength |
| SECRET_KEY | 16+ char string | JWT signing key |
| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation |
### URL Configuration
| URL | Development | Production |
|-----|-------------|------------|
| apiUrl | http://localhost:3000/api | (remote)/api |
| swaggerUrl | http://localhost:3000 | (remote) |
| uiUrl | http://localhost:3001/# | (remote)/# |
| backUrl | http://localhost:3001 | (remote) |
| URL | Development | Production |
| ---------- | ------------------------- | ------------ |
| apiUrl | http://localhost:3000/api | (remote)/api |
| swaggerUrl | http://localhost:3000 | (remote) |
| uiUrl | http://localhost:3001/# | (remote)/# |
| backUrl | http://localhost:3001 | (remote) |
---
## Running Commands
### Development
```bash
cd backend
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`.
### VM / Dev Stage
```bash
cd backend
npm run start
@ -465,12 +473,14 @@ flow loads `.env` through `src/load-env.ts` and defaults missing `NODE_ENV` to
## Best Practices
### 1. Never Commit Secrets
```bash
# .env file should be in .gitignore
# Use environment variables in deployment
```
### 2. Use Migrations in Production
```javascript
// Never use sync.ts or reset.ts in production
if (process.env.NODE_ENV === 'production') {
@ -479,13 +489,15 @@ if (process.env.NODE_ENV === 'production') {
```
### 3. Validate Environment Early
```javascript
// config.ts loads validation at import time
import { validateEnv } from './utils/env-validation.ts';
validateEnv(); // Called before app starts
validateEnv(); // Called before app starts
```
### 4. Environment-Specific Logging
```javascript
// Production: logging disabled (performance)
// 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
Umzug types, `SequelizeStorage`, and the existing storage tables:
| Flow | Files | Storage Table | Stored Names |
|------|-------|---------------|--------------|
| 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 |
| Flow | Files | Storage Table | Stored Names |
| ---------- | -------------------------------------------------------------------- | --------------- | ------------------- |
| 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 |
Seeder files are typed ESM source, and the runner stores stable execution names
so already executed seeders are not treated as pending.
### NPM Scripts
```bash
# Run pending migrations
npm run db:migrate
@ -99,6 +100,7 @@ npm run db:seed
## Migration File Structure
### Standard Template
```javascript
'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.
### Naming Convention
```
YYYYMMDDHHMMSS-descriptive-name.js
@ -138,6 +141,7 @@ Examples:
## Migration Patterns
### 1. Transaction Wrapper Pattern
**Purpose:** Ensure atomic operations - all changes succeed or all fail.
```javascript
@ -164,6 +168,7 @@ module.exports = {
---
### 2. Idempotent Check Pattern
**Purpose:** Safely re-run migrations without errors.
```javascript
@ -188,6 +193,7 @@ await queryInterface.addColumn('tableName', 'columnName', { ... });
---
### 3. Helper Function Pattern
**Purpose:** Reduce repetition for bulk operations.
```javascript
@ -196,14 +202,19 @@ module.exports = {
const transaction = await queryInterface.sequelize.transaction();
// Define reusable helper
const addForeignKey = async (tableName, columnName, references, onDelete) => {
const addForeignKey = async (
tableName,
columnName,
references,
onDelete,
) => {
const constraintName = `${tableName}_${columnName}_fkey`;
// Check existence
const [results] = await queryInterface.sequelize.query(
`SELECT constraint_name FROM information_schema.table_constraints
WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`,
{ transaction }
{ transaction },
);
if (results.length === 0) {
@ -221,8 +232,18 @@ module.exports = {
};
// Use helper multiple times
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
await addForeignKey('tour_pages', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
await addForeignKey(
'assets',
'projectId',
{ table: 'projects', field: 'id' },
'CASCADE',
);
await addForeignKey(
'tour_pages',
'projectId',
{ table: 'projects', field: 'id' },
'CASCADE',
);
// ... more FKs
},
};
@ -233,6 +254,7 @@ module.exports = {
---
### 4. Safe Table Drop Pattern
**Purpose:** Prevent accidental data loss when dropping tables.
```javascript
@ -270,6 +292,7 @@ module.exports = {
---
### 5. ENUM to TEXT Conversion Pattern
**Purpose:** Convert restrictive ENUMs to flexible TEXT while preserving data.
```javascript
@ -279,33 +302,45 @@ module.exports = {
try {
// 1. Create temporary TEXT column
await queryInterface.addColumn('table', 'column_text', {
type: Sequelize.TEXT,
allowNull: true,
}, { transaction });
await queryInterface.addColumn(
'table',
'column_text',
{
type: Sequelize.TEXT,
allowNull: true,
},
{ transaction },
);
// 2. Copy ENUM values to TEXT
await queryInterface.sequelize.query(
`UPDATE table SET column_text = column::TEXT`,
{ transaction }
{ transaction },
);
// 3. Drop old ENUM column
await queryInterface.removeColumn('table', 'column', { transaction });
// 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
await queryInterface.changeColumn('table', 'column', {
type: Sequelize.TEXT,
allowNull: false,
}, { transaction });
await queryInterface.changeColumn(
'table',
'column',
{
type: Sequelize.TEXT,
allowNull: false,
},
{ transaction },
);
// 6. Drop ENUM type
await queryInterface.sequelize.query(
`DROP TYPE IF EXISTS "enum_table_column"`,
{ transaction }
{ transaction },
);
await transaction.commit();
@ -330,6 +365,7 @@ module.exports = {
---
### 6. Data Backfill Pattern
**Purpose:** Populate new tables/columns with data from existing records.
```javascript
@ -383,6 +419,7 @@ module.exports = {
---
### 7. Cross-Environment Data Copy Pattern
**Purpose:** Copy content between environments (dev → stage → production).
```javascript
@ -390,14 +427,14 @@ module.exports = {
async up(queryInterface, Sequelize) {
const projects = await queryInterface.sequelize.query(
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
{ type: Sequelize.QueryTypes.SELECT },
);
for (const project of projects) {
// Check if target environment already has content
const [stageCheck] = await queryInterface.sequelize.query(
`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;
@ -433,7 +470,7 @@ module.exports = {
async down(queryInterface) {
// Delete records with source_key (created by migration)
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
**Purpose:** Transform data stored in JSON columns.
```javascript
@ -456,24 +494,27 @@ module.exports = {
const [records] = await queryInterface.sequelize.query(
`SELECT id, "projectId", environment, slug, json_column
FROM table_name WHERE json_column IS NOT NULL`,
{ transaction }
{ transaction },
);
// Build lookup maps for ID → slug transformations
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
for (const record of records) {
const jsonData = typeof record.json_column === 'string'
? JSON.parse(record.json_column)
: record.json_column;
const jsonData =
typeof record.json_column === 'string'
? JSON.parse(record.json_column)
: record.json_column;
let hasChanges = false;
// Transform JSON structure
if (jsonData.elements) {
jsonData.elements.forEach(element => {
jsonData.elements.forEach((element) => {
if (element.targetPageId) {
const target = slugById.get(element.targetPageId);
if (target) {
@ -490,8 +531,8 @@ module.exports = {
`UPDATE table_name SET json_column = :json WHERE id = :id`,
{
replacements: { json: JSON.stringify(jsonData), id: record.id },
transaction
}
transaction,
},
);
}
}
@ -510,6 +551,7 @@ module.exports = {
---
### 9. Constraint Enforcement Pattern
**Purpose:** Add NOT NULL constraints after fixing existing NULL values.
```javascript
@ -517,7 +559,7 @@ module.exports = {
async up(queryInterface) {
// First, fix any NULL values
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
@ -543,6 +585,7 @@ module.exports = {
---
### 10. Safe Down Migration Pattern
**Purpose:** Handle cases where down migration isn't meaningful.
```javascript
@ -554,7 +597,9 @@ module.exports = {
async down(_queryInterface, _Sequelize) {
// 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
### Schema Changes
| Migration | Description |
|-----------|-------------|
| `add-foreign-key-constraints` | Add FK constraints to all model associations |
| `create-project-element-defaults` | Create new table with indexes |
| `drop-page-elements-table` | Drop unused table |
| `drop-page-links-table` | Drop unused table |
| `drop-transitions-table` | Drop unused table |
| Migration | Description |
| --------------------------------- | -------------------------------------------- |
| `add-foreign-key-constraints` | Add FK constraints to all model associations |
| `create-project-element-defaults` | Create new table with indexes |
| `drop-page-elements-table` | Drop unused table |
| `drop-page-links-table` | Drop unused table |
| `drop-transitions-table` | Drop unused table |
### Column Modifications
| Migration | Description |
|-----------|-------------|
| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` |
| `remove-project-phase-column` | Remove redundant `phase` column |
| `remove-entry-page-slug-column` | Remove unused column |
| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility |
| `enforce-environment-not-null` | Add NOT NULL constraint |
| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` |
| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages |
| `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 |
| Migration | Description |
| ------------------------------------------- | --------------------------------------------------------------------------------- |
| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` |
| `remove-project-phase-column` | Remove redundant `phase` column |
| `remove-entry-page-slug-column` | Remove unused column |
| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility |
| `enforce-environment-not-null` | Add NOT NULL constraint |
| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` |
| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages |
| `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
| Migration | Description |
|-----------|-------------|
| Migration | Description |
| --------------------------------------------- | ------------------ |
| `rename-ui-elements-to-element-type-defaults` | Rename for clarity |
### Data Migrations
| Migration | Description |
|-----------|-------------|
| `backfill-project-element-defaults` | Populate new table for existing projects |
| `copy-dev-to-stage` | Initialize stage environment |
| `convert-targetpageid-to-slug` | Transform JSON navigation references |
| `fix-project-audio-tracks-environment` | Fix environment values |
| `add-missing-element-type-defaults` | Insert missing default rows |
| `sync-all-element-type-defaults` | Full sync of all 11 element types |
| Migration | Description |
| ---------------------------------------- | ---------------------------------------------------------- |
| `backfill-project-element-defaults` | Populate new table for existing projects |
| `copy-dev-to-stage` | Initialize stage environment |
| `convert-targetpageid-to-slug` | Transform JSON navigation references |
| `fix-project-audio-tracks-environment` | Fix environment values |
| `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 |
| `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
| Strategy | When to Use | Example |
|----------|-------------|---------|
| `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` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` |
| Strategy | When to Use | Example |
| ------------------------------ | -------------------------------- | ------------------------------------------------ |
| `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` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` |
```javascript
// 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
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
### 1. Always Use Transactions
```javascript
const transaction = await queryInterface.sequelize.transaction();
try {
@ -639,20 +699,23 @@ try {
```
### 2. Check Before Modify
```javascript
// Always check existence before adding/removing
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
```javascript
console.log(`Migrating project ${projectId}: ${addedCount} records added`);
console.log('Migration complete: All foreign keys added');
```
### 4. Safe Drops
```javascript
// Never drop non-empty tables silently
if (count > 0) {
@ -661,6 +724,7 @@ if (count > 0) {
```
### 5. Reversible Operations
```javascript
// Down migration should restore previous state
async down(queryInterface, Sequelize) {
@ -671,16 +735,17 @@ async down(queryInterface, Sequelize) {
```
### 6. Use Parameterized Queries
```javascript
// Good - prevents SQL injection
await queryInterface.sequelize.query(
`UPDATE table SET column = :value WHERE id = :id`,
{ replacements: { value: 'safe', id: record.id } }
{ replacements: { value: 'safe', id: record.id } },
);
// Avoid - SQL injection risk
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
### Development
```bash
cd backend
npm run db:migrate
```
### Server Startup
Migrations run automatically via `npm start`:
```json
{
"scripts": {
@ -705,11 +773,13 @@ Migrations run automatically via `npm start`:
```
### Migration Status
```bash
npm run db:migrate:status
```
### Undo Migrations
```bash
# Undo last migration
npm run db:migrate:undo
@ -728,32 +798,32 @@ explicit rollback/backup plan.
## Current Migration Inventory
| # | Timestamp | Name | Type |
|---|-----------|------|------|
| 1 | 20260319000001 | add-foreign-key-constraints | Schema |
| 2 | 20260319000002 | remove-redundant-deletion-columns | Column |
| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename |
| 4 | 20260326000002 | convert-element-type-enum-to-text | Column |
| 5 | 20260326000003 | create-project-element-defaults | Schema |
| 6 | 20260326000004 | backfill-project-element-defaults | Data |
| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data |
| 8 | 20260326000006 | copy-dev-to-stage | Data |
| 9 | 20260326043002 | enforce-environment-not-null | Column |
| 10 | 20260326050442 | remove-project-phase-column | Column |
| 11 | 20260326054410 | remove-entry-page-slug-column | Column |
| 12 | 20260326060000 | convert-targetpageid-to-slug | Data |
| 13 | 20260326060001 | drop-page-elements-table | Schema |
| 14 | 20260326060002 | drop-page-links-table | Schema |
| 15 | 20260326060003 | drop-transitions-table | Schema |
| 16 | 20260326171017 | add-missing-element-type-defaults | Data |
| 17 | 20260327000001 | sync-all-element-type-defaults | Data |
| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column |
| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data |
| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data |
| 21 | 20260403000001 | add-background-video-settings | Column |
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column |
| 24 | 20260605000001 | add-background-audio-settings | Column |
| # | Timestamp | Name | Type |
| --- | -------------- | ------------------------------------------- | ------ |
| 1 | 20260319000001 | add-foreign-key-constraints | Schema |
| 2 | 20260319000002 | remove-redundant-deletion-columns | Column |
| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename |
| 4 | 20260326000002 | convert-element-type-enum-to-text | Column |
| 5 | 20260326000003 | create-project-element-defaults | Schema |
| 6 | 20260326000004 | backfill-project-element-defaults | Data |
| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data |
| 8 | 20260326000006 | copy-dev-to-stage | Data |
| 9 | 20260326043002 | enforce-environment-not-null | Column |
| 10 | 20260326050442 | remove-project-phase-column | Column |
| 11 | 20260326054410 | remove-entry-page-slug-column | Column |
| 12 | 20260326060000 | convert-targetpageid-to-slug | Data |
| 13 | 20260326060001 | drop-page-elements-table | Schema |
| 14 | 20260326060002 | drop-page-links-table | Schema |
| 15 | 20260326060003 | drop-transitions-table | Schema |
| 16 | 20260326171017 | add-missing-element-type-defaults | Data |
| 17 | 20260327000001 | sync-all-element-type-defaults | Data |
| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column |
| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data |
| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data |
| 21 | 20260403000001 | add-background-video-settings | Column |
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | 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
typed ESM source file. There is no model-level CommonJS compatibility facade.
| File | Model | Purpose | LOC |
|------|-------|---------|-----|
| `index.ts` | - | ESM entrypoint re-exporting `loader.ts` | 1 |
| `loader.ts` | - | Typed model registry and Sequelize initialization | 128 |
| `users.ts` + `.js` bridge | `users` | User accounts with authentication | 246 |
| `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 |
| `tour_pages.ts` + `.js` bridge | `tour_pages` | Individual tour pages with UI schema | 131 |
| `assets.ts` + `.js` bridge | `assets` | Uploaded media files | 169 |
| `asset_variants.ts` + `.js` bridge | `asset_variants` | Asset size/format variants | 103 |
| `roles.ts` + `roles.js` bridge | `roles` | RBAC roles | 85 |
| `permissions.ts` + `permissions.js` bridge | `permissions` | RBAC permissions | 52 |
| `project_memberships.ts` + `.js` bridge | `project_memberships` | User-project access | 89 |
| `publish_events.ts` + `.js` bridge | `publish_events` | Publishing history | 148 |
| `pwa_caches.ts` + `.js` bridge | `pwa_caches` | PWA offline cache manifests | 84 |
| `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 |
| `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_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_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 |
| `presigned_url_requests.ts` + `.js` bridge | `presigned_url_requests` | S3 presigned URL audit | 118 |
| `file.ts` + `.js` bridge | `file` | Generic file attachments | 53 |
| File | Model | Purpose | LOC |
| -------------------------------------------------- | -------------------------------- | ------------------------------------------------------------- | --- |
| `index.ts` | - | ESM entrypoint re-exporting `loader.ts` | 1 |
| `loader.ts` | - | Typed model registry and Sequelize initialization | 128 |
| `users.ts` + `.js` bridge | `users` | User accounts with authentication | 246 |
| `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 |
| `tour_pages.ts` + `.js` bridge | `tour_pages` | Individual tour pages with UI schema | 131 |
| `assets.ts` + `.js` bridge | `assets` | Uploaded media files | 169 |
| `asset_variants.ts` + `.js` bridge | `asset_variants` | Asset size/format variants | 103 |
| `roles.ts` + `roles.js` bridge | `roles` | RBAC roles | 85 |
| `permissions.ts` + `permissions.js` bridge | `permissions` | RBAC permissions | 52 |
| `project_memberships.ts` + `.js` bridge | `project_memberships` | User-project access | 89 |
| `publish_events.ts` + `.js` bridge | `publish_events` | Publishing history | 148 |
| `pwa_caches.ts` + `.js` bridge | `pwa_caches` | PWA offline cache manifests | 84 |
| `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 |
| `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_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_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 |
| `presigned_url_requests.ts` + `.js` bridge | `presigned_url_requests` | S3 presigned URL audit | 118 |
| `file.ts` + `.js` bridge | `file` | Generic file attachments | 53 |
---
@ -128,6 +128,7 @@ bridge or immutable migration.
## Model Loader
**Locations:**
- `backend/src/db/models/loader.ts`
- `backend/src/db/models/index.ts`
- `backend/src/types/db-models.ts`
@ -143,11 +144,11 @@ for service-specific model calls.
**Location:** `backend/src/db/db-config.ts`
| Environment | Database | Logging | Notes |
|-------------|----------|---------|-------|
| `production` | From env vars | Disabled | Live production |
| `development` | `db_tour_builder_platform` | Console | Local dev |
| `dev_stage` | From env vars | Console | Staging server |
| Environment | Database | Logging | Notes |
| ------------- | -------------------------- | -------- | --------------- |
| `production` | From env vars | Disabled | Live production |
| `development` | `db_tour_builder_platform` | Console | Local dev |
| `dev_stage` | From env vars | Console | Staging server |
---
@ -167,13 +168,13 @@ All models share these Sequelize options:
### Common Fields
| Field | Type | Description |
|-------|------|-------------|
| `id` | `UUID` | Primary key (auto-generated UUIDv4) |
| Field | Type | Description |
| ------------ | ------------- | ----------------------------------------- |
| `id` | `UUID` | Primary key (auto-generated UUIDv4) |
| `importHash` | `STRING(255)` | Unique hash for bulk import deduplication |
| `createdAt` | `DATE` | Auto-managed creation timestamp |
| `updatedAt` | `DATE` | Auto-managed update timestamp |
| `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) |
| `createdAt` | `DATE` | Auto-managed creation timestamp |
| `updatedAt` | `DATE` | Auto-managed update timestamp |
| `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) |
### Common Associations
@ -193,40 +194,49 @@ db.MODEL.belongsTo(db.users, { as: 'updatedBy' });
**Purpose:** User accounts for authentication and authorization.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `firstName` | TEXT | Yes | - | Trimmed |
| `lastName` | TEXT | Yes | - | Trimmed |
| `phoneNumber` | TEXT | Yes | - | - |
| `email` | TEXT | No | - | isEmail, notEmpty, unique |
| `password` | TEXT | No | - | Hashed with bcrypt |
| `disabled` | BOOLEAN | No | false | - |
| `emailVerified` | BOOLEAN | No | false | - |
| `emailVerificationToken` | TEXT | Yes | - | - |
| `emailVerificationTokenExpiresAt` | DATE | Yes | - | - |
| `passwordResetToken` | TEXT | Yes | - | - |
| `passwordResetTokenExpiresAt` | DATE | Yes | - | - |
| `provider` | TEXT | No | 'local' | OAuth provider |
| `app_roleId` | UUID | Yes | - | FK to roles |
| Field | Type | Nullable | Default | Validation |
| --------------------------------- | ------- | -------- | ------- | ------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `firstName` | TEXT | Yes | - | Trimmed |
| `lastName` | TEXT | Yes | - | Trimmed |
| `phoneNumber` | TEXT | Yes | - | - |
| `email` | TEXT | No | - | isEmail, notEmpty, unique |
| `password` | TEXT | No | - | Hashed with bcrypt |
| `disabled` | BOOLEAN | No | false | - |
| `emailVerified` | BOOLEAN | No | false | - |
| `emailVerificationToken` | TEXT | Yes | - | - |
| `emailVerificationTokenExpiresAt` | DATE | Yes | - | - |
| `passwordResetToken` | TEXT | Yes | - | - |
| `passwordResetTokenExpiresAt` | DATE | Yes | - | - |
| `provider` | TEXT | No | 'local' | OAuth provider |
| `app_roleId` | UUID | Yes | - | FK to roles |
**Indexes:**
- `email` (unique)
- `app_roleId`
- `deletedAt`
**Associations:**
```javascript
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(presigned_url_requests, { as: 'presigned_url_requests_user' });
users.hasMany(publish_events, { as: 'publish_events_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:**
```javascript
users.beforeCreate((user) => {
// Trim string fields
@ -245,34 +255,60 @@ users.beforeUpdate((user) => {
**Purpose:** Virtual tour projects container.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, len[1,255] |
| `slug` | TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores |
| `description` | TEXT | Yes | - | - |
| `logo_url` | TEXT | Yes | - | - |
| `favicon_url` | TEXT | Yes | - | - |
| `og_image_url` | TEXT | Yes | - | - |
| `production_presentation_visibility` | ENUM | No | public | public, private |
| Field | Type | Nullable | Default | Validation |
| ------------------------------------ | ---- | -------- | ------- | --------------------------------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, len[1,255] |
| `slug` | TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores |
| `description` | TEXT | Yes | - | - |
| `logo_url` | TEXT | Yes | - | - |
| `favicon_url` | TEXT | Yes | - | - |
| `og_image_url` | TEXT | Yes | - | - |
| `production_presentation_visibility` | ENUM | No | public | public, private |
**Indexes:**
- `slug` (unique)
- `deletedAt`
**Associations:**
```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(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(project_audio_tracks, { as: 'project_audio_tracks_project', 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(project_audio_tracks, {
as: 'project_audio_tracks_project',
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(access_logs, { as: 'access_logs_project', 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' });
projects.hasMany(access_logs, {
as: 'access_logs_project',
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
production presentations.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `projectId` | UUID | No | - | FK to projects |
| `userId` | UUID | No | - | FK to users |
| `createdById` | UUID | Yes | - | FK to users |
| `updatedById` | UUID | Yes | - | FK to users |
| `importHash` | STRING(255) | Yes | - | unique |
| Field | Type | Nullable | Default | Validation |
| ------------- | ----------- | -------- | ------- | -------------- |
| `id` | UUID | No | UUIDv4 | - |
| `projectId` | UUID | No | - | FK to projects |
| `userId` | UUID | No | - | FK to users |
| `createdById` | UUID | Yes | - | FK to users |
| `updatedById` | UUID | Yes | - | FK to users |
| `importHash` | STRING(255) | Yes | - | unique |
**Indexes:**
- `projectId`
- `userId`
- `projectId, userId` unique for active rows
**Associations:**
```javascript
production_presentation_access.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
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' });
production_presentation_access.belongsTo(projects, {
as: 'project',
onDelete: 'CASCADE',
});
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.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | 'dev' | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original page ID for cloning |
| `name` | TEXT | No | - | notEmpty, len[1,255] |
| `slug` | TEXT | No | - | notEmpty, alphanumeric + dashes |
| `sort_order` | INTEGER | No | 0 | - |
| `background_image_url` | TEXT | Yes | - | - |
| `background_video_url` | TEXT | Yes | - | - |
| `background_audio_url` | TEXT | Yes | - | - |
| `background_loop` | BOOLEAN | No | false | - |
| `requires_auth` | BOOLEAN | No | false | - |
| `ui_schema_json` | JSON | Yes | - | Page elements, links, transitions |
| `projectId` | UUID | Yes | - | FK to projects |
| Field | Type | Nullable | Default | Validation |
| ---------------------- | ------- | -------- | ------- | --------------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | 'dev' | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original page ID for cloning |
| `name` | TEXT | No | - | notEmpty, len[1,255] |
| `slug` | TEXT | No | - | notEmpty, alphanumeric + dashes |
| `sort_order` | INTEGER | No | 0 | - |
| `background_image_url` | TEXT | Yes | - | - |
| `background_video_url` | TEXT | Yes | - | - |
| `background_audio_url` | TEXT | Yes | - | - |
| `background_loop` | BOOLEAN | No | false | - |
| `requires_auth` | BOOLEAN | No | false | - |
| `ui_schema_json` | JSON | Yes | - | Page elements, links, transitions |
| `projectId` | UUID | Yes | - | FK to projects |
**Indexes:**
- `projectId`
- `[projectId, environment, slug]` (unique) - Composite unique per project+environment
- `[projectId, environment, sort_order]` - For ordering queries
@ -340,15 +391,19 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE
**Purpose:** RBAC role definitions.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, len[1,100] |
| `role_customization` | TEXT | Yes | - | Custom role metadata |
| Field | Type | Nullable | Default | Validation |
| -------------------- | ---- | -------- | ------- | -------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, len[1,100] |
| `role_customization` | TEXT | Yes | - | Custom role metadata |
**Associations:**
```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' });
```
@ -358,10 +413,10 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
**Purpose:** Individual permission definitions.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, unique, len[1,100] |
| Field | Type | Nullable | Default | Validation |
| ------ | ---- | -------- | ------- | ---------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | No | - | notEmpty, unique, len[1,100] |
**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).
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | Yes | - | len[0,255] |
| `asset_type` | ENUM | No | - | image, video, audio, file |
| `type` | ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general |
| `cdn_url` | TEXT | Yes | - | - |
| `storage_key` | TEXT | Yes | - | S3/storage path |
| `mime_type` | TEXT | Yes | - | MIME type format |
| `size_mb` | DECIMAL | Yes | - | - |
| `width_px` | INTEGER | Yes | - | Image/video width |
| `height_px` | INTEGER | Yes | - | Image/video height |
| `duration_sec` | DECIMAL | Yes | - | Audio/video duration |
| `checksum` | TEXT | Yes | - | File hash |
| `is_public` | BOOLEAN | No | false | - |
| `projectId` | UUID | Yes | - | FK to projects |
| Field | Type | Nullable | Default | Validation |
| -------------- | ------- | -------- | --------- | ---------------------------------------------------------------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `name` | TEXT | Yes | - | len[0,255] |
| `asset_type` | ENUM | No | - | image, video, audio, file |
| `type` | ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general |
| `cdn_url` | TEXT | Yes | - | - |
| `storage_key` | TEXT | Yes | - | S3/storage path |
| `mime_type` | TEXT | Yes | - | MIME type format |
| `size_mb` | DECIMAL | Yes | - | - |
| `width_px` | INTEGER | Yes | - | Image/video width |
| `height_px` | INTEGER | Yes | - | Image/video height |
| `duration_sec` | DECIMAL | Yes | - | Audio/video duration |
| `checksum` | TEXT | Yes | - | File hash |
| `is_public` | BOOLEAN | No | false | - |
| `projectId` | UUID | Yes | - | FK to projects |
**Indexes:**
- `projectId`
- `asset_type`
- `type`
@ -398,8 +454,12 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
- `deletedAt`
**Associations:**
```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' });
```
@ -409,17 +469,18 @@ assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
**Purpose:** Optimized versions of assets (thumbnails, different formats).
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `variant_type` | ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original |
| `cdn_url` | TEXT | Yes | - | len[0,2048], URL format |
| `width_px` | INTEGER | Yes | - | min: 0 |
| `height_px` | INTEGER | Yes | - | min: 0 |
| `size_mb` | DECIMAL | Yes | - | min: 0 |
| `assetId` | UUID | Yes | - | FK to assets |
| Field | Type | Nullable | Default | Validation |
| -------------- | ------- | -------- | ------- | ----------------------------------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `variant_type` | ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original |
| `cdn_url` | TEXT | Yes | - | len[0,2048], URL format |
| `width_px` | INTEGER | Yes | - | min: 0 |
| `height_px` | INTEGER | Yes | - | min: 0 |
| `size_mb` | DECIMAL | Yes | - | min: 0 |
| `assetId` | UUID | Yes | - | FK to assets |
**Associations:**
```javascript
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.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `element_type` | TEXT | No | - | notEmpty, unique, len[1,100] |
| `name` | TEXT | No | - | notEmpty, len[1,255] |
| `sort_order` | INTEGER | No | 0 | - |
| `is_active` | VIRTUAL | - | true | Always returns true |
| `default_settings_json` | TEXT | Yes | - | Mapped from `settings_json` column |
| Field | Type | Nullable | Default | Validation |
| ----------------------- | ------- | -------- | ------- | ---------------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `element_type` | TEXT | No | - | notEmpty, unique, len[1,100] |
| `name` | TEXT | No | - | notEmpty, len[1,255] |
| `sort_order` | INTEGER | No | 0 | - |
| `is_active` | VIRTUAL | - | true | Always returns true |
| `default_settings_json` | TEXT | Yes | - | Mapped from `settings_json` column |
**Indexes:**
- `element_type`
- `sort_order`
- `deletedAt`
**Associations:**
```javascript
element_type_defaults.hasMany(project_element_defaults, {
as: 'project_defaults',
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.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `element_type` | TEXT | No | - | notEmpty, len[1,100] |
| `name` | TEXT | Yes | - | len[0,255] |
| `sort_order` | INTEGER | No | 0 | - |
| `settings_json` | TEXT | Yes | - | Element configuration |
| `source_element_id` | UUID | Yes | - | FK to element_type_defaults |
| `snapshot_version` | INTEGER | No | 1 | Version tracking |
| `projectId` | UUID | No | - | FK to projects |
| Field | Type | Nullable | Default | Validation |
| ------------------- | ------- | -------- | ------- | --------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `element_type` | TEXT | No | - | notEmpty, len[1,100] |
| `name` | TEXT | Yes | - | len[0,255] |
| `sort_order` | INTEGER | No | 0 | - |
| `settings_json` | TEXT | Yes | - | Element configuration |
| `source_element_id` | UUID | Yes | - | FK to element_type_defaults |
| `snapshot_version` | INTEGER | No | 1 | Version tracking |
| `projectId` | UUID | No | - | FK to projects |
**Indexes:**
- `projectId`
- `[projectId, element_type]` (unique)
- `element_type`
@ -482,11 +546,15 @@ element_type_defaults.hasMany(project_element_defaults, {
- `deletedAt`
**Associations:**
```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, {
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.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `title` | STRING | Yes | - | len[0,255] |
| `description` | TEXT | Yes | - | len[0,5000] |
| `from_environment` | ENUM | No | - | dev, stage, production |
| `to_environment` | ENUM | No | - | dev, stage, production |
| `started_at` | DATE | Yes | - | - |
| `finished_at` | DATE | Yes | - | - |
| `status` | ENUM | No | 'queued' | queued, running, success, failed |
| `error_message` | TEXT | Yes | - | - |
| `pages_copied` | INTEGER | Yes | - | min: 0 |
| `transitions_copied` | INTEGER | Yes | - | min: 0 |
| `audios_copied` | INTEGER | Yes | - | min: 0 |
| `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users |
| Field | Type | Nullable | Default | Validation |
| -------------------- | ------- | -------- | -------- | -------------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `title` | STRING | Yes | - | len[0,255] |
| `description` | TEXT | Yes | - | len[0,5000] |
| `from_environment` | ENUM | No | - | dev, stage, production |
| `to_environment` | ENUM | No | - | dev, stage, production |
| `started_at` | DATE | Yes | - | - |
| `finished_at` | DATE | Yes | - | - |
| `status` | ENUM | No | 'queued' | queued, running, success, failed |
| `error_message` | TEXT | Yes | - | - |
| `pages_copied` | INTEGER | Yes | - | min: 0 |
| `transitions_copied` | INTEGER | Yes | - | min: 0 |
| `audios_copied` | INTEGER | Yes | - | min: 0 |
| `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users |
**Indexes:**
- `projectId`
- `userId`
- `status`
@ -527,18 +596,19 @@ project_element_defaults.belongsTo(element_type_defaults, {
**Purpose:** Audit trail for user activity.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | - | admin, stage, production |
| `path` | TEXT | Yes | - | len[0,2048] |
| `ip_address` | TEXT | Yes | - | len[0,45] (IPv6 max) |
| `user_agent` | TEXT | Yes | - | len[0,1024] |
| `accessed_at` | DATE | No | NOW | - |
| `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users |
| Field | Type | Nullable | Default | Validation |
| ------------- | ---- | -------- | ------- | ------------------------ |
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | - | admin, stage, production |
| `path` | TEXT | Yes | - | len[0,2048] |
| `ip_address` | TEXT | Yes | - | len[0,45] (IPv6 max) |
| `user_agent` | TEXT | Yes | - | len[0,1024] |
| `accessed_at` | DATE | No | NOW | - |
| `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users |
**Indexes:**
- `projectId`
- `environment`
- `userId`
@ -552,17 +622,18 @@ project_element_defaults.belongsTo(element_type_defaults, {
**Purpose:** User access to projects with role-based permissions.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `access_level` | ENUM | No | 'viewer' | owner, editor, reviewer, viewer |
| `is_active` | BOOLEAN | No | false | - |
| `invited_at` | DATE | Yes | - | - |
| `accepted_at` | DATE | Yes | - | - |
| `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users |
| Field | Type | Nullable | Default | Validation |
| -------------- | ------- | -------- | -------- | ------------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `access_level` | ENUM | No | 'viewer' | owner, editor, reviewer, viewer |
| `is_active` | BOOLEAN | No | false | - |
| `invited_at` | DATE | Yes | - | - |
| `accepted_at` | DATE | Yes | - | - |
| `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users |
**Indexes:**
- `projectId`
- `userId`
- `[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.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | Yes | - | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original track ID for cloning |
| `name` | TEXT | Yes | - | len[0,255] |
| `slug` | TEXT | Yes | - | - |
| `url` | TEXT | Yes | - | - |
| `loop` | BOOLEAN | No | false | - |
| `volume` | DECIMAL | Yes | - | min: 0, max: 1 |
| `sort_order` | INTEGER | Yes | - | - |
| `is_enabled` | BOOLEAN | No | false | - |
| `projectId` | UUID | Yes | - | FK to projects |
| Field | Type | Nullable | Default | Validation |
| ------------- | ------- | -------- | ------- | ----------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | Yes | - | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original track ID for cloning |
| `name` | TEXT | Yes | - | len[0,255] |
| `slug` | TEXT | Yes | - | - |
| `url` | TEXT | Yes | - | - |
| `loop` | BOOLEAN | No | false | - |
| `volume` | DECIMAL | Yes | - | min: 0, max: 1 |
| `sort_order` | INTEGER | Yes | - | - |
| `is_enabled` | BOOLEAN | No | false | - |
| `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.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | - | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original settings ID for cloning |
| `transition_type` | TEXT | No | 'fade' | CSS transition type |
| `duration_ms` | INTEGER | No | 700 | Transition duration in ms |
| `easing` | TEXT | No | 'ease-in-out' | CSS easing function |
| `overlay_color` | TEXT | No | '#000000' | Transition overlay color |
| `projectId` | UUID | No | - | FK to projects |
| `createdById` | UUID | Yes | - | FK to users |
| `updatedById` | UUID | Yes | - | FK to users |
| Field | Type | Nullable | Default | Validation |
| ----------------- | ------- | -------- | ------------- | -------------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | No | - | dev, stage, production |
| `source_key` | TEXT | Yes | - | Original settings ID for cloning |
| `transition_type` | TEXT | No | 'fade' | CSS transition type |
| `duration_ms` | INTEGER | No | 700 | Transition duration in ms |
| `easing` | TEXT | No | 'ease-in-out' | CSS easing function |
| `overlay_color` | TEXT | No | '#000000' | Transition overlay color |
| `projectId` | UUID | No | - | FK to projects |
| `createdById` | UUID | Yes | - | FK to users |
| `updatedById` | UUID | Yes | - | FK to users |
**Indexes:**
- `[projectId, environment]` (unique where deletedAt IS NULL)
- `projectId`
- `deletedAt`
**Associations:**
```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: 'updatedBy' });
```
@ -628,16 +704,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
**Purpose:** PWA offline cache manifest tracking.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | Yes | - | dev, stage, production |
| `cache_version` | TEXT | Yes | - | len[0,255] |
| `manifest_json` | JSON | Yes | - | PWA manifest |
| `asset_list_json` | JSON | Yes | - | Cached asset URLs |
| `generated_at` | DATE | Yes | - | - |
| `is_active` | BOOLEAN | No | false | - |
| `projectId` | UUID | Yes | - | FK to projects |
| Field | Type | Nullable | Default | Validation |
| ----------------- | ------- | -------- | ------- | ---------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `environment` | ENUM | Yes | - | dev, stage, production |
| `cache_version` | TEXT | Yes | - | len[0,255] |
| `manifest_json` | JSON | Yes | - | PWA manifest |
| `asset_list_json` | JSON | Yes | - | Cached asset URLs |
| `generated_at` | DATE | Yes | - | - |
| `is_active` | BOOLEAN | No | false | - |
| `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.
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `purpose` | ENUM | Yes | - | upload, download |
| `asset_type` | ENUM | Yes | - | image, video, audio, file |
| `requested_key` | TEXT | Yes | - | len[0,1024] |
| `mime_type` | TEXT | Yes | - | MIME format, len[0,255] |
| `requested_size_mb` | DECIMAL | Yes | - | min: 0 |
| `expires_at` | DATE | Yes | - | - |
| `status` | TEXT | Yes | - | - |
| `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users |
| Field | Type | Nullable | Default | Validation |
| ------------------- | ------- | -------- | ------- | ------------------------- |
| `id` | UUID | No | UUIDv4 | - |
| `purpose` | ENUM | Yes | - | upload, download |
| `asset_type` | ENUM | Yes | - | image, video, audio, file |
| `requested_key` | TEXT | Yes | - | len[0,1024] |
| `mime_type` | TEXT | Yes | - | MIME format, len[0,255] |
| `requested_size_mb` | DECIMAL | Yes | - | min: 0 |
| `expires_at` | DATE | Yes | - | - |
| `status` | TEXT | Yes | - | - |
| `projectId` | UUID | Yes | - | FK to projects |
| `userId` | UUID | Yes | - | FK to users |
---
@ -664,16 +740,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
**Purpose:** Generic file attachments (user avatars, etc.).
| Field | Type | Nullable | Default | Validation |
|-------|------|----------|---------|------------|
| `id` | UUID | No | UUIDv4 | - |
| `belongsTo` | STRING(255) | Yes | - | Parent table name |
| `belongsToId` | UUID | Yes | - | Parent record ID |
| `belongsToColumn` | STRING(255) | Yes | - | Parent column name |
| `name` | STRING(2083) | No | - | notEmpty |
| `sizeInBytes` | INTEGER | Yes | - | - |
| `privateUrl` | STRING(2083) | Yes | - | - |
| `publicUrl` | STRING(2083) | No | - | notEmpty |
| Field | Type | Nullable | Default | Validation |
| ----------------- | ------------ | -------- | ------- | ------------------ |
| `id` | UUID | No | UUIDv4 | - |
| `belongsTo` | STRING(255) | Yes | - | Parent table name |
| `belongsToId` | UUID | Yes | - | Parent record ID |
| `belongsToColumn` | STRING(255) | Yes | - | Parent column name |
| `name` | STRING(2083) | No | - | notEmpty |
| `sizeInBytes` | INTEGER | Yes | - | - |
| `privateUrl` | STRING(2083) | Yes | - | - |
| `publicUrl` | STRING(2083) | No | - | notEmpty |
**Usage Pattern:** Polymorphic association via `belongsTo`, `belongsToId`, `belongsToColumn` fields and scoped `hasMany` on parent models:
@ -811,19 +887,17 @@ importHash: {
### 4. Cascade Delete Patterns
| Relationship | onDelete | Use Case |
|--------------|----------|----------|
| `CASCADE` | Delete children when parent deleted | Projects → tour_pages |
| `SET NULL` | Keep children, null the FK | Roles → users |
| Relationship | onDelete | Use Case |
| ------------ | ----------------------------------- | --------------------- |
| `CASCADE` | Delete children when parent deleted | Projects → tour_pages |
| `SET NULL` | Keep children, null the FK | Roles → users |
### 5. Composite Unique Constraints
For scoped uniqueness:
```javascript
indexes: [
{ fields: ['projectId', 'environment', 'slug'], unique: true },
]
indexes: [{ fields: ['projectId', 'environment', 'slug'], unique: true }];
```
### 6. JSON Fields
@ -831,8 +905,12 @@ indexes: [
Complex configurations stored as JSON:
```javascript
ui_schema_json: { type: DataTypes.JSON } // Parsed JSON column
settings_json: { type: DataTypes.TEXT } // Stringified JSON text
ui_schema_json: {
type: DataTypes.JSON;
} // Parsed JSON column
settings_json: {
type: DataTypes.TEXT;
} // Stringified JSON text
```
### 7. Virtual Fields
@ -933,7 +1011,7 @@ const transaction = await db.sequelize.transaction();
// Access Sequelize operators
const { Op } = db.Sequelize;
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
### NPM Scripts
```bash
# Run pending seeders
npm run db:seed
@ -42,7 +43,9 @@ npm run db:reset
```
### Server Startup
Seeders run automatically via `npm start`:
```json
{
"scripts": {
@ -65,13 +68,14 @@ development and the compiled JavaScript file from `dist/` in production builds.
**Users Created:**
| User | Email | Role Assignment |
|------|-------|-----------------|
| Admin | `config.admin_email` | Administrator |
| John | john@doe.com | Account Manager |
| Client | client@hello.com | Platform Owner |
| User | Email | Role Assignment |
| ------ | -------------------- | --------------- |
| Admin | `config.admin_email` | Administrator |
| John | john@doe.com | Account Manager |
| Client | client@hello.com | Platform Owner |
**Key Features:**
- Uses bcrypt for password hashing with configured salt rounds
- Hardcoded UUIDs for consistent user IDs
- Reads credentials from `config.ts` (environment variables)
@ -112,14 +116,15 @@ data.
**Data Created:**
| Data Type | Count | Description |
|-----------|-------|-------------|
| Roles | 7 | User role definitions |
| Permissions | 54 | CRUD permissions for 13 entities + special |
| Role-Permission Links | 200+ | M:N relationships |
| Join Table | 1 | `rolesPermissionsPermissions` table |
| Data Type | Count | Description |
| --------------------- | ----- | ------------------------------------------ |
| Roles | 7 | User role definitions |
| Permissions | 54 | CRUD permissions for 13 entities + special |
| Role-Permission Links | 200+ | M:N relationships |
| Join Table | 1 | `rolesPermissionsPermissions` table |
**Key Features:**
- Uses stable named role and permission definitions, then reuses existing DB IDs
when the same role/permission name is already present.
- Inserts only missing roles, permissions, and role-permission links. This keeps
@ -128,19 +133,21 @@ data.
by email after RBAC data exists.
#### Roles
```javascript
const roles = [
'Administrator', // Full system access
'PlatformOwner', // Full project/content access
'AccountManager', // User and project management
'TourDesigner', // Content creation and editing
'ContentReviewer', // Read + limited update access
'AnalyticsViewer', // Read-only access
'Public', // Public/unauthenticated access
'Administrator', // Full system access
'PlatformOwner', // Full project/content access
'AccountManager', // User and project management
'TourDesigner', // Content creation and editing
'ContentReviewer', // Read + limited update access
'AnalyticsViewer', // Read-only access
'Public', // Public/unauthenticated access
];
```
#### Permission Generation Pattern
```javascript
// Generates CREATE, READ, UPDATE, DELETE permissions per entity
function createPermissions(name) {
@ -153,13 +160,26 @@ function createPermissions(name) {
}
const entities = [
'users', 'roles', 'permissions', 'projects', 'project_memberships',
'assets', 'asset_variants', 'presigned_url_requests', 'tour_pages',
'project_audio_tracks', 'publish_events', 'pwa_caches', 'access_logs'
'users',
'roles',
'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)
await queryInterface.bulkInsert('permissions', entities.flatMap(createPermissions));
await queryInterface.bulkInsert(
'permissions',
entities.flatMap(createPermissions),
);
// Plus special permissions
await queryInterface.bulkInsert('permissions', [
@ -169,6 +189,7 @@ await queryInterface.bulkInsert('permissions', [
```
#### ID Map Pattern
```javascript
// Consistent UUID generation using key-based map
const idMap = new Map();
@ -183,25 +204,26 @@ function getId(key) {
}
// Usage - same key always returns same UUID within seeder run
getId('Administrator') // Returns consistent UUID
getId('CREATE_USERS') // Returns consistent UUID
getId('Administrator'); // Returns consistent UUID
getId('CREATE_USERS'); // Returns consistent UUID
```
#### Permission Matrix
| Role | Users | Projects | Assets | Tour Pages | Access Logs |
|------|-------|----------|--------|------------|-------------|
| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD |
| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD |
| **AccountManager** | RU | CRU | CRU | CRU | R |
| **TourDesigner** | R | RU | CRU | CRU | R |
| **ContentReviewer** | R | RU | RU | RU | R |
| **AnalyticsViewer** | R | R | R | R | R |
| **Public** | - | - | - | - | - |
| Role | Users | Projects | Assets | Tour Pages | Access Logs |
| ------------------- | ----- | -------- | ------ | ---------- | ----------- |
| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD |
| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD |
| **AccountManager** | RU | CRU | CRU | CRU | R |
| **TourDesigner** | R | RU | CRU | CRU | R |
| **ContentReviewer** | R | RU | RU | RU | R |
| **AnalyticsViewer** | R | R | R | R | R |
| **Public** | - | - | - | - | - |
**Legend:** C=Create, R=Read, U=Update, D=Delete
#### Join Table Creation
```javascript
// Creates M:N relationship table directly in seeder
await queryInterface.sequelize.query(`
@ -235,6 +257,7 @@ separate. Umzug records the seeder under its legacy `.js` name for storage
compatibility only.
**Opt-In Activation:**
```bash
# Enable sample data seeding
export ENABLE_SAMPLE_DATA=true
@ -242,6 +265,7 @@ npm run db:seed
```
**Check in Code:**
```typescript
const sampleDataSeeder: SequelizeSeeder = {
async up() {
@ -253,20 +277,21 @@ const sampleDataSeeder: SequelizeSeeder = {
**Data Created:**
| Entity | Records | Description |
|--------|---------|-------------|
| Projects | 3 | Sample tour projects |
| Project Memberships | 3 | User-project associations |
| Assets | 3 | Images, videos, audio |
| Asset Variants | 3 | Thumbnail/preview variants |
| Presigned URL Requests | 3 | Upload/download requests |
| Tour Pages | 3 | Sample tour pages |
| Project Audio Tracks | 3 | Background audio |
| Publish Events | 3 | Deployment history |
| PWA Caches | 3 | Offline cache configs |
| Access Logs | 3 | Visitor tracking |
| Entity | Records | Description |
| ---------------------- | ------- | -------------------------- |
| Projects | 3 | Sample tour projects |
| Project Memberships | 3 | User-project associations |
| Assets | 3 | Images, videos, audio |
| Asset Variants | 3 | Thumbnail/preview variants |
| Presigned URL Requests | 3 | Upload/download requests |
| Tour Pages | 3 | Sample tour pages |
| Project Audio Tracks | 3 | Background audio |
| Publish Events | 3 | Deployment history |
| PWA Caches | 3 | Offline cache configs |
| Access Logs | 3 | Visitor tracking |
#### Sample Projects
```javascript
const ProjectsData = [
{
@ -293,6 +318,7 @@ const ProjectsData = [
```
#### Association Helper Pattern
```javascript
// Associates records after bulk creation using Sequelize model methods
async function associateAssetWithProject() {
@ -314,6 +340,7 @@ async function associateAssetWithProject() {
## Seeder Patterns
### 1. bulkInsert Pattern
**Purpose:** Insert multiple records efficiently.
```javascript
@ -329,6 +356,7 @@ await queryInterface.bulkInsert('tableName', [
```
### 2. bulkDelete Pattern
**Purpose:** Remove seeded data during rollback.
```javascript
@ -340,6 +368,7 @@ async down(queryInterface, Sequelize) {
```
### 3. Conditional Execution Pattern
**Purpose:** Enable/disable seeders based on environment.
Seeder-only environment gates are intentionally allowed to read `process.env`
@ -354,13 +383,14 @@ records, or sample-data entities.
```javascript
up: async () => {
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
return; // Skip seeding
return; // Skip seeding
}
// ... proceed with seeding
}
};
```
### 4. ID Consistency Pattern
**Purpose:** Use deterministic IDs for reliable down() migrations.
```javascript
@ -381,6 +411,7 @@ function getId(key) {
```
### 5. Model-Based Association Pattern
**Purpose:** Create relationships using Sequelize models after bulk insert.
```javascript
@ -391,6 +422,7 @@ await asset.setProject(project);
```
### 6. Raw SQL Pattern
**Purpose:** Create structures not managed by Sequelize models.
```javascript
@ -403,7 +435,7 @@ await queryInterface.sequelize.query(`
// Create indexes
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
### 1. Use Consistent IDs
```javascript
// Good - allows rollback
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
await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
```
### 2. Always Include Timestamps
```javascript
{
field: 'value',
@ -467,6 +504,7 @@ await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
```
### 3. Handle Errors
```javascript
try {
await queryInterface.bulkInsert('users', [...]);
@ -477,6 +515,7 @@ try {
```
### 4. Environment-Aware Seeding
```javascript
// Production - only essential data
// Development - include sample data
@ -486,6 +525,7 @@ if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
```
### 5. Idempotent Where Possible
```javascript
// Use IF NOT EXISTS for table/index creation
await queryInterface.sequelize.query(`
@ -523,23 +563,27 @@ sample-data.ts
## Running Seeders
### Development Setup
```bash
cd backend
npm run db:seed
```
### With Sample Data
```bash
export ENABLE_SAMPLE_DATA=true
npm run db:seed
```
### Fresh Database
```bash
npm run db:reset # drop, create, migrate, seed
```
### Undo Seeders
```bash
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
| # | Timestamp | Name | Records | Required |
|---|-----------|------|---------|----------|
| 1 | 20200430130759 | admin-user | 3 users | Yes |
| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes |
| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) |
| # | Timestamp | Name | Records | Required |
| --- | -------------- | ----------- | ----------------------------------- | ----------- |
| 1 | 20200430130759 | admin-user | 3 users | Yes |
| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes |
| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) |
---
## Environment Variables
| Variable | Purpose | Default |
|----------|---------|---------|
| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` |
| `ADMIN_EMAIL` | Admin user email | (from config) |
| `ADMIN_PASS` | Admin user password | (from config) |
| `USER_PASS` | Default user password | (from config) |
| Variable | Purpose | Default |
| -------------------- | ------------------------- | ------------- |
| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` |
| `ADMIN_EMAIL` | Admin user email | (from config) |
| `ADMIN_PASS` | Admin user password | (from config) |
| `USER_PASS` | Default user password | (from config) |
---

View File

@ -129,13 +129,13 @@ export default class EmailSender {
**Key Methods:**
| Method | Type | Description |
|--------|------|-------------|
| `constructor(email)` | Instance | Accepts email template object |
| `send()` | Async | Sends email via Nodemailer |
| `isConfigured` | Static getter | Checks if SMTP credentials exist |
| `transportConfig` | Getter | Returns SMTP config |
| `from` | Getter | Returns sender address |
| Method | Type | Description |
| -------------------- | ------------- | -------------------------------- |
| `constructor(email)` | Instance | Accepts email template object |
| `send()` | Async | Sends email via Nodemailer |
| `isConfigured` | Static getter | Checks if SMTP credentials exist |
| `transportConfig` | Getter | Returns SMTP config |
| `from` | Getter | Returns sender address |
---
@ -167,7 +167,7 @@ export default class PasswordResetEmail implements EmailTemplate {
get subject() {
return getNotification(
'emails.passwordReset.subject',
getNotification('app.title')
getNotification('app.title'),
);
// → "Reset your password for Tour Builder Platform"
}
@ -179,10 +179,11 @@ export default class PasswordResetEmail implements EmailTemplate {
.replace(/{resetUrl}/g, this.link)
.replace(/{accountName}/g, this.to);
}
};
}
```
**Template Variables:**
- `{appTitle}` - Application name
- `{resetUrl}` - Password reset link
- `{accountName}` - User email address
@ -201,7 +202,7 @@ export default class EmailAddressVerificationEmail implements EmailTemplate {
get subject() {
return getNotification(
'emails.emailAddressVerification.subject',
getNotification('app.title')
getNotification('app.title'),
);
// → "Verify your email for Tour Builder Platform"
}
@ -213,10 +214,11 @@ export default class EmailAddressVerificationEmail implements EmailTemplate {
.replace(/{signupUrl}/g, this.link)
.replace(/{to}/g, this.to);
}
};
}
```
**Template Variables:**
- `{appTitle}` - Application name
- `{signupUrl}` - Email verification link
- `{to}` - User email address
@ -235,7 +237,7 @@ export default class InvitationEmail implements EmailTemplate {
get subject() {
return getNotification(
'emails.invitation.subject',
getNotification('app.title')
getNotification('app.title'),
);
// → "You've been invited to Tour Builder Platform"
}
@ -248,10 +250,11 @@ export default class InvitationEmail implements EmailTemplate {
.replace(/{signupUrl}/g, signupUrl)
.replace(/{to}/g, this.to);
}
};
}
```
**Template Variables:**
- `{appTitle}` - Application name
- `{signupUrl}` - Account setup link with `&invitation=true`
- `{to}` - User email address
@ -267,66 +270,66 @@ All HTML templates follow consistent styling:
```html
<!DOCTYPE html>
<html>
<head>
<head>
<style>
.email-container {
max-width: 600px;
margin: auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.email-header {
background-color: #3498db; /* Primary blue */
color: #fff;
padding: 16px;
text-align: center;
}
.email-body {
padding: 16px;
}
.email-footer {
padding: 16px;
background-color: #f7fafc;
text-align: center;
color: #4a5568;
font-size: 14px;
}
.link-primary {
color: #3498db;
text-decoration: none;
}
.btn-primary {
background-color: #3498db;
color: #fff !important;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
.email-container {
max-width: 600px;
margin: auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.email-header {
background-color: #3498db; /* Primary blue */
color: #fff;
padding: 16px;
text-align: center;
}
.email-body {
padding: 16px;
}
.email-footer {
padding: 16px;
background-color: #f7fafc;
text-align: center;
color: #4a5568;
font-size: 14px;
}
.link-primary {
color: #3498db;
text-decoration: none;
}
.btn-primary {
background-color: #3498db;
color: #fff !important;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
</style>
</head>
<body>
</head>
<body>
<div class="email-container">
<div class="email-header">...</div>
<div class="email-body">...</div>
<div class="email-footer">
Thanks,<br/>
The {appTitle} Team
</div>
<div class="email-header">...</div>
<div class="email-body">...</div>
<div class="email-footer">
Thanks,<br />
The {appTitle} Team
</div>
</div>
</body>
</body>
</html>
```
### Template Comparison
| Template | Header Text | Call-to-Action | Button Style |
|----------|-------------|----------------|--------------|
| Password Reset | "Reset your password for {appTitle}" | Link | Text link |
| Email Verification | "Verify your email for {appTitle}!" | Link | Text link |
| Invitation | "Welcome to {appTitle}!" | Button | Primary button |
| Template | Header Text | Call-to-Action | Button Style |
| ------------------ | ------------------------------------ | -------------- | -------------- |
| Password Reset | "Reset your password for {appTitle}" | Link | Text link |
| Email Verification | "Verify your email for {appTitle}!" | Link | Text link |
| Invitation | "Welcome to {appTitle}!" | Button | Primary button |
---
@ -351,11 +354,11 @@ email: {
### Environment Variables
| Variable | Required | Description |
|----------|----------|-------------|
| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) |
| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification |
| Variable | Required | Description |
| ------------------------------- | -------- | ----------------------------------------- |
| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) |
| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification |
### AWS SES Configuration
@ -397,9 +400,10 @@ class Auth {
const token = await UsersDBApi.generatePasswordResetToken(email);
const link = `${host}/password-reset?token=${token}`;
const emailObj = type === 'invitation'
? new InvitationEmail({ to: email, host: link })
: new PasswordResetEmail({ to: email, link });
const emailObj =
type === 'invitation'
? new InvitationEmail({ to: email, host: link })
: new PasswordResetEmail({ to: email, link });
return new EmailSender(emailObj).send();
}
@ -445,10 +449,14 @@ router.get('/email-configured', (req, res) => {
});
// Resend verification email (authenticated)
router.put('/send-email-address-verification-email', jwtAuth, async (req, res) => {
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
res.status(200).send(true);
});
router.put(
'/send-email-address-verification-email',
jwtAuth,
async (req, res) => {
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
res.status(200).send(true);
},
);
// Request password reset (public)
router.put('/send-password-reset-email', async (req, res) => {
@ -542,10 +550,10 @@ static async markEmailVerified(id, options) {
### Token Properties
| Token Type | Field | Expiry Field | TTL |
|------------|-------|--------------|-----|
| Token Type | Field | Expiry Field | TTL |
| ------------------ | ------------------------ | --------------------------------- | -------- |
| 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
When `EMAIL_USER` and `EMAIL_PASS` are set:
- Email verification required before login
- Password reset emails sent on request
- User invitations include email
@ -677,6 +686,7 @@ When `EMAIL_USER` and `EMAIL_PASS` are set:
### Email Not Configured Mode
When credentials are missing:
- `EmailSender.isConfigured` returns `false`
- Users auto-verified on signin: `user.emailVerified = true`
- Password reset/invitation silently skipped
@ -746,13 +756,13 @@ emails: {
### Email-Related Errors
| Error Code | Message | When Thrown |
|------------|---------|-------------|
| `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.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.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification |
| Error Code | Message | When Thrown |
| ------------------------------------------------- | --------------------------------------------------- | ------------------------------------- |
| `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.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.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification |
### Error Flow
@ -827,7 +837,7 @@ describe('EmailSender', () => {
expect.objectContaining({
to: 'test@example.com',
subject: expect.stringContaining('Reset your password'),
})
}),
);
});
});
@ -843,6 +853,7 @@ EMAIL_PASS=
```
Result:
- `EmailSender.isConfigured` returns `false`
- Users auto-verified on login
- No emails sent
@ -859,19 +870,21 @@ Result:
<!-- services/email/htmlTemplates/welcome/welcomeEmail.html -->
<!DOCTYPE html>
<html>
<head>
<style>/* Same styles as other templates */</style>
</head>
<body>
<div class="email-container">
<div class="email-header">Welcome to {appTitle}!</div>
<div class="email-body">
<head>
<style>
/* Same styles as other templates */
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">Welcome to {appTitle}!</div>
<div class="email-body">
<p>Hello {userName},</p>
<p>Your account has been activated.</p>
</div>
<div class="email-footer">Thanks,<br />The {appTitle} Team</div>
</div>
<div class="email-footer">Thanks,<br/>The {appTitle} Team</div>
</div>
</body>
</body>
</html>
```
@ -944,12 +957,12 @@ await new EmailSender(email).send();
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `nodemailer` | ^6.x | SMTP transport |
| `assert` | built-in | Input validation |
| `fs.promises` | built-in | Template file reading |
| `path` | built-in | Template path resolution |
| Package | Version | Purpose |
| ------------- | -------- | ------------------------ |
| `nodemailer` | ^6.x | SMTP transport |
| `assert` | built-in | Input validation |
| `fs.promises` | built-in | Template file reading |
| `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/`
**Files:**
| File | Purpose | LOC |
|------|---------|-----|
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 |
| File | Purpose | LOC |
| -------------------- | --------------------------------------------------- | --- |
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 |
| `service.factory.ts` | Generates service classes with transaction handling | 350 |
---
@ -63,34 +64,34 @@ function createEntityRouter(entityName, Service, DBApi, options = {})
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `entityName` | `string` | Entity name for routes and permissions |
| `Service` | `class` | Service class with CRUD methods |
| `DBApi` | `class` | Database API class extending GenericDBApi |
| `options` | `object` | Configuration options |
| Parameter | Type | Description |
| ------------ | -------- | ----------------------------------------- |
| `entityName` | `string` | Entity name for routes and permissions |
| `Service` | `class` | Service class with CRUD methods |
| `DBApi` | `class` | Database API class extending GenericDBApi |
| `options` | `object` | Configuration options |
#### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| `permissionEntity` | `string` | `entityName` | Override permission entity name |
| `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export |
| `customRoutes` | `function` | `null` | Callback to add custom routes |
| Option | Type | Default | Description |
| ------------------ | ---------- | ------------------ | ------------------------------- |
| `permissionEntity` | `string` | `entityName` | Override permission entity name |
| `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export |
| `customRoutes` | `function` | `null` | Callback to add custom routes |
#### Generated Endpoints
| Method | Endpoint | Description |
|--------|----------|-------------|
| `POST` | `/` | Create new record |
| `POST` | `/bulk-import` | Bulk import from CSV |
| `PUT` | `/:id` | Update record by ID |
| `DELETE` | `/:id` | Delete record by ID |
| `POST` | `/deleteByIds` | Delete multiple records |
| `GET` | `/` | List all records (with filters, pagination) |
| `GET` | `/count` | Get record count |
| `GET` | `/autocomplete` | Get autocomplete suggestions |
| `GET` | `/:id` | Get single record by ID |
| Method | Endpoint | Description |
| -------- | --------------- | ------------------------------------------- |
| `POST` | `/` | Create new record |
| `POST` | `/bulk-import` | Bulk import from CSV |
| `PUT` | `/:id` | Update record by ID |
| `DELETE` | `/:id` | Delete record by ID |
| `POST` | `/deleteByIds` | Delete multiple records |
| `GET` | `/` | List all records (with filters, pagination) |
| `GET` | `/count` | Get record count |
| `GET` | `/autocomplete` | Get autocomplete suggestions |
| `GET` | `/:id` | Get single record by ID |
#### Implementation
@ -108,111 +109,153 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
router.use(checkCrudPermissions(permissionEntity));
// POST / - Create
router.post('/', wrapAsync(async (req, res) => {
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
const payload = await Service.create({
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
sendInvitationEmails: true,
host: link.host,
});
res.status(200).send(payload);
}));
router.post(
'/',
wrapAsync(async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
const payload = await Service.create({
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
sendInvitationEmails: true,
host: link.host,
});
res.status(200).send(payload);
}),
);
// POST /bulk-import - Bulk CSV import
router.post('/bulk-import', wrapAsync(async (req, res) => {
const referer = 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);
}));
router.post(
'/bulk-import',
wrapAsync(async (req, res) => {
const referer =
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
router.put('/:id', wrapAsync(async (req, res) => {
assertRouteIdMatchesBody(req);
await Service.update({
id: req.params.id,
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
router.put(
'/:id',
wrapAsync(async (req, res) => {
assertRouteIdMatchesBody(req);
await Service.update({
id: req.params.id,
data: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}),
);
// DELETE /:id - Delete single
router.delete('/:id', wrapAsync(async (req, res) => {
await Service.remove({
id: req.params.id,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
router.delete(
'/:id',
wrapAsync(async (req, res) => {
await Service.remove({
id: req.params.id,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}),
);
// POST /deleteByIds - Delete multiple
router.post('/deleteByIds', wrapAsync(async (req, res) => {
await Service.deleteByIds({
ids: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}));
router.post(
'/deleteByIds',
wrapAsync(async (req, res) => {
await Service.deleteByIds({
ids: req.body.data,
currentUser: req.currentUser,
runtimeContext: req.runtimeContext,
});
res.status(200).send(true);
}),
);
// GET / - List all with optional CSV export
router.get('/', wrapAsync(async (req, res) => {
const filetype = req.query.filetype;
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
router.get(
'/',
wrapAsync(async (req, res) => {
const filetype = req.query.filetype;
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi, {
csv: filetype === 'csv',
}), { currentUser, runtimeContext });
const payload = await DBApi.findAll(
normalizeQuery(req.query, DBApi, {
csv: filetype === 'csv',
}),
{ currentUser, runtimeContext },
);
if (filetype === 'csv') {
const fields = options.csvFields || DBApi.CSV_FIELDS || ['id', 'createdAt'];
const opts = { fields };
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment('export.csv').send(csv);
} catch (err) {
logger.error({ err, entityName }, 'CSV export error');
res.status(500).send('CSV export error');
if (filetype === 'csv') {
const fields = options.csvFields ||
DBApi.CSV_FIELDS || ['id', 'createdAt'];
const opts = { fields };
try {
const csv = parse(payload.rows, opts);
res.status(200).attachment('export.csv').send(csv);
} catch (err) {
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
router.get('/count', wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), { countOnly: true, currentUser, runtimeContext });
res.status(200).send(payload);
}));
router.get(
'/count',
wrapAsync(async (req, res) => {
const currentUser = req.currentUser;
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
router.get('/autocomplete', wrapAsync(async (req, res) => {
const payload = await DBApi.findAllAutocomplete({
query: req.query.query,
limit,
offset: req.query.offset,
});
res.status(200).send(payload);
}));
router.get(
'/autocomplete',
wrapAsync(async (req, res) => {
const payload = await DBApi.findAllAutocomplete({
query: req.query.query,
limit,
offset: req.query.offset,
});
res.status(200).send(payload);
}),
);
// GET /:id - Find by ID
router.get('/:id', wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send(`Invalid ${entityName} id`);
}
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findBy({ id: req.params.id }, { runtimeContext });
res.status(200).send(payload);
}));
router.get(
'/:id',
wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send(`Invalid ${entityName} id`);
}
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findBy(
{ id: req.params.id },
{ runtimeContext },
);
res.status(200).send(payload);
}),
);
// Custom routes hook
if (options.customRoutes) {
@ -241,10 +284,10 @@ Generic CRUD query safety:
#### Exports
| Export | Type | Description |
|--------|------|-------------|
| `createEntityRouter` | `function` | Factory function |
| `isUuidV4` | `function` | UUID validation helper |
| Export | Type | Description |
| -------------------- | ---------- | ---------------------- |
| `createEntityRouter` | `function` | Factory function |
| `isUuidV4` | `function` | UUID validation helper |
---
@ -262,26 +305,26 @@ function createEntityService(DBApi, options = {})
#### Parameters
| Parameter | Type | Description |
|-----------|------|-------------|
| `DBApi` | `class` | Database API class extending GenericDBApi |
| `options` | `object` | Configuration options |
| Parameter | Type | Description |
| --------- | -------- | ----------------------------------------- |
| `DBApi` | `class` | Database API class extending GenericDBApi |
| `options` | `object` | Configuration options |
#### Options
| Option | Type | Default | Description |
|--------|------|---------|-------------|
| Option | Type | Default | Description |
| ------------ | -------- | ---------- | --------------------------- |
| `entityName` | `string` | `'Entity'` | Name used in error messages |
#### Generated Methods
| Method | Description |
|--------|-------------|
| `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction |
| `bulkImport(req, res)` | Bulk import from CSV with transaction |
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction |
| `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction |
| Method | Description |
| ---------------------------------------------------------------- | ------------------------------------- |
| `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction |
| `bulkImport(req, res)` | Bulk import from CSV with transaction |
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction |
| `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction |
#### Implementation
@ -296,11 +339,22 @@ function createEntityService(DBApi, options = {}) {
const entityName = options.entityName || 'Entity';
return class GenericService {
static async create({ data, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
static async create({
data,
currentUser,
transaction: externalTransaction,
runtimeContext,
}) {
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction;
try {
const record = await DBApi.create({ data, currentUser, transaction, runtimeContext });
const record = await DBApi.create({
data,
currentUser,
transaction,
runtimeContext,
});
if (ownsTransaction) await transaction.commit();
return record;
} catch (error) {
@ -340,17 +394,33 @@ function createEntityService(DBApi, options = {}) {
}
}
static async update({ id, data, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
static async update({
id,
data,
currentUser,
transaction: externalTransaction,
runtimeContext,
}) {
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction;
try {
const record = await DBApi.findBy({ id }, { transaction, runtimeContext });
const record = await DBApi.findBy(
{ id },
{ transaction, runtimeContext },
);
if (!record) {
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();
return updated;
} catch (error) {
@ -359,11 +429,22 @@ function createEntityService(DBApi, options = {}) {
}
}
static async deleteByIds({ ids, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
static async deleteByIds({
ids,
currentUser,
transaction: externalTransaction,
runtimeContext,
}) {
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction;
try {
await DBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext });
await DBApi.deleteByIds({
ids,
currentUser,
transaction,
runtimeContext,
});
if (ownsTransaction) await transaction.commit();
} catch (error) {
if (ownsTransaction) await transaction.rollback();
@ -371,8 +452,14 @@ function createEntityService(DBApi, options = {}) {
}
}
static async remove({ id, currentUser, transaction: externalTransaction, runtimeContext }) {
const transaction = externalTransaction || await db.sequelize.transaction();
static async remove({
id,
currentUser,
transaction: externalTransaction,
runtimeContext,
}) {
const transaction =
externalTransaction || (await db.sequelize.transaction());
const ownsTransaction = !externalTransaction;
try {
await DBApi.remove({ id, currentUser, transaction, runtimeContext });
@ -444,7 +531,9 @@ module.exports = class Helpers {
// UUID v4 validation
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)
| Getter | Type | Description |
|--------|------|-------------|
| `MODEL` | `Model` | Sequelize model reference (required) |
| `TABLE_NAME` | `string` | Database table name |
| `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) |
| `RANGE_FIELDS` | `string[]` | Fields for range filtering |
| `ENUM_FIELDS` | `string[]` | Fields for exact match filtering |
| `RELATION_FILTERS` | `object[]` | Related entity filters |
| `CSV_FIELDS` | `string[]` | Fields for CSV export |
| `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete |
| `ASSOCIATIONS` | `object[]` | Related entity setters |
| `FIND_BY_INCLUDES` | `object[]` | Includes for findBy |
| `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll |
| `JSON_FIELDS` | `string[]` | Fields to auto-stringify |
| `FIELD_TRANSFORMERS` | `object` | Custom field transformers |
| `FIELD_DEFAULTS` | `object` | Default values for fields |
| Getter | Type | Description |
| -------------------- | ---------- | ------------------------------------ |
| `MODEL` | `Model` | Sequelize model reference (required) |
| `TABLE_NAME` | `string` | Database table name |
| `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) |
| `RANGE_FIELDS` | `string[]` | Fields for range filtering |
| `ENUM_FIELDS` | `string[]` | Fields for exact match filtering |
| `RELATION_FILTERS` | `object[]` | Related entity filters |
| `CSV_FIELDS` | `string[]` | Fields for CSV export |
| `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete |
| `ASSOCIATIONS` | `object[]` | Related entity setters |
| `FIND_BY_INCLUDES` | `object[]` | Includes for findBy |
| `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll |
| `JSON_FIELDS` | `string[]` | Fields to auto-stringify |
| `FIELD_TRANSFORMERS` | `object` | Custom field transformers |
| `FIELD_DEFAULTS` | `object` | Default values for fields |
### Methods
| Method | Description |
|--------|-------------|
| `getFieldMapping(data)` | Transform input data for database |
| `create(data, options)` | Create record |
| `bulkImport(data, options)` | Bulk create records |
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple |
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single |
| `findBy(where, options)` | Find single by criteria |
| `findAll(filter, options)` | Find all with pagination/filters |
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
| `toCSV(rows)` | Convert to CSV string |
| Method | Description |
| ---------------------------------------------------------------- | --------------------------------- |
| `getFieldMapping(data)` | Transform input data for database |
| `create(data, options)` | Create record |
| `bulkImport(data, options)` | Bulk create records |
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record |
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple |
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single |
| `findBy(where, options)` | Find single by criteria |
| `findAll(filter, options)` | Find all with pagination/filters |
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
| `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)
**Route (assets.ts):**
```typescript
import AssetsDBApi from '../db/api/assets.ts';
import { createEntityRouter } from '../factories/router.factory.ts';
@ -554,6 +644,7 @@ export default createEntityRouter('assets', AssetsService, AssetsDBApi);
```
**Service (assets.ts):**
```typescript
import AssetsDBApi from '../db/api/assets.ts';
import { createEntityService } from '../factories/service.factory.ts';
@ -565,6 +656,7 @@ export default createEntityService(AssetsDBApi, {
```
**DB API (assets.js):**
```javascript
const GenericDBApi = require('./base.api');
const db = require('../models');
@ -607,6 +699,7 @@ module.exports = AssetsDBApi;
### Entity with Custom Routes
**Route (project_element_defaults.ts):**
```typescript
import Service from '../services/project_element_defaults.ts';
import DBApi from '../db/api/project_element_defaults.ts';
@ -618,22 +711,28 @@ const baseRouter = createEntityRouter(
'project_element_defaults',
Service,
DBApi,
{ permissionEntity: 'page_elements' } // Override permission entity
{ permissionEntity: 'page_elements' }, // Override permission entity
);
// Add custom endpoint
baseRouter.post('/:id/reset', wrapAsync(async (req, res) => {
const payload = await Service.resetToGlobal(req.params.id, {
currentUser: req.currentUser,
});
res.status(200).json(payload);
}));
baseRouter.post(
'/:id/reset',
wrapAsync(async (req, res) => {
const payload = await Service.resetToGlobal(req.params.id, {
currentUser: req.currentUser,
});
res.status(200).json(payload);
}),
);
// Add another custom endpoint
baseRouter.get('/:id/diff', wrapAsync(async (req, res) => {
const payload = await Service.getDiffFromGlobal(req.params.id);
res.status(200).json(payload);
}));
baseRouter.get(
'/:id/diff',
wrapAsync(async (req, res) => {
const payload = await Service.getDiffFromGlobal(req.params.id);
res.status(200).json(payload);
}),
);
export default baseRouter;
```
@ -641,6 +740,7 @@ export default baseRouter;
### Service with Extended Methods
**Service (project_element_defaults.ts):**
```typescript
import DBApi from '../db/api/project_element_defaults.ts';
import { createEntityService } from '../factories/service.factory.ts';
@ -673,16 +773,22 @@ Alternative approach using the options callback:
```javascript
module.exports = createEntityRouter('entities', Service, DBApi, {
customRoutes: (router, Service, DBApi) => {
router.post('/:id/custom-action', wrapAsync(async (req, res) => {
const result = await Service.customAction(req.params.id);
res.status(200).json(result);
}));
router.post(
'/:id/custom-action',
wrapAsync(async (req, res) => {
const result = await Service.customAction(req.params.id);
res.status(200).json(result);
}),
);
router.get('/stats', wrapAsync(async (req, res) => {
const stats = await DBApi.getStatistics();
res.status(200).json(stats);
}));
}
router.get(
'/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)
| Entity | Permission Override | Custom Routes |
|--------|--------------------|--------------|
| `access_logs` | - | No |
| `asset_variants` | - | No |
| `assets` | - | No |
| `element_type_defaults` | - | No |
| `permissions` | - | No |
| `presigned_url_requests` | - | No |
| `project_audio_tracks` | - | No |
| `project_element_defaults` | `page_elements` | Yes (reset, diff) |
| `project_memberships` | - | No |
| `publish_events` | - | No |
| `pwa_caches` | - | No |
| `roles` | - | No |
| `tour_pages` | - | No |
| Entity | Permission Override | Custom Routes |
| -------------------------- | ------------------- | ----------------- |
| `access_logs` | - | No |
| `asset_variants` | - | No |
| `assets` | - | No |
| `element_type_defaults` | - | No |
| `permissions` | - | No |
| `presigned_url_requests` | - | No |
| `project_audio_tracks` | - | No |
| `project_element_defaults` | `page_elements` | Yes (reset, diff) |
| `project_memberships` | - | No |
| `publish_events` | - | No |
| `pwa_caches` | - | No |
| `roles` | - | No |
| `tour_pages` | - | No |
### Entities Using Service Factory (11)
| Entity | Custom Methods |
|--------|---------------|
| `access_logs` | No |
| `asset_variants` | No |
| `assets` | No |
| `element_type_defaults` | No |
| `permissions` | No |
| `presigned_url_requests` | No |
| `pwa_caches` | No |
| `publish_events` | No |
| `tour_pages` | No |
| Entity | Custom Methods |
| -------------------------- | -------------------------------------------------------------- |
| `access_logs` | No |
| `asset_variants` | No |
| `assets` | No |
| `element_type_defaults` | No |
| `permissions` | No |
| `presigned_url_requests` | No |
| `pwa_caches` | No |
| `publish_events` | No |
| `tour_pages` | No |
| `project_element_defaults` | Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults) |
| `project_memberships` | No |
| `project_memberships` | No |
### Entities NOT Using Factories
Some entities have custom implementations due to specialized requirements:
| Entity | Reason |
|--------|--------|
| `users` | Complex auth, password hashing, token management |
| `projects` | Publishing workflow, complex business logic |
| `auth` | Authentication flows (login, OAuth, password reset) |
| `file` | File upload/download, S3/GCloud/Local storage |
| `search` | Full-text search across multiple entities |
| `publish` | Multi-step publishing workflow |
| Entity | Reason |
| ---------- | --------------------------------------------------- |
| `users` | Complex auth, password hashing, token management |
| `projects` | Publishing workflow, complex business logic |
| `auth` | Authentication flows (login, OAuth, password reset) |
| `file` | File upload/download, S3/GCloud/Local storage |
| `search` | Full-text search across multiple entities |
| `publish` | Multi-step publishing workflow |
---
@ -797,15 +903,19 @@ HTTP Response
## Design Patterns
### Factory Pattern
Both `createEntityRouter` and `createEntityService` implement the Factory pattern, creating objects (router, service class) without specifying their exact classes.
### 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`).
### Strategy Pattern
The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding `permissionEntity` option.
### Decorator Pattern
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
Use factories when:
- Entity requires standard CRUD operations
- No complex business logic beyond data transformation
- Permissions follow standard READ/CREATE/UPDATE/DELETE pattern
@ -823,6 +934,7 @@ Use factories when:
### When NOT to Use Factories
Don't use factories when:
- Complex multi-step workflows (use custom service)
- Special authentication (OAuth flows, password reset)
- 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.
**Files:**
| File | Lines | Purpose |
|------|-------|---------|
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
| File | Lines | Purpose |
| -------------------------------------- | --------------------------------------------- | ------------------------------------------------------- |
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
| `src/middlewares/check-permissions.ts` | RBAC permission checking through AccessPolicy |
| `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/upload.ts` | 34 | Multer-based file upload handling |
| `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/upload.ts` | 34 | Multer-based file upload handling |
---
@ -126,19 +127,21 @@ setInterval(() => {
Creates a configurable rate limiter middleware.
**Parameters:**
| Parameter | Type | Default | Description |
|-----------|------|---------|-------------|
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
| `windowMs` | number | `900000` (15min) | Time window in milliseconds |
| `max` | number | `100` | Maximum requests per window |
| `message` | string | `'Too many requests...'` | Error message on limit |
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
| Parameter | Type | Default | Description |
| -------------------- | -------- | ------------------------ | -------------------------------------- |
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
| `windowMs` | number | `900000` (15min) | Time window in milliseconds |
| `max` | number | `100` | Maximum requests per window |
| `message` | string | `'Too many requests...'` | Error message on limit |
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
**Returns:** Express middleware function
**Response Headers:**
```
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 99
@ -147,6 +150,7 @@ Retry-After: 300 (only when limit exceeded)
```
**Rate Limit Exceeded Response (429):**
```json
{
"error": "Too Many Requests",
@ -174,14 +178,14 @@ const createAuthenticatedRateLimiter = (options = {}) => {
#### Pre-configured Limiters
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
|---------|------------|--------|-----|-------------|----------|
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
| ---------------------- | ---------------- | ------ | --- | ----------- | -------------- |
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
#### Route Mapping
@ -238,6 +242,7 @@ fetchAndCachePublicRole();
Creates middleware that checks if user has specific permission.
**Permission Check Flow:**
```
1. AccessPolicy.hasPermission(user, permission)
├── 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`.
**Usage:**
```javascript
const { checkPermissions } = require('./middlewares/check-permissions');
@ -265,6 +271,7 @@ router.get('/users', checkPermissions('READ_USERS'), handler);
```
**Error Response (403):**
```json
{
"message": "Forbidden"
@ -276,17 +283,19 @@ router.get('/users', checkPermissions('READ_USERS'), handler);
Creates middleware that maps HTTP method to CRUD permission.
**Method Mapping:**
| HTTP Method | Permission Prefix |
|-------------|-------------------|
| `POST` | `CREATE_` |
| `GET` | `READ_` |
| `PUT` | `UPDATE_` |
| `PATCH` | `UPDATE_` |
| `DELETE` | `DELETE_` |
| ----------- | ----------------- |
| `POST` | `CREATE_` |
| `GET` | `READ_` |
| `PUT` | `UPDATE_` |
| `PATCH` | `UPDATE_` |
| `DELETE` | `DELETE_` |
**Permission Name Format:** `{METHOD}_{ENTITY}`
Examples:
- `GET /api/users``READ_USERS`
- `POST /api/projects``CREATE_PROJECTS`
- `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`.
**Usage:**
```javascript
const { checkCrudPermissions } = require('./middlewares/check-permissions');
@ -352,7 +362,7 @@ For public read bypass to work, the middleware that sets `req.isRuntimePublicReq
```javascript
// ❌ WRONG - allowPublicRead runs AFTER checkCrudPermissions
router.use(checkCrudPermissions('entity'));
router.get('/', allowPublicRead, handler); // Too late!
router.get('/', allowPublicRead, handler); // Too late!
// ✅ CORRECT - allowPublicRead runs BEFORE checkCrudPermissions
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.
**Headers:**
| Header | Values | Description |
|--------|--------|-------------|
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
| `X-Runtime-Project-Slug` | string | Project identifier |
| Header | Values | Description |
| ------------------------ | ---------------------------- | ------------------- |
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
| `X-Runtime-Project-Slug` | string | Project identifier |
**Context Object:**
```javascript
req.runtimeContext = {
mode: 'admin', // Default mode
projectSlug: null, // Extracted from path or header
headerEnvironment: 'production', // From X-Runtime-Environment
headerProjectSlug: 'my-tour' // From X-Runtime-Project-Slug
mode: 'admin', // Default mode
projectSlug: null, // Extracted from path or header
headerEnvironment: 'production', // From X-Runtime-Environment
headerProjectSlug: 'my-tour', // From X-Runtime-Project-Slug
};
```
**Usage in Routes:**
```javascript
// index.js
app.use(runtimeContextMiddleware);
@ -401,11 +414,12 @@ if (env === 'production') {
```
**Route-Based Environment Access:**
| Route | Environment | Access |
|-------|-------------|--------|
| `/p/[slug]` | `production` | Public (no auth) |
| `/p/[slug]/stage` | `stage` | Authenticated only |
| `/constructor?projectId=` | `dev` | Authenticated only |
| Route | Environment | Access |
| ------------------------- | ------------ | ------------------ |
| `/p/[slug]` | `production` | Public (no auth) |
| `/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
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
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: [
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
'sort_order', 'background_image_url', 'background_video_url',
'background_audio_url', 'background_loop', 'requires_auth', 'ui_schema_json',
'id',
'projectId',
'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: [
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
'url', 'loop', 'volume', 'sort_order', 'is_enabled',
'id',
'projectId',
'environment',
'source_key',
'name',
'slug',
'url',
'loop',
'volume',
'sort_order',
'is_enabled',
],
};
```
@ -459,10 +498,12 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
```
**Blocked:**
- Individual record access: `GET /api/projects/123` → 404
- CSV exports: `GET /api/projects?filetype=csv` → 404
**Allowed:**
- List endpoints: `GET /api/projects/` → Continue
#### Function: sanitizePublicRuntimeListResponse(entityName)
@ -491,27 +532,33 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
```
**Before Sanitization:**
```json
{
"rows": [{
"id": "123",
"name": "My Tour",
"slug": "my-tour",
"createdAt": "2024-01-01",
"createdById": "user-456",
"internalNotes": "sensitive data"
}]
"rows": [
{
"id": "123",
"name": "My Tour",
"slug": "my-tour",
"createdAt": "2024-01-01",
"createdById": "user-456",
"internalNotes": "sensitive data"
}
]
}
```
**After Sanitization:**
```json
{
"rows": [{
"id": "123",
"name": "My Tour",
"slug": "my-tour"
}]
"rows": [
{
"id": "123",
"name": "My Tour",
"slug": "my-tour"
}
]
}
```
@ -552,13 +599,15 @@ module.exports = processFileMiddleware;
```
**Configuration:**
| Setting | Value |
|---------|-------|
| Storage | Memory (Buffer) |
| Field Name | `file` |
| Max Files | 1 (single) |
| Setting | Value |
| ---------- | --------------- |
| Storage | Memory (Buffer) |
| Field Name | `file` |
| Max Files | 1 (single) |
**Usage:**
```javascript
const upload = require('./middlewares/upload');
@ -729,8 +778,8 @@ Content-Type: multipart/form-data
### Environment Variables
| Variable | Affects | Description |
|----------|---------|-------------|
| Variable | Affects | Description |
| ---------- | ------------- | ----------------------------- |
| `NODE_ENV` | Rate limiting | Skip localhost in development |
### Constants
@ -749,8 +798,12 @@ const METHOD_MAP = {
};
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
'PROJECTS', 'TOUR_PAGES', 'PAGE_ELEMENTS',
'PAGE_LINKS', 'TRANSITIONS', 'PROJECT_AUDIO_TRACKS',
'PROJECTS',
'TOUR_PAGES',
'PAGE_ELEMENTS',
'PAGE_LINKS',
'TRANSITIONS',
'PROJECT_AUDIO_TRACKS',
]);
// runtime-public.ts
@ -761,12 +814,13 @@ const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
## Dependencies
| Package | Version | Purpose |
|---------|---------|---------|
| `multer` | ^1.4.5 | Multipart form data parsing |
| `util` | built-in | Promisify multer |
| Package | Version | Purpose |
| -------- | -------- | --------------------------- |
| `multer` | ^1.4.5 | Multipart form data parsing |
| `util` | built-in | Promisify multer |
**Internal Dependencies:**
- `../utils/logger` - Pino logger for rate limit logging
- `../services/notifications/errors/validation` - ValidationError class
- `../db/api/roles` - RolesDBApi for Public role
@ -835,6 +889,7 @@ The Middleware module provides:
5. **upload.ts** - Simple Multer-based file upload
**Key Features:**
- Configurable rate limiting per endpoint type
- Role-based permission checking with method-to-CRUD mapping
- Public runtime access for production presentations

View File

@ -107,7 +107,8 @@ const errors = {
error: 'Email not recognized',
},
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',
emailAddressVerificationEmail: {
@ -133,7 +134,8 @@ const errors = {
errors: {
invalidFileEmpty: 'The file is empty',
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',
importHashExistent: 'Data has already been imported',
userEmailMissing: 'Some items in the CSV do not have an email',
@ -173,14 +175,14 @@ module.exports = errors;
### Message Categories
| Category | Purpose | Example Key |
|----------|---------|-------------|
| `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userNotFound` |
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic error messages | `errors.validation.message` |
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
| Category | Purpose | Example Key |
| ---------- | -------------------------- | ---------------------------------- |
| `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userNotFound` |
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic error messages | `errors.validation.message` |
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
---
@ -228,7 +230,7 @@ const getNotification = (key, ...args) => {
const message = _get(errors, key);
if (!message) {
return key; // Return raw key as fallback
return key; // Return raw key as fallback
}
return format(message, args);
@ -252,8 +254,8 @@ getNotification('emails.invitation.subject', 'Tour Builder Platform');
// → "You've been invited to Tour Builder Platform"
// Check if key exists
isNotification('auth.userNotFound'); // → true
isNotification('custom.message'); // → false
isNotification('auth.userNotFound'); // → true
isNotification('custom.message'); // → false
// Unknown key returns the key itself
getNotification('unknown.key');
@ -290,10 +292,12 @@ module.exports = class ValidationError extends Error {
```
**Properties:**
- `message` - Human-readable error message
- `code` - HTTP status code (400)
**Usage:**
```javascript
const ValidationError = require('./notifications/errors/validation');
@ -336,10 +340,12 @@ module.exports = class ForbiddenError extends Error {
```
**Properties:**
- `message` - Human-readable error message
- `code` - HTTP status code (403)
**Usage:**
```javascript
const ForbiddenError = require('./notifications/errors/forbidden');
@ -358,62 +364,62 @@ throw new ForbiddenError();
### Authentication Messages (`auth.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `auth.userDisabled` | "Your account is disabled" | signin |
| `auth.forbidden` | "Forbidden" | authorization failures |
| `auth.unauthorized` | "Unauthorized" | missing authentication |
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
| `auth.invalidEmail` | "Please provide a valid email" | signup |
| `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.error` | "Email not recognized" | password reset request |
| `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.error` | "Email not recognized" | email verification |
| Key | Message | Used In |
| ------------------------------------------------- | --------------------------------------------------- | ----------------------- |
| `auth.userDisabled` | "Your account is disabled" | signin |
| `auth.forbidden` | "Forbidden" | authorization failures |
| `auth.unauthorized` | "Unauthorized" | missing authentication |
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
| `auth.invalidEmail` | "Please provide a valid email" | signup |
| `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.error` | "Email not recognized" | password reset request |
| `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.error` | "Email not recognized" | email verification |
### IAM Messages (`iam.errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
| `iam.errors.userNotFound` | "User not found" | user update/delete |
| `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.deletingHimself` | "You can't delete yourself" | user delete |
| `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.searchQueryRequired` | "Search query is required" | search |
| Key | Message | Used In |
| ---------------------------------- | ------------------------------------------------ | --------------------- |
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
| `iam.errors.userNotFound` | "User not found" | user update/delete |
| `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.deletingHimself` | "You can't delete yourself" | user delete |
| `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.searchQueryRequired` | "Search query is required" | search |
### Importer Messages (`importer.errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
| `importer.errors.importHashRequired` | "Import hash is required" | bulk 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 |
| Key | Message | Used In |
| ------------------------------------ | -------------------------------------------- | ---------------- |
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
| `importer.errors.importHashRequired` | "Import hash is required" | bulk 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 |
### Generic Messages (`errors.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
| `errors.validation.message` | "An error occurred" | ValidationError default |
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
| Key | Message | Used In |
| ------------------------------------ | -------------------------- | ----------------------- |
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
| `errors.validation.message` | "An error occurred" | ValidationError default |
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
### Email Messages (`emails.*`)
| Key | Message | Used In |
|-----|---------|---------|
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
| Key | Message | Used In |
| ----------------------------------------- | ----------------------------- | ------------------ |
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
---
@ -421,27 +427,27 @@ throw new ForbiddenError();
### Services Using Notifications
| Service | Errors Used | Common Keys |
|---------|-------------|-------------|
| `auth.js` | ValidationError, ForbiddenError | auth.*, iam.* |
| `users.ts` | ValidationError | iam.errors.* |
| `projects.ts` | ValidationError | projectsNotFound |
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
| Service | Errors Used | Common Keys |
| ------------------------- | ------------------------------- | ------------------------------------------------ |
| `auth.js` | ValidationError, ForbiddenError | auth._, iam._ |
| `users.ts` | ValidationError | iam.errors.* |
| `projects.ts` | ValidationError | projectsNotFound |
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
### Email Templates Using Notifications
| Template | Helper Usage |
|----------|--------------|
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
| Template | Helper Usage |
| ------------------------ | ------------------------------------------------------------ |
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
| `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` |
| `invitation.js` | `getNotification('emails.invitation.subject')` |
| `invitation.js` | `getNotification('emails.invitation.subject')` |
### Middleware Using Notifications
| Middleware | Error Used | Purpose |
|------------|------------|---------|
| Middleware | Error Used | Purpose |
| ---------------------- | --------------- | --------------------------- |
| `check-permissions.ts` | ValidationError | Permission denied responses |
### Service Factory Using Notifications
@ -596,13 +602,13 @@ class ValidationError extends AppError {
### When to Use Which
| Scenario | Use |
|----------|-----|
| Scenario | Use |
| ----------------------------- | -------------------------------------- |
| Service business logic errors | `notifications/errors/ValidationError` |
| Authorization failures | `notifications/errors/ForbiddenError` |
| Email subject/body text | `getNotification()` |
| Low-level utility errors | `utils/errors.js` |
| Errors needing details object | `utils/errors.js` |
| Authorization failures | `notifications/errors/ForbiddenError` |
| Email subject/body text | `getNotification()` |
| Low-level utility errors | `utils/errors.js` |
| Errors needing details object | `utils/errors.js` |
---
@ -619,7 +625,8 @@ const notifications = {
errors: {
notFound: 'Project not found',
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', () => {
it('should return message for valid key', () => {
expect(getNotification('auth.userNotFound')).toBe(
"Sorry, we don't recognize your credentials"
"Sorry, we don't recognize your credentials",
);
});
it('should substitute parameters', () => {
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
| Package | Version | Purpose |
|---------|---------|---------|
| `lodash/get` | ^4.x | Deep object property access |
| Package | Version | Purpose |
| ------------ | ------- | --------------------------- |
| `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/`
**Files (25 total):**
| File | Lines | Pattern | Description |
|------|-------|---------|-------------|
| `auth.ts` | 327 | Custom | Authentication endpoints |
| `file.ts` | 150 | Custom | File upload/download endpoints |
| `publish.ts` | 107 | Custom | Publishing workflow endpoints |
| `search.ts` | 64 | Custom | Global search endpoint |
| `runtime-context.ts` | 16 | Custom | Runtime context inspection |
| `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint |
| `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID |
| `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status |
| `roles.ts` | 141 | Factory | Roles CRUD via factory |
| `permissions.ts` | 188 | Factory | Permissions CRUD via factory |
| `assets.ts` | 155 | Factory | Assets CRUD via factory |
| `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory |
| `access_logs.ts` | 145 | Factory | Access logs CRUD via factory |
| `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory |
| `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory |
| `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults |
| `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults |
| `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides |
| `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides |
| `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory |
| `publish_events.ts` | 157 | Factory | Publish events CRUD via factory |
| `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory |
| `element_type_defaults.ts` | 12 | Factory | Element type defaults via factory |
| `project_element_defaults.ts` | 92 | Hybrid | Project element defaults CRUD + custom (reset, diff) |
| File | Lines | Pattern | Description |
| -------------------------------- | ----- | ----------- | ------------------------------------------------------------- |
| `auth.ts` | 327 | Custom | Authentication endpoints |
| `file.ts` | 150 | Custom | File upload/download endpoints |
| `publish.ts` | 107 | Custom | Publishing workflow endpoints |
| `search.ts` | 64 | Custom | Global search endpoint |
| `runtime-context.ts` | 16 | Custom | Runtime context inspection |
| `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint |
| `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID |
| `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status |
| `roles.ts` | 141 | Factory | Roles CRUD via factory |
| `permissions.ts` | 188 | Factory | Permissions CRUD via factory |
| `assets.ts` | 155 | Factory | Assets CRUD via factory |
| `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory |
| `access_logs.ts` | 145 | Factory | Access logs CRUD via factory |
| `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory |
| `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory |
| `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults |
| `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults |
| `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides |
| `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides |
| `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory |
| `publish_events.ts` | 157 | Factory | Publish events CRUD via factory |
| `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory |
| `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
const router = createEntityRouter(
'tour_pages', // Entity name
Tour_pagesService, // Service class
Tour_pagesDBApi, // Database API class
'tour_pages', // Entity name
Tour_pagesService, // Service class
Tour_pagesDBApi, // Database API class
{
permissionEntity: 'tour_pages', // Permission entity name (optional)
csvFields: ['id', 'name'], // CSV export fields (optional)
validation: { // Request validation overrides (optional)
permissionEntity: 'tour_pages', // Permission entity name (optional)
csvFields: ['id', 'name'], // CSV export fields (optional)
validation: {
// Request validation overrides (optional)
create: customCreateSchema,
update: customUpdateSchema,
},
customRoutes: (router, Service, DBApi) => { // Custom routes (optional)
customRoutes: (router, Service, DBApi) => {
// Custom routes (optional)
router.post('/custom', handler);
}
}
},
},
);
```
#### Generated Endpoints
| Method | Path | Description |
|--------|------|-------------|
| `POST` | `/` | Create new item |
| `POST` | `/bulk-import` | Bulk import items |
| `PUT` | `/:id` | Update item by ID |
| `DELETE` | `/:id` | Delete item by ID |
| `POST` | `/deleteByIds` | Delete multiple items |
| `GET` | `/` | List items (with pagination, filters) |
| `GET` | `/count` | Count items matching filters |
| `GET` | `/autocomplete` | Autocomplete search |
| `GET` | `/:id` | Get single item by ID |
| Method | Path | Description |
| -------- | --------------- | ------------------------------------- |
| `POST` | `/` | Create new item |
| `POST` | `/bulk-import` | Bulk import items |
| `PUT` | `/:id` | Update item by ID |
| `DELETE` | `/:id` | Delete item by ID |
| `POST` | `/deleteByIds` | Delete multiple items |
| `GET` | `/` | List items (with pagination, filters) |
| `GET` | `/count` | Count items matching filters |
| `GET` | `/autocomplete` | Autocomplete search |
| `GET` | `/:id` | Get single item by ID |
#### Factory Features
**Permission Checking:**
```javascript
router.use(checkCrudPermissions(permissionEntity));
// Maps HTTP methods to permissions:
@ -142,6 +146,7 @@ router.use(checkCrudPermissions(permissionEntity));
```
**Request Validation:**
```typescript
import { validateRequest } from '../middlewares/validate-request.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.
**CSV Export:**
```javascript
// GET /?filetype=csv
if (filetype === 'csv') {
@ -168,6 +174,7 @@ if (filetype === 'csv') {
```
**Runtime Context:**
```javascript
const runtimeContext = req.runtimeContext;
const payload = await DBApi.findAll(req.query, {
@ -226,22 +233,22 @@ mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', ...
Authentication and account management.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/signin/local` | No | Email/password login |
| POST | `/signup` | No | Register new user |
| GET | `/me` | JWT | Get current user |
| PUT | `/password-reset` | No | Reset password with token |
| PUT | `/password-update` | JWT | Change password |
| PUT | `/profile` | JWT | Update user profile |
| PUT | `/verify-email` | No | Verify email with token |
| POST | `/send-email-address-verification-email` | JWT | Resend verification |
| POST | `/send-password-reset-email` | No | Send reset email |
| GET | `/email-configured` | No | Check email config |
| GET | `/signin/google` | No | Google OAuth start |
| GET | `/signin/google/callback` | No | Google OAuth callback |
| GET | `/signin/microsoft` | No | Microsoft OAuth start |
| GET | `/signin/microsoft/callback` | No | Microsoft OAuth callback |
| Method | Path | Auth | Description |
| ------ | ---------------------------------------- | ---- | ------------------------- |
| POST | `/signin/local` | No | Email/password login |
| POST | `/signup` | No | Register new user |
| GET | `/me` | JWT | Get current user |
| PUT | `/password-reset` | No | Reset password with token |
| PUT | `/password-update` | JWT | Change password |
| PUT | `/profile` | JWT | Update user profile |
| PUT | `/verify-email` | No | Verify email with token |
| POST | `/send-email-address-verification-email` | JWT | Resend verification |
| POST | `/send-password-reset-email` | No | Send reset email |
| GET | `/email-configured` | No | Check email config |
| GET | `/signin/google` | No | Google OAuth start |
| GET | `/signin/google/callback` | No | Google OAuth callback |
| GET | `/signin/microsoft` | No | Microsoft OAuth start |
| 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`.
@ -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.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/download` | No | Download file by privateUrl |
| POST | `/presign` | No | Generate presigned URLs (max 50) |
| POST | `/upload/:table/:field` | JWT | Legacy single file upload |
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
| PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk |
| POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload |
| Method | Path | Auth | Description |
| ------ | ------------------------------------------------ | ---- | -------------------------------- |
| GET | `/download` | No | Download file by privateUrl |
| POST | `/presign` | No | Generate presigned URLs (max 50) |
| POST | `/upload/:table/:field` | JWT | Legacy single file upload |
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
| PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk |
| POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload |
**Presigned URLs Request:**
```json
{
"urls": ["assets/image.jpg", "assets/video.mp4"]
@ -271,6 +279,7 @@ File upload and download operations.
```
**Presigned URLs Response:**
```json
{
"presignedUrls": {
@ -286,13 +295,14 @@ File upload and download operations.
Publishing workflow for Dev → Stage → Production.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/` | JWT | Publish stage to production |
| POST | `/publish` | JWT | Alias for publish |
| POST | `/save-to-stage` | JWT | Save dev to stage |
| Method | Path | Auth | Description |
| ------ | ---------------- | ---- | --------------------------- |
| POST | `/` | JWT | Publish stage to production |
| POST | `/publish` | JWT | Alias for publish |
| POST | `/save-to-stage` | JWT | Save dev to stage |
**Publish Request:**
```json
{
"projectId": "uuid",
@ -302,6 +312,7 @@ Publishing workflow for Dev → Stage → Production.
```
**Save to Stage Request:**
```json
{
"projectId": "uuid"
@ -314,11 +325,12 @@ Publishing workflow for Dev → Stage → Production.
Global full-text search across entities.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| POST | `/` | JWT | Search across all entities |
| Method | Path | Auth | Description |
| ------ | ---- | ---- | -------------------------- |
| POST | `/` | JWT | Search across all entities |
**Request:**
```json
{
"searchQuery": "my search term"
@ -326,6 +338,7 @@ Global full-text search across entities.
```
**Response:**
```json
{
"users": [...],
@ -338,11 +351,12 @@ Global full-text search across entities.
Runtime context inspection for debugging.
| Method | Path | Auth | Description |
|--------|------|------|-------------|
| GET | `/` | No | Get current runtime context |
| Method | Path | Auth | Description |
| ------ | ---- | ---- | --------------------------- |
| GET | `/` | No | Get current runtime context |
**Response:**
```json
{
"mode": "admin",
@ -361,22 +375,25 @@ Runtime context inspection for debugging.
Projects with factory CRUD plus a clone endpoint.
**Standard CRUD Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/` | Create project |
| POST | `/bulk-import` | Bulk import projects |
| PUT | `/:id` | Update project |
| DELETE | `/:id` | Delete project |
| POST | `/deleteByIds` | Delete multiple projects |
| GET | `/` | List projects |
| GET | `/count` | Count projects |
| GET | `/autocomplete` | Autocomplete search |
| GET | `/:id` | Get project by ID |
| Method | Path | Description |
| ------ | --------------- | ------------------------ |
| POST | `/` | Create project |
| POST | `/bulk-import` | Bulk import projects |
| PUT | `/:id` | Update project |
| DELETE | `/:id` | Delete project |
| POST | `/deleteByIds` | Delete multiple projects |
| GET | `/` | List projects |
| GET | `/count` | Count projects |
| GET | `/autocomplete` | Autocomplete search |
| GET | `/:id` | Get project by ID |
**Custom Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/:id/clone` | Clone project with all pages |
| Method | Path | Description |
| ------ | ------------ | ---------------------------- |
| POST | `/:id/clone` | Clone project with all pages |
---
### 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`.
**Schema Fields:**
- `source_key`, `name`, `slug`
- `background_image_url`, `background_video_url`, `background_audio_url`
- `ui_schema_json`, `sort_order`
**Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/` | Create page |
| POST | `/bulk-import` | Bulk import pages |
| POST | `/reorder` | Reorder dev pages in a project/environment |
| POST | `/:id/duplicate` | Duplicate a dev page |
| PUT | `/:id` | Update page |
| DELETE | `/:id` | Remove page |
| POST | `/deleteByIds` | Remove multiple pages |
| GET | `/` | List pages with reverse video URL population and optional CSV export |
| POST | `/reverse-video-status` | Check generated reverse-video variants |
| GET | `/count` | Count pages |
| GET | `/autocomplete` | Page autocomplete |
| GET | `/:id` | Get one page with reverse video URL population |
| Method | Path | Description |
| ------ | ----------------------- | -------------------------------------------------------------------- |
| POST | `/` | Create page |
| POST | `/bulk-import` | Bulk import pages |
| POST | `/reorder` | Reorder dev pages in a project/environment |
| POST | `/:id/duplicate` | Duplicate a dev page |
| PUT | `/:id` | Update page |
| DELETE | `/:id` | Remove page |
| POST | `/deleteByIds` | Remove multiple pages |
| GET | `/` | List pages with reverse video URL population and optional CSV export |
| POST | `/reverse-video-status` | Check generated reverse-video variants |
| GET | `/count` | Count pages |
| GET | `/autocomplete` | Page autocomplete |
| GET | `/:id` | Get one page with reverse video URL population |
- `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`.
- `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
regenerates inline element IDs.
- Constructor page deletion uses the standard `DELETE /api/tour_pages/:id`
@ -431,12 +451,13 @@ module.exports = createEntityRouter(
Element_type_defaultsService,
Element_type_defaultsDBApi,
{
permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions
permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions
},
);
```
**URL Aliases:**
- `/api/element-type-defaults` (primary)
- `/api/ui-elements` (backwards compatibility)
@ -462,21 +483,24 @@ baseRouter.get('/:id/diff', ...);
```
**Standard CRUD Endpoints:** (via factory)
| Method | Path | Description |
|--------|------|-------------|
| POST | `/` | Create project element default |
| PUT | `/:id` | Update project element default |
| DELETE | `/:id` | Delete project element default |
| GET | `/` | List project element defaults |
| GET | `/:id` | Get project element default by ID |
| Method | Path | Description |
| ------ | ------ | --------------------------------- |
| POST | `/` | Create project element default |
| PUT | `/:id` | Update project element default |
| DELETE | `/:id` | Delete project element default |
| GET | `/` | List project element defaults |
| GET | `/:id` | Get project element default by ID |
**Custom Endpoints:**
| Method | Path | Description |
|--------|------|-------------|
| POST | `/:id/reset` | Reset project element default to global |
| GET | `/:id/diff` | Get diff from global element type default |
| Method | Path | Description |
| ------ | ------------ | ----------------------------------------- |
| POST | `/:id/reset` | Reset project element default to global |
| GET | `/:id/diff` | Get diff from global element type default |
**URL Alias:**
- `/api/project-element-defaults` (primary)
---
@ -486,17 +510,19 @@ baseRouter.get('/:id/diff', ...);
### List Endpoint (GET /)
**Query Parameters:**
| Param | Type | Description |
|-------|------|-------------|
| `page` | number | Page number (0-indexed) |
| `limit` | number | Items per page |
| `field` | string | Sort field |
| `sort` | string | Sort direction (`asc`/`desc`) |
| `filetype` | string | Export format (`csv`) |
| `[fieldName]` | string | Filter by field value |
| `[fieldName]Range` | array | Filter by range `[start, end]` |
| Param | Type | Description |
| ------------------ | ------ | ------------------------------ |
| `page` | number | Page number (0-indexed) |
| `limit` | number | Items per page |
| `field` | string | Sort field |
| `sort` | string | Sort direction (`asc`/`desc`) |
| `filetype` | string | Export format (`csv`) |
| `[fieldName]` | string | Filter by field value |
| `[fieldName]Range` | array | Filter by range `[start, end]` |
**Response:**
```json
{
"rows": [...],
@ -507,6 +533,7 @@ baseRouter.get('/:id/diff', ...);
### Create Endpoint (POST /)
**Request:**
```json
{
"data": {
@ -517,6 +544,7 @@ baseRouter.get('/:id/diff', ...);
```
**Response:**
```json
{
"id": "uuid",
@ -529,6 +557,7 @@ baseRouter.get('/:id/diff', ...);
### Update Endpoint (PUT /:id)
**Request:**
```json
{
"id": "uuid",
@ -539,6 +568,7 @@ baseRouter.get('/:id/diff', ...);
```
**Response:**
```json
true
```
@ -546,11 +576,13 @@ true
### Delete Endpoints
**Single Delete (DELETE /:id):**
```json
true
```
**Bulk Delete (POST /deleteByIds):**
```json
{
"data": ["uuid1", "uuid2", "uuid3"]
@ -625,15 +657,16 @@ router.use('/', require('../helpers').commonErrorHandler);
```
**Error Response Mapping:**
| Status Code | Description |
|-------------|-------------|
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 409 | Conflict |
| 422 | Unprocessable Entity |
| 500 | Internal Server Error |
| Status Code | Description |
| ----------- | --------------------- |
| 400 | Bad Request |
| 401 | Unauthorized |
| 403 | Forbidden |
| 404 | Not Found |
| 409 | Conflict |
| 422 | Unprocessable Entity |
| 500 | Internal Server Error |
---
@ -641,49 +674,50 @@ router.use('/', require('../helpers').commonErrorHandler);
### Public Routes (No Auth)
| Route | Description |
|-------|-------------|
| `GET /api/health` | Health check |
| `POST /api/auth/signin/local` | Login |
| `GET /api/auth/signin/google` | Google OAuth |
| `GET /api/auth/signin/microsoft` | Microsoft OAuth |
| `GET /api/file/download` | File download |
| `POST /api/file/presign` | Generate presigned URLs |
| `GET /api/runtime-context` | Runtime context |
| Route | Description |
| -------------------------------- | ----------------------- |
| `GET /api/health` | Health check |
| `POST /api/auth/signin/local` | Login |
| `GET /api/auth/signin/google` | Google OAuth |
| `GET /api/auth/signin/microsoft` | Microsoft OAuth |
| `GET /api/file/download` | File download |
| `POST /api/file/presign` | Generate presigned URLs |
| `GET /api/runtime-context` | Runtime context |
### Runtime Public Routes (Production Environment)
| Route | Description |
|-------|-------------|
| `GET /api/projects` | List projects (sanitized) |
| `GET /api/tour_pages` | List tour pages (sanitized) |
| Route | Description |
| ------------------------------- | ----------------------------- |
| `GET /api/projects` | List projects (sanitized) |
| `GET /api/tour_pages` | List tour pages (sanitized) |
| `GET /api/project_audio_tracks` | List audio tracks (sanitized) |
### Authenticated Routes (JWT Required)
All other routes require JWT authentication via:
```javascript
app.use('/api/users', jwtAuth, usersRoutes);
```
### Rate Limited Routes
| Route | Limiter | Config |
|-------|---------|--------|
| `/api/auth/signin/local` | authLimiter | 10/15min |
| `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour |
| `/api/file/upload*` | uploadLimiter | 10/min |
| `/api/file/download`, `/presign` | downloadLimiter | 200/min |
| `/api/search` | searchLimiter | 30/min |
| Route | Limiter | Config |
| ------------------------------------- | -------------------- | -------- |
| `/api/auth/signin/local` | authLimiter | 10/15min |
| `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour |
| `/api/file/upload*` | uploadLimiter | 10/min |
| `/api/file/download`, `/presign` | downloadLimiter | 200/min |
| `/api/search` | searchLimiter | 30/min |
## Dependencies
| Package | Purpose |
|---------|---------|
| `express` | Router and middleware |
| `json2csv` | CSV export functionality |
| `passport` | JWT authentication |
| `body-parser` | JSON body parsing |
| Package | Purpose |
| ------------- | ------------------------ |
| `express` | Router and middleware |
| `json2csv` | CSV export functionality |
| `passport` | JWT authentication |
| `body-parser` | JSON body parsing |
---
@ -750,6 +784,7 @@ The Routes module provides:
10. **Error Handling** - Centralized via wrapAsync and commonErrorHandler
**Route Statistics:**
- 26 route files
- ~50+ unique endpoints
- 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.
| Service | File | Entity | LOC |
| -------------------------- | ------------------------------- | ---------------------------------------------- | ------ |
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 |
| permissions | `permissions.ts` | Permissions | 6 |
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
| publish_events | `publish_events.ts` | Publish Events | 6 |
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
| access_logs | `access_logs.ts` | Access Logs | 6 |
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
| Service | File | Entity | LOC |
| -------------------------- | ------------------------------- | --------------------------------------------------------------------------- | ------ |
| tour_pages | `tour_pages.ts` | Tour Pages (reverse video generation plus targeted back-transition refresh) | ~1,500 |
| permissions | `permissions.ts` | Permissions | 6 |
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
| publish_events | `publish_events.ts` | Publish Events | 6 |
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
| access_logs | `access_logs.ts` | Access Logs | 6 |
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
**Example - Factory Service:**
@ -565,6 +565,16 @@ Phase G: Clone tour_pages, audio_tracks, element_defaults
- Primary assets: `assets/{projectId}/{uuid}.ext`
- 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:**
- 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.
**Locations:**
- `backend/src/utils/` - Core utilities (errors, logging, env validation, request context)
- `backend/src/helpers.ts` - Request helpers (async wrapper, error handler, JWT)
- `backend/src/db/utils.ts` - Database utilities
@ -47,7 +48,7 @@ class AppError extends Error {
super(message);
this.statusCode = statusCode;
this.details = details;
this.isOperational = true; // Distinguishes from programming errors
this.isOperational = true; // Distinguishes from programming errors
Error.captureStackTrace(this, this.constructor);
}
}
@ -55,16 +56,17 @@ class AppError extends Error {
**Error Types:**
| Class | Status Code | Default Message | Usage |
|-------|-------------|-----------------|-------|
| `AppError` | 500 | (custom) | Base class |
| `NotFoundError` | 404 | `{resource} not found` | Missing resources |
| `ValidationError` | 400 | (custom) | Invalid input |
| `ForbiddenError` | 403 | `Access denied` | Permission denied |
| `UnauthorizedError` | 401 | `Unauthorized` | Auth required |
| `ConflictError` | 409 | `Resource conflict` | Duplicate resources |
| Class | Status Code | Default Message | Usage |
| ------------------- | ----------- | ---------------------- | ------------------- |
| `AppError` | 500 | (custom) | Base class |
| `NotFoundError` | 404 | `{resource} not found` | Missing resources |
| `ValidationError` | 400 | (custom) | Invalid input |
| `ForbiddenError` | 403 | `Access denied` | Permission denied |
| `UnauthorizedError` | 401 | `Unauthorized` | Auth required |
| `ConflictError` | 409 | `Resource conflict` | Duplicate resources |
**Usage:**
```javascript
const { NotFoundError, ValidationError, ForbiddenError } = require('./utils');
@ -110,6 +112,7 @@ const logger = pino({
```
**Log Levels:**
- `fatal` - Unrecoverable errors
- `error` - Errors requiring attention
- `warn` - Warning conditions (400-499 responses)
@ -139,6 +142,7 @@ process.on('unhandledRejection', (reason) => {
```
**Request Logger Middleware:**
```javascript
function requestLogger(req, res, next) {
// Generate or use existing request ID
@ -162,9 +166,15 @@ function requestLogger(req, res, next) {
// Log level based on status code
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) {
getRequestLogger(req)?.warn(logData, 'Request completed with client error');
getRequestLogger(req)?.warn(
logData,
'Request completed with client error',
);
} else {
getRequestLogger(req)?.info(logData, 'Request completed');
}
@ -177,6 +187,7 @@ function requestLogger(req, res, next) {
**Log Output Examples:**
Development (pino-pretty):
```
[12:34:56.789] INFO (tour-builder-api): Request completed
requestId: "abc-123"
@ -187,11 +198,24 @@ Development (pino-pretty):
```
Production (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:**
```javascript
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.
**Schema Definition:**
```javascript
const Joi = require('joi');
@ -275,15 +300,16 @@ const envSchema = Joi.object({
LOG_LEVEL: Joi.string()
.valid('fatal', 'error', 'warn', 'info', 'debug', 'trace')
.default('info'),
}).unknown(true); // Allow additional env vars
}).unknown(true); // Allow additional env vars
```
**Validation Function:**
```javascript
function validateEnv() {
const { error, value } = envSchema.validate(process.env, {
abortEarly: false, // Report all errors
stripUnknown: false, // Keep unknown vars
abortEarly: false, // Report all errors
stripUnknown: false, // Keep unknown vars
});
if (error) {
@ -298,22 +324,22 @@ function validateEnv() {
}
}
return value; // Returns validated/defaulted values
return value; // Returns validated/defaulted values
}
```
**Environment Variable Categories:**
| Category | Variables | Required |
|----------|-----------|----------|
| **Server** | `NODE_ENV`, `PORT` | Defaults |
| **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | 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 |
| **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 |
| **External APIs** | `PEXELS_KEY` | Optional |
| **Logging** | `LOG_LEVEL` | Defaults |
| Category | Variables | Required |
| ----------------- | ----------------------------------------------------------------------------------------------- | -------- |
| **Server** | `NODE_ENV`, `PORT` | Defaults |
| **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | 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 |
| **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 |
| **External APIs** | `PEXELS_KEY` | Optional |
| **Logging** | `LOG_LEVEL` | Defaults |
---
@ -330,6 +356,7 @@ module.exports = {
```
**Exported:**
- `AppError`, `NotFoundError`, `ValidationError`, `ForbiddenError`, `UnauthorizedError`, `ConflictError`
- `logger`, `requestLogger`, `registerProcessErrorHandlers`,
`exitAfterLogging`,
@ -382,12 +409,12 @@ module.exports = class Helpers {
**Functions:**
| Function | Purpose | Usage |
|----------|---------|-------|
| `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers |
| `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware |
| `jwtSign(data)` | Creates JWT with 6h expiry | Auth service |
| `isUuidV4(value)` | Validates UUID v4 format | Route parameter validation |
| Function | Purpose | Usage |
| ----------------------------------------- | ---------------------------------------- | -------------------------- |
| `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers |
| `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware |
| `jwtSign(data)` | Creates JWT with 6h expiry | Auth service |
| `isUuidV4(value)` | Validates UUID v4 format | Route parameter 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.
**Usage Pattern:**
```javascript
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
// Async route handler
router.get('/users/:id', wrapAsync(async (req, res) => {
if (!isUuidV4(req.params.id)) {
return res.status(400).send('Invalid ID format');
}
router.get(
'/users/:id',
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);
res.json(user);
}));
const user = await UserService.findOne(req.params.id);
res.json(user);
}),
);
// Register error handler at end of router
router.use('/', commonErrorHandler);
@ -489,19 +520,21 @@ module.exports = class Utils {
**Functions:**
| Function | Purpose | Returns |
|----------|---------|---------|
| `isValidUuid(value)` | Check if value is a valid UUID | `boolean` |
| `generateUuid()` | Generate a new UUID v4 | `string` |
| `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` |
| `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause |
| Function | Purpose | Returns |
| ----------------------------- | -------------------------------- | ---------------------- |
| `isValidUuid(value)` | Check if value is a valid UUID | `boolean` |
| `generateUuid()` | Generate a new UUID v4 | `string` |
| `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` |
| `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause |
**UUID Validation Behavior:**
- 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 UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
**Usage in DB API:**
```javascript
const Utils = require('../utils');
@ -596,7 +629,8 @@ const errors = {
errors: {
invalidFileEmpty: 'The file is empty',
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',
importHashExistent: 'Data has already been imported',
userEmailMissing: 'Some items in the CSV do not have an email',
@ -628,14 +662,14 @@ const errors = {
**Message Categories:**
| Category | Purpose | Examples |
|----------|---------|----------|
| `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` |
| `iam` | User management errors | `iam.errors.userAlreadyExists` |
| `importer` | Import/export errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic errors | `errors.forbidden.message` |
| `emails` | Email templates | `emails.invitation.subject` |
| Category | Purpose | Examples |
| ---------- | ---------------------- | ----------------------------------------- |
| `app` | Application metadata | `app.title` |
| `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` |
| `iam` | User management errors | `iam.errors.userAlreadyExists` |
| `importer` | Import/export errors | `importer.errors.invalidFileEmpty` |
| `errors` | Generic errors | `errors.forbidden.message` |
| `emails` | Email templates | `emails.invitation.subject` |
---
@ -670,6 +704,7 @@ const getNotification = (key, ...args) => {
```
**Usage:**
```javascript
const { getNotification, isNotification } = require('./helpers');
@ -682,8 +717,8 @@ getNotification('emails.invitation.subject', 'Tour Builder');
// → "You've been invited to Tour Builder"
// Check existence
isNotification('auth.userDisabled'); // → true
isNotification('unknown.key'); // → false
isNotification('auth.userDisabled'); // → true
isNotification('unknown.key'); // → false
```
---
@ -693,6 +728,7 @@ isNotification('unknown.key'); // → false
i18n-aware error classes (legacy pattern, prefer `utils/errors.js`):
**ForbiddenError:**
```javascript
const { getNotification, isNotification } = require('../helpers');
@ -713,6 +749,7 @@ module.exports = class ForbiddenError extends Error {
```
**ValidationError:**
```javascript
module.exports = class ValidationError extends Error {
constructor(messageCode) {
@ -731,6 +768,7 @@ module.exports = class ValidationError extends Error {
```
**Usage:**
```javascript
const ForbiddenError = require('./services/notifications/errors/forbidden');
const ValidationError = require('./services/notifications/errors/validation');
@ -740,7 +778,7 @@ throw new ForbiddenError('auth.forbidden');
throw new ValidationError('iam.errors.emailRequired');
// With default message
throw new ForbiddenError(); // → 'Forbidden'
throw new ForbiddenError(); // → 'Forbidden'
throw new ValidationError(); // → 'An error occurred'
```
@ -804,46 +842,46 @@ const { getNotification } = require('./services/notifications/helpers');
### Error Class Selection
| Scenario | Recommended Class |
|----------|-------------------|
| Resource not found | `NotFoundError` from `utils/errors.js` |
| Invalid input | `ValidationError` from `utils/errors.js` |
| Permission denied | `ForbiddenError` from `utils/errors.js` |
| Auth required | `UnauthorizedError` from `utils/errors.js` |
| Duplicate resource | `ConflictError` from `utils/errors.js` |
| Scenario | Recommended Class |
| ------------------ | ------------------------------------------- |
| Resource not found | `NotFoundError` from `utils/errors.js` |
| Invalid input | `ValidationError` from `utils/errors.js` |
| Permission denied | `ForbiddenError` from `utils/errors.js` |
| Auth required | `UnauthorizedError` from `utils/errors.js` |
| Duplicate resource | `ConflictError` from `utils/errors.js` |
| i18n error message | Legacy classes from `notifications/errors/` |
---
## Environment Variables Reference
| Variable | Type | Default | Description |
|----------|------|---------|-------------|
| `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` |
| `PORT` | number | `8080` | Server port |
| `DB_HOST` | string | `localhost` | PostgreSQL host |
| `DB_PORT` | number | `5432` | PostgreSQL port |
| `DB_NAME` | string | `db_tour_builder_platform` | Database name |
| `DB_USER` | string | `postgres` | Database user |
| `DB_PASS` | string | `` | Database password |
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
| `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email |
| `ADMIN_PASS` | string | `88dbeaf8` | Admin account password |
| `USER_PASS` | string | `c3baadeda5c6` | Default user password |
| `GOOGLE_CLIENT_ID` | string | `` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | string | `` | Google OAuth client secret |
| `MS_CLIENT_ID` | string | `` | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | string | `` | Microsoft OAuth client secret |
| `AWS_ACCESS_KEY_ID` | string | `` | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key |
| `AWS_S3_BUCKET` | string | `` | S3 bucket name |
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
| `AWS_S3_PREFIX` | string | UUID | S3 key prefix |
| `EMAIL_USER` | string | `` | SMTP username |
| `EMAIL_PASS` | string | `` | SMTP password |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation |
| `PEXELS_KEY` | string | `` | Pexels API key |
| `LOG_LEVEL` | string | `info` | Pino log level |
| Variable | Type | Default | Description |
| ------------------------------- | ------ | -------------------------- | ------------------------------------------------ |
| `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` |
| `PORT` | number | `8080` | Server port |
| `DB_HOST` | string | `localhost` | PostgreSQL host |
| `DB_PORT` | number | `5432` | PostgreSQL port |
| `DB_NAME` | string | `db_tour_builder_platform` | Database name |
| `DB_USER` | string | `postgres` | Database user |
| `DB_PASS` | string | `` | Database password |
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
| `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email |
| `ADMIN_PASS` | string | `88dbeaf8` | Admin account password |
| `USER_PASS` | string | `c3baadeda5c6` | Default user password |
| `GOOGLE_CLIENT_ID` | string | `` | Google OAuth client ID |
| `GOOGLE_CLIENT_SECRET` | string | `` | Google OAuth client secret |
| `MS_CLIENT_ID` | string | `` | Microsoft OAuth client ID |
| `MS_CLIENT_SECRET` | string | `` | Microsoft OAuth client secret |
| `AWS_ACCESS_KEY_ID` | string | `` | AWS access key |
| `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key |
| `AWS_S3_BUCKET` | string | `` | S3 bucket name |
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
| `AWS_S3_PREFIX` | string | UUID | S3 key prefix |
| `EMAIL_USER` | string | `` | SMTP username |
| `EMAIL_PASS` | string | `` | SMTP password |
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation |
| `PEXELS_KEY` | string | `` | Pexels API key |
| `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.
The suite is split into three layers:
| Layer | Command | Location | Purpose |
|-------|---------|----------|---------|
| 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 |
| 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 |
| Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite |
| Layer | Command | Location | Purpose |
| ------------ | -------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
| 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 |
| 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 |
| Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite |
## Current Coverage

View File

@ -32,7 +32,9 @@ function toProjectPath(filePath: string): string {
}
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 {
@ -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;
return {
@ -70,7 +75,9 @@ async function checkFile(filePath: string): Promise<BoundaryViolation | null> {
async function main(): Promise<void> {
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();
const checks = await Promise.all(files.map((file) => checkFile(file)));
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 UsersDBApi from '../db/api/users.ts';
import { jwtSign } from '../helpers.ts';
import { setCurrentUser, setSocialAuthToken } from '../utils/request-context.ts';
import {
setCurrentUser,
setSocialAuthToken,
} from '../utils/request-context.ts';
import type {
AuthTokenPayload,
CurrentUser,
@ -111,9 +114,11 @@ async function socialStrategy(
}
try {
const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate({
where: { email, provider },
});
const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate(
{
where: { email, provider },
},
);
const body: AuthTokenPayload['user'] = {
id: user.id,
email: user.email,
@ -165,11 +170,7 @@ passport.use(
secretOrKey: config.secret_key,
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
},
(
req: Request,
token: unknown,
done: VerifiedCallback,
) => {
(req: Request, token: unknown, done: VerifiedCallback) => {
void verifyJwt(req, token, done);
},
),

View File

@ -47,7 +47,9 @@ function authenticatePassport(
const middleware: unknown = passport.authenticate(strategy, options);
if (!isRequestHandler(middleware)) {
throw new Error(`Passport ${strategy} authentication middleware is unavailable.`);
throw new Error(
`Passport ${strategy} authentication middleware is unavailable.`,
);
}
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 {
id: data.id || undefined,
assetId: data.assetId || null,

View File

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

View File

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

View File

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

View File

@ -34,7 +34,9 @@ function isGlobalTransitionListFilter(
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 (
value !== null &&
typeof value === 'object' &&

View File

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

View File

@ -49,7 +49,10 @@ function normalizeFilter(
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];
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;
const [start, end] = range;
@ -85,7 +92,8 @@ function getDefinedProjectFields(data: ProjectData): ProjectFieldMapping {
description: 'description' in data ? data.description || null : undefined,
logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
favicon_url: 'favicon_url' in data ? data.favicon_url || null : undefined,
og_image_url: 'og_image_url' in data ? data.og_image_url || null : undefined,
og_image_url:
'og_image_url' in data ? data.og_image_url || null : undefined,
design_width: 'design_width' in data ? data.design_width : undefined,
design_height: 'design_height' in data ? data.design_height : undefined,
production_presentation_visibility:

View File

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

View File

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

View File

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

View File

@ -66,7 +66,9 @@ type FunctionPropertyName =
function isDatabaseConfigEnvironment(
value: string,
): value is keyof typeof dbConfig {
return value === 'development' || value === 'production' || value === 'dev_stage';
return (
value === 'development' || value === 'production' || value === 'dev_stage'
);
}
function getDatabaseConfig(): DatabaseEnvironmentConfig {
@ -115,7 +117,9 @@ function isSampleDataModel(value: object): value is SampleDataModel {
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, [
'bulkCreate',
'count',
@ -127,9 +131,7 @@ function isProjectModel(value: object): value is ProjectModel & SampleDataModel
]);
}
function isProjectCloneAssetModel(
value: object,
): value is DbModels['assets'] {
function isProjectCloneAssetModel(value: object): value is DbModels['assets'] {
return hasFunctionProperties(value, [
'bulkCreate',
'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, [
'bulkCreate',
'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, [
'bulkCreate',
'count',
@ -244,7 +250,12 @@ function isProductionPresentationAccessModel(
}
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 {

View File

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

View File

@ -25,7 +25,9 @@ interface UsersSequelizeModel extends ModelStatic<UserModelInstance> {
}
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 {

View File

@ -6,11 +6,7 @@ import { Sequelize } from 'sequelize';
import * as SequelizeModule from 'sequelize';
import type { QueryInterface } from 'sequelize';
import { SequelizeStorage, Umzug } from 'umzug';
import type {
MigrationMeta,
MigrationParams,
RunnableMigration,
} from 'umzug';
import type { MigrationMeta, MigrationParams, RunnableMigration } from 'umzug';
import '../load-env.ts';
import dbConfig from './db-config.ts';
@ -120,7 +116,9 @@ function getModuleDefault(value: unknown): unknown {
return value;
}
function isLegacyMigrationModule(value: unknown): value is LegacyMigrationModule {
function isLegacyMigrationModule(
value: unknown,
): value is LegacyMigrationModule {
return (
isRecord(value) &&
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}`);
for (const migration of migrations) {
console.log(`- ${migration.name}`);
@ -370,9 +371,7 @@ function selectMigrator(
command: DbUmzugCommand,
migrators: DbMigrators,
): Umzug<DbUmzugContext> {
return command.startsWith('seed:')
? migrators.seeders
: migrators.migrations;
return command.startsWith('seed:') ? migrators.seeders : migrators.migrations;
}
async function runCommand(command: DbUmzugCommand): Promise<void> {
@ -410,7 +409,9 @@ async function main(): Promise<void> {
}
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) {
void main();

View File

@ -19,9 +19,8 @@ export default class Utils {
}
static ilike(model: string, column: string, value: string): Where {
return where(
fn('lower', col(`${model}.${column}`)),
{ [Op.like]: `%${value}%`.toLowerCase() },
);
return where(fn('lower', col(`${model}.${column}`)), {
[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 { crud as crudSchemas } from '../validators/request-schemas.ts';
import { logger } from '../utils/logger.ts';
import {
getCurrentUser,
getRuntimeContext,
} from '../utils/request-context.ts';
import { getCurrentUser, getRuntimeContext } from '../utils/request-context.ts';
import type {
EntityRouterDbApi,
EntityRouterOptions,
@ -69,7 +66,9 @@ function hasRawAttributes(value: unknown): value is {
}
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 {
@ -83,7 +82,8 @@ function clampLimit(value: unknown, options: ClampLimitOptions): number {
function getSortableFields(DBApi: EntityRouterDbApi): readonly string[] {
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 [];
}
@ -115,10 +115,7 @@ function normalizeQuery(
}
const sortableFields = getSortableFields(DBApi);
if (
typeof query.field === 'string' &&
sortableFields.includes(query.field)
) {
if (typeof query.field === 'string' && sortableFields.includes(query.field)) {
normalized.field = query.field;
}
@ -278,20 +275,22 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
router.post(
'/',
validateRequest(schemaFor(options.validation, 'create')),
wrapAsync<never, unknown, EntityDataRequestBody<TCreate>>(async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
const payload = await Service.create(
buildCreateOptions(req.body.data, {
currentUser: getCurrentUser(req),
runtimeContext: getRuntimeContext(req),
host: link.origin,
}),
);
res.status(200).send(payload);
}),
wrapAsync<never, unknown, EntityDataRequestBody<TCreate>>(
async (req, res) => {
const referer =
req.headers.referer ||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
const link = new URL(referer);
const payload = await Service.create(
buildCreateOptions(req.body.data, {
currentUser: getCurrentUser(req),
runtimeContext: getRuntimeContext(req),
host: link.origin,
}),
);
res.status(200).send(payload);
},
),
);
router.post(
@ -307,14 +306,14 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
validateRequest(schemaFor(options.validation, 'update')),
wrapAsync<{ id: string }, unknown, RouteEntityDataRequestBody<TUpdate>>(
async (req, res) => {
assertRouteIdMatchesBody(req);
await Service.update(
buildUpdateOptions(req.params.id, req.body.data, {
currentUser: getCurrentUser(req),
runtimeContext: getRuntimeContext(req),
}),
);
res.status(200).send(true);
assertRouteIdMatchesBody(req);
await Service.update(
buildUpdateOptions(req.params.id, req.body.data, {
currentUser: getCurrentUser(req),
runtimeContext: getRuntimeContext(req),
}),
);
res.status(200).send(true);
},
),
);
@ -336,15 +335,17 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
router.post(
'/deleteByIds',
validateRequest(schemaFor(options.validation, 'deleteByIds')),
wrapAsync<never, unknown, EntityDeleteByIdsRequestBody>(async (req, res) => {
await Service.deleteByIds(
buildDeleteByIdsOptions(req.body.data, {
currentUser: getCurrentUser(req),
runtimeContext: getRuntimeContext(req),
}),
);
res.status(200).send(true);
}),
wrapAsync<never, unknown, EntityDeleteByIdsRequestBody>(
async (req, res) => {
await Service.deleteByIds(
buildDeleteByIdsOptions(req.body.data, {
currentUser: getCurrentUser(req),
runtimeContext: getRuntimeContext(req),
}),
);
res.status(200).send(true);
},
),
);
router.get(
@ -367,10 +368,8 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
return;
}
const fields = options.csvFields || DBApi.CSV_FIELDS || [
'id',
'createdAt',
];
const fields = options.csvFields ||
DBApi.CSV_FIELDS || ['id', 'createdAt'];
try {
const csv = parse(payload.rows, { fields: [...fields] });
res.status(200).attachment('export.csv').send(csv);

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -33,7 +33,9 @@ export default class AccessPolicy {
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;
}
@ -105,7 +107,9 @@ export default class AccessPolicy {
): Promise<boolean> {
if (!user || !permission) 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 {

View File

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

View File

@ -1,52 +1,52 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<head>
<style>
.email-container {
max-width: 600px;
margin: auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.email-header {
background-color: #3498db;
color: #fff;
padding: 16px;
text-align: center;
}
.email-body {
padding: 16px;
}
.email-footer {
padding: 16px;
background-color: #f7fafc;
text-align: center;
color: #4a5568;
font-size: 14px;
}
.link-primary {
color: #3498db;
text-decoration: none;
}
.email-container {
max-width: 600px;
margin: auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.email-header {
background-color: #3498db;
color: #fff;
padding: 16px;
text-align: center;
}
.email-body {
padding: 16px;
}
.email-footer {
padding: 16px;
background-color: #f7fafc;
text-align: center;
color: #4a5568;
font-size: 14px;
}
.link-primary {
color: #3498db;
text-decoration: none;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
Verify your email for {appTitle}!
</div>
<div class="email-body">
</head>
<body>
<div class="email-container">
<div class="email-header">Verify your email for {appTitle}!</div>
<div class="email-body">
<p>Hello,</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>
</div>
<div class="email-footer">
Thanks,<br/>
</div>
<div class="email-footer">
Thanks,<br />
The {appTitle} Team
</div>
</div>
</div>
</body>
</html>
</body>
</html>

View File

@ -1,55 +1,56 @@
<!DOCTYPE html>
<!doctype html>
<html>
<head>
<head>
<style>
.email-container {
max-width: 600px;
margin: auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.email-header {
background-color: #3498db;
color: #fff;
padding: 16px;
text-align: center;
}
.email-body {
padding: 16px;
}
.email-footer {
padding: 16px;
background-color: #f7fafc;
text-align: center;
color: #4a5568;
font-size: 14px;
}
.btn-primary {
background-color: #3498db;
color: #fff!important;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
.email-container {
max-width: 600px;
margin: auto;
background-color: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 4px;
overflow: hidden;
}
.email-header {
background-color: #3498db;
color: #fff;
padding: 16px;
text-align: center;
}
.email-body {
padding: 16px;
}
.email-footer {
padding: 16px;
background-color: #f7fafc;
text-align: center;
color: #4a5568;
font-size: 14px;
}
.btn-primary {
background-color: #3498db;
color: #fff !important;
padding: 8px 16px;
border-radius: 4px;
text-decoration: none;
display: inline-block;
}
</style>
</head>
<body>
<div class="email-container">
<div class="email-header">
Welcome to {appTitle}!
</div>
<div class="email-body">
</head>
<body>
<div class="email-container">
<div class="email-header">Welcome to {appTitle}!</div>
<div class="email-body">
<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>
</div>
<div class="email-footer">
Thanks,<br/>
</div>
<div class="email-footer">
Thanks,<br />
The {appTitle} Team
</div>
</div>
</div>
</body>
</html>
</body>
</html>

View File

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

View File

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

View File

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

View File

@ -61,7 +61,9 @@ const isUploadSessionChunkMeta = (
const isUploadSessionUploadedChunks = (
value: unknown,
): 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 => {
@ -160,7 +162,11 @@ export default class UploadSessionManager {
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);
ensureDirectoryExistence(chunkPath);
fs.writeFileSync(chunkPath, data);

View File

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

View File

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

View File

@ -536,7 +536,8 @@ export default class ProjectsService extends BaseProjectsService {
if (sourceAsset.mime_type !== undefined) {
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) {
payload.width_px = sourceAsset.width_px;
}

View File

@ -162,7 +162,11 @@ export default class PublishService {
{ 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;
function hasPermissionMethod(value: unknown): value is RoleWithPermissionMethod {
function hasPermissionMethod(
value: unknown,
): value is RoleWithPermissionMethod {
return (
value !== null &&
typeof value === 'object' &&

View File

@ -165,69 +165,79 @@ const regenerateElementInstanceIds = (
if (!uiSchema || typeof uiSchema !== 'object') return 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;
clonedSchema.elements = clonedSchema.elements.map((element): TourPageElement => {
const clonedElement: TourPageElement = {
...element,
id: createLocalElementId(),
};
clonedSchema.elements = clonedSchema.elements.map(
(element): TourPageElement => {
const clonedElement: TourPageElement = {
...element,
id: createLocalElementId(),
};
const galleryCards = regenerateNestedItemIds(clonedElement.galleryCards);
if (galleryCards !== undefined) {
clonedElement.galleryCards = galleryCards;
}
const galleryCards = regenerateNestedItemIds(clonedElement.galleryCards);
if (galleryCards !== undefined) {
clonedElement.galleryCards = galleryCards;
}
const galleryInfoSpans = regenerateNestedItemIds(
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;
},
const galleryInfoSpans = regenerateNestedItemIds(
clonedElement.galleryInfoSpans,
);
}
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;
};
// Create base service from factory
const BaseService = createEntityService<TourPageRecord, TourPageData, TourPageData>(
Tour_pagesDBApi,
{
const BaseService = createEntityService<
TourPageRecord,
TourPageData,
TourPageData
>(Tour_pagesDBApi, {
entityName: 'tour_pages',
},
);
});
const buildCreateServiceOptions = (
options: TourPageCreateOptions,
): Pick<TourPageCreateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> => {
): Pick<
TourPageCreateOptions,
'currentUser' | 'transaction' | 'runtimeContext'
> => {
const serviceOptions: Pick<
TourPageCreateOptions,
'currentUser' | 'transaction' | 'runtimeContext'
@ -248,7 +258,10 @@ const buildCreateServiceOptions = (
const buildUpdateServiceOptions = (
options: TourPageUpdateOptions,
): Pick<TourPageUpdateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> => {
): Pick<
TourPageUpdateOptions,
'currentUser' | 'transaction' | 'runtimeContext'
> => {
const serviceOptions: Pick<
TourPageUpdateOptions,
'currentUser' | 'transaction' | 'runtimeContext'
@ -270,8 +283,10 @@ const buildUpdateServiceOptions = (
const buildFindByOptions = (
options: TourPageUpdateOptions,
): { transaction?: Transaction; runtimeContext?: RuntimeContext } => {
const findByOptions: { transaction?: Transaction; runtimeContext?: RuntimeContext } =
{};
const findByOptions: {
transaction?: Transaction;
runtimeContext?: RuntimeContext;
} = {};
if (options.transaction !== undefined) {
findByOptions.transaction = options.transaction;
@ -317,6 +332,10 @@ const toPlainTourPage = (
return page;
};
const getStringValue = (value: unknown): string => {
return typeof value === 'string' ? value.trim() : '';
};
/**
* Tour Pages Service with reversed video generation
*/
@ -325,7 +344,9 @@ class TourPagesService extends BaseService {
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;
const sizeMb = Number(asset.size_mb);
@ -740,7 +761,16 @@ class TourPagesService extends BaseService {
static isBackElement(element: TourPageElement): boolean {
return Boolean(
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;
}
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
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',
);
let wasModified =
await TourPagesService.refreshBackNavigationTransitionSourcesForPage(
data,
uiSchema,
projectId,
);
const storageKeysToValidate = new Set<string>();
let wasModified = false;
for (const element of uiSchema.elements) {
const isBack = TourPagesService.isBackElement(element);
@ -972,38 +1177,39 @@ class TourPagesService extends BaseService {
setImmediate(() => {
void (async () => {
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(
const log = logger.child({
projectId,
storageKey,
reversedUrl,
currentUser,
);
} catch (err) {
log.error({ err }, 'Background reversed generation failed');
} finally {
singleReverseGenerationInProgress.delete(taskKey);
}
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,
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(() => {
void (async () => {
try {
await TourPagesService.regenerateProjectReversedVideos(
projectId,
currentUser,
excludePageId,
);
} catch (err) {
logger.error(
{ err, projectId },
'Background project regeneration failed',
);
} finally {
projectRegenInProgress.delete(projectId);
}
try {
await TourPagesService.regenerateProjectReversedVideos(
projectId,
currentUser,
excludePageId,
);
} catch (err) {
logger.error(
{ err, projectId },
'Background project regeneration failed',
);
} finally {
projectRegenInProgress.delete(projectId);
}
})();
});
}
@ -1197,9 +1403,13 @@ class TourPagesService extends BaseService {
// Upload reversed video to storage
const reversedKey = `assets/${asset.id}/reversed.mp4`;
const result = await FileService.uploadBuffer(reversedKey, reversedBuffer, {
contentType: 'video/mp4',
});
const result = await FileService.uploadBuffer(
reversedKey,
reversedBuffer,
{
contentType: 'video/mp4',
},
);
// Create variant record
await Asset_variantsDBApi.create({
@ -1266,7 +1476,12 @@ class TourPagesService extends BaseService {
continue;
}
let pageModified = false;
let pageModified =
TourPagesService.refreshBackNavigationTransitionSources(
uiSchema,
pages,
{ id: page.id, slug: page.slug },
);
for (const element of uiSchema.elements) {
// Process both forward elements AND back elements with their own transition
@ -1274,7 +1489,7 @@ class TourPagesService extends BaseService {
TourPagesService.isForwardElementWithTarget(element);
const isBackWithTransition = Boolean(
TourPagesService.isBackElement(element) &&
element.transitionVideoUrl,
element.transitionVideoUrl,
);
log.debug(

View File

@ -33,12 +33,9 @@ const BaseUsersService = createEntityService<
UserData,
UserListFilter,
UserAutocompleteOption
>(
UsersDBApi,
{
entityName: 'Users',
},
);
>(UsersDBApi, {
entityName: 'Users',
});
const buildRuntimeOptions = (
transaction: Transaction,
@ -230,7 +227,9 @@ export default class UsersService extends BaseUsersService {
await this.createProductionPresentationAccessForPublicUser(options);
}
static override async create(options: UserCreateOptions): Promise<UserRecord> {
static override async create(
options: UserCreateOptions,
): Promise<UserRecord> {
assertCreateOptions(options, 'Service');
const {
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');
const {
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');
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 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 queuedFfmpegJobs = 0;
@ -47,9 +48,11 @@ interface MediaProbeStream {
interface MediaProbeOutput {
streams?: MediaProbeStream[] | undefined;
format?: {
duration?: string | number | undefined;
} | undefined;
format?:
| {
duration?: string | number | undefined;
}
| undefined;
}
interface ProcessResult {
@ -360,7 +363,9 @@ async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> {
}
metadata = parsedMetadata;
} 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 ?? [];

View File

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

View File

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

View File

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

View File

@ -103,8 +103,9 @@ export interface DbFindAllOptions<TFilter> extends ServiceOptions {
offset?: number;
}
export interface DbFindByOptions<TWhere extends WhereOptions = WhereOptions>
extends ServiceOptions {
export interface DbFindByOptions<
TWhere extends WhereOptions = WhereOptions,
> extends ServiceOptions {
where: TWhere;
include?: Includeable[];
}
@ -135,13 +136,20 @@ export interface EntityDbApi<
remove(options: EntityIdOptions): Promise<TEntity>;
deleteByIds(options: DeleteByIdsOptions): Promise<TEntity[]>;
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[]>;
}
export interface SingletonDbApi<TEntity extends EntityRecord, TUpdate>
extends EntityDbApi<TEntity, TUpdate, TUpdate, unknown> {
export interface SingletonDbApi<
TEntity extends EntityRecord,
TUpdate,
> extends EntityDbApi<TEntity, TUpdate, TUpdate, unknown> {
findOne(options?: ServiceOptions): 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
extends Options,
SequelizeCliStorageOptions {
extends Options, SequelizeCliStorageOptions {
dialect: 'postgres';
use_env_variable?: string;
}

View File

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

View File

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

View File

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

View File

@ -34,12 +34,12 @@ export interface EntityServiceConstructor<
TFilter = unknown,
TAutocomplete extends EntityRecord | { id: string } = TEntity,
> extends EntityServiceClass<
TEntity,
TCreate,
TUpdate,
TFilter,
TAutocomplete
> {
TEntity,
TCreate,
TUpdate,
TFilter,
TAutocomplete
> {
new (): object;
}
@ -58,7 +58,10 @@ export interface EntityServiceDbApi<
readonly __filterType?: TFilter;
readonly __autocompleteType?: TAutocomplete;
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>;
findBy(
where: { id: string },

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,10 +1,18 @@
import type { Transaction } from 'sequelize';
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 { 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';
@ -38,8 +46,7 @@ export interface ProjectFieldMapping {
design_width: number | null | undefined;
design_height: number | null | undefined;
production_presentation_visibility:
| ProjectProductionPresentationVisibility
| undefined;
ProjectProductionPresentationVisibility | undefined;
}
export interface ProjectCreatePayload extends ProjectFieldMapping {
@ -233,14 +240,20 @@ export interface ProjectCloneCreateOptions extends ProjectCloneTransactionOption
export type ProjectCloneCurrentUser = Pick<CurrentUser, 'id'>;
export interface ProjectsDbApi
extends EntityDbApi<ProjectRecord, ProjectData, ProjectData, ProjectListFilter> {
export interface ProjectsDbApi extends EntityDbApi<
ProjectRecord,
ProjectData,
ProjectData,
ProjectListFilter
> {
findBy(options: DbFindByOptions): Promise<ProjectRecord | null>;
findBy(
where: { id: string },
options?: ProjectFindAllOptions,
): Promise<ProjectRecord | null>;
findAll(options: DbFindAllOptions<unknown>): Promise<PaginatedResult<ProjectRecord>>;
findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<ProjectRecord>>;
findAll(
filter?: ProjectListFilter,
options?: ProjectFindAllOptions,

View File

@ -33,7 +33,10 @@ export interface SaveToStageResult {
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<
RuntimeEnvironment,

View File

@ -2,6 +2,8 @@ import type Joi from 'joi';
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 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 {
id: string;
@ -16,16 +19,17 @@ export interface ProductionPresentationAccessGrantPlain {
project?: ProductionPresentationAccessProject | null;
}
export interface ProductionPresentationAccessGrantRow
extends ProductionPresentationAccessGrantPlain {
export interface ProductionPresentationAccessGrantRow extends ProductionPresentationAccessGrantPlain {
get?: (options: { plain: true }) => ProductionPresentationAccessGrantPlain;
}
export interface PrivateProductionProjectRow
extends Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'> {
get?: (
options: { plain: true },
) => Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'>;
export interface PrivateProductionProjectRow extends Pick<
ProductionPresentationProject,
'id' | 'name' | 'slug'
> {
get?: (options: {
plain: true;
}) => Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'>;
}
export type RuntimePresentationAccessOptions = AccessPolicyOptions;

View File

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

View File

@ -68,8 +68,7 @@ export interface TourPageSequelizeRecord extends TourPageRecord {
}
export type TourPageMaybeSequelizeRecord =
| TourPageRecord
| TourPageSequelizeRecord;
TourPageRecord | TourPageSequelizeRecord;
export interface TourPageProjectRef {
id: string;
@ -121,8 +120,7 @@ export interface TourPageRecord extends TourPageData {
export type TourPageCreateBody = EntityDataRequestBody<TourPageData>;
export interface TourPageUpdateBody
extends EntityDataRequestBody<TourPageData> {
export interface TourPageUpdateBody extends EntityDataRequestBody<TourPageData> {
id?: string;
}
@ -194,12 +192,11 @@ export interface TourPageReverseGenerationTask {
pageId?: string | null | undefined;
}
export interface TourPagesDbApi
extends EntityDbApi<
TourPageRecord,
TourPageData,
TourPageData,
TourPageListQuery
export interface TourPagesDbApi extends EntityDbApi<
TourPageRecord,
TourPageData,
TourPageData,
TourPageListQuery
> {
readonly CSV_FIELDS: readonly string[];
findBy(options: DbFindByOptions): Promise<TourPageRecord | null>;
@ -207,12 +204,16 @@ export interface TourPagesDbApi
where: { id: string },
options?: ServiceOptions,
): Promise<TourPageRecord | null>;
findAll(options: DbFindAllOptions<TourPageListQuery>): Promise<TourPageListResult>;
findAll(
options: DbFindAllOptions<TourPageListQuery>,
): Promise<TourPageListResult>;
findAll(
filter?: TourPageListQuery,
options?: TourPageFindAllOptions,
): Promise<TourPageListResult>;
findAllAutocomplete(options: TourPageAutocompleteOptions): Promise<TourPageRecord[]>;
findAllAutocomplete(
options: TourPageAutocompleteOptions,
): 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 { PermissionRecord, RoleRecord } from './auth.ts';
import type { AutocompleteOptions, DeleteByIdsOptions } from './service-options.ts';
import type { DbFindAllOptions, DbFindByOptions, EntityDbApi } from './db-api.ts';
import type {
AutocompleteOptions,
DeleteByIdsOptions,
} from './service-options.ts';
import type {
DbFindAllOptions,
DbFindByOptions,
EntityDbApi,
} from './db-api.ts';
import type { EntityDataRequestBody } from './http.ts';
import type { PaginatedResult } from './pagination.ts';
import type { QueryWhere } from './runtime.ts';
@ -157,15 +164,10 @@ export interface UserFindAllOptions extends ServiceOptions {
}
export type UserSelectableIdInput =
| string
| { id?: string | null; value?: string | null }
| null
| undefined;
string | { id?: string | null; value?: string | null } | null | undefined;
export type UserSelectableIdArrayInput =
| Array<UserSelectableIdInput>
| null
| undefined;
Array<UserSelectableIdInput> | null | undefined;
export interface UserAccessMutationOptions {
user: Pick<UserRecord, 'id'> | null | undefined;
@ -287,19 +289,15 @@ export interface UserModelApi {
): Promise<PaginatedResult<UserModelRecord>>;
}
export interface UsersDbApi
extends EntityDbApi<
UserRecord,
UserData,
UserData,
UserListFilter,
UserAutocompleteOption
> {
export interface UsersDbApi extends EntityDbApi<
UserRecord,
UserData,
UserData,
UserListFilter,
UserAutocompleteOption
> {
create(options: CreateOptions<UserData>): Promise<UserRecord>;
bulkImport(
data: UserData[],
options?: ServiceOptions,
): Promise<UserRecord[]>;
bulkImport(data: UserData[], options?: ServiceOptions): Promise<UserRecord[]>;
update(options: UpdateOptions<UserData>): Promise<UserRecord>;
deleteByIds(options: DeleteByIdsOptions): Promise<UserRecord[]>;
remove(options: EntityIdOptions): Promise<UserRecord>;
@ -312,7 +310,9 @@ export interface UsersDbApi
where: UserFindByWhere,
options?: ServiceOptions,
): Promise<UserRecord | null>;
findAll(options: DbFindAllOptions<unknown>): Promise<PaginatedResult<UserRecord>>;
findAll(
options: DbFindAllOptions<unknown>,
): Promise<PaginatedResult<UserRecord>>;
findAll(
filter?: UserListFilter,
options?: UserFindAllOptions,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -71,9 +71,7 @@ export function setPermissionNameOverride(
permissionNameOverride;
}
export function getPermissionNameOverride(
req: Request,
): string | undefined {
export function getPermissionNameOverride(req: Request): string | undefined {
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', () => {
assert.equal(
AccessPolicy.isPlatformWideRole(userWithRole('admin-1', { name: 'Administrator' })),
AccessPolicy.isPlatformWideRole(
userWithRole('admin-1', { name: 'Administrator' }),
),
true,
);
assert.equal(
AccessPolicy.isPlatformWideRole(userWithRole('designer-1', { name: 'Tour Designer' })),
AccessPolicy.isPlatformWideRole(
userWithRole('designer-1', { name: 'Tour Designer' }),
),
false,
);
});

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