fixed fullscreen issue and big videos errors persistance
This commit is contained in:
parent
9c12c3f539
commit
76871f4542
File diff suppressed because it is too large
Load Diff
@ -213,17 +213,35 @@ Base class with configurable hooks for entity-specific behavior:
|
|||||||
```javascript
|
```javascript
|
||||||
class AssetsDBApi extends GenericDBApi {
|
class AssetsDBApi extends GenericDBApi {
|
||||||
// Required: Define the Sequelize model
|
// Required: Define the Sequelize model
|
||||||
static get MODEL() { return db.assets; }
|
static get MODEL() {
|
||||||
|
return db.assets;
|
||||||
|
}
|
||||||
|
|
||||||
// Configurable behavior via static getters
|
// Configurable behavior via static getters
|
||||||
static get SEARCHABLE_FIELDS() { return ['name', 'cdn_url']; }
|
static get SEARCHABLE_FIELDS() {
|
||||||
static get RANGE_FIELDS() { return ['size_mb', 'width_px']; }
|
return ['name', 'cdn_url'];
|
||||||
static get ENUM_FIELDS() { return ['asset_type', 'is_public']; }
|
}
|
||||||
static get JSON_FIELDS() { return ['settings_json']; }
|
static get RANGE_FIELDS() {
|
||||||
static get FIELD_DEFAULTS() { return { type: { default: 'general' } }; }
|
return ['size_mb', 'width_px'];
|
||||||
static get ASSOCIATIONS() { return [{ field: 'project', setter: 'setProject' }]; }
|
}
|
||||||
static get FIND_BY_INCLUDES() { return [{ association: 'project' }]; }
|
static get ENUM_FIELDS() {
|
||||||
static get FIND_ALL_INCLUDES() { return [{ model: db.projects, as: 'project' }]; }
|
return ['asset_type', 'is_public'];
|
||||||
|
}
|
||||||
|
static get JSON_FIELDS() {
|
||||||
|
return ['settings_json'];
|
||||||
|
}
|
||||||
|
static get FIELD_DEFAULTS() {
|
||||||
|
return { type: { default: 'general' } };
|
||||||
|
}
|
||||||
|
static get ASSOCIATIONS() {
|
||||||
|
return [{ field: 'project', setter: 'setProject' }];
|
||||||
|
}
|
||||||
|
static get FIND_BY_INCLUDES() {
|
||||||
|
return [{ association: 'project' }];
|
||||||
|
}
|
||||||
|
static get FIND_ALL_INCLUDES() {
|
||||||
|
return [{ model: db.projects, as: 'project' }];
|
||||||
|
}
|
||||||
|
|
||||||
// Custom field transformation
|
// Custom field transformation
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
@ -253,6 +271,7 @@ BaseStorageProvider (abstract)
|
|||||||
The storage provider base, S3 provider, and local provider are migrated TS/ESM modules. The S3 implementation uses official AWS SDK v3 types; shared provider-domain contracts are in `src/types/file.ts`.
|
The storage provider base, S3 provider, and local provider are migrated TS/ESM modules. The S3 implementation uses official AWS SDK v3 types; shared provider-domain contracts are in `src/types/file.ts`.
|
||||||
|
|
||||||
Interface:
|
Interface:
|
||||||
|
|
||||||
- `upload(key, data, options)` → `{ key, url }`
|
- `upload(key, data, options)` → `{ key, url }`
|
||||||
- `download(key)` → `{ body, contentType }`
|
- `download(key)` → `{ body, contentType }`
|
||||||
- `delete(key)` → `void`
|
- `delete(key)` → `void`
|
||||||
@ -292,16 +311,17 @@ Application bootstrap:
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Key route mounting patterns
|
// Key route mounting patterns
|
||||||
app.use('/api/auth', authRoutes); // No JWT required
|
app.use('/api/auth', authRoutes); // No JWT required
|
||||||
app.use('/api/users', jwtAuth, usersRoutes); // JWT required
|
app.use('/api/users', jwtAuth, usersRoutes); // JWT required
|
||||||
|
|
||||||
// Runtime public routes (production content accessible without auth)
|
// Runtime public routes (production content accessible without auth)
|
||||||
const mountRuntimeEntityRoute = (path, entityName, router) => {
|
const mountRuntimeEntityRoute = (path, entityName, router) => {
|
||||||
app.use(path,
|
app.use(
|
||||||
requireRuntimeReadOrAuth, // JWT or public production
|
path,
|
||||||
|
requireRuntimeReadOrAuth, // JWT or public production
|
||||||
blockNonPublicRuntimeListEndpoints, // Block non-list endpoints
|
blockNonPublicRuntimeListEndpoints, // Block non-list endpoints
|
||||||
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
|
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
|
||||||
router
|
router,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
|
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
|
||||||
@ -312,27 +332,27 @@ mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
|
|||||||
|
|
||||||
**Factory-Generated Routes** provide standard CRUD:
|
**Factory-Generated Routes** provide standard CRUD:
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
| ------ | --------------- | ------------------------------ |
|
||||||
| POST | `/` | Create record |
|
| POST | `/` | Create record |
|
||||||
| POST | `/bulk-import` | Bulk import from CSV |
|
| POST | `/bulk-import` | Bulk import from CSV |
|
||||||
| PUT | `/:id` | Update record |
|
| PUT | `/:id` | Update record |
|
||||||
| DELETE | `/:id` | Delete record |
|
| DELETE | `/:id` | Delete record |
|
||||||
| POST | `/deleteByIds` | Bulk delete |
|
| POST | `/deleteByIds` | Bulk delete |
|
||||||
| GET | `/` | List with pagination & filters |
|
| GET | `/` | List with pagination & filters |
|
||||||
| GET | `/count` | Count only |
|
| GET | `/count` | Count only |
|
||||||
| GET | `/autocomplete` | Autocomplete search |
|
| GET | `/autocomplete` | Autocomplete search |
|
||||||
| GET | `/:id` | Get single record |
|
| GET | `/:id` | Get single record |
|
||||||
|
|
||||||
**Custom Routes** (auth, file, publish, search, runtime-context):
|
**Custom Routes** (auth, file, publish, search, runtime-context):
|
||||||
|
|
||||||
| Route | Endpoints |
|
| Route | Endpoints |
|
||||||
|-------|-----------|
|
| ---------------------- | ------------------------------------------------------------------------ |
|
||||||
| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth |
|
| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth |
|
||||||
| `/api/file` | upload, download, presign, upload-sessions (chunked) |
|
| `/api/file` | upload, download, presign, upload-sessions (chunked) |
|
||||||
| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) |
|
| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) |
|
||||||
| `/api/search` | Global full-text search |
|
| `/api/search` | Global full-text search |
|
||||||
| `/api/runtime-context` | Runtime environment detection |
|
| `/api/runtime-context` | Runtime environment detection |
|
||||||
|
|
||||||
### Service Layer
|
### Service Layer
|
||||||
|
|
||||||
@ -356,6 +376,7 @@ static async create({ data, currentUser, transaction: externalTransaction, runti
|
|||||||
**Publish Service** (`services/publish.ts`):
|
**Publish Service** (`services/publish.ts`):
|
||||||
|
|
||||||
Implements the dev→stage→production workflow with:
|
Implements the dev→stage→production workflow with:
|
||||||
|
|
||||||
- Transaction locking to prevent concurrent publishes
|
- Transaction locking to prevent concurrent publishes
|
||||||
- Source key tracking for content lineage
|
- Source key tracking for content lineage
|
||||||
- Bulk copy operations for pages and audio tracks
|
- Bulk copy operations for pages and audio tracks
|
||||||
@ -364,14 +385,14 @@ Implements the dev→stage→production workflow with:
|
|||||||
|
|
||||||
**Query Building** in `findAll()`:
|
**Query Building** in `findAll()`:
|
||||||
|
|
||||||
| Filter Type | Example | SQL |
|
| Filter Type | Example | SQL |
|
||||||
|-------------|---------|-----|
|
| ----------- | ----------------------- | --------------------------------- |
|
||||||
| Text search | `?name=foo` | `name ILIKE '%foo%'` |
|
| Text search | `?name=foo` | `name ILIKE '%foo%'` |
|
||||||
| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` |
|
| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` |
|
||||||
| Enum | `?asset_type=image` | `asset_type = 'image'` |
|
| Enum | `?asset_type=image` | `asset_type = 'image'` |
|
||||||
| Relation | `?project=uuid` | JOIN with projects table |
|
| Relation | `?project=uuid` | JOIN with projects table |
|
||||||
| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` |
|
| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` |
|
||||||
| Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` |
|
| Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -402,6 +423,7 @@ Implements the dev→stage→production workflow with:
|
|||||||
4. Fallback to Public role for unauthenticated
|
4. Fallback to Public role for unauthenticated
|
||||||
|
|
||||||
**Permission Naming Convention**:
|
**Permission Naming Convention**:
|
||||||
|
|
||||||
- `CREATE_<ENTITY>` - Create records
|
- `CREATE_<ENTITY>` - Create records
|
||||||
- `READ_<ENTITY>` - Read records
|
- `READ_<ENTITY>` - Read records
|
||||||
- `UPDATE_<ENTITY>` - Modify records
|
- `UPDATE_<ENTITY>` - Modify records
|
||||||
@ -420,15 +442,16 @@ For production content accessible without authentication:
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const requireRuntimeReadOrAuth = (req, res, next) => {
|
const requireRuntimeReadOrAuth = (req, res, next) => {
|
||||||
const isPublicEnvironment = req.runtimeContext?.headerEnvironment === 'production';
|
const isPublicEnvironment =
|
||||||
|
req.runtimeContext?.headerEnvironment === 'production';
|
||||||
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
||||||
|
|
||||||
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
|
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
|
||||||
req.isRuntimePublicRequest = true;
|
req.isRuntimePublicRequest = true;
|
||||||
return next(); // Allow without JWT
|
return next(); // Allow without JWT
|
||||||
}
|
}
|
||||||
|
|
||||||
return jwtAuth(req, res, next); // Require JWT
|
return jwtAuth(req, res, next); // Require JWT
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -438,16 +461,17 @@ const requireRuntimeReadOrAuth = (req, res, next) => {
|
|||||||
|
|
||||||
Pre-configured limiters (`middlewares/rateLimiter.ts`):
|
Pre-configured limiters (`middlewares/rateLimiter.ts`):
|
||||||
|
|
||||||
| Limiter | Window | Max Requests | Use Case |
|
| Limiter | Window | Max Requests | Use Case |
|
||||||
|---------|--------|--------------|----------|
|
| ---------------------- | ------ | ------------ | ------------------------ |
|
||||||
| `authLimiter` | 15 min | 10 | Authentication endpoints |
|
| `authLimiter` | 15 min | 10 | Authentication endpoints |
|
||||||
| `passwordResetLimiter` | 1 hour | 5 | Password reset |
|
| `passwordResetLimiter` | 1 hour | 5 | Password reset |
|
||||||
| `apiLimiter` | 1 min | 100 | General API |
|
| `apiLimiter` | 1 min | 100 | General API |
|
||||||
| `uploadLimiter` | 1 min | 10 | File uploads |
|
| `uploadLimiter` | 1 min | 10 | File uploads |
|
||||||
| `downloadLimiter` | 1 min | 200 | File downloads |
|
| `downloadLimiter` | 1 min | 200 | File downloads |
|
||||||
| `searchLimiter` | 1 min | 30 | Search queries |
|
| `searchLimiter` | 1 min | 30 | Search queries |
|
||||||
|
|
||||||
Headers returned:
|
Headers returned:
|
||||||
|
|
||||||
- `X-RateLimit-Limit`: Maximum requests
|
- `X-RateLimit-Limit`: Maximum requests
|
||||||
- `X-RateLimit-Remaining`: Remaining requests
|
- `X-RateLimit-Remaining`: Remaining requests
|
||||||
- `X-RateLimit-Reset`: Reset time (ISO timestamp)
|
- `X-RateLimit-Reset`: Reset time (ISO timestamp)
|
||||||
@ -460,24 +484,26 @@ Headers returned:
|
|||||||
**Storage Provider Selection**:
|
**Storage Provider Selection**:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const provider = config.fileStorage.provider ||
|
const provider =
|
||||||
|
config.fileStorage.provider ||
|
||||||
(hasS3Credentials ? 's3' : hasGCloudCredentials ? 'gcloud' : 'local');
|
(hasS3Credentials ? 's3' : hasGCloudCredentials ? 'gcloud' : 'local');
|
||||||
```
|
```
|
||||||
|
|
||||||
**S3 Operations**:
|
**S3 Operations**:
|
||||||
|
|
||||||
| Operation | Method | Description |
|
| Operation | Method | Description |
|
||||||
|-----------|--------|-------------|
|
| --------- | ---------------------------------- | ------------------------ |
|
||||||
| Upload | `upload(key, data, options)` | Put object with metadata |
|
| Upload | `upload(key, data, options)` | Put object with metadata |
|
||||||
| Download | `download(key)` | Get object stream |
|
| Download | `download(key)` | Get object stream |
|
||||||
| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL |
|
| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL |
|
||||||
| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects |
|
| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects |
|
||||||
| Check | `exists(key)` | Head object |
|
| Check | `exists(key)` | Head object |
|
||||||
| List | `list(prefix)` | List objects with prefix |
|
| List | `list(prefix)` | List objects with prefix |
|
||||||
|
|
||||||
**Chunked Uploads** (`UploadSessionManager`):
|
**Chunked Uploads** (`UploadSessionManager`):
|
||||||
|
|
||||||
For large files, supports multipart upload sessions:
|
For large files, supports multipart upload sessions:
|
||||||
|
|
||||||
1. `POST /upload-sessions/init` - Create session
|
1. `POST /upload-sessions/init` - Create session
|
||||||
2. `POST /upload-sessions/:id/chunk` - Upload chunk
|
2. `POST /upload-sessions/:id/chunk` - Upload chunk
|
||||||
3. `POST /upload-sessions/:id/finalize` - Complete upload
|
3. `POST /upload-sessions/:id/finalize` - Complete upload
|
||||||
@ -498,11 +524,21 @@ class AppError extends Error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class NotFoundError extends AppError { statusCode = 404 }
|
class NotFoundError extends AppError {
|
||||||
class ValidationError extends AppError { statusCode = 400 }
|
statusCode = 404;
|
||||||
class ForbiddenError extends AppError { statusCode = 403 }
|
}
|
||||||
class UnauthorizedError extends AppError { statusCode = 401 }
|
class ValidationError extends AppError {
|
||||||
class ConflictError extends AppError { statusCode = 409 }
|
statusCode = 400;
|
||||||
|
}
|
||||||
|
class ForbiddenError extends AppError {
|
||||||
|
statusCode = 403;
|
||||||
|
}
|
||||||
|
class UnauthorizedError extends AppError {
|
||||||
|
statusCode = 401;
|
||||||
|
}
|
||||||
|
class ConflictError extends AppError {
|
||||||
|
statusCode = 409;
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Async Handler** (`helpers.ts`):
|
**Async Handler** (`helpers.ts`):
|
||||||
@ -563,12 +599,15 @@ function requestLogger(req, res, next) {
|
|||||||
res.setHeader('X-Request-Id', requestId);
|
res.setHeader('X-Request-Id', requestId);
|
||||||
|
|
||||||
res.on('finish', () => {
|
res.on('finish', () => {
|
||||||
req.log.info({
|
req.log.info(
|
||||||
method: req.method,
|
{
|
||||||
url: req.originalUrl,
|
method: req.method,
|
||||||
status: res.statusCode,
|
url: req.originalUrl,
|
||||||
duration: Date.now() - start,
|
status: res.statusCode,
|
||||||
}, 'Request completed');
|
duration: Date.now() - start,
|
||||||
|
},
|
||||||
|
'Request completed',
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
@ -579,30 +618,30 @@ function requestLogger(req, res, next) {
|
|||||||
|
|
||||||
**Environment Variables** (`config.ts`):
|
**Environment Variables** (`config.ts`):
|
||||||
|
|
||||||
| Variable | Description | Default |
|
| Variable | Description | Default |
|
||||||
|----------|-------------|---------|
|
| ----------------------- | ----------------------------- | --------------------- |
|
||||||
| `SECRET_KEY` | JWT signing key | UUID-based default |
|
| `SECRET_KEY` | JWT signing key | UUID-based default |
|
||||||
| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` |
|
| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` |
|
||||||
| `ADMIN_PASS` | Admin user password | Generated |
|
| `ADMIN_PASS` | Admin user password | Generated |
|
||||||
| `AWS_S3_BUCKET` | S3 bucket name | - |
|
| `AWS_S3_BUCKET` | S3 bucket name | - |
|
||||||
| `AWS_S3_REGION` | S3 region | `us-east-1` |
|
| `AWS_S3_REGION` | S3 region | `us-east-1` |
|
||||||
| `AWS_ACCESS_KEY_ID` | AWS access key | - |
|
| `AWS_ACCESS_KEY_ID` | AWS access key | - |
|
||||||
| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - |
|
| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - |
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - |
|
||||||
| `MS_CLIENT_ID` | Microsoft OAuth client ID | - |
|
| `MS_CLIENT_ID` | Microsoft OAuth client ID | - |
|
||||||
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - |
|
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - |
|
||||||
| `EMAIL_USER` | SMTP username | - |
|
| `EMAIL_USER` | SMTP username | - |
|
||||||
| `EMAIL_PASS` | SMTP password | - |
|
| `EMAIL_PASS` | SMTP password | - |
|
||||||
| `LOG_LEVEL` | Logging level | `info` |
|
| `LOG_LEVEL` | Logging level | `info` |
|
||||||
|
|
||||||
**Database Configuration** (`db/db-config.ts`):
|
**Database Configuration** (`db/db-config.ts`):
|
||||||
|
|
||||||
| Environment | Database | Logging |
|
| Environment | Database | Logging |
|
||||||
|-------------|----------|---------|
|
| ------------- | -------------------------- | -------- |
|
||||||
| `production` | `DB_*` env vars | Disabled |
|
| `production` | `DB_*` env vars | Disabled |
|
||||||
| `development` | `db_tour_builder_platform` | Console |
|
| `development` | `db_tour_builder_platform` | Console |
|
||||||
| `dev_stage` | `DB_*` env vars | Console |
|
| `dev_stage` | `DB_*` env vars | Console |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -647,23 +686,23 @@ GET /api/health
|
|||||||
|
|
||||||
## Key Implementation Files
|
## Key Implementation Files
|
||||||
|
|
||||||
| File | Purpose |
|
| File | Purpose |
|
||||||
|------|---------|
|
| ---------------------------------------- | --------------------------------------------------------- |
|
||||||
| `src/index.ts` | Application entry, middleware setup, route mounting |
|
| `src/index.ts` | Application entry, middleware setup, route mounting |
|
||||||
| `src/config.ts` | Environment configuration |
|
| `src/config.ts` | Environment configuration |
|
||||||
| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 |
|
| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 |
|
||||||
| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) |
|
| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) |
|
||||||
| `src/factories/router.factory.ts` | Route generator for entities |
|
| `src/factories/router.factory.ts` | Route generator for entities |
|
||||||
| `src/factories/service.factory.ts` | Service generator for entities |
|
| `src/factories/service.factory.ts` | Service generator for entities |
|
||||||
| `src/db/api/base.api.ts` | GenericDBApi base class |
|
| `src/db/api/base.api.ts` | GenericDBApi base class |
|
||||||
| `src/middlewares/check-permissions.ts` | RBAC permission checking |
|
| `src/middlewares/check-permissions.ts` | RBAC permission checking |
|
||||||
| `src/middlewares/rateLimiter.ts` | Rate limiting configuration |
|
| `src/middlewares/rateLimiter.ts` | Rate limiting configuration |
|
||||||
| `src/middlewares/runtime-context.ts` | Runtime environment detection |
|
| `src/middlewares/runtime-context.ts` | Runtime environment detection |
|
||||||
| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization |
|
| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization |
|
||||||
| `src/services/publish.ts` | Publishing workflow service |
|
| `src/services/publish.ts` | Publishing workflow service |
|
||||||
| `src/services/file/S3StorageProvider.ts` | S3 storage implementation using official AWS SDK v3 types |
|
| `src/services/file/S3StorageProvider.ts` | S3 storage implementation using official AWS SDK v3 types |
|
||||||
| `src/utils/logger.ts` | Pino logger configuration |
|
| `src/utils/logger.ts` | Pino logger configuration |
|
||||||
| `src/utils/errors.ts` | Error class definitions |
|
| `src/utils/errors.ts` | Error class definitions |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -5,14 +5,15 @@
|
|||||||
The Auth module provides comprehensive authentication and authorization for the application. It supports local email/password authentication, OAuth 2.0 (Google, Microsoft), JWT-based session management, email verification, and password reset flows.
|
The Auth module provides comprehensive authentication and authorization for the application. It supports local email/password authentication, OAuth 2.0 (Google, Microsoft), JWT-based session management, email verification, and password reset flows.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
| File | Purpose |
|
|
||||||
|------|---------|
|
| File | Purpose |
|
||||||
| `src/auth/auth.ts` | Passport.js strategy configurations (JWT, Google, Microsoft) |
|
| -------------------------------- | ----------------------------------------------------------------------- |
|
||||||
| `src/services/auth.ts` | Auth business logic (signin, password reset/update, email verification) |
|
| `src/auth/auth.ts` | Passport.js strategy configurations (JWT, Google, Microsoft) |
|
||||||
| `src/routes/auth.ts` | REST API endpoints for authentication |
|
| `src/services/auth.ts` | Auth business logic (signin, password reset/update, email verification) |
|
||||||
| `src/helpers.ts` | JWT signing utility (`jwtSign`) |
|
| `src/routes/auth.ts` | REST API endpoints for authentication |
|
||||||
| `src/db/api/users.js` | User database operations (tokens, password updates) |
|
| `src/helpers.ts` | JWT signing utility (`jwtSign`) |
|
||||||
| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters |
|
| `src/db/api/users.js` | User database operations (tokens, password updates) |
|
||||||
|
| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -103,26 +104,31 @@ The Auth module provides comprehensive authentication and authorization for the
|
|||||||
Used for API authentication on all protected routes.
|
Used for API authentication on all protected routes.
|
||||||
|
|
||||||
**Configuration (auth/auth.ts):**
|
**Configuration (auth/auth.ts):**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
passport.use(
|
passport.use(
|
||||||
new JWTstrategy({
|
new JWTstrategy(
|
||||||
passReqToCallback: true,
|
{
|
||||||
secretOrKey: config.secret_key,
|
passReqToCallback: true,
|
||||||
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
|
secretOrKey: config.secret_key,
|
||||||
}, async (req, token, done) => {
|
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
|
||||||
const user = await UsersDBApi.findBy({ email: token.user.email });
|
},
|
||||||
|
async (req, token, done) => {
|
||||||
|
const user = await UsersDBApi.findBy({ email: token.user.email });
|
||||||
|
|
||||||
if (user && user.disabled) {
|
if (user && user.disabled) {
|
||||||
return done(new Error(`User '${user.email}' is disabled`));
|
return done(new Error(`User '${user.email}' is disabled`));
|
||||||
}
|
}
|
||||||
|
|
||||||
req.currentUser = user;
|
req.currentUser = user;
|
||||||
return done(null, user);
|
return done(null, user);
|
||||||
})
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Token Structure:**
|
**Token Structure:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
user: {
|
user: {
|
||||||
@ -135,6 +141,7 @@ passport.use(
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Protect route with JWT
|
// Protect route with JWT
|
||||||
router.get('/me', passport.authenticate('jwt', { session: false }), handler);
|
router.get('/me', passport.authenticate('jwt', { session: false }), handler);
|
||||||
@ -146,23 +153,28 @@ const currentUser = req.currentUser;
|
|||||||
### 2. Google OAuth Strategy
|
### 2. Google OAuth Strategy
|
||||||
|
|
||||||
**Configuration (auth/auth.ts):**
|
**Configuration (auth/auth.ts):**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
passport.use(
|
passport.use(
|
||||||
new GoogleStrategy({
|
new GoogleStrategy(
|
||||||
clientID: config.google.clientId,
|
{
|
||||||
clientSecret: config.google.clientSecret,
|
clientID: config.google.clientId,
|
||||||
callbackURL: config.apiUrl + '/auth/signin/google/callback',
|
clientSecret: config.google.clientSecret,
|
||||||
passReqToCallback: true,
|
callbackURL: config.apiUrl + '/auth/signin/google/callback',
|
||||||
}, (request, accessToken, refreshToken, profile, done) => {
|
passReqToCallback: true,
|
||||||
socialStrategy(profile.email, profile, providers.GOOGLE, done);
|
},
|
||||||
})
|
(request, accessToken, refreshToken, profile, done) => {
|
||||||
|
socialStrategy(profile.email, profile, providers.GOOGLE, done);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
| Variable | Description |
|
||||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
| ---------------------- | -------------------------- |
|
||||||
|
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||||
|
|
||||||
**OAuth Scopes:** `profile`, `email`
|
**OAuth Scopes:** `profile`, `email`
|
||||||
@ -170,24 +182,29 @@ passport.use(
|
|||||||
### 3. Microsoft OAuth Strategy
|
### 3. Microsoft OAuth Strategy
|
||||||
|
|
||||||
**Configuration (auth/auth.ts):**
|
**Configuration (auth/auth.ts):**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
passport.use(
|
passport.use(
|
||||||
new MicrosoftStrategy({
|
new MicrosoftStrategy(
|
||||||
clientID: config.microsoft.clientId,
|
{
|
||||||
clientSecret: config.microsoft.clientSecret,
|
clientID: config.microsoft.clientId,
|
||||||
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
|
clientSecret: config.microsoft.clientSecret,
|
||||||
passReqToCallback: true,
|
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
|
||||||
}, (request, accessToken, refreshToken, profile, done) => {
|
passReqToCallback: true,
|
||||||
const email = profile._json.mail || profile._json.userPrincipalName;
|
},
|
||||||
socialStrategy(email, profile, providers.MICROSOFT, done);
|
(request, accessToken, refreshToken, profile, done) => {
|
||||||
})
|
const email = profile._json.mail || profile._json.userPrincipalName;
|
||||||
|
socialStrategy(email, profile, providers.MICROSOFT, done);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**Environment Variables:**
|
**Environment Variables:**
|
||||||
| Variable | Description |
|
|
||||||
|----------|-------------|
|
| Variable | Description |
|
||||||
| `MS_CLIENT_ID` | Microsoft OAuth client ID |
|
| ------------------ | ----------------------------- |
|
||||||
|
| `MS_CLIENT_ID` | Microsoft OAuth client ID |
|
||||||
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret |
|
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret |
|
||||||
|
|
||||||
**OAuth Scopes:** `https://graph.microsoft.com/user.read`, `openid`
|
**OAuth Scopes:** `https://graph.microsoft.com/user.read`, `openid`
|
||||||
@ -222,13 +239,13 @@ Core authentication business logic.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class Auth {
|
class Auth {
|
||||||
static async signin(email, password)
|
static async signin(email, password);
|
||||||
static async verifyEmail(token, options)
|
static async verifyEmail(token, options);
|
||||||
static async passwordUpdate(currentPassword, newPassword, options)
|
static async passwordUpdate(currentPassword, newPassword, options);
|
||||||
static async passwordReset(token, password, options)
|
static async passwordReset(token, password, options);
|
||||||
static async sendEmailAddressVerificationEmail(email, host)
|
static async sendEmailAddressVerificationEmail(email, host);
|
||||||
static async sendPasswordResetEmail(email, type, host)
|
static async sendPasswordResetEmail(email, type, host);
|
||||||
static async updateProfile(data, currentUser)
|
static async updateProfile(data, currentUser);
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -237,6 +254,7 @@ class Auth {
|
|||||||
Registers a new user or updates password for existing unverified user.
|
Registers a new user or updates password for existing unverified user.
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Check if user exists by email
|
1. Check if user exists by email
|
||||||
├── User exists with authenticationUid → Error: emailAlreadyInUse
|
├── User exists with authenticationUid → Error: emailAlreadyInUse
|
||||||
@ -257,6 +275,7 @@ Registers a new user or updates password for existing unverified user.
|
|||||||
Authenticates user with email and password.
|
Authenticates user with email and password.
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Find user by email
|
1. Find user by email
|
||||||
└── Not found → Error: userNotFound
|
└── Not found → Error: userNotFound
|
||||||
@ -278,6 +297,7 @@ Authenticates user with email and password.
|
|||||||
Verifies user email address using token.
|
Verifies user email address using token.
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Find user by email verification token
|
1. Find user by email verification token
|
||||||
└── Not found or expired → Error: invalidToken
|
└── Not found or expired → Error: invalidToken
|
||||||
@ -290,6 +310,7 @@ Verifies user email address using token.
|
|||||||
Updates password for authenticated user.
|
Updates password for authenticated user.
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Verify currentUser exists
|
1. Verify currentUser exists
|
||||||
└── Not authenticated → ForbiddenError
|
└── Not authenticated → ForbiddenError
|
||||||
@ -305,6 +326,7 @@ Updates password for authenticated user.
|
|||||||
Resets password using reset token.
|
Resets password using reset token.
|
||||||
|
|
||||||
**Flow:**
|
**Flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
1. Find user by password reset token
|
1. Find user by password reset token
|
||||||
└── Not found or expired → Error: invalidToken
|
└── Not found or expired → Error: invalidToken
|
||||||
@ -320,27 +342,28 @@ REST API endpoints for authentication.
|
|||||||
|
|
||||||
#### Endpoints Overview
|
#### Endpoints Overview
|
||||||
|
|
||||||
| Method | Path | Auth | Rate Limit | Description |
|
| Method | Path | Auth | Rate Limit | Description |
|
||||||
|--------|------|------|------------|-------------|
|
| ------ | ---------------------------------------- | ---- | -------------------- | ---------------------------- |
|
||||||
| POST | `/signin/local` | No | authLimiter | Login with email/password |
|
| POST | `/signin/local` | No | authLimiter | Login with email/password |
|
||||||
| GET | `/me` | JWT | - | Get current user |
|
| GET | `/me` | JWT | - | Get current user |
|
||||||
| PUT | `/password-reset` | No | - | Reset password with token |
|
| PUT | `/password-reset` | No | - | Reset password with token |
|
||||||
| PUT | `/password-update` | JWT | - | Change password |
|
| PUT | `/password-update` | JWT | - | Change password |
|
||||||
| PUT | `/profile` | JWT | - | Update user profile |
|
| PUT | `/profile` | JWT | - | Update user profile |
|
||||||
| PUT | `/verify-email` | No | - | Verify email with token |
|
| PUT | `/verify-email` | No | - | Verify email with token |
|
||||||
| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email |
|
| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email |
|
||||||
| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email |
|
| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email |
|
||||||
| GET | `/email-configured` | No | - | Check if email is configured |
|
| GET | `/email-configured` | No | - | Check if email is configured |
|
||||||
| GET | `/signin/google` | No | - | Initiate Google OAuth |
|
| GET | `/signin/google` | No | - | Initiate Google OAuth |
|
||||||
| GET | `/signin/google/callback` | No | - | Google OAuth callback |
|
| GET | `/signin/google/callback` | No | - | Google OAuth callback |
|
||||||
| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth |
|
| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth |
|
||||||
| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback |
|
| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback |
|
||||||
|
|
||||||
#### POST /api/auth/signin/local
|
#### POST /api/auth/signin/local
|
||||||
|
|
||||||
Login with email and password.
|
Login with email and password.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"email": "user@example.com",
|
"email": "user@example.com",
|
||||||
@ -349,18 +372,20 @@ Login with email and password.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
| Code | Message | Cause |
|
|
||||||
|------|---------|-------|
|
| Code | Message | Cause |
|
||||||
| 400 | `auth.userNotFound` | User doesn't exist |
|
| ---- | ---------------------- | ------------------------ |
|
||||||
| 400 | `auth.userDisabled` | User account is disabled |
|
| 400 | `auth.userNotFound` | User doesn't exist |
|
||||||
| 400 | `auth.wrongPassword` | Invalid password |
|
| 400 | `auth.userDisabled` | User account is disabled |
|
||||||
| 400 | `auth.userNotVerified` | Email not verified |
|
| 400 | `auth.wrongPassword` | Invalid password |
|
||||||
| 429 | Too Many Requests | Rate limit exceeded |
|
| 400 | `auth.userNotVerified` | Email not verified |
|
||||||
|
| 429 | Too Many Requests | Rate limit exceeded |
|
||||||
|
|
||||||
#### Self-Registration
|
#### Self-Registration
|
||||||
|
|
||||||
@ -373,11 +398,13 @@ invitation/setup link.
|
|||||||
Get current authenticated user.
|
Get current authenticated user.
|
||||||
|
|
||||||
**Headers:**
|
**Headers:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer <JWT_TOKEN>
|
Authorization: Bearer <JWT_TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
@ -404,6 +431,7 @@ Authorization: Bearer <JWT_TOKEN>
|
|||||||
Reset password using token from email.
|
Reset password using token from email.
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"token": "abc123...",
|
"token": "abc123...",
|
||||||
@ -412,6 +440,7 @@ Reset password using token from email.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response (200):**
|
**Response (200):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "success": true }
|
{ "success": true }
|
||||||
```
|
```
|
||||||
@ -421,11 +450,13 @@ Reset password using token from email.
|
|||||||
Change password for authenticated user.
|
Change password for authenticated user.
|
||||||
|
|
||||||
**Headers:**
|
**Headers:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer <JWT_TOKEN>
|
Authorization: Bearer <JWT_TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"currentPassword": "oldPassword123",
|
"currentPassword": "oldPassword123",
|
||||||
@ -434,22 +465,25 @@ Authorization: Bearer <JWT_TOKEN>
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Errors:**
|
**Errors:**
|
||||||
| Code | Message | Cause |
|
|
||||||
|------|---------|-------|
|
| Code | Message | Cause |
|
||||||
| 400 | `auth.wrongPassword` | Current password incorrect |
|
| ---- | ---------------------------------- | -------------------------- |
|
||||||
| 400 | `auth.passwordUpdate.samePassword` | New password same as old |
|
| 400 | `auth.wrongPassword` | Current password incorrect |
|
||||||
| 403 | Forbidden | Not authenticated |
|
| 400 | `auth.passwordUpdate.samePassword` | New password same as old |
|
||||||
|
| 403 | Forbidden | Not authenticated |
|
||||||
|
|
||||||
#### PUT /api/auth/profile
|
#### PUT /api/auth/profile
|
||||||
|
|
||||||
Update user profile.
|
Update user profile.
|
||||||
|
|
||||||
**Headers:**
|
**Headers:**
|
||||||
|
|
||||||
```
|
```
|
||||||
Authorization: Bearer <JWT_TOKEN>
|
Authorization: Bearer <JWT_TOKEN>
|
||||||
```
|
```
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"profile": {
|
"profile": {
|
||||||
@ -463,18 +497,22 @@ Authorization: Bearer <JWT_TOKEN>
|
|||||||
#### OAuth Endpoints
|
#### OAuth Endpoints
|
||||||
|
|
||||||
**GET /api/auth/signin/google**
|
**GET /api/auth/signin/google**
|
||||||
|
|
||||||
- Redirects to Google OAuth consent screen
|
- Redirects to Google OAuth consent screen
|
||||||
- Query param: `app` (passed as state)
|
- Query param: `app` (passed as state)
|
||||||
|
|
||||||
**GET /api/auth/signin/google/callback**
|
**GET /api/auth/signin/google/callback**
|
||||||
|
|
||||||
- Handles Google OAuth callback
|
- Handles Google OAuth callback
|
||||||
- Redirects to: `{uiUrl}/login?token={jwt}`
|
- Redirects to: `{uiUrl}/login?token={jwt}`
|
||||||
|
|
||||||
**GET /api/auth/signin/microsoft**
|
**GET /api/auth/signin/microsoft**
|
||||||
|
|
||||||
- Redirects to Microsoft OAuth consent screen
|
- Redirects to Microsoft OAuth consent screen
|
||||||
- Query param: `app` (passed as state)
|
- Query param: `app` (passed as state)
|
||||||
|
|
||||||
**GET /api/auth/signin/microsoft/callback**
|
**GET /api/auth/signin/microsoft/callback**
|
||||||
|
|
||||||
- Handles Microsoft OAuth callback
|
- Handles Microsoft OAuth callback
|
||||||
- Redirects to: `{uiUrl}/login?token={jwt}`
|
- Redirects to: `{uiUrl}/login?token={jwt}`
|
||||||
|
|
||||||
@ -495,11 +533,12 @@ static jwtSign(data) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Configuration:**
|
**Configuration:**
|
||||||
| Setting | Value | Description |
|
|
||||||
|---------|-------|-------------|
|
| Setting | Value | Description |
|
||||||
|
| ---------- | ------------------- | ------------------------- |
|
||||||
| Secret Key | `config.secret_key` | From `SECRET_KEY` env var |
|
| Secret Key | `config.secret_key` | From `SECRET_KEY` env var |
|
||||||
| Expiration | `6h` | Token valid for 6 hours |
|
| Expiration | `6h` | Token valid for 6 hours |
|
||||||
| Algorithm | `HS256` | Default HMAC SHA-256 |
|
| Algorithm | `HS256` | Default HMAC SHA-256 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -549,17 +588,19 @@ static async generateEmailVerificationToken(email, options) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Token Properties:**
|
**Token Properties:**
|
||||||
| Property | Value |
|
|
||||||
|----------|-------|
|
| Property | Value |
|
||||||
| Length | 40 hex characters |
|
| -------- | ------------------------------- |
|
||||||
| Expiry | 24 hours |
|
| Length | 40 hex characters |
|
||||||
| Storage | `emailVerificationToken` column |
|
| Expiry | 24 hours |
|
||||||
|
| Storage | `emailVerificationToken` column |
|
||||||
|
|
||||||
#### Method: generatePasswordResetToken(email)
|
#### Method: generatePasswordResetToken(email)
|
||||||
|
|
||||||
Generates secure token for password reset.
|
Generates secure token for password reset.
|
||||||
|
|
||||||
Same implementation as `generateEmailVerificationToken` but stores in:
|
Same implementation as `generateEmailVerificationToken` but stores in:
|
||||||
|
|
||||||
- `passwordResetToken`
|
- `passwordResetToken`
|
||||||
- `passwordResetTokenExpiresAt`
|
- `passwordResetTokenExpiresAt`
|
||||||
|
|
||||||
@ -610,11 +651,11 @@ const authLimiter = createRateLimiter({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
|---------|-------|
|
| ------------ | --------------- |
|
||||||
| Window | 15 minutes |
|
| Window | 15 minutes |
|
||||||
| Max Requests | 10 |
|
| Max Requests | 10 |
|
||||||
| Applied To | `/signin/local` |
|
| Applied To | `/signin/local` |
|
||||||
|
|
||||||
### Signup Limiter
|
### Signup Limiter
|
||||||
|
|
||||||
@ -631,11 +672,11 @@ const passwordResetLimiter = createRateLimiter({
|
|||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
| Setting | Value |
|
| Setting | Value |
|
||||||
|---------|-------|
|
| ------------ | ---------------------------- |
|
||||||
| Window | 1 hour |
|
| Window | 1 hour |
|
||||||
| Max Requests | 5 |
|
| Max Requests | 5 |
|
||||||
| Applied To | `/send-password-reset-email` |
|
| Applied To | `/send-password-reset-email` |
|
||||||
|
|
||||||
### Rate Limit Response
|
### Rate Limit Response
|
||||||
|
|
||||||
@ -648,6 +689,7 @@ const passwordResetLimiter = createRateLimiter({
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Headers:**
|
**Headers:**
|
||||||
|
|
||||||
```
|
```
|
||||||
X-RateLimit-Limit: 10
|
X-RateLimit-Limit: 10
|
||||||
X-RateLimit-Remaining: 0
|
X-RateLimit-Remaining: 0
|
||||||
@ -668,15 +710,16 @@ bcrypt: {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
| Setting | Value | Security Impact |
|
| Setting | Value | Security Impact |
|
||||||
|---------|-------|-----------------|
|
| ----------- | -------------- | ------------------- |
|
||||||
| Algorithm | bcrypt | Industry standard |
|
| Algorithm | bcrypt | Industry standard |
|
||||||
| Salt Rounds | 12 | ~200ms hash time |
|
| Salt Rounds | 12 | ~200ms hash time |
|
||||||
| Salt | Auto-generated | Per-password unique |
|
| Salt | Auto-generated | Per-password unique |
|
||||||
|
|
||||||
### Password Validation
|
### Password Validation
|
||||||
|
|
||||||
Passwords are:
|
Passwords are:
|
||||||
|
|
||||||
1. Hashed before storage (never stored in plain text)
|
1. Hashed before storage (never stored in plain text)
|
||||||
2. Compared using `bcrypt.compare()` (timing-attack safe)
|
2. Compared using `bcrypt.compare()` (timing-attack safe)
|
||||||
3. Required for local authentication
|
3. Required for local authentication
|
||||||
@ -696,6 +739,7 @@ if (EmailSender.isConfigured) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
When email is NOT configured:
|
When email is NOT configured:
|
||||||
|
|
||||||
- Signup succeeds without verification email
|
- Signup succeeds without verification email
|
||||||
- Users are auto-verified on signin
|
- Users are auto-verified on signin
|
||||||
- Password reset emails not sent
|
- Password reset emails not sent
|
||||||
@ -705,6 +749,7 @@ When email is NOT configured:
|
|||||||
**Email Class:** `EmailAddressVerificationEmail`
|
**Email Class:** `EmailAddressVerificationEmail`
|
||||||
|
|
||||||
**Link Format:**
|
**Link Format:**
|
||||||
|
|
||||||
```
|
```
|
||||||
{host}/verify-email?token={token}
|
{host}/verify-email?token={token}
|
||||||
```
|
```
|
||||||
@ -712,10 +757,12 @@ When email is NOT configured:
|
|||||||
### Password Reset Email
|
### Password Reset Email
|
||||||
|
|
||||||
**Email Classes:**
|
**Email Classes:**
|
||||||
|
|
||||||
- `PasswordResetEmail` - Standard reset
|
- `PasswordResetEmail` - Standard reset
|
||||||
- `InvitationEmail` - New user invitation
|
- `InvitationEmail` - New user invitation
|
||||||
|
|
||||||
**Link Format:**
|
**Link Format:**
|
||||||
|
|
||||||
```
|
```
|
||||||
{host}/password-reset?token={token}
|
{host}/password-reset?token={token}
|
||||||
```
|
```
|
||||||
@ -726,16 +773,16 @@ When email is NOT configured:
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------|----------|---------|-------------|
|
| ---------------------- | -------- | -------------------------------------- | ----------------------------- |
|
||||||
| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret |
|
| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret |
|
||||||
| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID |
|
| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID |
|
||||||
| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret |
|
| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret |
|
||||||
| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID |
|
| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID |
|
||||||
| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret |
|
| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret |
|
||||||
| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email |
|
| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email |
|
||||||
| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password |
|
| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password |
|
||||||
| `USER_PASS` | No | `c3baadeda5c6` | Default user password |
|
| `USER_PASS` | No | `c3baadeda5c6` | Default user password |
|
||||||
|
|
||||||
### config.ts Settings
|
### config.ts Settings
|
||||||
|
|
||||||
@ -862,18 +909,18 @@ When email is NOT configured:
|
|||||||
|
|
||||||
## Error Codes
|
## Error Codes
|
||||||
|
|
||||||
| Error Key | HTTP Status | Description |
|
| Error Key | HTTP Status | Description |
|
||||||
|-----------|-------------|-------------|
|
| ------------------------------------------------- | ----------- | ------------------------------ |
|
||||||
| `auth.userNotFound` | 400 | User with email doesn't exist |
|
| `auth.userNotFound` | 400 | User with email doesn't exist |
|
||||||
| `auth.userDisabled` | 400 | User account is disabled |
|
| `auth.userDisabled` | 400 | User account is disabled |
|
||||||
| `auth.wrongPassword` | 400 | Password doesn't match |
|
| `auth.wrongPassword` | 400 | Password doesn't match |
|
||||||
| `auth.userNotVerified` | 400 | Email not verified |
|
| `auth.userNotVerified` | 400 | Email not verified |
|
||||||
| `auth.emailAlreadyInUse` | 400 | Email already registered |
|
| `auth.emailAlreadyInUse` | 400 | Email already registered |
|
||||||
| `auth.passwordUpdate.samePassword` | 400 | New password same as current |
|
| `auth.passwordUpdate.samePassword` | 400 | New password same as current |
|
||||||
| `auth.passwordReset.error` | 400 | Token generation failed |
|
| `auth.passwordReset.error` | 400 | Token generation failed |
|
||||||
| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token |
|
| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token |
|
||||||
| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed |
|
| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed |
|
||||||
| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token |
|
| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -913,18 +960,18 @@ Use the authenticated Users API/UI to create invited users.
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
| Package | Version | Purpose |
|
| Package | Version | Purpose |
|
||||||
|---------|---------|---------|
|
| ------------------------------- | -------- | -------------------------------------------------------------------- |
|
||||||
| `passport` | ^0.6.0 | Authentication middleware |
|
| `passport` | ^0.6.0 | Authentication middleware |
|
||||||
| `passport-jwt` | ^4.0.0 | JWT strategy for Passport |
|
| `passport-jwt` | ^4.0.0 | JWT strategy for Passport |
|
||||||
| `passport-google-oauth2` | ^0.2.0 | Google OAuth strategy |
|
| `passport-google-oauth2` | ^0.2.0 | Google OAuth strategy |
|
||||||
| `passport-microsoft` | ^2.0.0 | Microsoft OAuth strategy |
|
| `passport-microsoft` | ^2.0.0 | Microsoft OAuth strategy |
|
||||||
| `@types/passport-jwt` | ^4.0.1 | Maintained TypeScript definitions for JWT Passport strategy |
|
| `@types/passport-jwt` | ^4.0.1 | Maintained TypeScript definitions for JWT Passport strategy |
|
||||||
| `@types/passport-google-oauth2` | ^0.1.10 | Maintained TypeScript definitions for Google OAuth Passport strategy |
|
| `@types/passport-google-oauth2` | ^0.1.10 | Maintained TypeScript definitions for Google OAuth Passport strategy |
|
||||||
| `@types/passport-microsoft` | ^2.1.1 | Maintained TypeScript definitions for Microsoft Passport strategy |
|
| `@types/passport-microsoft` | ^2.1.1 | Maintained TypeScript definitions for Microsoft Passport strategy |
|
||||||
| `jsonwebtoken` | ^9.0.0 | JWT sign/verify |
|
| `jsonwebtoken` | ^9.0.0 | JWT sign/verify |
|
||||||
| `bcrypt` | ^5.1.0 | Password hashing |
|
| `bcrypt` | ^5.1.0 | Password hashing |
|
||||||
| `crypto` | built-in | Token generation |
|
| `crypto` | built-in | Token generation |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -4,13 +4,13 @@ The Core module provides the foundational components of the backend application:
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
|
|
||||||
| File | Purpose | Lines |
|
| File | Purpose | Lines |
|
||||||
|------|---------|-------|
|
| ----------------- | ------------------------------------------------------------------ | ------ |
|
||||||
| `src/index.ts` | Application entry point, Express setup, middleware, route mounting | varies |
|
| `src/index.ts` | Application entry point, Express setup, middleware, route mounting | varies |
|
||||||
| `src/config.ts` | Environment configuration and settings | varies |
|
| `src/config.ts` | Environment configuration and settings | varies |
|
||||||
| `src/helpers.js` | Utility functions (wrapAsync, JWT, validation) | 32 |
|
| `src/helpers.js` | Utility functions (wrapAsync, JWT, validation) | 32 |
|
||||||
| `src/types/` | Shared strict TypeScript contracts for migrated backend code | varies |
|
| `src/types/` | Shared strict TypeScript contracts for migrated backend code | varies |
|
||||||
| `src/load-env.ts` | Central backend `.env` bootstrap for app and DB entrypoints | varies |
|
| `src/load-env.ts` | Central backend `.env` bootstrap for app and DB entrypoints | varies |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -29,11 +29,18 @@ import express from 'express';
|
|||||||
import helmet from 'helmet';
|
import helmet from 'helmet';
|
||||||
import * as swaggerUI from 'swagger-ui-express';
|
import * as swaggerUI from 'swagger-ui-express';
|
||||||
|
|
||||||
import { authenticateJwt, authenticateJwtWithCallback } from './auth/passport-middleware.ts';
|
import {
|
||||||
|
authenticateJwt,
|
||||||
|
authenticateJwtWithCallback,
|
||||||
|
} from './auth/passport-middleware.ts';
|
||||||
import config from './config.ts';
|
import config from './config.ts';
|
||||||
import { wrapAsync } from './helpers.ts';
|
import { wrapAsync } from './helpers.ts';
|
||||||
import { runtimeContextMiddleware } from './middlewares/runtime-context.ts';
|
import { runtimeContextMiddleware } from './middlewares/runtime-context.ts';
|
||||||
import { downloadLimiter, searchLimiter, uploadLimiter } from './middlewares/rateLimiter.ts';
|
import {
|
||||||
|
downloadLimiter,
|
||||||
|
searchLimiter,
|
||||||
|
uploadLimiter,
|
||||||
|
} from './middlewares/rateLimiter.ts';
|
||||||
import { createOpenApiDocument } from './openapi/document.ts';
|
import { createOpenApiDocument } from './openapi/document.ts';
|
||||||
import {
|
import {
|
||||||
exitAfterLogging,
|
exitAfterLogging,
|
||||||
@ -159,21 +166,25 @@ app.use('/api/file', fileRoutes) // File download/presign (partial)
|
|||||||
#### Protected Routes (JWT Required)
|
#### Protected Routes (JWT Required)
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
app.use('/api/users', jwtAuth, usersRoutes)
|
app.use('/api/users', jwtAuth, usersRoutes);
|
||||||
app.use('/api/roles', jwtAuth, rolesRoutes)
|
app.use('/api/roles', jwtAuth, rolesRoutes);
|
||||||
app.use('/api/permissions', jwtAuth, permissionsRoutes)
|
app.use('/api/permissions', jwtAuth, permissionsRoutes);
|
||||||
app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes)
|
app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes);
|
||||||
app.use('/api/assets', jwtAuth, assetsRoutes)
|
app.use('/api/assets', jwtAuth, assetsRoutes);
|
||||||
app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes)
|
app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes);
|
||||||
app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes)
|
app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes);
|
||||||
app.use('/api/publish_events', jwtAuth, publish_eventsRoutes)
|
app.use('/api/publish_events', jwtAuth, publish_eventsRoutes);
|
||||||
app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes)
|
app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes);
|
||||||
app.use('/api/access_logs', jwtAuth, access_logsRoutes)
|
app.use('/api/access_logs', jwtAuth, access_logsRoutes);
|
||||||
app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes)
|
app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes);
|
||||||
app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes) // Alias
|
app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes); // Alias
|
||||||
app.use('/api/project-element-defaults', jwtAuth, project_element_defaultsRoutes)
|
app.use(
|
||||||
app.use('/api/publish', jwtAuth, publishRoutes)
|
'/api/project-element-defaults',
|
||||||
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes)
|
jwtAuth,
|
||||||
|
project_element_defaultsRoutes,
|
||||||
|
);
|
||||||
|
app.use('/api/publish', jwtAuth, publishRoutes);
|
||||||
|
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Runtime Public Routes (Production Content Without Auth)
|
#### Runtime Public Routes (Production Content Without Auth)
|
||||||
@ -182,9 +193,13 @@ app.use('/api/search', jwtAuth, searchLimiter, searchRoutes)
|
|||||||
// These routes use requireRuntimeReadOrAuth middleware
|
// These routes use requireRuntimeReadOrAuth middleware
|
||||||
// Allows unauthenticated GET requests in production environment
|
// Allows unauthenticated GET requests in production environment
|
||||||
|
|
||||||
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes)
|
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
|
||||||
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes)
|
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
|
||||||
mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', project_audio_tracksRoutes)
|
mountRuntimeEntityRoute(
|
||||||
|
'/api/project_audio_tracks',
|
||||||
|
'project_audio_tracks',
|
||||||
|
project_audio_tracksRoutes,
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Key Functions
|
### Key Functions
|
||||||
@ -204,11 +219,11 @@ const requireRuntimeReadOrAuth = (req, res, next) => {
|
|||||||
|
|
||||||
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
|
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
|
||||||
req.isRuntimePublicRequest = true;
|
req.isRuntimePublicRequest = true;
|
||||||
return next(); // Allow without JWT
|
return next(); // Allow without JWT
|
||||||
}
|
}
|
||||||
|
|
||||||
req.isRuntimePublicRequest = false;
|
req.isRuntimePublicRequest = false;
|
||||||
return jwtAuth(req, res, next); // Require JWT
|
return jwtAuth(req, res, next); // Require JWT
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -220,9 +235,9 @@ Helper to mount routes with runtime public access middleware stack:
|
|||||||
const mountRuntimeEntityRoute = (path, entityName, router) => {
|
const mountRuntimeEntityRoute = (path, entityName, router) => {
|
||||||
app.use(
|
app.use(
|
||||||
path,
|
path,
|
||||||
requireRuntimeReadOrAuth, // JWT or public production
|
requireRuntimeReadOrAuth, // JWT or public production
|
||||||
blockNonPublicRuntimeListEndpoints, // Block non-list for public
|
blockNonPublicRuntimeListEndpoints, // Block non-list for public
|
||||||
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
|
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
|
||||||
router,
|
router,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@ -309,10 +324,7 @@ breaker rejections use status `503` instead of being collapsed to `500`.
|
|||||||
const PORT = config.server.port;
|
const PORT = config.server.port;
|
||||||
|
|
||||||
const server = app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
logger.info(
|
logger.info({ port: PORT, env: config.server.env }, 'Server started');
|
||||||
{ port: PORT, env: config.server.env },
|
|
||||||
'Server started',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on('error', (err) => {
|
server.on('error', (err) => {
|
||||||
@ -454,33 +466,32 @@ const config = {
|
|||||||
port: serverPort,
|
port: serverPort,
|
||||||
swaggerServerUrl,
|
swaggerServerUrl,
|
||||||
},
|
},
|
||||||
|
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### Environment Variables Reference
|
### Environment Variables Reference
|
||||||
|
|
||||||
| Variable | Type | Default | Description |
|
| Variable | Type | Default | Description |
|
||||||
|----------|------|---------|-------------|
|
| ------------------------------- | ------ | --------------------- | ------------------------------------------------------------- |
|
||||||
| `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` |
|
| `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` |
|
||||||
| `PORT` | number | `8080` | Server port |
|
| `PORT` | number | `8080` | Server port |
|
||||||
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
|
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
|
||||||
| `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email |
|
| `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email |
|
||||||
| `ADMIN_PASS` | string | Generated | Admin user password |
|
| `ADMIN_PASS` | string | Generated | Admin user password |
|
||||||
| `USER_PASS` | string | Generated | Default user password |
|
| `USER_PASS` | string | Generated | Default user password |
|
||||||
| `AWS_S3_BUCKET` | string | - | S3 bucket name |
|
| `AWS_S3_BUCKET` | string | - | S3 bucket name |
|
||||||
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
|
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
|
||||||
| `AWS_ACCESS_KEY_ID` | string | - | AWS access key |
|
| `AWS_ACCESS_KEY_ID` | string | - | AWS access key |
|
||||||
| `AWS_SECRET_ACCESS_KEY` | string | - | AWS secret key |
|
| `AWS_SECRET_ACCESS_KEY` | string | - | AWS secret key |
|
||||||
| `AWS_S3_PREFIX` | string | Hash | S3 key prefix |
|
| `AWS_S3_PREFIX` | string | Hash | S3 key prefix |
|
||||||
| `GOOGLE_CLIENT_ID` | string | - | Google OAuth client ID |
|
| `GOOGLE_CLIENT_ID` | string | - | Google OAuth client ID |
|
||||||
| `GOOGLE_CLIENT_SECRET` | string | - | Google OAuth client secret |
|
| `GOOGLE_CLIENT_SECRET` | string | - | Google OAuth client secret |
|
||||||
| `MS_CLIENT_ID` | string | - | Microsoft OAuth client ID |
|
| `MS_CLIENT_ID` | string | - | Microsoft OAuth client ID |
|
||||||
| `MS_CLIENT_SECRET` | string | - | Microsoft OAuth client secret |
|
| `MS_CLIENT_SECRET` | string | - | Microsoft OAuth client secret |
|
||||||
| `EMAIL_USER` | string | - | SMTP username |
|
| `EMAIL_USER` | string | - | SMTP username |
|
||||||
| `EMAIL_PASS` | string | - | SMTP password |
|
| `EMAIL_PASS` | string | - | SMTP password |
|
||||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS validation |
|
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS validation |
|
||||||
| `LOG_LEVEL` | string | `info` | Pino log level |
|
| `LOG_LEVEL` | string | `info` | Pino log level |
|
||||||
|
|
||||||
### Environment Validation
|
### Environment Validation
|
||||||
|
|
||||||
@ -520,7 +531,7 @@ function validateEnv() {
|
|||||||
logger.error({ errors: messages }, 'Environment validation failed');
|
logger.error({ errors: messages }, 'Environment validation failed');
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
process.exit(1); // Fatal in production
|
process.exit(1); // Fatal in production
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Continuing with default values in non-production mode');
|
logger.warn('Continuing with default values in non-production mode');
|
||||||
}
|
}
|
||||||
@ -617,10 +628,13 @@ router.get('/', async (req, res, next) => {
|
|||||||
// With wrapAsync - cleaner code
|
// With wrapAsync - cleaner code
|
||||||
const wrapAsync = require('../helpers').wrapAsync;
|
const wrapAsync = require('../helpers').wrapAsync;
|
||||||
|
|
||||||
router.get('/', wrapAsync(async (req, res) => {
|
router.get(
|
||||||
const data = await Service.findAll();
|
'/',
|
||||||
res.json(data);
|
wrapAsync(async (req, res) => {
|
||||||
}));
|
const data = await Service.findAll();
|
||||||
|
res.json(data);
|
||||||
|
}),
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### commonErrorHandler
|
#### commonErrorHandler
|
||||||
@ -634,10 +648,10 @@ router.use('/', commonErrorHandler);
|
|||||||
// Errors with code/status are returned as-is
|
// Errors with code/status are returned as-is
|
||||||
const error = new Error('Not found');
|
const error = new Error('Not found');
|
||||||
error.code = 404;
|
error.code = 404;
|
||||||
throw error; // → 404 "Not found"
|
throw error; // → 404 "Not found"
|
||||||
|
|
||||||
// Unknown errors return 500
|
// Unknown errors return 500
|
||||||
throw new Error('Database connection failed'); // → 500 "Internal server error"
|
throw new Error('Database connection failed'); // → 500 "Internal server error"
|
||||||
```
|
```
|
||||||
|
|
||||||
#### jwtSign
|
#### jwtSign
|
||||||
@ -663,10 +677,10 @@ const token = jwtSign({
|
|||||||
const { isUuidV4 } = require('./helpers');
|
const { isUuidV4 } = require('./helpers');
|
||||||
|
|
||||||
// Validate UUID format
|
// Validate UUID format
|
||||||
isUuidV4('550e8400-e29b-41d4-a716-446655440000'); // true
|
isUuidV4('550e8400-e29b-41d4-a716-446655440000'); // true
|
||||||
isUuidV4('550e8400-e29b-31d4-a716-446655440000'); // false (version 3)
|
isUuidV4('550e8400-e29b-31d4-a716-446655440000'); // false (version 3)
|
||||||
isUuidV4('not-a-uuid'); // false
|
isUuidV4('not-a-uuid'); // false
|
||||||
isUuidV4(''); // false
|
isUuidV4(''); // false
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -767,12 +781,12 @@ External Dependencies:
|
|||||||
|
|
||||||
## Server Modes
|
## Server Modes
|
||||||
|
|
||||||
| NODE_ENV | Port | Database | Swagger | Description |
|
| NODE_ENV | Port | Database | Swagger | Description |
|
||||||
|----------|------|----------|---------|-------------|
|
| ------------- | ---- | -------- | -------------- | ------------------------ |
|
||||||
| `development` | 8080 | Local | localhost:8080 | Legacy local development |
|
| `development` | 8080 | Local | localhost:8080 | Legacy local development |
|
||||||
| `dev_stage` | 3000 | Remote | localhost:3000 | Staging preview |
|
| `dev_stage` | 3000 | Remote | localhost:3000 | Staging preview |
|
||||||
| `production` | 8080 | Remote | Disabled | Production deployment |
|
| `production` | 8080 | Remote | Disabled | Production deployment |
|
||||||
| `test` | 8080 | Test DB | Disabled | Automated testing |
|
| `test` | 8080 | Test DB | Disabled | Automated testing |
|
||||||
|
|
||||||
**Standard VM note:** the VM PM2 setup runs the backend with
|
**Standard VM note:** the VM PM2 setup runs the backend with
|
||||||
`NODE_ENV=dev_stage`, so the backend listens on port `3000`. The frontend runs
|
`NODE_ENV=dev_stage`, so the backend listens on port `3000`. The frontend runs
|
||||||
|
|||||||
@ -8,30 +8,30 @@ The DB API module provides the data access layer that sits between services and
|
|||||||
|
|
||||||
**Files:** 20 files (1 base class + 18 entity APIs + 1 utility)
|
**Files:** 20 files (1 base class + 18 entity APIs + 1 utility)
|
||||||
|
|
||||||
| File | Class/Purpose | LOC | Extends GenericDBApi |
|
| File | Class/Purpose | LOC | Extends GenericDBApi |
|
||||||
|------|---------------|-----|---------------------|
|
| -------------------------------- | ---------------------------------------------------------------- | ---- | -------------------- |
|
||||||
| `base.api.ts` | `GenericDBApi` - Base class | 726 | - |
|
| `base.api.ts` | `GenericDBApi` - Base class | 726 | - |
|
||||||
| `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) |
|
| `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) |
|
||||||
| `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes |
|
| `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes |
|
||||||
| `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes |
|
| `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes |
|
||||||
| `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes |
|
| `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes |
|
||||||
| `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes |
|
| `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes |
|
||||||
| `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes |
|
| `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes |
|
||||||
| `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes |
|
| `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes |
|
||||||
| `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes |
|
| `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes |
|
||||||
| `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes |
|
| `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes |
|
||||||
| `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes |
|
| `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes |
|
||||||
| `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | Yes |
|
| `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | Yes |
|
||||||
| `project_transition_settings.ts` | `Project_transition_settingsDBApi` - Project transition settings | ~277 | Yes |
|
| `project_transition_settings.ts` | `Project_transition_settingsDBApi` - Project transition settings | ~277 | Yes |
|
||||||
| `global_transition_defaults.ts` | `Global_transition_defaultsDBApi` - Global transition defaults | ~155 | Yes |
|
| `global_transition_defaults.ts` | `Global_transition_defaultsDBApi` - Global transition defaults | ~155 | Yes |
|
||||||
| `global_ui_control_defaults.ts` | `Global_ui_control_defaultsDBApi` - Global UI control defaults | ~160 | Yes |
|
| `global_ui_control_defaults.ts` | `Global_ui_control_defaultsDBApi` - Global UI control defaults | ~160 | Yes |
|
||||||
| `project_ui_control_settings.ts` | `Project_ui_control_settingsDBApi` - Project UI control settings | ~150 | Yes |
|
| `project_ui_control_settings.ts` | `Project_ui_control_settingsDBApi` - Project UI control settings | ~150 | Yes |
|
||||||
| `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes |
|
| `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes |
|
||||||
| `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes |
|
| `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes |
|
||||||
| `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes |
|
| `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes |
|
||||||
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
|
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
|
||||||
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
|
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
|
||||||
| `runtime-context.ts` | Runtime context helpers | 57 | - |
|
| `runtime-context.ts` | Runtime context helpers | 57 | - |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -111,23 +111,23 @@ The base class provides a Template Method pattern where subclasses configure beh
|
|||||||
|
|
||||||
### Static Getters (Configuration)
|
### Static Getters (Configuration)
|
||||||
|
|
||||||
| Getter | Type | Default | Description |
|
| Getter | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| -------------------- | -------- | --------------------- | ------------------------------------------------ |
|
||||||
| `MODEL` | Model | (required) | Sequelize model reference |
|
| `MODEL` | Model | (required) | Sequelize model reference |
|
||||||
| `TABLE_NAME` | string | From MODEL | Database table name |
|
| `TABLE_NAME` | string | From MODEL | Database table name |
|
||||||
| `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search |
|
| `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search |
|
||||||
| `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) |
|
| `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) |
|
||||||
| `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering |
|
| `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering |
|
||||||
| `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) |
|
| `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) |
|
||||||
| `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs |
|
| `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs |
|
||||||
| `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters |
|
| `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters |
|
||||||
| `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() |
|
| `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() |
|
||||||
| `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() |
|
| `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() |
|
||||||
| `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export |
|
| `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export |
|
||||||
| `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete |
|
| `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete |
|
||||||
| `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify |
|
| `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify |
|
||||||
| `FIELD_DEFAULTS` | object | `{}` | Default values for fields |
|
| `FIELD_DEFAULTS` | object | `{}` | Default values for fields |
|
||||||
| `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations |
|
| `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations |
|
||||||
|
|
||||||
### Methods
|
### Methods
|
||||||
|
|
||||||
@ -320,14 +320,14 @@ static async findAll(filter = {}, options = {}) {
|
|||||||
|
|
||||||
#### Other Methods
|
#### Other Methods
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
| ---------------------------------------------------------------- | ---------------------------------- |
|
||||||
| `bulkImport(data, options)` | Bulk create with timestamps offset |
|
| `bulkImport(data, options)` | Bulk create with timestamps offset |
|
||||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records |
|
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records |
|
||||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record |
|
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record |
|
||||||
| `findBy(where, options)` | Find single record by criteria |
|
| `findBy(where, options)` | Find single record by criteria |
|
||||||
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
|
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
|
||||||
| `toCSV(rows)` | Convert rows to CSV string |
|
| `toCSV(rows)` | Convert rows to CSV string |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -421,13 +421,25 @@ Extend `GenericDBApi` with minimal configuration. Only override static getters a
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class PermissionsDBApi extends GenericDBApi {
|
class PermissionsDBApi extends GenericDBApi {
|
||||||
static override get MODEL(): unknown { return db.permissions; }
|
static override get MODEL(): unknown {
|
||||||
static override get TABLE_NAME(): string { return 'permissions'; }
|
return db.permissions;
|
||||||
static override get SEARCHABLE_FIELDS(): string[] { return ['name']; }
|
}
|
||||||
static override get CSV_FIELDS(): string[] { return ['id', 'name', 'createdAt']; }
|
static override get TABLE_NAME(): string {
|
||||||
static override get AUTOCOMPLETE_FIELD(): string { return 'name'; }
|
return 'permissions';
|
||||||
|
}
|
||||||
|
static override get SEARCHABLE_FIELDS(): string[] {
|
||||||
|
return ['name'];
|
||||||
|
}
|
||||||
|
static override get CSV_FIELDS(): string[] {
|
||||||
|
return ['id', 'name', 'createdAt'];
|
||||||
|
}
|
||||||
|
static override get AUTOCOMPLETE_FIELD(): string {
|
||||||
|
return 'name';
|
||||||
|
}
|
||||||
|
|
||||||
static override getFieldMapping(data: PermissionData): PermissionFieldMapping {
|
static override getFieldMapping(
|
||||||
|
data: PermissionData,
|
||||||
|
): PermissionFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
name: data.name || null,
|
name: data.name || null,
|
||||||
@ -437,6 +449,7 @@ class PermissionsDBApi extends GenericDBApi {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Entities using this pattern:**
|
**Entities using this pattern:**
|
||||||
|
|
||||||
- `PermissionsDBApi`
|
- `PermissionsDBApi`
|
||||||
- `AssetsDBApi`
|
- `AssetsDBApi`
|
||||||
- `Asset_variantsDBApi`
|
- `Asset_variantsDBApi`
|
||||||
@ -542,6 +555,7 @@ class ProjectsDBApi extends GenericDBApi {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Entities using this pattern:**
|
**Entities using this pattern:**
|
||||||
|
|
||||||
- `Tour_pagesDBApi` - Environment filtering via `applyRuntimeEnvironment()`
|
- `Tour_pagesDBApi` - Environment filtering via `applyRuntimeEnvironment()`
|
||||||
- `ProjectsDBApi` - Slug filtering with ID bypass and auto-snapshot on create
|
- `ProjectsDBApi` - Slug filtering with ID bypass and auto-snapshot on create
|
||||||
- `Project_audio_tracksDBApi` - Environment filtering
|
- `Project_audio_tracksDBApi` - Environment filtering
|
||||||
@ -642,6 +656,7 @@ Don't extend `GenericDBApi` due to significantly different requirements.
|
|||||||
**Example: UsersDBApi**
|
**Example: UsersDBApi**
|
||||||
|
|
||||||
Complex user management with:
|
Complex user management with:
|
||||||
|
|
||||||
- Password hashing (bcrypt)
|
- Password hashing (bcrypt)
|
||||||
- File avatar handling
|
- File avatar handling
|
||||||
- Token generation for email verification and password reset
|
- Token generation for email verification and password reset
|
||||||
@ -651,13 +666,16 @@ Complex user management with:
|
|||||||
class UsersDBApi {
|
class UsersDBApi {
|
||||||
static async create(options: UserCreateOptions): Promise<UserRecord> {
|
static async create(options: UserCreateOptions): Promise<UserRecord> {
|
||||||
const { data, currentUser = { id: null }, transaction } = options;
|
const { data, currentUser = { id: null }, transaction } = options;
|
||||||
const users = await db.users.create({
|
const users = await db.users.create(
|
||||||
firstName: data.firstName || null,
|
{
|
||||||
lastName: data.lastName || null,
|
firstName: data.firstName || null,
|
||||||
email: data.email || null,
|
lastName: data.lastName || null,
|
||||||
password: data.password || null, // Already hashed by service
|
email: data.email || null,
|
||||||
// ...
|
password: data.password || null, // Already hashed by service
|
||||||
}, { transaction });
|
// ...
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
// Auto-assign default role
|
// Auto-assign default role
|
||||||
if (!data.app_role) {
|
if (!data.app_role) {
|
||||||
@ -698,9 +716,11 @@ class UsersDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static async _generateToken(keyNames, email, options) {
|
static async _generateToken(keyNames, email, options) {
|
||||||
const users = await db.users.findOne({ where: { email: email.toLowerCase() } });
|
const users = await db.users.findOne({
|
||||||
|
where: { email: email.toLowerCase() },
|
||||||
|
});
|
||||||
const token = crypto.randomBytes(20).toString('hex');
|
const token = crypto.randomBytes(20).toString('hex');
|
||||||
const tokenExpiresAt = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
|
const tokenExpiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
|
||||||
|
|
||||||
await users.update({
|
await users.update({
|
||||||
[keyNames[0]]: token,
|
[keyNames[0]]: token,
|
||||||
@ -719,7 +739,7 @@ class UsersDBApi {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Example: FileDBApi**
|
**Example: FileDBApi**
|
||||||
@ -733,7 +753,11 @@ export default class FileDBApi {
|
|||||||
assert(relation.belongsToColumn);
|
assert(relation.belongsToColumn);
|
||||||
assert(relation.belongsToId);
|
assert(relation.belongsToId);
|
||||||
|
|
||||||
const files = Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : [];
|
const files = Array.isArray(rawFiles)
|
||||||
|
? rawFiles
|
||||||
|
: rawFiles
|
||||||
|
? [rawFiles]
|
||||||
|
: [];
|
||||||
|
|
||||||
await this._removeLegacyFiles(relation, files, options);
|
await this._removeLegacyFiles(relation, files, options);
|
||||||
await this._addFiles(relation, files, options);
|
await this._addFiles(relation, files, options);
|
||||||
@ -743,14 +767,17 @@ export default class FileDBApi {
|
|||||||
const inexistentFiles = files.filter((file) => !!file.new);
|
const inexistentFiles = files.filter((file) => !!file.new);
|
||||||
|
|
||||||
for (const file of inexistentFiles) {
|
for (const file of inexistentFiles) {
|
||||||
await db.file.create({
|
await db.file.create(
|
||||||
belongsTo: relation.belongsTo,
|
{
|
||||||
belongsToColumn: relation.belongsToColumn,
|
belongsTo: relation.belongsTo,
|
||||||
belongsToId: relation.belongsToId,
|
belongsToColumn: relation.belongsToColumn,
|
||||||
name: file.name,
|
belongsToId: relation.belongsToId,
|
||||||
publicUrl: file.publicUrl,
|
name: file.name,
|
||||||
privateUrl: file.privateUrl,
|
publicUrl: file.publicUrl,
|
||||||
}, { transaction: options.transaction });
|
privateUrl: file.privateUrl,
|
||||||
|
},
|
||||||
|
{ transaction: options.transaction },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -769,7 +796,7 @@ export default class FileDBApi {
|
|||||||
await file.destroy({ transaction: options.transaction });
|
await file.destroy({ transaction: options.transaction });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -875,14 +902,15 @@ module.exports = class Utils {
|
|||||||
|
|
||||||
**UUID Utility Functions:**
|
**UUID Utility Functions:**
|
||||||
|
|
||||||
| Function | Purpose | Returns |
|
| Function | Purpose | Returns |
|
||||||
|----------|---------|---------|
|
| ----------------------------- | -------------------------------- | ---------------------- |
|
||||||
| `isValidUuid(value)` | Check if valid UUID | `boolean` |
|
| `isValidUuid(value)` | Check if valid UUID | `boolean` |
|
||||||
| `generateUuid()` | Create new UUID v4 | `string` |
|
| `generateUuid()` | Create new UUID v4 | `string` |
|
||||||
| `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` |
|
| `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` |
|
||||||
| `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause |
|
| `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause |
|
||||||
|
|
||||||
**UUID Validation Behavior:**
|
**UUID Validation Behavior:**
|
||||||
|
|
||||||
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
||||||
- Invalid UUID in relation filter (`?project=uuid|name`) → filters out invalid UUIDs for ID search, keeps all terms for text search
|
- Invalid UUID in relation filter (`?project=uuid|name`) → filters out invalid UUIDs for ID search, keeps all terms for text search
|
||||||
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
||||||
@ -895,8 +923,12 @@ module.exports = class Utils {
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
class AssetsDBApi extends GenericDBApi {
|
class AssetsDBApi extends GenericDBApi {
|
||||||
static get MODEL() { return db.assets; }
|
static get MODEL() {
|
||||||
static get TABLE_NAME() { return 'assets'; }
|
return db.assets;
|
||||||
|
}
|
||||||
|
static get TABLE_NAME() {
|
||||||
|
return 'assets';
|
||||||
|
}
|
||||||
|
|
||||||
static get SEARCHABLE_FIELDS() {
|
static get SEARCHABLE_FIELDS() {
|
||||||
return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
|
return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
|
||||||
@ -928,7 +960,12 @@ class AssetsDBApi extends GenericDBApi {
|
|||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static get RELATION_FILTERS() {
|
||||||
return [
|
return [
|
||||||
{ filterKey: 'project', model: db.projects, as: 'project', searchField: 'name' },
|
{
|
||||||
|
filterKey: 'project',
|
||||||
|
model: db.projects,
|
||||||
|
as: 'project',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -958,6 +995,7 @@ class AssetsDBApi extends GenericDBApi {
|
|||||||
**Two ways to handle foreign keys:**
|
**Two ways to handle foreign keys:**
|
||||||
|
|
||||||
1. **Direct field mapping** (preferred for programmatic use):
|
1. **Direct field mapping** (preferred for programmatic use):
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// In getFieldMapping()
|
// In getFieldMapping()
|
||||||
static getFieldMapping(data) {
|
static getFieldMapping(data) {
|
||||||
@ -973,6 +1011,7 @@ await Asset_variantsDBApi.create({ assetId: asset.id, ... });
|
|||||||
```
|
```
|
||||||
|
|
||||||
2. **Via ASSOCIATIONS setter** (used by frontend forms):
|
2. **Via ASSOCIATIONS setter** (used by frontend forms):
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// ASSOCIATIONS config uses 'asset' (relation name)
|
// ASSOCIATIONS config uses 'asset' (relation name)
|
||||||
static get ASSOCIATIONS() {
|
static get ASSOCIATIONS() {
|
||||||
@ -996,28 +1035,30 @@ undefined source instance and fail before the foreign key can be saved.
|
|||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
class RolesDBApi extends GenericDBApi {
|
class RolesDBApi extends GenericDBApi {
|
||||||
static override get MODEL(): unknown { return db.roles; }
|
static override get MODEL(): unknown {
|
||||||
|
return db.roles;
|
||||||
|
}
|
||||||
|
|
||||||
static override get ASSOCIATIONS(): RoleAssociationConfig[] {
|
static override get ASSOCIATIONS(): RoleAssociationConfig[] {
|
||||||
return [{ field: 'permissions', setter: 'setPermissions', isArray: true }];
|
return [{ field: 'permissions', setter: 'setPermissions', isArray: true }];
|
||||||
}
|
}
|
||||||
|
|
||||||
static override get FIND_BY_INCLUDES(): unknown[] {
|
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||||
return [
|
return [{ association: 'users_app_role' }, { association: 'permissions' }];
|
||||||
{ association: 'users_app_role' },
|
|
||||||
{ association: 'permissions' },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static override get FIND_ALL_INCLUDES(): unknown[] {
|
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||||
return [
|
return [{ model: db.permissions, as: 'permissions', required: false }];
|
||||||
{ model: db.permissions, as: 'permissions', required: false },
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
static get RELATION_FILTERS() {
|
static get RELATION_FILTERS() {
|
||||||
return [
|
return [
|
||||||
{ filterKey: 'permissions', model: db.permissions, as: 'permissions_filter', searchField: 'name' },
|
{
|
||||||
|
filterKey: 'permissions',
|
||||||
|
model: db.permissions,
|
||||||
|
as: 'permissions_filter',
|
||||||
|
searchField: 'name',
|
||||||
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1060,18 +1101,18 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
|||||||
|
|
||||||
## API Summary Table
|
## API Summary Table
|
||||||
|
|
||||||
| API | Pattern | Getters | Custom Methods | Notes |
|
| API | Pattern | Getters | Custom Methods | Notes |
|
||||||
|-----|---------|---------|----------------|-------|
|
| ------------------------------- | ------------- | ------- | -------------- | ----------------------------------------------------------- |
|
||||||
| `PermissionsDBApi` | Simple | 6 | 0 | Minimal config |
|
| `PermissionsDBApi` | Simple | 6 | 0 | Minimal config |
|
||||||
| `AssetsDBApi` | Simple | 9 | 0 | With associations |
|
| `AssetsDBApi` | Simple | 9 | 0 | With associations |
|
||||||
| `RolesDBApi` | Simple | 10 | 0 | M:N permissions |
|
| `RolesDBApi` | Simple | 10 | 0 | M:N permissions |
|
||||||
| `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups |
|
| `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups |
|
||||||
| `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering |
|
| `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering |
|
||||||
| `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering |
|
| `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering |
|
||||||
| `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding |
|
| `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding |
|
||||||
| `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff |
|
| `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff |
|
||||||
| `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files |
|
| `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files |
|
||||||
| `FileDBApi` | Fully custom | - | 3 | Polymorphic files |
|
| `FileDBApi` | Fully custom | - | 3 | Polymorphic files |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -7,6 +7,7 @@ The DB Config module manages database connection settings, environment validatio
|
|||||||
**Location:** `backend/src/db/`
|
**Location:** `backend/src/db/`
|
||||||
|
|
||||||
**Key Files:**
|
**Key Files:**
|
||||||
|
|
||||||
- `db-config.ts` - Typed ESM database connection settings per environment
|
- `db-config.ts` - Typed ESM database connection settings per environment
|
||||||
- `umzug.ts` - Typed Umzug runner for migrations, seeders, create/drop
|
- `umzug.ts` - Typed Umzug runner for migrations, seeders, create/drop
|
||||||
- `utils.ts` - Database utility functions
|
- `utils.ts` - Database utility functions
|
||||||
@ -14,6 +15,7 @@ The DB Config module manages database connection settings, environment validatio
|
|||||||
- `reset.ts` - Database reset script
|
- `reset.ts` - Database reset script
|
||||||
|
|
||||||
**Related Files:**
|
**Related Files:**
|
||||||
|
|
||||||
- `backend/src/config.ts` - Application configuration
|
- `backend/src/config.ts` - Application configuration
|
||||||
- `backend/src/utils/env-validation.ts` - Environment variable validation
|
- `backend/src/utils/env-validation.ts` - Environment variable validation
|
||||||
|
|
||||||
@ -58,14 +60,14 @@ env var is absent or invalid, the `port` property is omitted.
|
|||||||
|
|
||||||
### Environment Comparison
|
### Environment Comparison
|
||||||
|
|
||||||
| Setting | Production | Development | Dev Stage |
|
| Setting | Production | Development | Dev Stage |
|
||||||
|---------|------------|-------------|-----------|
|
| --------------------- | ------------- | ------------- | ------------- |
|
||||||
| **Dialect** | postgres | postgres | postgres |
|
| **Dialect** | postgres | postgres | postgres |
|
||||||
| **Credentials** | Env vars | Hardcoded | Env vars |
|
| **Credentials** | Env vars | Hardcoded | Env vars |
|
||||||
| **Logging** | Disabled | Pino debug | Pino debug |
|
| **Logging** | Disabled | Pino debug | Pino debug |
|
||||||
| **Host** | Env var | localhost | Env var |
|
| **Host** | Env var | localhost | Env var |
|
||||||
| **Migration Storage** | SequelizeMeta | SequelizeMeta | SequelizeMeta |
|
| **Migration Storage** | SequelizeMeta | SequelizeMeta | SequelizeMeta |
|
||||||
| **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData |
|
| **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -73,14 +75,14 @@ env var is absent or invalid, the `port` property is omitted.
|
|||||||
|
|
||||||
### Database Variables
|
### Database Variables
|
||||||
|
|
||||||
| Variable | Required | Default | Description |
|
| Variable | Required | Default | Description |
|
||||||
|----------|----------|---------|-------------|
|
| ---------- | ---------- | ------------------------ | --------------------- |
|
||||||
| `NODE_ENV` | No | development | Environment selection |
|
| `NODE_ENV` | No | development | Environment selection |
|
||||||
| `DB_HOST` | Prod/Stage | localhost | Database host |
|
| `DB_HOST` | Prod/Stage | localhost | Database host |
|
||||||
| `DB_PORT` | Prod/Stage | 5432 | Database port |
|
| `DB_PORT` | Prod/Stage | 5432 | Database port |
|
||||||
| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name |
|
| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name |
|
||||||
| `DB_USER` | Prod/Stage | postgres | Database username |
|
| `DB_USER` | Prod/Stage | postgres | Database username |
|
||||||
| `DB_PASS` | Prod/Stage | (empty) | Database password |
|
| `DB_PASS` | Prod/Stage | (empty) | Database password |
|
||||||
|
|
||||||
### Environment Validation
|
### Environment Validation
|
||||||
|
|
||||||
@ -112,47 +114,47 @@ const envSchema = Joi.object({
|
|||||||
|
|
||||||
### Complete Environment Variable Schema
|
### Complete Environment Variable Schema
|
||||||
|
|
||||||
| Category | Variable | Validation | Default |
|
| Category | Variable | Validation | Default |
|
||||||
|----------|----------|------------|---------|
|
| ----------------- | ----------------------------- | ---------------------------------------------- | ------------------------ |
|
||||||
| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development |
|
| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development |
|
||||||
| | PORT | number | 8080 |
|
| | PORT | number | 8080 |
|
||||||
| **Database** | DB_HOST | string | localhost |
|
| **Database** | DB_HOST | string | localhost |
|
||||||
| | DB_PORT | number | 5432 |
|
| | DB_PORT | number | 5432 |
|
||||||
| | DB_NAME | string | db_tour_builder_platform |
|
| | DB_NAME | string | db_tour_builder_platform |
|
||||||
| | DB_USER | string | postgres |
|
| | DB_USER | string | postgres |
|
||||||
| | DB_PASS | string (allow empty) | (empty) |
|
| | DB_PASS | string (allow empty) | (empty) |
|
||||||
| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) |
|
| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) |
|
||||||
| | ADMIN_PASS | string | 88dbeaf8 |
|
| | ADMIN_PASS | string | 88dbeaf8 |
|
||||||
| | USER_PASS | string | c3baadeda5c6 |
|
| | USER_PASS | string | c3baadeda5c6 |
|
||||||
| | ADMIN_EMAIL | email | admin@flatlogic.com |
|
| | ADMIN_EMAIL | email | admin@flatlogic.com |
|
||||||
| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) |
|
| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) |
|
||||||
| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) |
|
| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) |
|
||||||
| | MS_CLIENT_ID | string (allow empty) | (empty) |
|
| | MS_CLIENT_ID | string (allow empty) | (empty) |
|
||||||
| | MS_CLIENT_SECRET | string (allow empty) | (empty) |
|
| | MS_CLIENT_SECRET | string (allow empty) | (empty) |
|
||||||
| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) |
|
| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) |
|
||||||
| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) |
|
| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) |
|
||||||
| | AWS_S3_BUCKET | string (allow empty) | (empty) |
|
| | AWS_S3_BUCKET | string (allow empty) | (empty) |
|
||||||
| | AWS_S3_REGION | string | us-east-1 |
|
| | AWS_S3_REGION | string | us-east-1 |
|
||||||
| | AWS_S3_PREFIX | string | (default hash) |
|
| | AWS_S3_PREFIX | string | (default hash) |
|
||||||
| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 |
|
| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 |
|
||||||
| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 |
|
| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 |
|
||||||
| | AWS_S3_MAX_ATTEMPTS | number | 3 |
|
| | AWS_S3_MAX_ATTEMPTS | number | 3 |
|
||||||
| | AWS_S3_MAX_SOCKETS | number | 50 |
|
| | AWS_S3_MAX_SOCKETS | number | 50 |
|
||||||
| | AWS_S3_KEEP_ALIVE | boolean string | true |
|
| | AWS_S3_KEEP_ALIVE | boolean string | true |
|
||||||
| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 |
|
| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 |
|
||||||
| **Email** | EMAIL_USER | string (allow empty) | (empty) |
|
| **Email** | EMAIL_USER | string (allow empty) | (empty) |
|
||||||
| | EMAIL_PASS | string (allow empty) | (empty) |
|
| | EMAIL_PASS | string (allow empty) | (empty) |
|
||||||
| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true |
|
| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true |
|
||||||
| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) |
|
| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) |
|
||||||
| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info |
|
| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info |
|
||||||
|
|
||||||
### Validation Behavior
|
### Validation Behavior
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
function validateEnv() {
|
function validateEnv() {
|
||||||
const { error, value } = envSchema.validate(process.env, {
|
const { error, value } = envSchema.validate(process.env, {
|
||||||
abortEarly: false, // Report all errors, not just first
|
abortEarly: false, // Report all errors, not just first
|
||||||
stripUnknown: false, // Keep unknown env vars
|
stripUnknown: false, // Keep unknown env vars
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -160,7 +162,7 @@ function validateEnv() {
|
|||||||
logger.error({ errors: messages }, 'Environment validation failed');
|
logger.error({ errors: messages }, 'Environment validation failed');
|
||||||
|
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
process.exit(1); // Fatal in production
|
process.exit(1); // Fatal in production
|
||||||
} else {
|
} else {
|
||||||
logger.warn('Continuing with default values in non-production mode');
|
logger.warn('Continuing with default values in non-production mode');
|
||||||
}
|
}
|
||||||
@ -178,13 +180,13 @@ The database command entrypoint is `backend/src/db/umzug.ts`.
|
|||||||
|
|
||||||
### Runtime Paths
|
### Runtime Paths
|
||||||
|
|
||||||
| Setting | Path |
|
| Setting | Path |
|
||||||
|---------|------|
|
| ---------- | --------------------- |
|
||||||
| Config | `src/db/db-config.ts` |
|
| Config | `src/db/db-config.ts` |
|
||||||
| Runner | `src/db/umzug.ts` |
|
| Runner | `src/db/umzug.ts` |
|
||||||
| Models | `src/db/models/` |
|
| Models | `src/db/models/` |
|
||||||
| Seeders | `src/db/seeders/` |
|
| Seeders | `src/db/seeders/` |
|
||||||
| Migrations | `src/db/migrations/` |
|
| Migrations | `src/db/migrations/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -245,11 +247,11 @@ through `src/db/models/index.ts`, whose typed facade is provided by
|
|||||||
import Utils from '../db/utils.ts';
|
import Utils from '../db/utils.ts';
|
||||||
|
|
||||||
// UUID validation
|
// UUID validation
|
||||||
Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true
|
Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true
|
||||||
Utils.isValidUuid('not-a-uuid'); // false
|
Utils.isValidUuid('not-a-uuid'); // false
|
||||||
|
|
||||||
// Generate new UUID
|
// Generate new UUID
|
||||||
const id = Utils.generateUuid(); // Returns new UUID v4
|
const id = Utils.generateUuid(); // Returns new UUID v4
|
||||||
|
|
||||||
// Filter array to valid UUIDs only
|
// Filter array to valid UUIDs only
|
||||||
const validIds = Utils.filterValidUuids(['uuid1', 'invalid', 'uuid2']);
|
const validIds = Utils.filterValidUuids(['uuid1', 'invalid', 'uuid2']);
|
||||||
@ -260,7 +262,7 @@ const where = {
|
|||||||
Utils.ilike('users', 'firstName', searchTerm),
|
Utils.ilike('users', 'firstName', searchTerm),
|
||||||
Utils.ilike('users', 'lastName', searchTerm),
|
Utils.ilike('users', 'lastName', searchTerm),
|
||||||
Utils.ilike('users', 'email', searchTerm),
|
Utils.ilike('users', 'email', searchTerm),
|
||||||
]
|
],
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -276,7 +278,9 @@ Synchronizes models to database schema using Sequelize's `alter` mode.
|
|||||||
async function syncDatabase() {
|
async function syncDatabase() {
|
||||||
// Safety check - never run in production
|
// Safety check - never run in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
console.error('ERROR: sync.ts should not be run in production. Use migrations instead.');
|
console.error(
|
||||||
|
'ERROR: sync.ts should not be run in production. Use migrations instead.',
|
||||||
|
);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -293,17 +297,18 @@ async function syncDatabase() {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node src/db/sync.ts
|
node src/db/sync.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
**Sync Modes:**
|
**Sync Modes:**
|
||||||
|
|
||||||
| Mode | Description | Use Case |
|
| Mode | Description | Use Case |
|
||||||
|------|-------------|----------|
|
| ----------------- | ----------------------------- | ------------ |
|
||||||
| `{ force: true }` | Drop and recreate all tables | Fresh start |
|
| `{ force: true }` | Drop and recreate all tables | Fresh start |
|
||||||
| `{ alter: true }` | Modify tables to match models | Development |
|
| `{ alter: true }` | Modify tables to match models | Development |
|
||||||
| (none) | Create only missing tables | Safe default |
|
| (none) | Create only missing tables | Safe default |
|
||||||
|
|
||||||
### reset.ts
|
### reset.ts
|
||||||
|
|
||||||
@ -324,6 +329,7 @@ db.sequelize
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
node src/db/reset.ts
|
node src/db/reset.ts
|
||||||
```
|
```
|
||||||
@ -397,45 +403,46 @@ const config = {
|
|||||||
|
|
||||||
### Storage Configuration
|
### Storage Configuration
|
||||||
|
|
||||||
| Provider | Variables | Purpose |
|
| Provider | Variables | Purpose |
|
||||||
|----------|-----------|---------|
|
| ---------- | ---------------------------------------------------------------------- | -------------------- |
|
||||||
| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage |
|
| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage |
|
||||||
| **GCloud** | (hardcoded bucket) | Legacy support |
|
| **GCloud** | (hardcoded bucket) | Legacy support |
|
||||||
| **Local** | uploadDir (os.tmpdir()) | Development fallback |
|
| **Local** | uploadDir (os.tmpdir()) | Development fallback |
|
||||||
|
|
||||||
### S3 Performance Tuning
|
### S3 Performance Tuning
|
||||||
|
|
||||||
| Variable | Default | Description |
|
| Variable | Default | Description |
|
||||||
|----------|---------|-------------|
|
| ------------------------- | ------- | ------------------------------- |
|
||||||
| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout |
|
| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout |
|
||||||
| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout |
|
| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout |
|
||||||
| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure |
|
| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure |
|
||||||
| AWS_S3_MAX_SOCKETS | 50 | Connection pool size |
|
| AWS_S3_MAX_SOCKETS | 50 | Connection pool size |
|
||||||
| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections |
|
| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections |
|
||||||
| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) |
|
| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) |
|
||||||
|
|
||||||
### Security Configuration
|
### Security Configuration
|
||||||
|
|
||||||
| Setting | Value | Purpose |
|
| Setting | Value | Purpose |
|
||||||
|---------|-------|---------|
|
| ----------------------------- | --------------- | -------------------------- |
|
||||||
| bcrypt.saltRounds | 12 | Password hashing strength |
|
| bcrypt.saltRounds | 12 | Password hashing strength |
|
||||||
| SECRET_KEY | 16+ char string | JWT signing key |
|
| SECRET_KEY | 16+ char string | JWT signing key |
|
||||||
| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation |
|
| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation |
|
||||||
|
|
||||||
### URL Configuration
|
### URL Configuration
|
||||||
|
|
||||||
| URL | Development | Production |
|
| URL | Development | Production |
|
||||||
|-----|-------------|------------|
|
| ---------- | ------------------------- | ------------ |
|
||||||
| apiUrl | http://localhost:3000/api | (remote)/api |
|
| apiUrl | http://localhost:3000/api | (remote)/api |
|
||||||
| swaggerUrl | http://localhost:3000 | (remote) |
|
| swaggerUrl | http://localhost:3000 | (remote) |
|
||||||
| uiUrl | http://localhost:3001/# | (remote)/# |
|
| uiUrl | http://localhost:3001/# | (remote)/# |
|
||||||
| backUrl | http://localhost:3001 | (remote) |
|
| backUrl | http://localhost:3001 | (remote) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Running Commands
|
## Running Commands
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
npm run start-dev
|
npm run start-dev
|
||||||
@ -447,6 +454,7 @@ DB config selection; when `NODE_ENV` is absent it defaults to `dev_stage`, which
|
|||||||
matches the standard VM backend flow and listens on port `3000`.
|
matches the standard VM backend flow and listens on port `3000`.
|
||||||
|
|
||||||
### VM / Dev Stage
|
### VM / Dev Stage
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
npm run start
|
npm run start
|
||||||
@ -465,12 +473,14 @@ flow loads `.env` through `src/load-env.ts` and defaults missing `NODE_ENV` to
|
|||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### 1. Never Commit Secrets
|
### 1. Never Commit Secrets
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .env file should be in .gitignore
|
# .env file should be in .gitignore
|
||||||
# Use environment variables in deployment
|
# Use environment variables in deployment
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Use Migrations in Production
|
### 2. Use Migrations in Production
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Never use sync.ts or reset.ts in production
|
// Never use sync.ts or reset.ts in production
|
||||||
if (process.env.NODE_ENV === 'production') {
|
if (process.env.NODE_ENV === 'production') {
|
||||||
@ -479,13 +489,15 @@ if (process.env.NODE_ENV === 'production') {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 3. Validate Environment Early
|
### 3. Validate Environment Early
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// config.ts loads validation at import time
|
// config.ts loads validation at import time
|
||||||
import { validateEnv } from './utils/env-validation.ts';
|
import { validateEnv } from './utils/env-validation.ts';
|
||||||
validateEnv(); // Called before app starts
|
validateEnv(); // Called before app starts
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Environment-Specific Logging
|
### 4. Environment-Specific Logging
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Production: logging disabled (performance)
|
// Production: logging disabled (performance)
|
||||||
// Development/dev_stage: SQL logs use structured Pino debug entries
|
// Development/dev_stage: SQL logs use structured Pino debug entries
|
||||||
|
|||||||
@ -65,15 +65,16 @@ backend/
|
|||||||
`backend/src/db/umzug.ts` owns migration and seeder execution. It uses official
|
`backend/src/db/umzug.ts` owns migration and seeder execution. It uses official
|
||||||
Umzug types, `SequelizeStorage`, and the existing storage tables:
|
Umzug types, `SequelizeStorage`, and the existing storage tables:
|
||||||
|
|
||||||
| Flow | Files | Storage Table | Stored Names |
|
| Flow | Files | Storage Table | Stored Names |
|
||||||
|------|-------|---------------|--------------|
|
| ---------- | -------------------------------------------------------------------- | --------------- | ------------------- |
|
||||||
| Migrations | `src/db/migrations/*.js` | `SequelizeMeta` | `*.js` |
|
| Migrations | `src/db/migrations/*.js` | `SequelizeMeta` | `*.js` |
|
||||||
| Seeders | `src/db/seeders/*.ts` in source, `dist/src/db/seeders/*.js` in build | `SequelizeData` | stable `*.js` names |
|
| Seeders | `src/db/seeders/*.ts` in source, `dist/src/db/seeders/*.js` in build | `SequelizeData` | stable `*.js` names |
|
||||||
|
|
||||||
Seeder files are typed ESM source, and the runner stores stable execution names
|
Seeder files are typed ESM source, and the runner stores stable execution names
|
||||||
so already executed seeders are not treated as pending.
|
so already executed seeders are not treated as pending.
|
||||||
|
|
||||||
### NPM Scripts
|
### NPM Scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run pending migrations
|
# Run pending migrations
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
@ -99,6 +100,7 @@ npm run db:seed
|
|||||||
## Migration File Structure
|
## Migration File Structure
|
||||||
|
|
||||||
### Standard Template
|
### Standard Template
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
@ -122,6 +124,7 @@ Use one project-wide migration template for new schema changes. Do not modify
|
|||||||
already applied migration files to match newer style choices.
|
already applied migration files to match newer style choices.
|
||||||
|
|
||||||
### Naming Convention
|
### Naming Convention
|
||||||
|
|
||||||
```
|
```
|
||||||
YYYYMMDDHHMMSS-descriptive-name.js
|
YYYYMMDDHHMMSS-descriptive-name.js
|
||||||
|
|
||||||
@ -138,6 +141,7 @@ Examples:
|
|||||||
## Migration Patterns
|
## Migration Patterns
|
||||||
|
|
||||||
### 1. Transaction Wrapper Pattern
|
### 1. Transaction Wrapper Pattern
|
||||||
|
|
||||||
**Purpose:** Ensure atomic operations - all changes succeed or all fail.
|
**Purpose:** Ensure atomic operations - all changes succeed or all fail.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -164,6 +168,7 @@ module.exports = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 2. Idempotent Check Pattern
|
### 2. Idempotent Check Pattern
|
||||||
|
|
||||||
**Purpose:** Safely re-run migrations without errors.
|
**Purpose:** Safely re-run migrations without errors.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -188,6 +193,7 @@ await queryInterface.addColumn('tableName', 'columnName', { ... });
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 3. Helper Function Pattern
|
### 3. Helper Function Pattern
|
||||||
|
|
||||||
**Purpose:** Reduce repetition for bulk operations.
|
**Purpose:** Reduce repetition for bulk operations.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -196,14 +202,19 @@ module.exports = {
|
|||||||
const transaction = await queryInterface.sequelize.transaction();
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
|
|
||||||
// Define reusable helper
|
// Define reusable helper
|
||||||
const addForeignKey = async (tableName, columnName, references, onDelete) => {
|
const addForeignKey = async (
|
||||||
|
tableName,
|
||||||
|
columnName,
|
||||||
|
references,
|
||||||
|
onDelete,
|
||||||
|
) => {
|
||||||
const constraintName = `${tableName}_${columnName}_fkey`;
|
const constraintName = `${tableName}_${columnName}_fkey`;
|
||||||
|
|
||||||
// Check existence
|
// Check existence
|
||||||
const [results] = await queryInterface.sequelize.query(
|
const [results] = await queryInterface.sequelize.query(
|
||||||
`SELECT constraint_name FROM information_schema.table_constraints
|
`SELECT constraint_name FROM information_schema.table_constraints
|
||||||
WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`,
|
WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`,
|
||||||
{ transaction }
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
if (results.length === 0) {
|
if (results.length === 0) {
|
||||||
@ -221,8 +232,18 @@ module.exports = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Use helper multiple times
|
// Use helper multiple times
|
||||||
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
|
await addForeignKey(
|
||||||
await addForeignKey('tour_pages', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
|
'assets',
|
||||||
|
'projectId',
|
||||||
|
{ table: 'projects', field: 'id' },
|
||||||
|
'CASCADE',
|
||||||
|
);
|
||||||
|
await addForeignKey(
|
||||||
|
'tour_pages',
|
||||||
|
'projectId',
|
||||||
|
{ table: 'projects', field: 'id' },
|
||||||
|
'CASCADE',
|
||||||
|
);
|
||||||
// ... more FKs
|
// ... more FKs
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -233,6 +254,7 @@ module.exports = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 4. Safe Table Drop Pattern
|
### 4. Safe Table Drop Pattern
|
||||||
|
|
||||||
**Purpose:** Prevent accidental data loss when dropping tables.
|
**Purpose:** Prevent accidental data loss when dropping tables.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -270,6 +292,7 @@ module.exports = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 5. ENUM to TEXT Conversion Pattern
|
### 5. ENUM to TEXT Conversion Pattern
|
||||||
|
|
||||||
**Purpose:** Convert restrictive ENUMs to flexible TEXT while preserving data.
|
**Purpose:** Convert restrictive ENUMs to flexible TEXT while preserving data.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -279,33 +302,45 @@ module.exports = {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// 1. Create temporary TEXT column
|
// 1. Create temporary TEXT column
|
||||||
await queryInterface.addColumn('table', 'column_text', {
|
await queryInterface.addColumn(
|
||||||
type: Sequelize.TEXT,
|
'table',
|
||||||
allowNull: true,
|
'column_text',
|
||||||
}, { transaction });
|
{
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: true,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
// 2. Copy ENUM values to TEXT
|
// 2. Copy ENUM values to TEXT
|
||||||
await queryInterface.sequelize.query(
|
await queryInterface.sequelize.query(
|
||||||
`UPDATE table SET column_text = column::TEXT`,
|
`UPDATE table SET column_text = column::TEXT`,
|
||||||
{ transaction }
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 3. Drop old ENUM column
|
// 3. Drop old ENUM column
|
||||||
await queryInterface.removeColumn('table', 'column', { transaction });
|
await queryInterface.removeColumn('table', 'column', { transaction });
|
||||||
|
|
||||||
// 4. Rename TEXT column
|
// 4. Rename TEXT column
|
||||||
await queryInterface.renameColumn('table', 'column_text', 'column', { transaction });
|
await queryInterface.renameColumn('table', 'column_text', 'column', {
|
||||||
|
transaction,
|
||||||
|
});
|
||||||
|
|
||||||
// 5. Add NOT NULL constraint
|
// 5. Add NOT NULL constraint
|
||||||
await queryInterface.changeColumn('table', 'column', {
|
await queryInterface.changeColumn(
|
||||||
type: Sequelize.TEXT,
|
'table',
|
||||||
allowNull: false,
|
'column',
|
||||||
}, { transaction });
|
{
|
||||||
|
type: Sequelize.TEXT,
|
||||||
|
allowNull: false,
|
||||||
|
},
|
||||||
|
{ transaction },
|
||||||
|
);
|
||||||
|
|
||||||
// 6. Drop ENUM type
|
// 6. Drop ENUM type
|
||||||
await queryInterface.sequelize.query(
|
await queryInterface.sequelize.query(
|
||||||
`DROP TYPE IF EXISTS "enum_table_column"`,
|
`DROP TYPE IF EXISTS "enum_table_column"`,
|
||||||
{ transaction }
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@ -330,6 +365,7 @@ module.exports = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 6. Data Backfill Pattern
|
### 6. Data Backfill Pattern
|
||||||
|
|
||||||
**Purpose:** Populate new tables/columns with data from existing records.
|
**Purpose:** Populate new tables/columns with data from existing records.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -383,6 +419,7 @@ module.exports = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 7. Cross-Environment Data Copy Pattern
|
### 7. Cross-Environment Data Copy Pattern
|
||||||
|
|
||||||
**Purpose:** Copy content between environments (dev → stage → production).
|
**Purpose:** Copy content between environments (dev → stage → production).
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -390,14 +427,14 @@ module.exports = {
|
|||||||
async up(queryInterface, Sequelize) {
|
async up(queryInterface, Sequelize) {
|
||||||
const projects = await queryInterface.sequelize.query(
|
const projects = await queryInterface.sequelize.query(
|
||||||
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
|
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
|
||||||
{ type: Sequelize.QueryTypes.SELECT }
|
{ type: Sequelize.QueryTypes.SELECT },
|
||||||
);
|
);
|
||||||
|
|
||||||
for (const project of projects) {
|
for (const project of projects) {
|
||||||
// Check if target environment already has content
|
// Check if target environment already has content
|
||||||
const [stageCheck] = await queryInterface.sequelize.query(
|
const [stageCheck] = await queryInterface.sequelize.query(
|
||||||
`SELECT COUNT(*)::int as count FROM tour_pages
|
`SELECT COUNT(*)::int as count FROM tour_pages
|
||||||
WHERE "projectId" = '${project.id}' AND environment = 'stage'`
|
WHERE "projectId" = '${project.id}' AND environment = 'stage'`,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (stageCheck?.count > 0) continue;
|
if (stageCheck?.count > 0) continue;
|
||||||
@ -433,7 +470,7 @@ module.exports = {
|
|||||||
async down(queryInterface) {
|
async down(queryInterface) {
|
||||||
// Delete records with source_key (created by migration)
|
// Delete records with source_key (created by migration)
|
||||||
await queryInterface.sequelize.query(
|
await queryInterface.sequelize.query(
|
||||||
`DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL`
|
`DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL`,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -444,6 +481,7 @@ module.exports = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 8. JSON Field Transformation Pattern
|
### 8. JSON Field Transformation Pattern
|
||||||
|
|
||||||
**Purpose:** Transform data stored in JSON columns.
|
**Purpose:** Transform data stored in JSON columns.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -456,24 +494,27 @@ module.exports = {
|
|||||||
const [records] = await queryInterface.sequelize.query(
|
const [records] = await queryInterface.sequelize.query(
|
||||||
`SELECT id, "projectId", environment, slug, json_column
|
`SELECT id, "projectId", environment, slug, json_column
|
||||||
FROM table_name WHERE json_column IS NOT NULL`,
|
FROM table_name WHERE json_column IS NOT NULL`,
|
||||||
{ transaction }
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Build lookup maps for ID → slug transformations
|
// Build lookup maps for ID → slug transformations
|
||||||
const slugById = new Map();
|
const slugById = new Map();
|
||||||
records.forEach(r => slugById.set(r.id, { projectId: r.projectId, slug: r.slug }));
|
records.forEach((r) =>
|
||||||
|
slugById.set(r.id, { projectId: r.projectId, slug: r.slug }),
|
||||||
|
);
|
||||||
|
|
||||||
// Transform each record
|
// Transform each record
|
||||||
for (const record of records) {
|
for (const record of records) {
|
||||||
const jsonData = typeof record.json_column === 'string'
|
const jsonData =
|
||||||
? JSON.parse(record.json_column)
|
typeof record.json_column === 'string'
|
||||||
: record.json_column;
|
? JSON.parse(record.json_column)
|
||||||
|
: record.json_column;
|
||||||
|
|
||||||
let hasChanges = false;
|
let hasChanges = false;
|
||||||
|
|
||||||
// Transform JSON structure
|
// Transform JSON structure
|
||||||
if (jsonData.elements) {
|
if (jsonData.elements) {
|
||||||
jsonData.elements.forEach(element => {
|
jsonData.elements.forEach((element) => {
|
||||||
if (element.targetPageId) {
|
if (element.targetPageId) {
|
||||||
const target = slugById.get(element.targetPageId);
|
const target = slugById.get(element.targetPageId);
|
||||||
if (target) {
|
if (target) {
|
||||||
@ -490,8 +531,8 @@ module.exports = {
|
|||||||
`UPDATE table_name SET json_column = :json WHERE id = :id`,
|
`UPDATE table_name SET json_column = :json WHERE id = :id`,
|
||||||
{
|
{
|
||||||
replacements: { json: JSON.stringify(jsonData), id: record.id },
|
replacements: { json: JSON.stringify(jsonData), id: record.id },
|
||||||
transaction
|
transaction,
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -510,6 +551,7 @@ module.exports = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 9. Constraint Enforcement Pattern
|
### 9. Constraint Enforcement Pattern
|
||||||
|
|
||||||
**Purpose:** Add NOT NULL constraints after fixing existing NULL values.
|
**Purpose:** Add NOT NULL constraints after fixing existing NULL values.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -517,7 +559,7 @@ module.exports = {
|
|||||||
async up(queryInterface) {
|
async up(queryInterface) {
|
||||||
// First, fix any NULL values
|
// First, fix any NULL values
|
||||||
await queryInterface.sequelize.query(
|
await queryInterface.sequelize.query(
|
||||||
`UPDATE table_name SET column = 'default' WHERE column IS NULL`
|
`UPDATE table_name SET column = 'default' WHERE column IS NULL`,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Then add NOT NULL constraint with default
|
// Then add NOT NULL constraint with default
|
||||||
@ -543,6 +585,7 @@ module.exports = {
|
|||||||
---
|
---
|
||||||
|
|
||||||
### 10. Safe Down Migration Pattern
|
### 10. Safe Down Migration Pattern
|
||||||
|
|
||||||
**Purpose:** Handle cases where down migration isn't meaningful.
|
**Purpose:** Handle cases where down migration isn't meaningful.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -554,7 +597,9 @@ module.exports = {
|
|||||||
|
|
||||||
async down(_queryInterface, _Sequelize) {
|
async down(_queryInterface, _Sequelize) {
|
||||||
// This migration only adds missing data, not destructive
|
// This migration only adds missing data, not destructive
|
||||||
console.log('No down migration needed - this migration only adds missing data.');
|
console.log(
|
||||||
|
'No down migration needed - this migration only adds missing data.',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@ -566,60 +611,74 @@ module.exports = {
|
|||||||
## Migration Categories
|
## Migration Categories
|
||||||
|
|
||||||
### Schema Changes
|
### Schema Changes
|
||||||
| Migration | Description |
|
|
||||||
|-----------|-------------|
|
| Migration | Description |
|
||||||
| `add-foreign-key-constraints` | Add FK constraints to all model associations |
|
| --------------------------------- | -------------------------------------------- |
|
||||||
| `create-project-element-defaults` | Create new table with indexes |
|
| `add-foreign-key-constraints` | Add FK constraints to all model associations |
|
||||||
| `drop-page-elements-table` | Drop unused table |
|
| `create-project-element-defaults` | Create new table with indexes |
|
||||||
| `drop-page-links-table` | Drop unused table |
|
| `drop-page-elements-table` | Drop unused table |
|
||||||
| `drop-transitions-table` | Drop unused table |
|
| `drop-page-links-table` | Drop unused table |
|
||||||
|
| `drop-transitions-table` | Drop unused table |
|
||||||
|
|
||||||
### Column Modifications
|
### Column Modifications
|
||||||
| Migration | Description |
|
|
||||||
|-----------|-------------|
|
| Migration | Description |
|
||||||
| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` |
|
| ------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||||
| `remove-project-phase-column` | Remove redundant `phase` column |
|
| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` |
|
||||||
| `remove-entry-page-slug-column` | Remove unused column |
|
| `remove-project-phase-column` | Remove redundant `phase` column |
|
||||||
| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility |
|
| `remove-entry-page-slug-column` | Remove unused column |
|
||||||
| `enforce-environment-not-null` | Add NOT NULL constraint |
|
| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility |
|
||||||
| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` |
|
| `enforce-environment-not-null` | Add NOT NULL constraint |
|
||||||
| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages |
|
| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` |
|
||||||
| `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table |
|
| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages |
|
||||||
| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table |
|
| `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table |
|
||||||
|
| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table |
|
||||||
|
|
||||||
### Table Renames
|
### Table Renames
|
||||||
| Migration | Description |
|
|
||||||
|-----------|-------------|
|
| Migration | Description |
|
||||||
|
| --------------------------------------------- | ------------------ |
|
||||||
| `rename-ui-elements-to-element-type-defaults` | Rename for clarity |
|
| `rename-ui-elements-to-element-type-defaults` | Rename for clarity |
|
||||||
|
|
||||||
### Data Migrations
|
### Data Migrations
|
||||||
| Migration | Description |
|
|
||||||
|-----------|-------------|
|
| Migration | Description |
|
||||||
| `backfill-project-element-defaults` | Populate new table for existing projects |
|
| ---------------------------------------- | ---------------------------------------------------------- |
|
||||||
| `copy-dev-to-stage` | Initialize stage environment |
|
| `backfill-project-element-defaults` | Populate new table for existing projects |
|
||||||
| `convert-targetpageid-to-slug` | Transform JSON navigation references |
|
| `copy-dev-to-stage` | Initialize stage environment |
|
||||||
| `fix-project-audio-tracks-environment` | Fix environment values |
|
| `convert-targetpageid-to-slug` | Transform JSON navigation references |
|
||||||
| `add-missing-element-type-defaults` | Insert missing default rows |
|
| `fix-project-audio-tracks-environment` | Fix environment values |
|
||||||
| `sync-all-element-type-defaults` | Full sync of all 11 element types |
|
| `add-missing-element-type-defaults` | Insert missing default rows |
|
||||||
|
| `sync-all-element-type-defaults` | Full sync of all 11 element types |
|
||||||
| `remove-duplicate-element-type-defaults` | Remove duplicate records created during earlier migrations |
|
| `remove-duplicate-element-type-defaults` | Remove duplicate records created during earlier migrations |
|
||||||
| `cleanup-invalid-element-type-defaults` | Clean up invalid entries and ensure data integrity |
|
| `cleanup-invalid-element-type-defaults` | Clean up invalid entries and ensure data integrity |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Foreign Key Strategies
|
## Foreign Key Strategies
|
||||||
|
|
||||||
| Strategy | When to Use | Example |
|
| Strategy | When to Use | Example |
|
||||||
|----------|-------------|---------|
|
| ------------------------------ | -------------------------------- | ------------------------------------------------ |
|
||||||
| `CASCADE` | Delete child when parent deleted | `assets.projectId → projects.id` |
|
| `CASCADE` | Delete child when parent deleted | `assets.projectId → projects.id` |
|
||||||
| `SET NULL` | Preserve record, nullify FK | `publish_events.userId → users.id` (audit trail) |
|
| `SET NULL` | Preserve record, nullify FK | `publish_events.userId → users.id` (audit trail) |
|
||||||
| `SET NULL` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` |
|
| `SET NULL` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` |
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// CASCADE - delete assets when project is deleted
|
// CASCADE - delete assets when project is deleted
|
||||||
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
|
await addForeignKey(
|
||||||
|
'assets',
|
||||||
|
'projectId',
|
||||||
|
{ table: 'projects', field: 'id' },
|
||||||
|
'CASCADE',
|
||||||
|
);
|
||||||
|
|
||||||
// SET NULL - preserve audit log when user is deleted
|
// SET NULL - preserve audit log when user is deleted
|
||||||
await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'SET NULL');
|
await addForeignKey(
|
||||||
|
'access_logs',
|
||||||
|
'userId',
|
||||||
|
{ table: 'users', field: 'id' },
|
||||||
|
'SET NULL',
|
||||||
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -627,6 +686,7 @@ await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'S
|
|||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### 1. Always Use Transactions
|
### 1. Always Use Transactions
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const transaction = await queryInterface.sequelize.transaction();
|
const transaction = await queryInterface.sequelize.transaction();
|
||||||
try {
|
try {
|
||||||
@ -639,20 +699,23 @@ try {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. Check Before Modify
|
### 2. Check Before Modify
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Always check existence before adding/removing
|
// Always check existence before adding/removing
|
||||||
const tableExists = await queryInterface.sequelize.query(
|
const tableExists = await queryInterface.sequelize.query(
|
||||||
`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')`
|
`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')`,
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
### 3. Log Progress
|
### 3. Log Progress
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
console.log(`Migrating project ${projectId}: ${addedCount} records added`);
|
console.log(`Migrating project ${projectId}: ${addedCount} records added`);
|
||||||
console.log('Migration complete: All foreign keys added');
|
console.log('Migration complete: All foreign keys added');
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. Safe Drops
|
### 4. Safe Drops
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Never drop non-empty tables silently
|
// Never drop non-empty tables silently
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
@ -661,6 +724,7 @@ if (count > 0) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 5. Reversible Operations
|
### 5. Reversible Operations
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Down migration should restore previous state
|
// Down migration should restore previous state
|
||||||
async down(queryInterface, Sequelize) {
|
async down(queryInterface, Sequelize) {
|
||||||
@ -671,16 +735,17 @@ async down(queryInterface, Sequelize) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 6. Use Parameterized Queries
|
### 6. Use Parameterized Queries
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Good - prevents SQL injection
|
// Good - prevents SQL injection
|
||||||
await queryInterface.sequelize.query(
|
await queryInterface.sequelize.query(
|
||||||
`UPDATE table SET column = :value WHERE id = :id`,
|
`UPDATE table SET column = :value WHERE id = :id`,
|
||||||
{ replacements: { value: 'safe', id: record.id } }
|
{ replacements: { value: 'safe', id: record.id } },
|
||||||
);
|
);
|
||||||
|
|
||||||
// Avoid - SQL injection risk
|
// Avoid - SQL injection risk
|
||||||
await queryInterface.sequelize.query(
|
await queryInterface.sequelize.query(
|
||||||
`UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'`
|
`UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'`,
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -689,13 +754,16 @@ await queryInterface.sequelize.query(
|
|||||||
## Running Migrations
|
## Running Migrations
|
||||||
|
|
||||||
### Development
|
### Development
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
npm run db:migrate
|
npm run db:migrate
|
||||||
```
|
```
|
||||||
|
|
||||||
### Server Startup
|
### Server Startup
|
||||||
|
|
||||||
Migrations run automatically via `npm start`:
|
Migrations run automatically via `npm start`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -705,11 +773,13 @@ Migrations run automatically via `npm start`:
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Migration Status
|
### Migration Status
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:migrate:status
|
npm run db:migrate:status
|
||||||
```
|
```
|
||||||
|
|
||||||
### Undo Migrations
|
### Undo Migrations
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Undo last migration
|
# Undo last migration
|
||||||
npm run db:migrate:undo
|
npm run db:migrate:undo
|
||||||
@ -728,32 +798,32 @@ explicit rollback/backup plan.
|
|||||||
|
|
||||||
## Current Migration Inventory
|
## Current Migration Inventory
|
||||||
|
|
||||||
| # | Timestamp | Name | Type |
|
| # | Timestamp | Name | Type |
|
||||||
|---|-----------|------|------|
|
| --- | -------------- | ------------------------------------------- | ------ |
|
||||||
| 1 | 20260319000001 | add-foreign-key-constraints | Schema |
|
| 1 | 20260319000001 | add-foreign-key-constraints | Schema |
|
||||||
| 2 | 20260319000002 | remove-redundant-deletion-columns | Column |
|
| 2 | 20260319000002 | remove-redundant-deletion-columns | Column |
|
||||||
| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename |
|
| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename |
|
||||||
| 4 | 20260326000002 | convert-element-type-enum-to-text | Column |
|
| 4 | 20260326000002 | convert-element-type-enum-to-text | Column |
|
||||||
| 5 | 20260326000003 | create-project-element-defaults | Schema |
|
| 5 | 20260326000003 | create-project-element-defaults | Schema |
|
||||||
| 6 | 20260326000004 | backfill-project-element-defaults | Data |
|
| 6 | 20260326000004 | backfill-project-element-defaults | Data |
|
||||||
| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data |
|
| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data |
|
||||||
| 8 | 20260326000006 | copy-dev-to-stage | Data |
|
| 8 | 20260326000006 | copy-dev-to-stage | Data |
|
||||||
| 9 | 20260326043002 | enforce-environment-not-null | Column |
|
| 9 | 20260326043002 | enforce-environment-not-null | Column |
|
||||||
| 10 | 20260326050442 | remove-project-phase-column | Column |
|
| 10 | 20260326050442 | remove-project-phase-column | Column |
|
||||||
| 11 | 20260326054410 | remove-entry-page-slug-column | Column |
|
| 11 | 20260326054410 | remove-entry-page-slug-column | Column |
|
||||||
| 12 | 20260326060000 | convert-targetpageid-to-slug | Data |
|
| 12 | 20260326060000 | convert-targetpageid-to-slug | Data |
|
||||||
| 13 | 20260326060001 | drop-page-elements-table | Schema |
|
| 13 | 20260326060001 | drop-page-elements-table | Schema |
|
||||||
| 14 | 20260326060002 | drop-page-links-table | Schema |
|
| 14 | 20260326060002 | drop-page-links-table | Schema |
|
||||||
| 15 | 20260326060003 | drop-transitions-table | Schema |
|
| 15 | 20260326060003 | drop-transitions-table | Schema |
|
||||||
| 16 | 20260326171017 | add-missing-element-type-defaults | Data |
|
| 16 | 20260326171017 | add-missing-element-type-defaults | Data |
|
||||||
| 17 | 20260327000001 | sync-all-element-type-defaults | Data |
|
| 17 | 20260327000001 | sync-all-element-type-defaults | Data |
|
||||||
| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column |
|
| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column |
|
||||||
| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data |
|
| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data |
|
||||||
| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data |
|
| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data |
|
||||||
| 21 | 20260403000001 | add-background-video-settings | Column |
|
| 21 | 20260403000001 | add-background-video-settings | Column |
|
||||||
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
|
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
|
||||||
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column |
|
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column |
|
||||||
| 24 | 20260605000001 | add-background-audio-settings | Column |
|
| 24 | 20260605000001 | add-background-audio-settings | Column |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -10,31 +10,31 @@ The DB Models module defines the Sequelize ORM models that map to PostgreSQL dat
|
|||||||
the backend TS/ESM migration, model entries have a typed `.ts` source plus a
|
the backend TS/ESM migration, model entries have a typed `.ts` source plus a
|
||||||
typed ESM source file. There is no model-level CommonJS compatibility facade.
|
typed ESM source file. There is no model-level CommonJS compatibility facade.
|
||||||
|
|
||||||
| File | Model | Purpose | LOC |
|
| File | Model | Purpose | LOC |
|
||||||
|------|-------|---------|-----|
|
| -------------------------------------------------- | -------------------------------- | ------------------------------------------------------------- | --- |
|
||||||
| `index.ts` | - | ESM entrypoint re-exporting `loader.ts` | 1 |
|
| `index.ts` | - | ESM entrypoint re-exporting `loader.ts` | 1 |
|
||||||
| `loader.ts` | - | Typed model registry and Sequelize initialization | 128 |
|
| `loader.ts` | - | Typed model registry and Sequelize initialization | 128 |
|
||||||
| `users.ts` + `.js` bridge | `users` | User accounts with authentication | 246 |
|
| `users.ts` + `.js` bridge | `users` | User accounts with authentication | 246 |
|
||||||
| `projects.ts` + `.js` bridge | `projects` | Virtual tour projects | 211 |
|
| `projects.ts` + `.js` bridge | `projects` | Virtual tour projects | 211 |
|
||||||
| `production_presentation_access.ts` + `.js` bridge | `production_presentation_access` | Customer grants for private production presentations | 67 |
|
| `production_presentation_access.ts` + `.js` bridge | `production_presentation_access` | Customer grants for private production presentations | 67 |
|
||||||
| `tour_pages.ts` + `.js` bridge | `tour_pages` | Individual tour pages with UI schema | 131 |
|
| `tour_pages.ts` + `.js` bridge | `tour_pages` | Individual tour pages with UI schema | 131 |
|
||||||
| `assets.ts` + `.js` bridge | `assets` | Uploaded media files | 169 |
|
| `assets.ts` + `.js` bridge | `assets` | Uploaded media files | 169 |
|
||||||
| `asset_variants.ts` + `.js` bridge | `asset_variants` | Asset size/format variants | 103 |
|
| `asset_variants.ts` + `.js` bridge | `asset_variants` | Asset size/format variants | 103 |
|
||||||
| `roles.ts` + `roles.js` bridge | `roles` | RBAC roles | 85 |
|
| `roles.ts` + `roles.js` bridge | `roles` | RBAC roles | 85 |
|
||||||
| `permissions.ts` + `permissions.js` bridge | `permissions` | RBAC permissions | 52 |
|
| `permissions.ts` + `permissions.js` bridge | `permissions` | RBAC permissions | 52 |
|
||||||
| `project_memberships.ts` + `.js` bridge | `project_memberships` | User-project access | 89 |
|
| `project_memberships.ts` + `.js` bridge | `project_memberships` | User-project access | 89 |
|
||||||
| `publish_events.ts` + `.js` bridge | `publish_events` | Publishing history | 148 |
|
| `publish_events.ts` + `.js` bridge | `publish_events` | Publishing history | 148 |
|
||||||
| `pwa_caches.ts` + `.js` bridge | `pwa_caches` | PWA offline cache manifests | 84 |
|
| `pwa_caches.ts` + `.js` bridge | `pwa_caches` | PWA offline cache manifests | 84 |
|
||||||
| `access_logs.ts` + `.js` bridge | `access_logs` | Activity audit trail | 105 |
|
| `access_logs.ts` + `.js` bridge | `access_logs` | Activity audit trail | 105 |
|
||||||
| `element_type_defaults.ts` + `.js` bridge | `element_type_defaults` | Global UI element defaults | 91 |
|
| `element_type_defaults.ts` + `.js` bridge | `element_type_defaults` | Global UI element defaults | 91 |
|
||||||
| `project_element_defaults.ts` + `.js` bridge | `project_element_defaults` | Project-specific element defaults | 101 |
|
| `project_element_defaults.ts` + `.js` bridge | `project_element_defaults` | Project-specific element defaults | 101 |
|
||||||
| `project_audio_tracks.ts` + `.js` bridge | `project_audio_tracks` | Background audio tracks | 103 |
|
| `project_audio_tracks.ts` + `.js` bridge | `project_audio_tracks` | Background audio tracks | 103 |
|
||||||
| `project_transition_settings.ts` + `.js` bridge | `project_transition_settings` | Environment-aware CSS transition settings | 95 |
|
| `project_transition_settings.ts` + `.js` bridge | `project_transition_settings` | Environment-aware CSS transition settings | 95 |
|
||||||
| `global_transition_defaults.ts` + `.js` bridge | `global_transition_defaults` | Platform defaults for CSS page transitions | 65 |
|
| `global_transition_defaults.ts` + `.js` bridge | `global_transition_defaults` | Platform defaults for CSS page transitions | 65 |
|
||||||
| `global_ui_control_defaults.ts` + `.js` bridge | `global_ui_control_defaults` | Platform defaults for fullscreen, sound, and offline controls | 33 |
|
| `global_ui_control_defaults.ts` + `.js` bridge | `global_ui_control_defaults` | Platform defaults for fullscreen, sound, and offline controls | 33 |
|
||||||
| `project_ui_control_settings.ts` + `.js` bridge | `project_ui_control_settings` | Project/environment overrides for global UI controls | 61 |
|
| `project_ui_control_settings.ts` + `.js` bridge | `project_ui_control_settings` | Project/environment overrides for global UI controls | 61 |
|
||||||
| `presigned_url_requests.ts` + `.js` bridge | `presigned_url_requests` | S3 presigned URL audit | 118 |
|
| `presigned_url_requests.ts` + `.js` bridge | `presigned_url_requests` | S3 presigned URL audit | 118 |
|
||||||
| `file.ts` + `.js` bridge | `file` | Generic file attachments | 53 |
|
| `file.ts` + `.js` bridge | `file` | Generic file attachments | 53 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -128,6 +128,7 @@ bridge or immutable migration.
|
|||||||
## Model Loader
|
## Model Loader
|
||||||
|
|
||||||
**Locations:**
|
**Locations:**
|
||||||
|
|
||||||
- `backend/src/db/models/loader.ts`
|
- `backend/src/db/models/loader.ts`
|
||||||
- `backend/src/db/models/index.ts`
|
- `backend/src/db/models/index.ts`
|
||||||
- `backend/src/types/db-models.ts`
|
- `backend/src/types/db-models.ts`
|
||||||
@ -143,11 +144,11 @@ for service-specific model calls.
|
|||||||
|
|
||||||
**Location:** `backend/src/db/db-config.ts`
|
**Location:** `backend/src/db/db-config.ts`
|
||||||
|
|
||||||
| Environment | Database | Logging | Notes |
|
| Environment | Database | Logging | Notes |
|
||||||
|-------------|----------|---------|-------|
|
| ------------- | -------------------------- | -------- | --------------- |
|
||||||
| `production` | From env vars | Disabled | Live production |
|
| `production` | From env vars | Disabled | Live production |
|
||||||
| `development` | `db_tour_builder_platform` | Console | Local dev |
|
| `development` | `db_tour_builder_platform` | Console | Local dev |
|
||||||
| `dev_stage` | From env vars | Console | Staging server |
|
| `dev_stage` | From env vars | Console | Staging server |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -167,13 +168,13 @@ All models share these Sequelize options:
|
|||||||
|
|
||||||
### Common Fields
|
### Common Fields
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
|-------|------|-------------|
|
| ------------ | ------------- | ----------------------------------------- |
|
||||||
| `id` | `UUID` | Primary key (auto-generated UUIDv4) |
|
| `id` | `UUID` | Primary key (auto-generated UUIDv4) |
|
||||||
| `importHash` | `STRING(255)` | Unique hash for bulk import deduplication |
|
| `importHash` | `STRING(255)` | Unique hash for bulk import deduplication |
|
||||||
| `createdAt` | `DATE` | Auto-managed creation timestamp |
|
| `createdAt` | `DATE` | Auto-managed creation timestamp |
|
||||||
| `updatedAt` | `DATE` | Auto-managed update timestamp |
|
| `updatedAt` | `DATE` | Auto-managed update timestamp |
|
||||||
| `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) |
|
| `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) |
|
||||||
|
|
||||||
### Common Associations
|
### Common Associations
|
||||||
|
|
||||||
@ -193,40 +194,49 @@ db.MODEL.belongsTo(db.users, { as: 'updatedBy' });
|
|||||||
|
|
||||||
**Purpose:** User accounts for authentication and authorization.
|
**Purpose:** User accounts for authentication and authorization.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| --------------------------------- | ------- | -------- | ------- | ------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `firstName` | TEXT | Yes | - | Trimmed |
|
| `firstName` | TEXT | Yes | - | Trimmed |
|
||||||
| `lastName` | TEXT | Yes | - | Trimmed |
|
| `lastName` | TEXT | Yes | - | Trimmed |
|
||||||
| `phoneNumber` | TEXT | Yes | - | - |
|
| `phoneNumber` | TEXT | Yes | - | - |
|
||||||
| `email` | TEXT | No | - | isEmail, notEmpty, unique |
|
| `email` | TEXT | No | - | isEmail, notEmpty, unique |
|
||||||
| `password` | TEXT | No | - | Hashed with bcrypt |
|
| `password` | TEXT | No | - | Hashed with bcrypt |
|
||||||
| `disabled` | BOOLEAN | No | false | - |
|
| `disabled` | BOOLEAN | No | false | - |
|
||||||
| `emailVerified` | BOOLEAN | No | false | - |
|
| `emailVerified` | BOOLEAN | No | false | - |
|
||||||
| `emailVerificationToken` | TEXT | Yes | - | - |
|
| `emailVerificationToken` | TEXT | Yes | - | - |
|
||||||
| `emailVerificationTokenExpiresAt` | DATE | Yes | - | - |
|
| `emailVerificationTokenExpiresAt` | DATE | Yes | - | - |
|
||||||
| `passwordResetToken` | TEXT | Yes | - | - |
|
| `passwordResetToken` | TEXT | Yes | - | - |
|
||||||
| `passwordResetTokenExpiresAt` | DATE | Yes | - | - |
|
| `passwordResetTokenExpiresAt` | DATE | Yes | - | - |
|
||||||
| `provider` | TEXT | No | 'local' | OAuth provider |
|
| `provider` | TEXT | No | 'local' | OAuth provider |
|
||||||
| `app_roleId` | UUID | Yes | - | FK to roles |
|
| `app_roleId` | UUID | Yes | - | FK to roles |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `email` (unique)
|
- `email` (unique)
|
||||||
- `app_roleId`
|
- `app_roleId`
|
||||||
- `deletedAt`
|
- `deletedAt`
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
users.belongsTo(roles, { as: 'app_role' });
|
users.belongsTo(roles, { as: 'app_role' });
|
||||||
users.belongsToMany(permissions, { as: 'custom_permissions', through: 'usersCustom_permissionsPermissions' });
|
users.belongsToMany(permissions, {
|
||||||
|
as: 'custom_permissions',
|
||||||
|
through: 'usersCustom_permissionsPermissions',
|
||||||
|
});
|
||||||
users.hasMany(project_memberships, { as: 'project_memberships_user' });
|
users.hasMany(project_memberships, { as: 'project_memberships_user' });
|
||||||
users.hasMany(presigned_url_requests, { as: 'presigned_url_requests_user' });
|
users.hasMany(presigned_url_requests, { as: 'presigned_url_requests_user' });
|
||||||
users.hasMany(publish_events, { as: 'publish_events_user' });
|
users.hasMany(publish_events, { as: 'publish_events_user' });
|
||||||
users.hasMany(access_logs, { as: 'access_logs_user' });
|
users.hasMany(access_logs, { as: 'access_logs_user' });
|
||||||
users.hasMany(file, { as: 'avatar', scope: { belongsTo: 'users', belongsToColumn: 'avatar' } });
|
users.hasMany(file, {
|
||||||
|
as: 'avatar',
|
||||||
|
scope: { belongsTo: 'users', belongsToColumn: 'avatar' },
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
**Hooks:**
|
**Hooks:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
users.beforeCreate((user) => {
|
users.beforeCreate((user) => {
|
||||||
// Trim string fields
|
// Trim string fields
|
||||||
@ -245,34 +255,60 @@ users.beforeUpdate((user) => {
|
|||||||
|
|
||||||
**Purpose:** Virtual tour projects container.
|
**Purpose:** Virtual tour projects container.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ------------------------------------ | ---- | -------- | ------- | --------------------------------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||||
| `slug` | TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores |
|
| `slug` | TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores |
|
||||||
| `description` | TEXT | Yes | - | - |
|
| `description` | TEXT | Yes | - | - |
|
||||||
| `logo_url` | TEXT | Yes | - | - |
|
| `logo_url` | TEXT | Yes | - | - |
|
||||||
| `favicon_url` | TEXT | Yes | - | - |
|
| `favicon_url` | TEXT | Yes | - | - |
|
||||||
| `og_image_url` | TEXT | Yes | - | - |
|
| `og_image_url` | TEXT | Yes | - | - |
|
||||||
| `production_presentation_visibility` | ENUM | No | public | public, private |
|
| `production_presentation_visibility` | ENUM | No | public | public, private |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `slug` (unique)
|
- `slug` (unique)
|
||||||
- `deletedAt`
|
- `deletedAt`
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
projects.hasMany(project_memberships, { as: 'project_memberships_project', onDelete: 'CASCADE' });
|
projects.hasMany(project_memberships, {
|
||||||
|
as: 'project_memberships_project',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
projects.hasMany(assets, { as: 'assets_project', onDelete: 'CASCADE' });
|
projects.hasMany(assets, { as: 'assets_project', onDelete: 'CASCADE' });
|
||||||
projects.hasMany(presigned_url_requests, { as: 'presigned_url_requests_project', onDelete: 'CASCADE' });
|
projects.hasMany(presigned_url_requests, {
|
||||||
|
as: 'presigned_url_requests_project',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
projects.hasMany(tour_pages, { as: 'tour_pages_project', onDelete: 'CASCADE' });
|
projects.hasMany(tour_pages, { as: 'tour_pages_project', onDelete: 'CASCADE' });
|
||||||
projects.hasMany(project_audio_tracks, { as: 'project_audio_tracks_project', onDelete: 'CASCADE' });
|
projects.hasMany(project_audio_tracks, {
|
||||||
projects.hasMany(project_transition_settings, { as: 'project_transition_settings_project', onDelete: 'CASCADE' });
|
as: 'project_audio_tracks_project',
|
||||||
projects.hasMany(publish_events, { as: 'publish_events_project', onDelete: 'CASCADE' });
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
|
projects.hasMany(project_transition_settings, {
|
||||||
|
as: 'project_transition_settings_project',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
|
projects.hasMany(publish_events, {
|
||||||
|
as: 'publish_events_project',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
projects.hasMany(pwa_caches, { as: 'pwa_caches_project', onDelete: 'CASCADE' });
|
projects.hasMany(pwa_caches, { as: 'pwa_caches_project', onDelete: 'CASCADE' });
|
||||||
projects.hasMany(access_logs, { as: 'access_logs_project', onDelete: 'CASCADE' });
|
projects.hasMany(access_logs, {
|
||||||
projects.hasMany(project_element_defaults, { as: 'project_element_defaults_project', onDelete: 'CASCADE' });
|
as: 'access_logs_project',
|
||||||
projects.hasMany(production_presentation_access, { as: 'production_presentation_access_project', onDelete: 'CASCADE' });
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
|
projects.hasMany(project_element_defaults, {
|
||||||
|
as: 'project_element_defaults_project',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
|
projects.hasMany(production_presentation_access, {
|
||||||
|
as: 'production_presentation_access_project',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -282,26 +318,40 @@ projects.hasMany(production_presentation_access, { as: 'production_presentation_
|
|||||||
**Purpose:** Grants Public-role customer users access to selected private
|
**Purpose:** Grants Public-role customer users access to selected private
|
||||||
production presentations.
|
production presentations.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ------------- | ----------- | -------- | ------- | -------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `projectId` | UUID | No | - | FK to projects |
|
| `projectId` | UUID | No | - | FK to projects |
|
||||||
| `userId` | UUID | No | - | FK to users |
|
| `userId` | UUID | No | - | FK to users |
|
||||||
| `createdById` | UUID | Yes | - | FK to users |
|
| `createdById` | UUID | Yes | - | FK to users |
|
||||||
| `updatedById` | UUID | Yes | - | FK to users |
|
| `updatedById` | UUID | Yes | - | FK to users |
|
||||||
| `importHash` | STRING(255) | Yes | - | unique |
|
| `importHash` | STRING(255) | Yes | - | unique |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `userId`
|
- `userId`
|
||||||
- `projectId, userId` unique for active rows
|
- `projectId, userId` unique for active rows
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
production_presentation_access.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
production_presentation_access.belongsTo(projects, {
|
||||||
production_presentation_access.belongsTo(users, { as: 'user', onDelete: 'CASCADE' });
|
as: 'project',
|
||||||
production_presentation_access.belongsTo(users, { as: 'createdBy', onDelete: 'SET NULL' });
|
onDelete: 'CASCADE',
|
||||||
production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SET NULL' });
|
});
|
||||||
|
production_presentation_access.belongsTo(users, {
|
||||||
|
as: 'user',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
|
production_presentation_access.belongsTo(users, {
|
||||||
|
as: 'createdBy',
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
});
|
||||||
|
production_presentation_access.belongsTo(users, {
|
||||||
|
as: 'updatedBy',
|
||||||
|
onDelete: 'SET NULL',
|
||||||
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -310,23 +360,24 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE
|
|||||||
|
|
||||||
**Purpose:** Individual pages within a tour with UI elements schema.
|
**Purpose:** Individual pages within a tour with UI elements schema.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ---------------------- | ------- | -------- | ------- | --------------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `environment` | ENUM | No | 'dev' | dev, stage, production |
|
| `environment` | ENUM | No | 'dev' | dev, stage, production |
|
||||||
| `source_key` | TEXT | Yes | - | Original page ID for cloning |
|
| `source_key` | TEXT | Yes | - | Original page ID for cloning |
|
||||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||||
| `slug` | TEXT | No | - | notEmpty, alphanumeric + dashes |
|
| `slug` | TEXT | No | - | notEmpty, alphanumeric + dashes |
|
||||||
| `sort_order` | INTEGER | No | 0 | - |
|
| `sort_order` | INTEGER | No | 0 | - |
|
||||||
| `background_image_url` | TEXT | Yes | - | - |
|
| `background_image_url` | TEXT | Yes | - | - |
|
||||||
| `background_video_url` | TEXT | Yes | - | - |
|
| `background_video_url` | TEXT | Yes | - | - |
|
||||||
| `background_audio_url` | TEXT | Yes | - | - |
|
| `background_audio_url` | TEXT | Yes | - | - |
|
||||||
| `background_loop` | BOOLEAN | No | false | - |
|
| `background_loop` | BOOLEAN | No | false | - |
|
||||||
| `requires_auth` | BOOLEAN | No | false | - |
|
| `requires_auth` | BOOLEAN | No | false | - |
|
||||||
| `ui_schema_json` | JSON | Yes | - | Page elements, links, transitions |
|
| `ui_schema_json` | JSON | Yes | - | Page elements, links, transitions |
|
||||||
| `projectId` | UUID | Yes | - | FK to projects |
|
| `projectId` | UUID | Yes | - | FK to projects |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `[projectId, environment, slug]` (unique) - Composite unique per project+environment
|
- `[projectId, environment, slug]` (unique) - Composite unique per project+environment
|
||||||
- `[projectId, environment, sort_order]` - For ordering queries
|
- `[projectId, environment, sort_order]` - For ordering queries
|
||||||
@ -340,15 +391,19 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE
|
|||||||
|
|
||||||
**Purpose:** RBAC role definitions.
|
**Purpose:** RBAC role definitions.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| -------------------- | ---- | -------- | ------- | -------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `name` | TEXT | No | - | notEmpty, len[1,100] |
|
| `name` | TEXT | No | - | notEmpty, len[1,100] |
|
||||||
| `role_customization` | TEXT | Yes | - | Custom role metadata |
|
| `role_customization` | TEXT | Yes | - | Custom role metadata |
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
roles.belongsToMany(permissions, { as: 'permissions', through: 'rolesPermissionsPermissions' });
|
roles.belongsToMany(permissions, {
|
||||||
|
as: 'permissions',
|
||||||
|
through: 'rolesPermissionsPermissions',
|
||||||
|
});
|
||||||
roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -358,10 +413,10 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
|||||||
|
|
||||||
**Purpose:** Individual permission definitions.
|
**Purpose:** Individual permission definitions.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ------ | ---- | -------- | ------- | ---------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `name` | TEXT | No | - | notEmpty, unique, len[1,100] |
|
| `name` | TEXT | No | - | notEmpty, unique, len[1,100] |
|
||||||
|
|
||||||
**Permission Naming Convention:** `{ACTION}_{ENTITY}` (e.g., `READ_USERS`, `CREATE_ASSETS`)
|
**Permission Naming Convention:** `{ACTION}_{ENTITY}` (e.g., `READ_USERS`, `CREATE_ASSETS`)
|
||||||
|
|
||||||
@ -373,24 +428,25 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
|||||||
|
|
||||||
**Purpose:** Uploaded media files (images, videos, audio, documents).
|
**Purpose:** Uploaded media files (images, videos, audio, documents).
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| -------------- | ------- | -------- | --------- | ---------------------------------------------------------------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `name` | TEXT | Yes | - | len[0,255] |
|
| `name` | TEXT | Yes | - | len[0,255] |
|
||||||
| `asset_type` | ENUM | No | - | image, video, audio, file |
|
| `asset_type` | ENUM | No | - | image, video, audio, file |
|
||||||
| `type` | ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general |
|
| `type` | ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general |
|
||||||
| `cdn_url` | TEXT | Yes | - | - |
|
| `cdn_url` | TEXT | Yes | - | - |
|
||||||
| `storage_key` | TEXT | Yes | - | S3/storage path |
|
| `storage_key` | TEXT | Yes | - | S3/storage path |
|
||||||
| `mime_type` | TEXT | Yes | - | MIME type format |
|
| `mime_type` | TEXT | Yes | - | MIME type format |
|
||||||
| `size_mb` | DECIMAL | Yes | - | - |
|
| `size_mb` | DECIMAL | Yes | - | - |
|
||||||
| `width_px` | INTEGER | Yes | - | Image/video width |
|
| `width_px` | INTEGER | Yes | - | Image/video width |
|
||||||
| `height_px` | INTEGER | Yes | - | Image/video height |
|
| `height_px` | INTEGER | Yes | - | Image/video height |
|
||||||
| `duration_sec` | DECIMAL | Yes | - | Audio/video duration |
|
| `duration_sec` | DECIMAL | Yes | - | Audio/video duration |
|
||||||
| `checksum` | TEXT | Yes | - | File hash |
|
| `checksum` | TEXT | Yes | - | File hash |
|
||||||
| `is_public` | BOOLEAN | No | false | - |
|
| `is_public` | BOOLEAN | No | false | - |
|
||||||
| `projectId` | UUID | Yes | - | FK to projects |
|
| `projectId` | UUID | Yes | - | FK to projects |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `asset_type`
|
- `asset_type`
|
||||||
- `type`
|
- `type`
|
||||||
@ -398,8 +454,12 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
|||||||
- `deletedAt`
|
- `deletedAt`
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
assets.hasMany(asset_variants, { as: 'asset_variants_asset', onDelete: 'CASCADE' });
|
assets.hasMany(asset_variants, {
|
||||||
|
as: 'asset_variants_asset',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -409,17 +469,18 @@ assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
|||||||
|
|
||||||
**Purpose:** Optimized versions of assets (thumbnails, different formats).
|
**Purpose:** Optimized versions of assets (thumbnails, different formats).
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| -------------- | ------- | -------- | ------- | ----------------------------------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `variant_type` | ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original |
|
| `variant_type` | ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original |
|
||||||
| `cdn_url` | TEXT | Yes | - | len[0,2048], URL format |
|
| `cdn_url` | TEXT | Yes | - | len[0,2048], URL format |
|
||||||
| `width_px` | INTEGER | Yes | - | min: 0 |
|
| `width_px` | INTEGER | Yes | - | min: 0 |
|
||||||
| `height_px` | INTEGER | Yes | - | min: 0 |
|
| `height_px` | INTEGER | Yes | - | min: 0 |
|
||||||
| `size_mb` | DECIMAL | Yes | - | min: 0 |
|
| `size_mb` | DECIMAL | Yes | - | min: 0 |
|
||||||
| `assetId` | UUID | Yes | - | FK to assets |
|
| `assetId` | UUID | Yes | - | FK to assets |
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' });
|
asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' });
|
||||||
```
|
```
|
||||||
@ -432,26 +493,28 @@ asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' });
|
|||||||
|
|
||||||
**Purpose:** Global platform-wide default settings for UI element types.
|
**Purpose:** Global platform-wide default settings for UI element types.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ----------------------- | ------- | -------- | ------- | ---------------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `element_type` | TEXT | No | - | notEmpty, unique, len[1,100] |
|
| `element_type` | TEXT | No | - | notEmpty, unique, len[1,100] |
|
||||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||||
| `sort_order` | INTEGER | No | 0 | - |
|
| `sort_order` | INTEGER | No | 0 | - |
|
||||||
| `is_active` | VIRTUAL | - | true | Always returns true |
|
| `is_active` | VIRTUAL | - | true | Always returns true |
|
||||||
| `default_settings_json` | TEXT | Yes | - | Mapped from `settings_json` column |
|
| `default_settings_json` | TEXT | Yes | - | Mapped from `settings_json` column |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `element_type`
|
- `element_type`
|
||||||
- `sort_order`
|
- `sort_order`
|
||||||
- `deletedAt`
|
- `deletedAt`
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
element_type_defaults.hasMany(project_element_defaults, {
|
element_type_defaults.hasMany(project_element_defaults, {
|
||||||
as: 'project_defaults',
|
as: 'project_defaults',
|
||||||
foreignKey: 'source_element_id',
|
foreignKey: 'source_element_id',
|
||||||
onDelete: 'SET NULL'
|
onDelete: 'SET NULL',
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -463,18 +526,19 @@ element_type_defaults.hasMany(project_element_defaults, {
|
|||||||
|
|
||||||
**Purpose:** Project-specific overrides for element defaults.
|
**Purpose:** Project-specific overrides for element defaults.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ------------------- | ------- | -------- | ------- | --------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `element_type` | TEXT | No | - | notEmpty, len[1,100] |
|
| `element_type` | TEXT | No | - | notEmpty, len[1,100] |
|
||||||
| `name` | TEXT | Yes | - | len[0,255] |
|
| `name` | TEXT | Yes | - | len[0,255] |
|
||||||
| `sort_order` | INTEGER | No | 0 | - |
|
| `sort_order` | INTEGER | No | 0 | - |
|
||||||
| `settings_json` | TEXT | Yes | - | Element configuration |
|
| `settings_json` | TEXT | Yes | - | Element configuration |
|
||||||
| `source_element_id` | UUID | Yes | - | FK to element_type_defaults |
|
| `source_element_id` | UUID | Yes | - | FK to element_type_defaults |
|
||||||
| `snapshot_version` | INTEGER | No | 1 | Version tracking |
|
| `snapshot_version` | INTEGER | No | 1 | Version tracking |
|
||||||
| `projectId` | UUID | No | - | FK to projects |
|
| `projectId` | UUID | No | - | FK to projects |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `[projectId, element_type]` (unique)
|
- `[projectId, element_type]` (unique)
|
||||||
- `element_type`
|
- `element_type`
|
||||||
@ -482,11 +546,15 @@ element_type_defaults.hasMany(project_element_defaults, {
|
|||||||
- `deletedAt`
|
- `deletedAt`
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
project_element_defaults.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
project_element_defaults.belongsTo(projects, {
|
||||||
|
as: 'project',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
project_element_defaults.belongsTo(element_type_defaults, {
|
project_element_defaults.belongsTo(element_type_defaults, {
|
||||||
as: 'source_element',
|
as: 'source_element',
|
||||||
onDelete: 'SET NULL'
|
onDelete: 'SET NULL',
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -498,24 +566,25 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
|||||||
|
|
||||||
**Purpose:** Track publishing actions between environments.
|
**Purpose:** Track publishing actions between environments.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| -------------------- | ------- | -------- | -------- | -------------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `title` | STRING | Yes | - | len[0,255] |
|
| `title` | STRING | Yes | - | len[0,255] |
|
||||||
| `description` | TEXT | Yes | - | len[0,5000] |
|
| `description` | TEXT | Yes | - | len[0,5000] |
|
||||||
| `from_environment` | ENUM | No | - | dev, stage, production |
|
| `from_environment` | ENUM | No | - | dev, stage, production |
|
||||||
| `to_environment` | ENUM | No | - | dev, stage, production |
|
| `to_environment` | ENUM | No | - | dev, stage, production |
|
||||||
| `started_at` | DATE | Yes | - | - |
|
| `started_at` | DATE | Yes | - | - |
|
||||||
| `finished_at` | DATE | Yes | - | - |
|
| `finished_at` | DATE | Yes | - | - |
|
||||||
| `status` | ENUM | No | 'queued' | queued, running, success, failed |
|
| `status` | ENUM | No | 'queued' | queued, running, success, failed |
|
||||||
| `error_message` | TEXT | Yes | - | - |
|
| `error_message` | TEXT | Yes | - | - |
|
||||||
| `pages_copied` | INTEGER | Yes | - | min: 0 |
|
| `pages_copied` | INTEGER | Yes | - | min: 0 |
|
||||||
| `transitions_copied` | INTEGER | Yes | - | min: 0 |
|
| `transitions_copied` | INTEGER | Yes | - | min: 0 |
|
||||||
| `audios_copied` | INTEGER | Yes | - | min: 0 |
|
| `audios_copied` | INTEGER | Yes | - | min: 0 |
|
||||||
| `projectId` | UUID | Yes | - | FK to projects |
|
| `projectId` | UUID | Yes | - | FK to projects |
|
||||||
| `userId` | UUID | Yes | - | FK to users |
|
| `userId` | UUID | Yes | - | FK to users |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `userId`
|
- `userId`
|
||||||
- `status`
|
- `status`
|
||||||
@ -527,18 +596,19 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
|||||||
|
|
||||||
**Purpose:** Audit trail for user activity.
|
**Purpose:** Audit trail for user activity.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ------------- | ---- | -------- | ------- | ------------------------ |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `environment` | ENUM | No | - | admin, stage, production |
|
| `environment` | ENUM | No | - | admin, stage, production |
|
||||||
| `path` | TEXT | Yes | - | len[0,2048] |
|
| `path` | TEXT | Yes | - | len[0,2048] |
|
||||||
| `ip_address` | TEXT | Yes | - | len[0,45] (IPv6 max) |
|
| `ip_address` | TEXT | Yes | - | len[0,45] (IPv6 max) |
|
||||||
| `user_agent` | TEXT | Yes | - | len[0,1024] |
|
| `user_agent` | TEXT | Yes | - | len[0,1024] |
|
||||||
| `accessed_at` | DATE | No | NOW | - |
|
| `accessed_at` | DATE | No | NOW | - |
|
||||||
| `projectId` | UUID | Yes | - | FK to projects |
|
| `projectId` | UUID | Yes | - | FK to projects |
|
||||||
| `userId` | UUID | Yes | - | FK to users |
|
| `userId` | UUID | Yes | - | FK to users |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `environment`
|
- `environment`
|
||||||
- `userId`
|
- `userId`
|
||||||
@ -552,17 +622,18 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
|||||||
|
|
||||||
**Purpose:** User access to projects with role-based permissions.
|
**Purpose:** User access to projects with role-based permissions.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| -------------- | ------- | -------- | -------- | ------------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `access_level` | ENUM | No | 'viewer' | owner, editor, reviewer, viewer |
|
| `access_level` | ENUM | No | 'viewer' | owner, editor, reviewer, viewer |
|
||||||
| `is_active` | BOOLEAN | No | false | - |
|
| `is_active` | BOOLEAN | No | false | - |
|
||||||
| `invited_at` | DATE | Yes | - | - |
|
| `invited_at` | DATE | Yes | - | - |
|
||||||
| `accepted_at` | DATE | Yes | - | - |
|
| `accepted_at` | DATE | Yes | - | - |
|
||||||
| `projectId` | UUID | Yes | - | FK to projects |
|
| `projectId` | UUID | Yes | - | FK to projects |
|
||||||
| `userId` | UUID | Yes | - | FK to users |
|
| `userId` | UUID | Yes | - | FK to users |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `userId`
|
- `userId`
|
||||||
- `[projectId, userId]` (unique) - One membership per user per project
|
- `[projectId, userId]` (unique) - One membership per user per project
|
||||||
@ -575,19 +646,19 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
|||||||
|
|
||||||
**Purpose:** Background audio tracks for projects.
|
**Purpose:** Background audio tracks for projects.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ------------- | ------- | -------- | ------- | ----------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `environment` | ENUM | Yes | - | dev, stage, production |
|
| `environment` | ENUM | Yes | - | dev, stage, production |
|
||||||
| `source_key` | TEXT | Yes | - | Original track ID for cloning |
|
| `source_key` | TEXT | Yes | - | Original track ID for cloning |
|
||||||
| `name` | TEXT | Yes | - | len[0,255] |
|
| `name` | TEXT | Yes | - | len[0,255] |
|
||||||
| `slug` | TEXT | Yes | - | - |
|
| `slug` | TEXT | Yes | - | - |
|
||||||
| `url` | TEXT | Yes | - | - |
|
| `url` | TEXT | Yes | - | - |
|
||||||
| `loop` | BOOLEAN | No | false | - |
|
| `loop` | BOOLEAN | No | false | - |
|
||||||
| `volume` | DECIMAL | Yes | - | min: 0, max: 1 |
|
| `volume` | DECIMAL | Yes | - | min: 0, max: 1 |
|
||||||
| `sort_order` | INTEGER | Yes | - | - |
|
| `sort_order` | INTEGER | Yes | - | - |
|
||||||
| `is_enabled` | BOOLEAN | No | false | - |
|
| `is_enabled` | BOOLEAN | No | false | - |
|
||||||
| `projectId` | UUID | Yes | - | FK to projects |
|
| `projectId` | UUID | Yes | - | FK to projects |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -595,27 +666,32 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
|||||||
|
|
||||||
**Purpose:** Environment-aware CSS transition settings for page navigation.
|
**Purpose:** Environment-aware CSS transition settings for page navigation.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ----------------- | ------- | -------- | ------------- | -------------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `environment` | ENUM | No | - | dev, stage, production |
|
| `environment` | ENUM | No | - | dev, stage, production |
|
||||||
| `source_key` | TEXT | Yes | - | Original settings ID for cloning |
|
| `source_key` | TEXT | Yes | - | Original settings ID for cloning |
|
||||||
| `transition_type` | TEXT | No | 'fade' | CSS transition type |
|
| `transition_type` | TEXT | No | 'fade' | CSS transition type |
|
||||||
| `duration_ms` | INTEGER | No | 700 | Transition duration in ms |
|
| `duration_ms` | INTEGER | No | 700 | Transition duration in ms |
|
||||||
| `easing` | TEXT | No | 'ease-in-out' | CSS easing function |
|
| `easing` | TEXT | No | 'ease-in-out' | CSS easing function |
|
||||||
| `overlay_color` | TEXT | No | '#000000' | Transition overlay color |
|
| `overlay_color` | TEXT | No | '#000000' | Transition overlay color |
|
||||||
| `projectId` | UUID | No | - | FK to projects |
|
| `projectId` | UUID | No | - | FK to projects |
|
||||||
| `createdById` | UUID | Yes | - | FK to users |
|
| `createdById` | UUID | Yes | - | FK to users |
|
||||||
| `updatedById` | UUID | Yes | - | FK to users |
|
| `updatedById` | UUID | Yes | - | FK to users |
|
||||||
|
|
||||||
**Indexes:**
|
**Indexes:**
|
||||||
|
|
||||||
- `[projectId, environment]` (unique where deletedAt IS NULL)
|
- `[projectId, environment]` (unique where deletedAt IS NULL)
|
||||||
- `projectId`
|
- `projectId`
|
||||||
- `deletedAt`
|
- `deletedAt`
|
||||||
|
|
||||||
**Associations:**
|
**Associations:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
project_transition_settings.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
project_transition_settings.belongsTo(projects, {
|
||||||
|
as: 'project',
|
||||||
|
onDelete: 'CASCADE',
|
||||||
|
});
|
||||||
project_transition_settings.belongsTo(users, { as: 'createdBy' });
|
project_transition_settings.belongsTo(users, { as: 'createdBy' });
|
||||||
project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
||||||
```
|
```
|
||||||
@ -628,16 +704,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
|||||||
|
|
||||||
**Purpose:** PWA offline cache manifest tracking.
|
**Purpose:** PWA offline cache manifest tracking.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ----------------- | ------- | -------- | ------- | ---------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `environment` | ENUM | Yes | - | dev, stage, production |
|
| `environment` | ENUM | Yes | - | dev, stage, production |
|
||||||
| `cache_version` | TEXT | Yes | - | len[0,255] |
|
| `cache_version` | TEXT | Yes | - | len[0,255] |
|
||||||
| `manifest_json` | JSON | Yes | - | PWA manifest |
|
| `manifest_json` | JSON | Yes | - | PWA manifest |
|
||||||
| `asset_list_json` | JSON | Yes | - | Cached asset URLs |
|
| `asset_list_json` | JSON | Yes | - | Cached asset URLs |
|
||||||
| `generated_at` | DATE | Yes | - | - |
|
| `generated_at` | DATE | Yes | - | - |
|
||||||
| `is_active` | BOOLEAN | No | false | - |
|
| `is_active` | BOOLEAN | No | false | - |
|
||||||
| `projectId` | UUID | Yes | - | FK to projects |
|
| `projectId` | UUID | Yes | - | FK to projects |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -645,18 +721,18 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
|||||||
|
|
||||||
**Purpose:** Audit log for S3 presigned URL requests.
|
**Purpose:** Audit log for S3 presigned URL requests.
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ------------------- | ------- | -------- | ------- | ------------------------- |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `purpose` | ENUM | Yes | - | upload, download |
|
| `purpose` | ENUM | Yes | - | upload, download |
|
||||||
| `asset_type` | ENUM | Yes | - | image, video, audio, file |
|
| `asset_type` | ENUM | Yes | - | image, video, audio, file |
|
||||||
| `requested_key` | TEXT | Yes | - | len[0,1024] |
|
| `requested_key` | TEXT | Yes | - | len[0,1024] |
|
||||||
| `mime_type` | TEXT | Yes | - | MIME format, len[0,255] |
|
| `mime_type` | TEXT | Yes | - | MIME format, len[0,255] |
|
||||||
| `requested_size_mb` | DECIMAL | Yes | - | min: 0 |
|
| `requested_size_mb` | DECIMAL | Yes | - | min: 0 |
|
||||||
| `expires_at` | DATE | Yes | - | - |
|
| `expires_at` | DATE | Yes | - | - |
|
||||||
| `status` | TEXT | Yes | - | - |
|
| `status` | TEXT | Yes | - | - |
|
||||||
| `projectId` | UUID | Yes | - | FK to projects |
|
| `projectId` | UUID | Yes | - | FK to projects |
|
||||||
| `userId` | UUID | Yes | - | FK to users |
|
| `userId` | UUID | Yes | - | FK to users |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -664,16 +740,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
|||||||
|
|
||||||
**Purpose:** Generic file attachments (user avatars, etc.).
|
**Purpose:** Generic file attachments (user avatars, etc.).
|
||||||
|
|
||||||
| Field | Type | Nullable | Default | Validation |
|
| Field | Type | Nullable | Default | Validation |
|
||||||
|-------|------|----------|---------|------------|
|
| ----------------- | ------------ | -------- | ------- | ------------------ |
|
||||||
| `id` | UUID | No | UUIDv4 | - |
|
| `id` | UUID | No | UUIDv4 | - |
|
||||||
| `belongsTo` | STRING(255) | Yes | - | Parent table name |
|
| `belongsTo` | STRING(255) | Yes | - | Parent table name |
|
||||||
| `belongsToId` | UUID | Yes | - | Parent record ID |
|
| `belongsToId` | UUID | Yes | - | Parent record ID |
|
||||||
| `belongsToColumn` | STRING(255) | Yes | - | Parent column name |
|
| `belongsToColumn` | STRING(255) | Yes | - | Parent column name |
|
||||||
| `name` | STRING(2083) | No | - | notEmpty |
|
| `name` | STRING(2083) | No | - | notEmpty |
|
||||||
| `sizeInBytes` | INTEGER | Yes | - | - |
|
| `sizeInBytes` | INTEGER | Yes | - | - |
|
||||||
| `privateUrl` | STRING(2083) | Yes | - | - |
|
| `privateUrl` | STRING(2083) | Yes | - | - |
|
||||||
| `publicUrl` | STRING(2083) | No | - | notEmpty |
|
| `publicUrl` | STRING(2083) | No | - | notEmpty |
|
||||||
|
|
||||||
**Usage Pattern:** Polymorphic association via `belongsTo`, `belongsToId`, `belongsToColumn` fields and scoped `hasMany` on parent models:
|
**Usage Pattern:** Polymorphic association via `belongsTo`, `belongsToId`, `belongsToColumn` fields and scoped `hasMany` on parent models:
|
||||||
|
|
||||||
@ -811,19 +887,17 @@ importHash: {
|
|||||||
|
|
||||||
### 4. Cascade Delete Patterns
|
### 4. Cascade Delete Patterns
|
||||||
|
|
||||||
| Relationship | onDelete | Use Case |
|
| Relationship | onDelete | Use Case |
|
||||||
|--------------|----------|----------|
|
| ------------ | ----------------------------------- | --------------------- |
|
||||||
| `CASCADE` | Delete children when parent deleted | Projects → tour_pages |
|
| `CASCADE` | Delete children when parent deleted | Projects → tour_pages |
|
||||||
| `SET NULL` | Keep children, null the FK | Roles → users |
|
| `SET NULL` | Keep children, null the FK | Roles → users |
|
||||||
|
|
||||||
### 5. Composite Unique Constraints
|
### 5. Composite Unique Constraints
|
||||||
|
|
||||||
For scoped uniqueness:
|
For scoped uniqueness:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
indexes: [
|
indexes: [{ fields: ['projectId', 'environment', 'slug'], unique: true }];
|
||||||
{ fields: ['projectId', 'environment', 'slug'], unique: true },
|
|
||||||
]
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 6. JSON Fields
|
### 6. JSON Fields
|
||||||
@ -831,8 +905,12 @@ indexes: [
|
|||||||
Complex configurations stored as JSON:
|
Complex configurations stored as JSON:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
ui_schema_json: { type: DataTypes.JSON } // Parsed JSON column
|
ui_schema_json: {
|
||||||
settings_json: { type: DataTypes.TEXT } // Stringified JSON text
|
type: DataTypes.JSON;
|
||||||
|
} // Parsed JSON column
|
||||||
|
settings_json: {
|
||||||
|
type: DataTypes.TEXT;
|
||||||
|
} // Stringified JSON text
|
||||||
```
|
```
|
||||||
|
|
||||||
### 7. Virtual Fields
|
### 7. Virtual Fields
|
||||||
@ -933,7 +1011,7 @@ const transaction = await db.sequelize.transaction();
|
|||||||
// Access Sequelize operators
|
// Access Sequelize operators
|
||||||
const { Op } = db.Sequelize;
|
const { Op } = db.Sequelize;
|
||||||
const users = await db.users.findAll({
|
const users = await db.users.findAll({
|
||||||
where: { email: { [Op.like]: '%@example.com' } }
|
where: { email: { [Op.like]: '%@example.com' } },
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@ -30,6 +30,7 @@ backend/src/db/seeders/
|
|||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
### NPM Scripts
|
### NPM Scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Run pending seeders
|
# Run pending seeders
|
||||||
npm run db:seed
|
npm run db:seed
|
||||||
@ -42,7 +43,9 @@ npm run db:reset
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Server Startup
|
### Server Startup
|
||||||
|
|
||||||
Seeders run automatically via `npm start`:
|
Seeders run automatically via `npm start`:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@ -65,13 +68,14 @@ development and the compiled JavaScript file from `dist/` in production builds.
|
|||||||
|
|
||||||
**Users Created:**
|
**Users Created:**
|
||||||
|
|
||||||
| User | Email | Role Assignment |
|
| User | Email | Role Assignment |
|
||||||
|------|-------|-----------------|
|
| ------ | -------------------- | --------------- |
|
||||||
| Admin | `config.admin_email` | Administrator |
|
| Admin | `config.admin_email` | Administrator |
|
||||||
| John | john@doe.com | Account Manager |
|
| John | john@doe.com | Account Manager |
|
||||||
| Client | client@hello.com | Platform Owner |
|
| Client | client@hello.com | Platform Owner |
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Uses bcrypt for password hashing with configured salt rounds
|
- Uses bcrypt for password hashing with configured salt rounds
|
||||||
- Hardcoded UUIDs for consistent user IDs
|
- Hardcoded UUIDs for consistent user IDs
|
||||||
- Reads credentials from `config.ts` (environment variables)
|
- Reads credentials from `config.ts` (environment variables)
|
||||||
@ -112,14 +116,15 @@ data.
|
|||||||
|
|
||||||
**Data Created:**
|
**Data Created:**
|
||||||
|
|
||||||
| Data Type | Count | Description |
|
| Data Type | Count | Description |
|
||||||
|-----------|-------|-------------|
|
| --------------------- | ----- | ------------------------------------------ |
|
||||||
| Roles | 7 | User role definitions |
|
| Roles | 7 | User role definitions |
|
||||||
| Permissions | 54 | CRUD permissions for 13 entities + special |
|
| Permissions | 54 | CRUD permissions for 13 entities + special |
|
||||||
| Role-Permission Links | 200+ | M:N relationships |
|
| Role-Permission Links | 200+ | M:N relationships |
|
||||||
| Join Table | 1 | `rolesPermissionsPermissions` table |
|
| Join Table | 1 | `rolesPermissionsPermissions` table |
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Uses stable named role and permission definitions, then reuses existing DB IDs
|
- Uses stable named role and permission definitions, then reuses existing DB IDs
|
||||||
when the same role/permission name is already present.
|
when the same role/permission name is already present.
|
||||||
- Inserts only missing roles, permissions, and role-permission links. This keeps
|
- Inserts only missing roles, permissions, and role-permission links. This keeps
|
||||||
@ -128,19 +133,21 @@ data.
|
|||||||
by email after RBAC data exists.
|
by email after RBAC data exists.
|
||||||
|
|
||||||
#### Roles
|
#### Roles
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const roles = [
|
const roles = [
|
||||||
'Administrator', // Full system access
|
'Administrator', // Full system access
|
||||||
'PlatformOwner', // Full project/content access
|
'PlatformOwner', // Full project/content access
|
||||||
'AccountManager', // User and project management
|
'AccountManager', // User and project management
|
||||||
'TourDesigner', // Content creation and editing
|
'TourDesigner', // Content creation and editing
|
||||||
'ContentReviewer', // Read + limited update access
|
'ContentReviewer', // Read + limited update access
|
||||||
'AnalyticsViewer', // Read-only access
|
'AnalyticsViewer', // Read-only access
|
||||||
'Public', // Public/unauthenticated access
|
'Public', // Public/unauthenticated access
|
||||||
];
|
];
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Permission Generation Pattern
|
#### Permission Generation Pattern
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Generates CREATE, READ, UPDATE, DELETE permissions per entity
|
// Generates CREATE, READ, UPDATE, DELETE permissions per entity
|
||||||
function createPermissions(name) {
|
function createPermissions(name) {
|
||||||
@ -153,13 +160,26 @@ function createPermissions(name) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const entities = [
|
const entities = [
|
||||||
'users', 'roles', 'permissions', 'projects', 'project_memberships',
|
'users',
|
||||||
'assets', 'asset_variants', 'presigned_url_requests', 'tour_pages',
|
'roles',
|
||||||
'project_audio_tracks', 'publish_events', 'pwa_caches', 'access_logs'
|
'permissions',
|
||||||
|
'projects',
|
||||||
|
'project_memberships',
|
||||||
|
'assets',
|
||||||
|
'asset_variants',
|
||||||
|
'presigned_url_requests',
|
||||||
|
'tour_pages',
|
||||||
|
'project_audio_tracks',
|
||||||
|
'publish_events',
|
||||||
|
'pwa_caches',
|
||||||
|
'access_logs',
|
||||||
];
|
];
|
||||||
|
|
||||||
// Creates 52 permissions (13 entities × 4 CRUD operations)
|
// Creates 52 permissions (13 entities × 4 CRUD operations)
|
||||||
await queryInterface.bulkInsert('permissions', entities.flatMap(createPermissions));
|
await queryInterface.bulkInsert(
|
||||||
|
'permissions',
|
||||||
|
entities.flatMap(createPermissions),
|
||||||
|
);
|
||||||
|
|
||||||
// Plus special permissions
|
// Plus special permissions
|
||||||
await queryInterface.bulkInsert('permissions', [
|
await queryInterface.bulkInsert('permissions', [
|
||||||
@ -169,6 +189,7 @@ await queryInterface.bulkInsert('permissions', [
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### ID Map Pattern
|
#### ID Map Pattern
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Consistent UUID generation using key-based map
|
// Consistent UUID generation using key-based map
|
||||||
const idMap = new Map();
|
const idMap = new Map();
|
||||||
@ -183,25 +204,26 @@ function getId(key) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Usage - same key always returns same UUID within seeder run
|
// Usage - same key always returns same UUID within seeder run
|
||||||
getId('Administrator') // Returns consistent UUID
|
getId('Administrator'); // Returns consistent UUID
|
||||||
getId('CREATE_USERS') // Returns consistent UUID
|
getId('CREATE_USERS'); // Returns consistent UUID
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Permission Matrix
|
#### Permission Matrix
|
||||||
|
|
||||||
| Role | Users | Projects | Assets | Tour Pages | Access Logs |
|
| Role | Users | Projects | Assets | Tour Pages | Access Logs |
|
||||||
|------|-------|----------|--------|------------|-------------|
|
| ------------------- | ----- | -------- | ------ | ---------- | ----------- |
|
||||||
| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD |
|
| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD |
|
||||||
| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD |
|
| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD |
|
||||||
| **AccountManager** | RU | CRU | CRU | CRU | R |
|
| **AccountManager** | RU | CRU | CRU | CRU | R |
|
||||||
| **TourDesigner** | R | RU | CRU | CRU | R |
|
| **TourDesigner** | R | RU | CRU | CRU | R |
|
||||||
| **ContentReviewer** | R | RU | RU | RU | R |
|
| **ContentReviewer** | R | RU | RU | RU | R |
|
||||||
| **AnalyticsViewer** | R | R | R | R | R |
|
| **AnalyticsViewer** | R | R | R | R | R |
|
||||||
| **Public** | - | - | - | - | - |
|
| **Public** | - | - | - | - | - |
|
||||||
|
|
||||||
**Legend:** C=Create, R=Read, U=Update, D=Delete
|
**Legend:** C=Create, R=Read, U=Update, D=Delete
|
||||||
|
|
||||||
#### Join Table Creation
|
#### Join Table Creation
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Creates M:N relationship table directly in seeder
|
// Creates M:N relationship table directly in seeder
|
||||||
await queryInterface.sequelize.query(`
|
await queryInterface.sequelize.query(`
|
||||||
@ -235,6 +257,7 @@ separate. Umzug records the seeder under its legacy `.js` name for storage
|
|||||||
compatibility only.
|
compatibility only.
|
||||||
|
|
||||||
**Opt-In Activation:**
|
**Opt-In Activation:**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Enable sample data seeding
|
# Enable sample data seeding
|
||||||
export ENABLE_SAMPLE_DATA=true
|
export ENABLE_SAMPLE_DATA=true
|
||||||
@ -242,6 +265,7 @@ npm run db:seed
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Check in Code:**
|
**Check in Code:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const sampleDataSeeder: SequelizeSeeder = {
|
const sampleDataSeeder: SequelizeSeeder = {
|
||||||
async up() {
|
async up() {
|
||||||
@ -253,20 +277,21 @@ const sampleDataSeeder: SequelizeSeeder = {
|
|||||||
|
|
||||||
**Data Created:**
|
**Data Created:**
|
||||||
|
|
||||||
| Entity | Records | Description |
|
| Entity | Records | Description |
|
||||||
|--------|---------|-------------|
|
| ---------------------- | ------- | -------------------------- |
|
||||||
| Projects | 3 | Sample tour projects |
|
| Projects | 3 | Sample tour projects |
|
||||||
| Project Memberships | 3 | User-project associations |
|
| Project Memberships | 3 | User-project associations |
|
||||||
| Assets | 3 | Images, videos, audio |
|
| Assets | 3 | Images, videos, audio |
|
||||||
| Asset Variants | 3 | Thumbnail/preview variants |
|
| Asset Variants | 3 | Thumbnail/preview variants |
|
||||||
| Presigned URL Requests | 3 | Upload/download requests |
|
| Presigned URL Requests | 3 | Upload/download requests |
|
||||||
| Tour Pages | 3 | Sample tour pages |
|
| Tour Pages | 3 | Sample tour pages |
|
||||||
| Project Audio Tracks | 3 | Background audio |
|
| Project Audio Tracks | 3 | Background audio |
|
||||||
| Publish Events | 3 | Deployment history |
|
| Publish Events | 3 | Deployment history |
|
||||||
| PWA Caches | 3 | Offline cache configs |
|
| PWA Caches | 3 | Offline cache configs |
|
||||||
| Access Logs | 3 | Visitor tracking |
|
| Access Logs | 3 | Visitor tracking |
|
||||||
|
|
||||||
#### Sample Projects
|
#### Sample Projects
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const ProjectsData = [
|
const ProjectsData = [
|
||||||
{
|
{
|
||||||
@ -293,6 +318,7 @@ const ProjectsData = [
|
|||||||
```
|
```
|
||||||
|
|
||||||
#### Association Helper Pattern
|
#### Association Helper Pattern
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Associates records after bulk creation using Sequelize model methods
|
// Associates records after bulk creation using Sequelize model methods
|
||||||
async function associateAssetWithProject() {
|
async function associateAssetWithProject() {
|
||||||
@ -314,6 +340,7 @@ async function associateAssetWithProject() {
|
|||||||
## Seeder Patterns
|
## Seeder Patterns
|
||||||
|
|
||||||
### 1. bulkInsert Pattern
|
### 1. bulkInsert Pattern
|
||||||
|
|
||||||
**Purpose:** Insert multiple records efficiently.
|
**Purpose:** Insert multiple records efficiently.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -329,6 +356,7 @@ await queryInterface.bulkInsert('tableName', [
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 2. bulkDelete Pattern
|
### 2. bulkDelete Pattern
|
||||||
|
|
||||||
**Purpose:** Remove seeded data during rollback.
|
**Purpose:** Remove seeded data during rollback.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -340,6 +368,7 @@ async down(queryInterface, Sequelize) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 3. Conditional Execution Pattern
|
### 3. Conditional Execution Pattern
|
||||||
|
|
||||||
**Purpose:** Enable/disable seeders based on environment.
|
**Purpose:** Enable/disable seeders based on environment.
|
||||||
|
|
||||||
Seeder-only environment gates are intentionally allowed to read `process.env`
|
Seeder-only environment gates are intentionally allowed to read `process.env`
|
||||||
@ -354,13 +383,14 @@ records, or sample-data entities.
|
|||||||
```javascript
|
```javascript
|
||||||
up: async () => {
|
up: async () => {
|
||||||
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
|
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
|
||||||
return; // Skip seeding
|
return; // Skip seeding
|
||||||
}
|
}
|
||||||
// ... proceed with seeding
|
// ... proceed with seeding
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
### 4. ID Consistency Pattern
|
### 4. ID Consistency Pattern
|
||||||
|
|
||||||
**Purpose:** Use deterministic IDs for reliable down() migrations.
|
**Purpose:** Use deterministic IDs for reliable down() migrations.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -381,6 +411,7 @@ function getId(key) {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 5. Model-Based Association Pattern
|
### 5. Model-Based Association Pattern
|
||||||
|
|
||||||
**Purpose:** Create relationships using Sequelize models after bulk insert.
|
**Purpose:** Create relationships using Sequelize models after bulk insert.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -391,6 +422,7 @@ await asset.setProject(project);
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 6. Raw SQL Pattern
|
### 6. Raw SQL Pattern
|
||||||
|
|
||||||
**Purpose:** Create structures not managed by Sequelize models.
|
**Purpose:** Create structures not managed by Sequelize models.
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
@ -403,7 +435,7 @@ await queryInterface.sequelize.query(`
|
|||||||
|
|
||||||
// Create indexes
|
// Create indexes
|
||||||
await queryInterface.sequelize.query(
|
await queryInterface.sequelize.query(
|
||||||
'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");'
|
'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");',
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -448,16 +480,21 @@ npm run db:seed
|
|||||||
## Best Practices
|
## Best Practices
|
||||||
|
|
||||||
### 1. Use Consistent IDs
|
### 1. Use Consistent IDs
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Good - allows rollback
|
// Good - allows rollback
|
||||||
const ids = ['uuid-1', 'uuid-2'];
|
const ids = ['uuid-1', 'uuid-2'];
|
||||||
await queryInterface.bulkInsert('table', records.map((r, i) => ({ id: ids[i], ...r })));
|
await queryInterface.bulkInsert(
|
||||||
|
'table',
|
||||||
|
records.map((r, i) => ({ id: ids[i], ...r })),
|
||||||
|
);
|
||||||
|
|
||||||
// Down migration can target specific IDs
|
// Down migration can target specific IDs
|
||||||
await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
|
await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
|
||||||
```
|
```
|
||||||
|
|
||||||
### 2. Always Include Timestamps
|
### 2. Always Include Timestamps
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
{
|
{
|
||||||
field: 'value',
|
field: 'value',
|
||||||
@ -467,6 +504,7 @@ await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 3. Handle Errors
|
### 3. Handle Errors
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
try {
|
try {
|
||||||
await queryInterface.bulkInsert('users', [...]);
|
await queryInterface.bulkInsert('users', [...]);
|
||||||
@ -477,6 +515,7 @@ try {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 4. Environment-Aware Seeding
|
### 4. Environment-Aware Seeding
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Production - only essential data
|
// Production - only essential data
|
||||||
// Development - include sample data
|
// Development - include sample data
|
||||||
@ -486,6 +525,7 @@ if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### 5. Idempotent Where Possible
|
### 5. Idempotent Where Possible
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Use IF NOT EXISTS for table/index creation
|
// Use IF NOT EXISTS for table/index creation
|
||||||
await queryInterface.sequelize.query(`
|
await queryInterface.sequelize.query(`
|
||||||
@ -523,23 +563,27 @@ sample-data.ts
|
|||||||
## Running Seeders
|
## Running Seeders
|
||||||
|
|
||||||
### Development Setup
|
### Development Setup
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
cd backend
|
cd backend
|
||||||
npm run db:seed
|
npm run db:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
### With Sample Data
|
### With Sample Data
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
export ENABLE_SAMPLE_DATA=true
|
export ENABLE_SAMPLE_DATA=true
|
||||||
npm run db:seed
|
npm run db:seed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Fresh Database
|
### Fresh Database
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:reset # drop, create, migrate, seed
|
npm run db:reset # drop, create, migrate, seed
|
||||||
```
|
```
|
||||||
|
|
||||||
### Undo Seeders
|
### Undo Seeders
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run db:seed:undo # Runs all down() methods in reverse order
|
npm run db:seed:undo # Runs all down() methods in reverse order
|
||||||
```
|
```
|
||||||
@ -548,22 +592,22 @@ npm run db:seed:undo # Runs all down() methods in reverse order
|
|||||||
|
|
||||||
## Seeder Inventory
|
## Seeder Inventory
|
||||||
|
|
||||||
| # | Timestamp | Name | Records | Required |
|
| # | Timestamp | Name | Records | Required |
|
||||||
|---|-----------|------|---------|----------|
|
| --- | -------------- | ----------- | ----------------------------------- | ----------- |
|
||||||
| 1 | 20200430130759 | admin-user | 3 users | Yes |
|
| 1 | 20200430130759 | admin-user | 3 users | Yes |
|
||||||
| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes |
|
| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes |
|
||||||
| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) |
|
| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Environment Variables
|
## Environment Variables
|
||||||
|
|
||||||
| Variable | Purpose | Default |
|
| Variable | Purpose | Default |
|
||||||
|----------|---------|---------|
|
| -------------------- | ------------------------- | ------------- |
|
||||||
| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` |
|
| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` |
|
||||||
| `ADMIN_EMAIL` | Admin user email | (from config) |
|
| `ADMIN_EMAIL` | Admin user email | (from config) |
|
||||||
| `ADMIN_PASS` | Admin user password | (from config) |
|
| `ADMIN_PASS` | Admin user password | (from config) |
|
||||||
| `USER_PASS` | Default user password | (from config) |
|
| `USER_PASS` | Default user password | (from config) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -129,13 +129,13 @@ export default class EmailSender {
|
|||||||
|
|
||||||
**Key Methods:**
|
**Key Methods:**
|
||||||
|
|
||||||
| Method | Type | Description |
|
| Method | Type | Description |
|
||||||
|--------|------|-------------|
|
| -------------------- | ------------- | -------------------------------- |
|
||||||
| `constructor(email)` | Instance | Accepts email template object |
|
| `constructor(email)` | Instance | Accepts email template object |
|
||||||
| `send()` | Async | Sends email via Nodemailer |
|
| `send()` | Async | Sends email via Nodemailer |
|
||||||
| `isConfigured` | Static getter | Checks if SMTP credentials exist |
|
| `isConfigured` | Static getter | Checks if SMTP credentials exist |
|
||||||
| `transportConfig` | Getter | Returns SMTP config |
|
| `transportConfig` | Getter | Returns SMTP config |
|
||||||
| `from` | Getter | Returns sender address |
|
| `from` | Getter | Returns sender address |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -167,7 +167,7 @@ export default class PasswordResetEmail implements EmailTemplate {
|
|||||||
get subject() {
|
get subject() {
|
||||||
return getNotification(
|
return getNotification(
|
||||||
'emails.passwordReset.subject',
|
'emails.passwordReset.subject',
|
||||||
getNotification('app.title')
|
getNotification('app.title'),
|
||||||
);
|
);
|
||||||
// → "Reset your password for Tour Builder Platform"
|
// → "Reset your password for Tour Builder Platform"
|
||||||
}
|
}
|
||||||
@ -179,10 +179,11 @@ export default class PasswordResetEmail implements EmailTemplate {
|
|||||||
.replace(/{resetUrl}/g, this.link)
|
.replace(/{resetUrl}/g, this.link)
|
||||||
.replace(/{accountName}/g, this.to);
|
.replace(/{accountName}/g, this.to);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Template Variables:**
|
**Template Variables:**
|
||||||
|
|
||||||
- `{appTitle}` - Application name
|
- `{appTitle}` - Application name
|
||||||
- `{resetUrl}` - Password reset link
|
- `{resetUrl}` - Password reset link
|
||||||
- `{accountName}` - User email address
|
- `{accountName}` - User email address
|
||||||
@ -201,7 +202,7 @@ export default class EmailAddressVerificationEmail implements EmailTemplate {
|
|||||||
get subject() {
|
get subject() {
|
||||||
return getNotification(
|
return getNotification(
|
||||||
'emails.emailAddressVerification.subject',
|
'emails.emailAddressVerification.subject',
|
||||||
getNotification('app.title')
|
getNotification('app.title'),
|
||||||
);
|
);
|
||||||
// → "Verify your email for Tour Builder Platform"
|
// → "Verify your email for Tour Builder Platform"
|
||||||
}
|
}
|
||||||
@ -213,10 +214,11 @@ export default class EmailAddressVerificationEmail implements EmailTemplate {
|
|||||||
.replace(/{signupUrl}/g, this.link)
|
.replace(/{signupUrl}/g, this.link)
|
||||||
.replace(/{to}/g, this.to);
|
.replace(/{to}/g, this.to);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Template Variables:**
|
**Template Variables:**
|
||||||
|
|
||||||
- `{appTitle}` - Application name
|
- `{appTitle}` - Application name
|
||||||
- `{signupUrl}` - Email verification link
|
- `{signupUrl}` - Email verification link
|
||||||
- `{to}` - User email address
|
- `{to}` - User email address
|
||||||
@ -235,7 +237,7 @@ export default class InvitationEmail implements EmailTemplate {
|
|||||||
get subject() {
|
get subject() {
|
||||||
return getNotification(
|
return getNotification(
|
||||||
'emails.invitation.subject',
|
'emails.invitation.subject',
|
||||||
getNotification('app.title')
|
getNotification('app.title'),
|
||||||
);
|
);
|
||||||
// → "You've been invited to Tour Builder Platform"
|
// → "You've been invited to Tour Builder Platform"
|
||||||
}
|
}
|
||||||
@ -248,10 +250,11 @@ export default class InvitationEmail implements EmailTemplate {
|
|||||||
.replace(/{signupUrl}/g, signupUrl)
|
.replace(/{signupUrl}/g, signupUrl)
|
||||||
.replace(/{to}/g, this.to);
|
.replace(/{to}/g, this.to);
|
||||||
}
|
}
|
||||||
};
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Template Variables:**
|
**Template Variables:**
|
||||||
|
|
||||||
- `{appTitle}` - Application name
|
- `{appTitle}` - Application name
|
||||||
- `{signupUrl}` - Account setup link with `&invitation=true`
|
- `{signupUrl}` - Account setup link with `&invitation=true`
|
||||||
- `{to}` - User email address
|
- `{to}` - User email address
|
||||||
@ -267,66 +270,66 @@ All HTML templates follow consistent styling:
|
|||||||
```html
|
```html
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
.email-container {
|
.email-container {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.email-header {
|
.email-header {
|
||||||
background-color: #3498db; /* Primary blue */
|
background-color: #3498db; /* Primary blue */
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.email-body {
|
.email-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.email-footer {
|
.email-footer {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: #f7fafc;
|
background-color: #f7fafc;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.link-primary {
|
.link-primary {
|
||||||
color: #3498db;
|
color: #3498db;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: #3498db;
|
background-color: #3498db;
|
||||||
color: #fff !important;
|
color: #fff !important;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="email-container">
|
<div class="email-container">
|
||||||
<div class="email-header">...</div>
|
<div class="email-header">...</div>
|
||||||
<div class="email-body">...</div>
|
<div class="email-body">...</div>
|
||||||
<div class="email-footer">
|
<div class="email-footer">
|
||||||
Thanks,<br/>
|
Thanks,<br />
|
||||||
The {appTitle} Team
|
The {appTitle} Team
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
```
|
```
|
||||||
|
|
||||||
### Template Comparison
|
### Template Comparison
|
||||||
|
|
||||||
| Template | Header Text | Call-to-Action | Button Style |
|
| Template | Header Text | Call-to-Action | Button Style |
|
||||||
|----------|-------------|----------------|--------------|
|
| ------------------ | ------------------------------------ | -------------- | -------------- |
|
||||||
| Password Reset | "Reset your password for {appTitle}" | Link | Text link |
|
| Password Reset | "Reset your password for {appTitle}" | Link | Text link |
|
||||||
| Email Verification | "Verify your email for {appTitle}!" | Link | Text link |
|
| Email Verification | "Verify your email for {appTitle}!" | Link | Text link |
|
||||||
| Invitation | "Welcome to {appTitle}!" | Button | Primary button |
|
| Invitation | "Welcome to {appTitle}!" | Button | Primary button |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -351,11 +354,11 @@ email: {
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Required | Description |
|
| Variable | Required | Description |
|
||||||
|----------|----------|-------------|
|
| ------------------------------- | -------- | ----------------------------------------- |
|
||||||
| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) |
|
| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) |
|
||||||
| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) |
|
| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) |
|
||||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification |
|
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification |
|
||||||
|
|
||||||
### AWS SES Configuration
|
### AWS SES Configuration
|
||||||
|
|
||||||
@ -397,9 +400,10 @@ class Auth {
|
|||||||
const token = await UsersDBApi.generatePasswordResetToken(email);
|
const token = await UsersDBApi.generatePasswordResetToken(email);
|
||||||
const link = `${host}/password-reset?token=${token}`;
|
const link = `${host}/password-reset?token=${token}`;
|
||||||
|
|
||||||
const emailObj = type === 'invitation'
|
const emailObj =
|
||||||
? new InvitationEmail({ to: email, host: link })
|
type === 'invitation'
|
||||||
: new PasswordResetEmail({ to: email, link });
|
? new InvitationEmail({ to: email, host: link })
|
||||||
|
: new PasswordResetEmail({ to: email, link });
|
||||||
|
|
||||||
return new EmailSender(emailObj).send();
|
return new EmailSender(emailObj).send();
|
||||||
}
|
}
|
||||||
@ -445,10 +449,14 @@ router.get('/email-configured', (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Resend verification email (authenticated)
|
// Resend verification email (authenticated)
|
||||||
router.put('/send-email-address-verification-email', jwtAuth, async (req, res) => {
|
router.put(
|
||||||
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
|
'/send-email-address-verification-email',
|
||||||
res.status(200).send(true);
|
jwtAuth,
|
||||||
});
|
async (req, res) => {
|
||||||
|
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
|
||||||
|
res.status(200).send(true);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Request password reset (public)
|
// Request password reset (public)
|
||||||
router.put('/send-password-reset-email', async (req, res) => {
|
router.put('/send-password-reset-email', async (req, res) => {
|
||||||
@ -542,10 +550,10 @@ static async markEmailVerified(id, options) {
|
|||||||
|
|
||||||
### Token Properties
|
### Token Properties
|
||||||
|
|
||||||
| Token Type | Field | Expiry Field | TTL |
|
| Token Type | Field | Expiry Field | TTL |
|
||||||
|------------|-------|--------------|-----|
|
| ------------------ | ------------------------ | --------------------------------- | -------- |
|
||||||
| Email Verification | `emailVerificationToken` | `emailVerificationTokenExpiresAt` | 24 hours |
|
| Email Verification | `emailVerificationToken` | `emailVerificationTokenExpiresAt` | 24 hours |
|
||||||
| Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours |
|
| Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -670,6 +678,7 @@ static async markEmailVerified(id, options) {
|
|||||||
### Email Configured Mode
|
### Email Configured Mode
|
||||||
|
|
||||||
When `EMAIL_USER` and `EMAIL_PASS` are set:
|
When `EMAIL_USER` and `EMAIL_PASS` are set:
|
||||||
|
|
||||||
- Email verification required before login
|
- Email verification required before login
|
||||||
- Password reset emails sent on request
|
- Password reset emails sent on request
|
||||||
- User invitations include email
|
- User invitations include email
|
||||||
@ -677,6 +686,7 @@ When `EMAIL_USER` and `EMAIL_PASS` are set:
|
|||||||
### Email Not Configured Mode
|
### Email Not Configured Mode
|
||||||
|
|
||||||
When credentials are missing:
|
When credentials are missing:
|
||||||
|
|
||||||
- `EmailSender.isConfigured` returns `false`
|
- `EmailSender.isConfigured` returns `false`
|
||||||
- Users auto-verified on signin: `user.emailVerified = true`
|
- Users auto-verified on signin: `user.emailVerified = true`
|
||||||
- Password reset/invitation silently skipped
|
- Password reset/invitation silently skipped
|
||||||
@ -746,13 +756,13 @@ emails: {
|
|||||||
|
|
||||||
### Email-Related Errors
|
### Email-Related Errors
|
||||||
|
|
||||||
| Error Code | Message | When Thrown |
|
| Error Code | Message | When Thrown |
|
||||||
|------------|---------|-------------|
|
| ------------------------------------------------- | --------------------------------------------------- | ------------------------------------- |
|
||||||
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | Token generation fails |
|
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | Token generation fails |
|
||||||
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | Invalid/expired verification token |
|
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | Invalid/expired verification token |
|
||||||
| `auth.passwordReset.error` | "Email not recognized" | Password reset token generation fails |
|
| `auth.passwordReset.error` | "Email not recognized" | Password reset token generation fails |
|
||||||
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | Invalid/expired reset token |
|
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | Invalid/expired reset token |
|
||||||
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification |
|
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification |
|
||||||
|
|
||||||
### Error Flow
|
### Error Flow
|
||||||
|
|
||||||
@ -827,7 +837,7 @@ describe('EmailSender', () => {
|
|||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
to: 'test@example.com',
|
to: 'test@example.com',
|
||||||
subject: expect.stringContaining('Reset your password'),
|
subject: expect.stringContaining('Reset your password'),
|
||||||
})
|
}),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@ -843,6 +853,7 @@ EMAIL_PASS=
|
|||||||
```
|
```
|
||||||
|
|
||||||
Result:
|
Result:
|
||||||
|
|
||||||
- `EmailSender.isConfigured` returns `false`
|
- `EmailSender.isConfigured` returns `false`
|
||||||
- Users auto-verified on login
|
- Users auto-verified on login
|
||||||
- No emails sent
|
- No emails sent
|
||||||
@ -859,19 +870,21 @@ Result:
|
|||||||
<!-- services/email/htmlTemplates/welcome/welcomeEmail.html -->
|
<!-- services/email/htmlTemplates/welcome/welcomeEmail.html -->
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>/* Same styles as other templates */</style>
|
<style>
|
||||||
</head>
|
/* Same styles as other templates */
|
||||||
<body>
|
</style>
|
||||||
<div class="email-container">
|
</head>
|
||||||
<div class="email-header">Welcome to {appTitle}!</div>
|
<body>
|
||||||
<div class="email-body">
|
<div class="email-container">
|
||||||
|
<div class="email-header">Welcome to {appTitle}!</div>
|
||||||
|
<div class="email-body">
|
||||||
<p>Hello {userName},</p>
|
<p>Hello {userName},</p>
|
||||||
<p>Your account has been activated.</p>
|
<p>Your account has been activated.</p>
|
||||||
|
</div>
|
||||||
|
<div class="email-footer">Thanks,<br />The {appTitle} Team</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-footer">Thanks,<br/>The {appTitle} Team</div>
|
</body>
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>
|
</html>
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -944,12 +957,12 @@ await new EmailSender(email).send();
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
| Package | Version | Purpose |
|
| Package | Version | Purpose |
|
||||||
|---------|---------|---------|
|
| ------------- | -------- | ------------------------ |
|
||||||
| `nodemailer` | ^6.x | SMTP transport |
|
| `nodemailer` | ^6.x | SMTP transport |
|
||||||
| `assert` | built-in | Input validation |
|
| `assert` | built-in | Input validation |
|
||||||
| `fs.promises` | built-in | Template file reading |
|
| `fs.promises` | built-in | Template file reading |
|
||||||
| `path` | built-in | Template path resolution |
|
| `path` | built-in | Template path resolution |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -7,9 +7,10 @@ The Factories module provides code generation patterns that eliminate boilerplat
|
|||||||
**Location:** `backend/src/factories/`
|
**Location:** `backend/src/factories/`
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
| File | Purpose | LOC |
|
|
||||||
|------|---------|-----|
|
| File | Purpose | LOC |
|
||||||
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 |
|
| -------------------- | --------------------------------------------------- | --- |
|
||||||
|
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 |
|
||||||
| `service.factory.ts` | Generates service classes with transaction handling | 350 |
|
| `service.factory.ts` | Generates service classes with transaction handling | 350 |
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -63,34 +64,34 @@ function createEntityRouter(entityName, Service, DBApi, options = {})
|
|||||||
|
|
||||||
#### Parameters
|
#### Parameters
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
|-----------|------|-------------|
|
| ------------ | -------- | ----------------------------------------- |
|
||||||
| `entityName` | `string` | Entity name for routes and permissions |
|
| `entityName` | `string` | Entity name for routes and permissions |
|
||||||
| `Service` | `class` | Service class with CRUD methods |
|
| `Service` | `class` | Service class with CRUD methods |
|
||||||
| `DBApi` | `class` | Database API class extending GenericDBApi |
|
| `DBApi` | `class` | Database API class extending GenericDBApi |
|
||||||
| `options` | `object` | Configuration options |
|
| `options` | `object` | Configuration options |
|
||||||
|
|
||||||
#### Options
|
#### Options
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| ------------------ | ---------- | ------------------ | ------------------------------- |
|
||||||
| `permissionEntity` | `string` | `entityName` | Override permission entity name |
|
| `permissionEntity` | `string` | `entityName` | Override permission entity name |
|
||||||
| `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export |
|
| `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export |
|
||||||
| `customRoutes` | `function` | `null` | Callback to add custom routes |
|
| `customRoutes` | `function` | `null` | Callback to add custom routes |
|
||||||
|
|
||||||
#### Generated Endpoints
|
#### Generated Endpoints
|
||||||
|
|
||||||
| Method | Endpoint | Description |
|
| Method | Endpoint | Description |
|
||||||
|--------|----------|-------------|
|
| -------- | --------------- | ------------------------------------------- |
|
||||||
| `POST` | `/` | Create new record |
|
| `POST` | `/` | Create new record |
|
||||||
| `POST` | `/bulk-import` | Bulk import from CSV |
|
| `POST` | `/bulk-import` | Bulk import from CSV |
|
||||||
| `PUT` | `/:id` | Update record by ID |
|
| `PUT` | `/:id` | Update record by ID |
|
||||||
| `DELETE` | `/:id` | Delete record by ID |
|
| `DELETE` | `/:id` | Delete record by ID |
|
||||||
| `POST` | `/deleteByIds` | Delete multiple records |
|
| `POST` | `/deleteByIds` | Delete multiple records |
|
||||||
| `GET` | `/` | List all records (with filters, pagination) |
|
| `GET` | `/` | List all records (with filters, pagination) |
|
||||||
| `GET` | `/count` | Get record count |
|
| `GET` | `/count` | Get record count |
|
||||||
| `GET` | `/autocomplete` | Get autocomplete suggestions |
|
| `GET` | `/autocomplete` | Get autocomplete suggestions |
|
||||||
| `GET` | `/:id` | Get single record by ID |
|
| `GET` | `/:id` | Get single record by ID |
|
||||||
|
|
||||||
#### Implementation
|
#### Implementation
|
||||||
|
|
||||||
@ -108,111 +109,153 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
|||||||
router.use(checkCrudPermissions(permissionEntity));
|
router.use(checkCrudPermissions(permissionEntity));
|
||||||
|
|
||||||
// POST / - Create
|
// POST / - Create
|
||||||
router.post('/', wrapAsync(async (req, res) => {
|
router.post(
|
||||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
'/',
|
||||||
const link = new URL(referer);
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await Service.create({
|
const referer =
|
||||||
data: req.body.data,
|
req.headers.referer ||
|
||||||
currentUser: req.currentUser,
|
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||||
runtimeContext: req.runtimeContext,
|
const link = new URL(referer);
|
||||||
sendInvitationEmails: true,
|
const payload = await Service.create({
|
||||||
host: link.host,
|
data: req.body.data,
|
||||||
});
|
currentUser: req.currentUser,
|
||||||
res.status(200).send(payload);
|
runtimeContext: req.runtimeContext,
|
||||||
}));
|
sendInvitationEmails: true,
|
||||||
|
host: link.host,
|
||||||
|
});
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// POST /bulk-import - Bulk CSV import
|
// POST /bulk-import - Bulk CSV import
|
||||||
router.post('/bulk-import', wrapAsync(async (req, res) => {
|
router.post(
|
||||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
'/bulk-import',
|
||||||
const link = new URL(referer);
|
wrapAsync(async (req, res) => {
|
||||||
await Service.bulkImport(req, res, true, link.host);
|
const referer =
|
||||||
res.status(200).send(true);
|
req.headers.referer ||
|
||||||
}));
|
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||||
|
const link = new URL(referer);
|
||||||
|
await Service.bulkImport(req, res, true, link.host);
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// PUT /:id - Update
|
// PUT /:id - Update
|
||||||
router.put('/:id', wrapAsync(async (req, res) => {
|
router.put(
|
||||||
assertRouteIdMatchesBody(req);
|
'/:id',
|
||||||
await Service.update({
|
wrapAsync(async (req, res) => {
|
||||||
id: req.params.id,
|
assertRouteIdMatchesBody(req);
|
||||||
data: req.body.data,
|
await Service.update({
|
||||||
currentUser: req.currentUser,
|
id: req.params.id,
|
||||||
runtimeContext: req.runtimeContext,
|
data: req.body.data,
|
||||||
});
|
currentUser: req.currentUser,
|
||||||
res.status(200).send(true);
|
runtimeContext: req.runtimeContext,
|
||||||
}));
|
});
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// DELETE /:id - Delete single
|
// DELETE /:id - Delete single
|
||||||
router.delete('/:id', wrapAsync(async (req, res) => {
|
router.delete(
|
||||||
await Service.remove({
|
'/:id',
|
||||||
id: req.params.id,
|
wrapAsync(async (req, res) => {
|
||||||
currentUser: req.currentUser,
|
await Service.remove({
|
||||||
runtimeContext: req.runtimeContext,
|
id: req.params.id,
|
||||||
});
|
currentUser: req.currentUser,
|
||||||
res.status(200).send(true);
|
runtimeContext: req.runtimeContext,
|
||||||
}));
|
});
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// POST /deleteByIds - Delete multiple
|
// POST /deleteByIds - Delete multiple
|
||||||
router.post('/deleteByIds', wrapAsync(async (req, res) => {
|
router.post(
|
||||||
await Service.deleteByIds({
|
'/deleteByIds',
|
||||||
ids: req.body.data,
|
wrapAsync(async (req, res) => {
|
||||||
currentUser: req.currentUser,
|
await Service.deleteByIds({
|
||||||
runtimeContext: req.runtimeContext,
|
ids: req.body.data,
|
||||||
});
|
currentUser: req.currentUser,
|
||||||
res.status(200).send(true);
|
runtimeContext: req.runtimeContext,
|
||||||
}));
|
});
|
||||||
|
res.status(200).send(true);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// GET / - List all with optional CSV export
|
// GET / - List all with optional CSV export
|
||||||
router.get('/', wrapAsync(async (req, res) => {
|
router.get(
|
||||||
const filetype = req.query.filetype;
|
'/',
|
||||||
const currentUser = req.currentUser;
|
wrapAsync(async (req, res) => {
|
||||||
const runtimeContext = req.runtimeContext;
|
const filetype = req.query.filetype;
|
||||||
|
const currentUser = req.currentUser;
|
||||||
|
const runtimeContext = req.runtimeContext;
|
||||||
|
|
||||||
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi, {
|
const payload = await DBApi.findAll(
|
||||||
csv: filetype === 'csv',
|
normalizeQuery(req.query, DBApi, {
|
||||||
}), { currentUser, runtimeContext });
|
csv: filetype === 'csv',
|
||||||
|
}),
|
||||||
|
{ currentUser, runtimeContext },
|
||||||
|
);
|
||||||
|
|
||||||
if (filetype === 'csv') {
|
if (filetype === 'csv') {
|
||||||
const fields = options.csvFields || DBApi.CSV_FIELDS || ['id', 'createdAt'];
|
const fields = options.csvFields ||
|
||||||
const opts = { fields };
|
DBApi.CSV_FIELDS || ['id', 'createdAt'];
|
||||||
try {
|
const opts = { fields };
|
||||||
const csv = parse(payload.rows, opts);
|
try {
|
||||||
res.status(200).attachment('export.csv').send(csv);
|
const csv = parse(payload.rows, opts);
|
||||||
} catch (err) {
|
res.status(200).attachment('export.csv').send(csv);
|
||||||
logger.error({ err, entityName }, 'CSV export error');
|
} catch (err) {
|
||||||
res.status(500).send('CSV export error');
|
logger.error({ err, entityName }, 'CSV export error');
|
||||||
|
res.status(500).send('CSV export error');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
res.status(200).send(payload);
|
||||||
}
|
}
|
||||||
} else {
|
}),
|
||||||
res.status(200).send(payload);
|
);
|
||||||
}
|
|
||||||
}));
|
|
||||||
|
|
||||||
// GET /count - Count only
|
// GET /count - Count only
|
||||||
router.get('/count', wrapAsync(async (req, res) => {
|
router.get(
|
||||||
const currentUser = req.currentUser;
|
'/count',
|
||||||
const runtimeContext = req.runtimeContext;
|
wrapAsync(async (req, res) => {
|
||||||
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), { countOnly: true, currentUser, runtimeContext });
|
const currentUser = req.currentUser;
|
||||||
res.status(200).send(payload);
|
const runtimeContext = req.runtimeContext;
|
||||||
}));
|
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), {
|
||||||
|
countOnly: true,
|
||||||
|
currentUser,
|
||||||
|
runtimeContext,
|
||||||
|
});
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// GET /autocomplete - Autocomplete search
|
// GET /autocomplete - Autocomplete search
|
||||||
router.get('/autocomplete', wrapAsync(async (req, res) => {
|
router.get(
|
||||||
const payload = await DBApi.findAllAutocomplete({
|
'/autocomplete',
|
||||||
query: req.query.query,
|
wrapAsync(async (req, res) => {
|
||||||
limit,
|
const payload = await DBApi.findAllAutocomplete({
|
||||||
offset: req.query.offset,
|
query: req.query.query,
|
||||||
});
|
limit,
|
||||||
res.status(200).send(payload);
|
offset: req.query.offset,
|
||||||
}));
|
});
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// GET /:id - Find by ID
|
// GET /:id - Find by ID
|
||||||
router.get('/:id', wrapAsync(async (req, res) => {
|
router.get(
|
||||||
if (!isUuidV4(req.params.id)) {
|
'/:id',
|
||||||
return res.status(400).send(`Invalid ${entityName} id`);
|
wrapAsync(async (req, res) => {
|
||||||
}
|
if (!isUuidV4(req.params.id)) {
|
||||||
const runtimeContext = req.runtimeContext;
|
return res.status(400).send(`Invalid ${entityName} id`);
|
||||||
const payload = await DBApi.findBy({ id: req.params.id }, { runtimeContext });
|
}
|
||||||
res.status(200).send(payload);
|
const runtimeContext = req.runtimeContext;
|
||||||
}));
|
const payload = await DBApi.findBy(
|
||||||
|
{ id: req.params.id },
|
||||||
|
{ runtimeContext },
|
||||||
|
);
|
||||||
|
res.status(200).send(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Custom routes hook
|
// Custom routes hook
|
||||||
if (options.customRoutes) {
|
if (options.customRoutes) {
|
||||||
@ -241,10 +284,10 @@ Generic CRUD query safety:
|
|||||||
|
|
||||||
#### Exports
|
#### Exports
|
||||||
|
|
||||||
| Export | Type | Description |
|
| Export | Type | Description |
|
||||||
|--------|------|-------------|
|
| -------------------- | ---------- | ---------------------- |
|
||||||
| `createEntityRouter` | `function` | Factory function |
|
| `createEntityRouter` | `function` | Factory function |
|
||||||
| `isUuidV4` | `function` | UUID validation helper |
|
| `isUuidV4` | `function` | UUID validation helper |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -262,26 +305,26 @@ function createEntityService(DBApi, options = {})
|
|||||||
|
|
||||||
#### Parameters
|
#### Parameters
|
||||||
|
|
||||||
| Parameter | Type | Description |
|
| Parameter | Type | Description |
|
||||||
|-----------|------|-------------|
|
| --------- | -------- | ----------------------------------------- |
|
||||||
| `DBApi` | `class` | Database API class extending GenericDBApi |
|
| `DBApi` | `class` | Database API class extending GenericDBApi |
|
||||||
| `options` | `object` | Configuration options |
|
| `options` | `object` | Configuration options |
|
||||||
|
|
||||||
#### Options
|
#### Options
|
||||||
|
|
||||||
| Option | Type | Default | Description |
|
| Option | Type | Default | Description |
|
||||||
|--------|------|---------|-------------|
|
| ------------ | -------- | ---------- | --------------------------- |
|
||||||
| `entityName` | `string` | `'Entity'` | Name used in error messages |
|
| `entityName` | `string` | `'Entity'` | Name used in error messages |
|
||||||
|
|
||||||
#### Generated Methods
|
#### Generated Methods
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
| ---------------------------------------------------------------- | ------------------------------------- |
|
||||||
| `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction |
|
| `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction |
|
||||||
| `bulkImport(req, res)` | Bulk import from CSV with transaction |
|
| `bulkImport(req, res)` | Bulk import from CSV with transaction |
|
||||||
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction |
|
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction |
|
||||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction |
|
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction |
|
||||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction |
|
| `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction |
|
||||||
|
|
||||||
#### Implementation
|
#### Implementation
|
||||||
|
|
||||||
@ -296,11 +339,22 @@ function createEntityService(DBApi, options = {}) {
|
|||||||
const entityName = options.entityName || 'Entity';
|
const entityName = options.entityName || 'Entity';
|
||||||
|
|
||||||
return class GenericService {
|
return class GenericService {
|
||||||
static async create({ data, currentUser, transaction: externalTransaction, runtimeContext }) {
|
static async create({
|
||||||
const transaction = externalTransaction || await db.sequelize.transaction();
|
data,
|
||||||
|
currentUser,
|
||||||
|
transaction: externalTransaction,
|
||||||
|
runtimeContext,
|
||||||
|
}) {
|
||||||
|
const transaction =
|
||||||
|
externalTransaction || (await db.sequelize.transaction());
|
||||||
const ownsTransaction = !externalTransaction;
|
const ownsTransaction = !externalTransaction;
|
||||||
try {
|
try {
|
||||||
const record = await DBApi.create({ data, currentUser, transaction, runtimeContext });
|
const record = await DBApi.create({
|
||||||
|
data,
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
runtimeContext,
|
||||||
|
});
|
||||||
if (ownsTransaction) await transaction.commit();
|
if (ownsTransaction) await transaction.commit();
|
||||||
return record;
|
return record;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -340,17 +394,33 @@ function createEntityService(DBApi, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async update({ id, data, currentUser, transaction: externalTransaction, runtimeContext }) {
|
static async update({
|
||||||
const transaction = externalTransaction || await db.sequelize.transaction();
|
id,
|
||||||
|
data,
|
||||||
|
currentUser,
|
||||||
|
transaction: externalTransaction,
|
||||||
|
runtimeContext,
|
||||||
|
}) {
|
||||||
|
const transaction =
|
||||||
|
externalTransaction || (await db.sequelize.transaction());
|
||||||
const ownsTransaction = !externalTransaction;
|
const ownsTransaction = !externalTransaction;
|
||||||
try {
|
try {
|
||||||
const record = await DBApi.findBy({ id }, { transaction, runtimeContext });
|
const record = await DBApi.findBy(
|
||||||
|
{ id },
|
||||||
|
{ transaction, runtimeContext },
|
||||||
|
);
|
||||||
|
|
||||||
if (!record) {
|
if (!record) {
|
||||||
throw new ValidationError(`${entityName}NotFound`);
|
throw new ValidationError(`${entityName}NotFound`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated = await DBApi.update({ id, data, currentUser, transaction, runtimeContext });
|
const updated = await DBApi.update({
|
||||||
|
id,
|
||||||
|
data,
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
runtimeContext,
|
||||||
|
});
|
||||||
if (ownsTransaction) await transaction.commit();
|
if (ownsTransaction) await transaction.commit();
|
||||||
return updated;
|
return updated;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@ -359,11 +429,22 @@ function createEntityService(DBApi, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async deleteByIds({ ids, currentUser, transaction: externalTransaction, runtimeContext }) {
|
static async deleteByIds({
|
||||||
const transaction = externalTransaction || await db.sequelize.transaction();
|
ids,
|
||||||
|
currentUser,
|
||||||
|
transaction: externalTransaction,
|
||||||
|
runtimeContext,
|
||||||
|
}) {
|
||||||
|
const transaction =
|
||||||
|
externalTransaction || (await db.sequelize.transaction());
|
||||||
const ownsTransaction = !externalTransaction;
|
const ownsTransaction = !externalTransaction;
|
||||||
try {
|
try {
|
||||||
await DBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext });
|
await DBApi.deleteByIds({
|
||||||
|
ids,
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
runtimeContext,
|
||||||
|
});
|
||||||
if (ownsTransaction) await transaction.commit();
|
if (ownsTransaction) await transaction.commit();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (ownsTransaction) await transaction.rollback();
|
if (ownsTransaction) await transaction.rollback();
|
||||||
@ -371,8 +452,14 @@ function createEntityService(DBApi, options = {}) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static async remove({ id, currentUser, transaction: externalTransaction, runtimeContext }) {
|
static async remove({
|
||||||
const transaction = externalTransaction || await db.sequelize.transaction();
|
id,
|
||||||
|
currentUser,
|
||||||
|
transaction: externalTransaction,
|
||||||
|
runtimeContext,
|
||||||
|
}) {
|
||||||
|
const transaction =
|
||||||
|
externalTransaction || (await db.sequelize.transaction());
|
||||||
const ownsTransaction = !externalTransaction;
|
const ownsTransaction = !externalTransaction;
|
||||||
try {
|
try {
|
||||||
await DBApi.remove({ id, currentUser, transaction, runtimeContext });
|
await DBApi.remove({ id, currentUser, transaction, runtimeContext });
|
||||||
@ -444,7 +531,9 @@ module.exports = class Helpers {
|
|||||||
|
|
||||||
// UUID v4 validation
|
// UUID v4 validation
|
||||||
static isUuidV4(value) {
|
static isUuidV4(value) {
|
||||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||||
|
value,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@ -505,37 +594,37 @@ The DB API base class that all entity APIs extend. Provides declarative configur
|
|||||||
|
|
||||||
### Static Getters (Override in Subclasses)
|
### Static Getters (Override in Subclasses)
|
||||||
|
|
||||||
| Getter | Type | Description |
|
| Getter | Type | Description |
|
||||||
|--------|------|-------------|
|
| -------------------- | ---------- | ------------------------------------ |
|
||||||
| `MODEL` | `Model` | Sequelize model reference (required) |
|
| `MODEL` | `Model` | Sequelize model reference (required) |
|
||||||
| `TABLE_NAME` | `string` | Database table name |
|
| `TABLE_NAME` | `string` | Database table name |
|
||||||
| `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) |
|
| `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) |
|
||||||
| `RANGE_FIELDS` | `string[]` | Fields for range filtering |
|
| `RANGE_FIELDS` | `string[]` | Fields for range filtering |
|
||||||
| `ENUM_FIELDS` | `string[]` | Fields for exact match filtering |
|
| `ENUM_FIELDS` | `string[]` | Fields for exact match filtering |
|
||||||
| `RELATION_FILTERS` | `object[]` | Related entity filters |
|
| `RELATION_FILTERS` | `object[]` | Related entity filters |
|
||||||
| `CSV_FIELDS` | `string[]` | Fields for CSV export |
|
| `CSV_FIELDS` | `string[]` | Fields for CSV export |
|
||||||
| `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete |
|
| `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete |
|
||||||
| `ASSOCIATIONS` | `object[]` | Related entity setters |
|
| `ASSOCIATIONS` | `object[]` | Related entity setters |
|
||||||
| `FIND_BY_INCLUDES` | `object[]` | Includes for findBy |
|
| `FIND_BY_INCLUDES` | `object[]` | Includes for findBy |
|
||||||
| `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll |
|
| `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll |
|
||||||
| `JSON_FIELDS` | `string[]` | Fields to auto-stringify |
|
| `JSON_FIELDS` | `string[]` | Fields to auto-stringify |
|
||||||
| `FIELD_TRANSFORMERS` | `object` | Custom field transformers |
|
| `FIELD_TRANSFORMERS` | `object` | Custom field transformers |
|
||||||
| `FIELD_DEFAULTS` | `object` | Default values for fields |
|
| `FIELD_DEFAULTS` | `object` | Default values for fields |
|
||||||
|
|
||||||
### Methods
|
### Methods
|
||||||
|
|
||||||
| Method | Description |
|
| Method | Description |
|
||||||
|--------|-------------|
|
| ---------------------------------------------------------------- | --------------------------------- |
|
||||||
| `getFieldMapping(data)` | Transform input data for database |
|
| `getFieldMapping(data)` | Transform input data for database |
|
||||||
| `create(data, options)` | Create record |
|
| `create(data, options)` | Create record |
|
||||||
| `bulkImport(data, options)` | Bulk create records |
|
| `bulkImport(data, options)` | Bulk create records |
|
||||||
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record |
|
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record |
|
||||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple |
|
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple |
|
||||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single |
|
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single |
|
||||||
| `findBy(where, options)` | Find single by criteria |
|
| `findBy(where, options)` | Find single by criteria |
|
||||||
| `findAll(filter, options)` | Find all with pagination/filters |
|
| `findAll(filter, options)` | Find all with pagination/filters |
|
||||||
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
|
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
|
||||||
| `toCSV(rows)` | Convert to CSV string |
|
| `toCSV(rows)` | Convert to CSV string |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -544,6 +633,7 @@ The DB API base class that all entity APIs extend. Provides declarative configur
|
|||||||
### Basic Entity (Minimal Configuration)
|
### Basic Entity (Minimal Configuration)
|
||||||
|
|
||||||
**Route (assets.ts):**
|
**Route (assets.ts):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import AssetsDBApi from '../db/api/assets.ts';
|
import AssetsDBApi from '../db/api/assets.ts';
|
||||||
import { createEntityRouter } from '../factories/router.factory.ts';
|
import { createEntityRouter } from '../factories/router.factory.ts';
|
||||||
@ -554,6 +644,7 @@ export default createEntityRouter('assets', AssetsService, AssetsDBApi);
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Service (assets.ts):**
|
**Service (assets.ts):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import AssetsDBApi from '../db/api/assets.ts';
|
import AssetsDBApi from '../db/api/assets.ts';
|
||||||
import { createEntityService } from '../factories/service.factory.ts';
|
import { createEntityService } from '../factories/service.factory.ts';
|
||||||
@ -565,6 +656,7 @@ export default createEntityService(AssetsDBApi, {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**DB API (assets.js):**
|
**DB API (assets.js):**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const GenericDBApi = require('./base.api');
|
const GenericDBApi = require('./base.api');
|
||||||
const db = require('../models');
|
const db = require('../models');
|
||||||
@ -607,6 +699,7 @@ module.exports = AssetsDBApi;
|
|||||||
### Entity with Custom Routes
|
### Entity with Custom Routes
|
||||||
|
|
||||||
**Route (project_element_defaults.ts):**
|
**Route (project_element_defaults.ts):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import Service from '../services/project_element_defaults.ts';
|
import Service from '../services/project_element_defaults.ts';
|
||||||
import DBApi from '../db/api/project_element_defaults.ts';
|
import DBApi from '../db/api/project_element_defaults.ts';
|
||||||
@ -618,22 +711,28 @@ const baseRouter = createEntityRouter(
|
|||||||
'project_element_defaults',
|
'project_element_defaults',
|
||||||
Service,
|
Service,
|
||||||
DBApi,
|
DBApi,
|
||||||
{ permissionEntity: 'page_elements' } // Override permission entity
|
{ permissionEntity: 'page_elements' }, // Override permission entity
|
||||||
);
|
);
|
||||||
|
|
||||||
// Add custom endpoint
|
// Add custom endpoint
|
||||||
baseRouter.post('/:id/reset', wrapAsync(async (req, res) => {
|
baseRouter.post(
|
||||||
const payload = await Service.resetToGlobal(req.params.id, {
|
'/:id/reset',
|
||||||
currentUser: req.currentUser,
|
wrapAsync(async (req, res) => {
|
||||||
});
|
const payload = await Service.resetToGlobal(req.params.id, {
|
||||||
res.status(200).json(payload);
|
currentUser: req.currentUser,
|
||||||
}));
|
});
|
||||||
|
res.status(200).json(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Add another custom endpoint
|
// Add another custom endpoint
|
||||||
baseRouter.get('/:id/diff', wrapAsync(async (req, res) => {
|
baseRouter.get(
|
||||||
const payload = await Service.getDiffFromGlobal(req.params.id);
|
'/:id/diff',
|
||||||
res.status(200).json(payload);
|
wrapAsync(async (req, res) => {
|
||||||
}));
|
const payload = await Service.getDiffFromGlobal(req.params.id);
|
||||||
|
res.status(200).json(payload);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
export default baseRouter;
|
export default baseRouter;
|
||||||
```
|
```
|
||||||
@ -641,6 +740,7 @@ export default baseRouter;
|
|||||||
### Service with Extended Methods
|
### Service with Extended Methods
|
||||||
|
|
||||||
**Service (project_element_defaults.ts):**
|
**Service (project_element_defaults.ts):**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import DBApi from '../db/api/project_element_defaults.ts';
|
import DBApi from '../db/api/project_element_defaults.ts';
|
||||||
import { createEntityService } from '../factories/service.factory.ts';
|
import { createEntityService } from '../factories/service.factory.ts';
|
||||||
@ -673,16 +773,22 @@ Alternative approach using the options callback:
|
|||||||
```javascript
|
```javascript
|
||||||
module.exports = createEntityRouter('entities', Service, DBApi, {
|
module.exports = createEntityRouter('entities', Service, DBApi, {
|
||||||
customRoutes: (router, Service, DBApi) => {
|
customRoutes: (router, Service, DBApi) => {
|
||||||
router.post('/:id/custom-action', wrapAsync(async (req, res) => {
|
router.post(
|
||||||
const result = await Service.customAction(req.params.id);
|
'/:id/custom-action',
|
||||||
res.status(200).json(result);
|
wrapAsync(async (req, res) => {
|
||||||
}));
|
const result = await Service.customAction(req.params.id);
|
||||||
|
res.status(200).json(result);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
router.get('/stats', wrapAsync(async (req, res) => {
|
router.get(
|
||||||
const stats = await DBApi.getStatistics();
|
'/stats',
|
||||||
res.status(200).json(stats);
|
wrapAsync(async (req, res) => {
|
||||||
}));
|
const stats = await DBApi.getStatistics();
|
||||||
}
|
res.status(200).json(stats);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -692,50 +798,50 @@ module.exports = createEntityRouter('entities', Service, DBApi, {
|
|||||||
|
|
||||||
### Entities Using Router Factory (13)
|
### Entities Using Router Factory (13)
|
||||||
|
|
||||||
| Entity | Permission Override | Custom Routes |
|
| Entity | Permission Override | Custom Routes |
|
||||||
|--------|--------------------|--------------|
|
| -------------------------- | ------------------- | ----------------- |
|
||||||
| `access_logs` | - | No |
|
| `access_logs` | - | No |
|
||||||
| `asset_variants` | - | No |
|
| `asset_variants` | - | No |
|
||||||
| `assets` | - | No |
|
| `assets` | - | No |
|
||||||
| `element_type_defaults` | - | No |
|
| `element_type_defaults` | - | No |
|
||||||
| `permissions` | - | No |
|
| `permissions` | - | No |
|
||||||
| `presigned_url_requests` | - | No |
|
| `presigned_url_requests` | - | No |
|
||||||
| `project_audio_tracks` | - | No |
|
| `project_audio_tracks` | - | No |
|
||||||
| `project_element_defaults` | `page_elements` | Yes (reset, diff) |
|
| `project_element_defaults` | `page_elements` | Yes (reset, diff) |
|
||||||
| `project_memberships` | - | No |
|
| `project_memberships` | - | No |
|
||||||
| `publish_events` | - | No |
|
| `publish_events` | - | No |
|
||||||
| `pwa_caches` | - | No |
|
| `pwa_caches` | - | No |
|
||||||
| `roles` | - | No |
|
| `roles` | - | No |
|
||||||
| `tour_pages` | - | No |
|
| `tour_pages` | - | No |
|
||||||
|
|
||||||
### Entities Using Service Factory (11)
|
### Entities Using Service Factory (11)
|
||||||
|
|
||||||
| Entity | Custom Methods |
|
| Entity | Custom Methods |
|
||||||
|--------|---------------|
|
| -------------------------- | -------------------------------------------------------------- |
|
||||||
| `access_logs` | No |
|
| `access_logs` | No |
|
||||||
| `asset_variants` | No |
|
| `asset_variants` | No |
|
||||||
| `assets` | No |
|
| `assets` | No |
|
||||||
| `element_type_defaults` | No |
|
| `element_type_defaults` | No |
|
||||||
| `permissions` | No |
|
| `permissions` | No |
|
||||||
| `presigned_url_requests` | No |
|
| `presigned_url_requests` | No |
|
||||||
| `pwa_caches` | No |
|
| `pwa_caches` | No |
|
||||||
| `publish_events` | No |
|
| `publish_events` | No |
|
||||||
| `tour_pages` | No |
|
| `tour_pages` | No |
|
||||||
| `project_element_defaults` | Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults) |
|
| `project_element_defaults` | Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults) |
|
||||||
| `project_memberships` | No |
|
| `project_memberships` | No |
|
||||||
|
|
||||||
### Entities NOT Using Factories
|
### Entities NOT Using Factories
|
||||||
|
|
||||||
Some entities have custom implementations due to specialized requirements:
|
Some entities have custom implementations due to specialized requirements:
|
||||||
|
|
||||||
| Entity | Reason |
|
| Entity | Reason |
|
||||||
|--------|--------|
|
| ---------- | --------------------------------------------------- |
|
||||||
| `users` | Complex auth, password hashing, token management |
|
| `users` | Complex auth, password hashing, token management |
|
||||||
| `projects` | Publishing workflow, complex business logic |
|
| `projects` | Publishing workflow, complex business logic |
|
||||||
| `auth` | Authentication flows (login, OAuth, password reset) |
|
| `auth` | Authentication flows (login, OAuth, password reset) |
|
||||||
| `file` | File upload/download, S3/GCloud/Local storage |
|
| `file` | File upload/download, S3/GCloud/Local storage |
|
||||||
| `search` | Full-text search across multiple entities |
|
| `search` | Full-text search across multiple entities |
|
||||||
| `publish` | Multi-step publishing workflow |
|
| `publish` | Multi-step publishing workflow |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -797,15 +903,19 @@ HTTP Response
|
|||||||
## Design Patterns
|
## Design Patterns
|
||||||
|
|
||||||
### Factory Pattern
|
### Factory Pattern
|
||||||
|
|
||||||
Both `createEntityRouter` and `createEntityService` implement the Factory pattern, creating objects (router, service class) without specifying their exact classes.
|
Both `createEntityRouter` and `createEntityService` implement the Factory pattern, creating objects (router, service class) without specifying their exact classes.
|
||||||
|
|
||||||
### Template Method Pattern
|
### Template Method Pattern
|
||||||
|
|
||||||
`GenericDBApi.getFieldMapping()` uses the Template Method pattern - the base class defines the algorithm skeleton, while subclasses can override specific steps via static getters (`JSON_FIELDS`, `FIELD_TRANSFORMERS`, `FIELD_DEFAULTS`).
|
`GenericDBApi.getFieldMapping()` uses the Template Method pattern - the base class defines the algorithm skeleton, while subclasses can override specific steps via static getters (`JSON_FIELDS`, `FIELD_TRANSFORMERS`, `FIELD_DEFAULTS`).
|
||||||
|
|
||||||
### Strategy Pattern
|
### Strategy Pattern
|
||||||
|
|
||||||
The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding `permissionEntity` option.
|
The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding `permissionEntity` option.
|
||||||
|
|
||||||
### Decorator Pattern
|
### Decorator Pattern
|
||||||
|
|
||||||
The router factory decorates Express routers with permission middleware and error handling.
|
The router factory decorates Express routers with permission middleware and error handling.
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -815,6 +925,7 @@ The router factory decorates Express routers with permission middleware and erro
|
|||||||
### When to Use Factories
|
### When to Use Factories
|
||||||
|
|
||||||
Use factories when:
|
Use factories when:
|
||||||
|
|
||||||
- Entity requires standard CRUD operations
|
- Entity requires standard CRUD operations
|
||||||
- No complex business logic beyond data transformation
|
- No complex business logic beyond data transformation
|
||||||
- Permissions follow standard READ/CREATE/UPDATE/DELETE pattern
|
- Permissions follow standard READ/CREATE/UPDATE/DELETE pattern
|
||||||
@ -823,6 +934,7 @@ Use factories when:
|
|||||||
### When NOT to Use Factories
|
### When NOT to Use Factories
|
||||||
|
|
||||||
Don't use factories when:
|
Don't use factories when:
|
||||||
|
|
||||||
- Complex multi-step workflows (use custom service)
|
- Complex multi-step workflows (use custom service)
|
||||||
- Special authentication (OAuth flows, password reset)
|
- Special authentication (OAuth flows, password reset)
|
||||||
- External API integration (file storage, AI)
|
- External API integration (file storage, AI)
|
||||||
|
|||||||
@ -5,13 +5,14 @@
|
|||||||
The Middleware module provides cross-cutting concerns for the Express application including rate limiting, permission checking, runtime context management, file uploads, and public access control.
|
The Middleware module provides cross-cutting concerns for the Express application including rate limiting, permission checking, runtime context management, file uploads, and public access control.
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
| File | Lines | Purpose |
|
|
||||||
|------|-------|---------|
|
| File | Lines | Purpose |
|
||||||
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
|
| -------------------------------------- | --------------------------------------------- | ------------------------------------------------------- |
|
||||||
|
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
|
||||||
| `src/middlewares/check-permissions.ts` | RBAC permission checking through AccessPolicy |
|
| `src/middlewares/check-permissions.ts` | RBAC permission checking through AccessPolicy |
|
||||||
| `src/middlewares/runtime-context.ts` | 34 | Runtime environment context from headers |
|
| `src/middlewares/runtime-context.ts` | 34 | Runtime environment context from headers |
|
||||||
| `src/middlewares/runtime-public.ts` | 200 | Public runtime access control and response sanitization |
|
| `src/middlewares/runtime-public.ts` | 200 | Public runtime access control and response sanitization |
|
||||||
| `src/middlewares/upload.ts` | 34 | Multer-based file upload handling |
|
| `src/middlewares/upload.ts` | 34 | Multer-based file upload handling |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -126,19 +127,21 @@ setInterval(() => {
|
|||||||
Creates a configurable rate limiter middleware.
|
Creates a configurable rate limiter middleware.
|
||||||
|
|
||||||
**Parameters:**
|
**Parameters:**
|
||||||
| Parameter | Type | Default | Description |
|
|
||||||
|-----------|------|---------|-------------|
|
| Parameter | Type | Default | Description |
|
||||||
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
|
| -------------------- | -------- | ------------------------ | -------------------------------------- |
|
||||||
| `windowMs` | number | `900000` (15min) | Time window in milliseconds |
|
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
|
||||||
| `max` | number | `100` | Maximum requests per window |
|
| `windowMs` | number | `900000` (15min) | Time window in milliseconds |
|
||||||
| `message` | string | `'Too many requests...'` | Error message on limit |
|
| `max` | number | `100` | Maximum requests per window |
|
||||||
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
|
| `message` | string | `'Too many requests...'` | Error message on limit |
|
||||||
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
|
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
|
||||||
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
|
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
|
||||||
|
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
|
||||||
|
|
||||||
**Returns:** Express middleware function
|
**Returns:** Express middleware function
|
||||||
|
|
||||||
**Response Headers:**
|
**Response Headers:**
|
||||||
|
|
||||||
```
|
```
|
||||||
X-RateLimit-Limit: 100
|
X-RateLimit-Limit: 100
|
||||||
X-RateLimit-Remaining: 99
|
X-RateLimit-Remaining: 99
|
||||||
@ -147,6 +150,7 @@ Retry-After: 300 (only when limit exceeded)
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Rate Limit Exceeded Response (429):**
|
**Rate Limit Exceeded Response (429):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"error": "Too Many Requests",
|
"error": "Too Many Requests",
|
||||||
@ -174,14 +178,14 @@ const createAuthenticatedRateLimiter = (options = {}) => {
|
|||||||
|
|
||||||
#### Pre-configured Limiters
|
#### Pre-configured Limiters
|
||||||
|
|
||||||
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
|
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
|
||||||
|---------|------------|--------|-----|-------------|----------|
|
| ---------------------- | ---------------- | ------ | --- | ----------- | -------------- |
|
||||||
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
|
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
|
||||||
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
|
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
|
||||||
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
|
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
|
||||||
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
|
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
|
||||||
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
|
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
|
||||||
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
|
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
|
||||||
|
|
||||||
#### Route Mapping
|
#### Route Mapping
|
||||||
|
|
||||||
@ -238,6 +242,7 @@ fetchAndCachePublicRole();
|
|||||||
Creates middleware that checks if user has specific permission.
|
Creates middleware that checks if user has specific permission.
|
||||||
|
|
||||||
**Permission Check Flow:**
|
**Permission Check Flow:**
|
||||||
|
|
||||||
```
|
```
|
||||||
1. AccessPolicy.hasPermission(user, permission)
|
1. AccessPolicy.hasPermission(user, permission)
|
||||||
├── Public users are always denied admin API permissions
|
├── Public users are always denied admin API permissions
|
||||||
@ -254,6 +259,7 @@ to `GET`, `PUT`, and `PATCH` on the authenticated user's own `/api/users/:id`
|
|||||||
route in `checkCrudPermissions`.
|
route in `checkCrudPermissions`.
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const { checkPermissions } = require('./middlewares/check-permissions');
|
const { checkPermissions } = require('./middlewares/check-permissions');
|
||||||
|
|
||||||
@ -265,6 +271,7 @@ router.get('/users', checkPermissions('READ_USERS'), handler);
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Error Response (403):**
|
**Error Response (403):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"message": "Forbidden"
|
"message": "Forbidden"
|
||||||
@ -276,17 +283,19 @@ router.get('/users', checkPermissions('READ_USERS'), handler);
|
|||||||
Creates middleware that maps HTTP method to CRUD permission.
|
Creates middleware that maps HTTP method to CRUD permission.
|
||||||
|
|
||||||
**Method Mapping:**
|
**Method Mapping:**
|
||||||
|
|
||||||
| HTTP Method | Permission Prefix |
|
| HTTP Method | Permission Prefix |
|
||||||
|-------------|-------------------|
|
| ----------- | ----------------- |
|
||||||
| `POST` | `CREATE_` |
|
| `POST` | `CREATE_` |
|
||||||
| `GET` | `READ_` |
|
| `GET` | `READ_` |
|
||||||
| `PUT` | `UPDATE_` |
|
| `PUT` | `UPDATE_` |
|
||||||
| `PATCH` | `UPDATE_` |
|
| `PATCH` | `UPDATE_` |
|
||||||
| `DELETE` | `DELETE_` |
|
| `DELETE` | `DELETE_` |
|
||||||
|
|
||||||
**Permission Name Format:** `{METHOD}_{ENTITY}`
|
**Permission Name Format:** `{METHOD}_{ENTITY}`
|
||||||
|
|
||||||
Examples:
|
Examples:
|
||||||
|
|
||||||
- `GET /api/users` → `READ_USERS`
|
- `GET /api/users` → `READ_USERS`
|
||||||
- `POST /api/projects` → `CREATE_PROJECTS`
|
- `POST /api/projects` → `CREATE_PROJECTS`
|
||||||
- `DELETE /api/assets/123` → `DELETE_ASSETS`
|
- `DELETE /api/assets/123` → `DELETE_ASSETS`
|
||||||
@ -300,6 +309,7 @@ is "use inherited defaults", so those routes require `UPDATE_PAGE_ELEMENTS`
|
|||||||
rather than `DELETE_PAGE_ELEMENTS`.
|
rather than `DELETE_PAGE_ELEMENTS`.
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const { checkCrudPermissions } = require('./middlewares/check-permissions');
|
const { checkCrudPermissions } = require('./middlewares/check-permissions');
|
||||||
|
|
||||||
@ -352,7 +362,7 @@ For public read bypass to work, the middleware that sets `req.isRuntimePublicReq
|
|||||||
```javascript
|
```javascript
|
||||||
// ❌ WRONG - allowPublicRead runs AFTER checkCrudPermissions
|
// ❌ WRONG - allowPublicRead runs AFTER checkCrudPermissions
|
||||||
router.use(checkCrudPermissions('entity'));
|
router.use(checkCrudPermissions('entity'));
|
||||||
router.get('/', allowPublicRead, handler); // Too late!
|
router.get('/', allowPublicRead, handler); // Too late!
|
||||||
|
|
||||||
// ✅ CORRECT - allowPublicRead runs BEFORE checkCrudPermissions
|
// ✅ CORRECT - allowPublicRead runs BEFORE checkCrudPermissions
|
||||||
router.use(allowPublicRead);
|
router.use(allowPublicRead);
|
||||||
@ -373,22 +383,25 @@ Middleware that extracts runtime environment context from request headers.
|
|||||||
Reads environment and project slug from headers for route-based access.
|
Reads environment and project slug from headers for route-based access.
|
||||||
|
|
||||||
**Headers:**
|
**Headers:**
|
||||||
| Header | Values | Description |
|
|
||||||
|--------|--------|-------------|
|
| Header | Values | Description |
|
||||||
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
|
| ------------------------ | ---------------------------- | ------------------- |
|
||||||
| `X-Runtime-Project-Slug` | string | Project identifier |
|
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
|
||||||
|
| `X-Runtime-Project-Slug` | string | Project identifier |
|
||||||
|
|
||||||
**Context Object:**
|
**Context Object:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
req.runtimeContext = {
|
req.runtimeContext = {
|
||||||
mode: 'admin', // Default mode
|
mode: 'admin', // Default mode
|
||||||
projectSlug: null, // Extracted from path or header
|
projectSlug: null, // Extracted from path or header
|
||||||
headerEnvironment: 'production', // From X-Runtime-Environment
|
headerEnvironment: 'production', // From X-Runtime-Environment
|
||||||
headerProjectSlug: 'my-tour' // From X-Runtime-Project-Slug
|
headerProjectSlug: 'my-tour', // From X-Runtime-Project-Slug
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
**Usage in Routes:**
|
**Usage in Routes:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// index.js
|
// index.js
|
||||||
app.use(runtimeContextMiddleware);
|
app.use(runtimeContextMiddleware);
|
||||||
@ -401,11 +414,12 @@ if (env === 'production') {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Route-Based Environment Access:**
|
**Route-Based Environment Access:**
|
||||||
| Route | Environment | Access |
|
|
||||||
|-------|-------------|--------|
|
| Route | Environment | Access |
|
||||||
| `/p/[slug]` | `production` | Public (no auth) |
|
| ------------------------- | ------------ | ------------------ |
|
||||||
| `/p/[slug]/stage` | `stage` | Authenticated only |
|
| `/p/[slug]` | `production` | Public (no auth) |
|
||||||
| `/constructor?projectId=` | `dev` | Authenticated only |
|
| `/p/[slug]/stage` | `stage` | Authenticated only |
|
||||||
|
| `/constructor?projectId=` | `dev` | Authenticated only |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -420,16 +434,41 @@ Only these fields are returned for public runtime requests:
|
|||||||
```javascript
|
```javascript
|
||||||
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||||
projects: [
|
projects: [
|
||||||
'id', 'name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url',
|
'id',
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'description',
|
||||||
|
'logo_url',
|
||||||
|
'favicon_url',
|
||||||
|
'og_image_url',
|
||||||
],
|
],
|
||||||
tour_pages: [
|
tour_pages: [
|
||||||
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
|
'id',
|
||||||
'sort_order', 'background_image_url', 'background_video_url',
|
'projectId',
|
||||||
'background_audio_url', 'background_loop', 'requires_auth', 'ui_schema_json',
|
'environment',
|
||||||
|
'source_key',
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'sort_order',
|
||||||
|
'background_image_url',
|
||||||
|
'background_video_url',
|
||||||
|
'background_audio_url',
|
||||||
|
'background_loop',
|
||||||
|
'requires_auth',
|
||||||
|
'ui_schema_json',
|
||||||
],
|
],
|
||||||
project_audio_tracks: [
|
project_audio_tracks: [
|
||||||
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
|
'id',
|
||||||
'url', 'loop', 'volume', 'sort_order', 'is_enabled',
|
'projectId',
|
||||||
|
'environment',
|
||||||
|
'source_key',
|
||||||
|
'name',
|
||||||
|
'slug',
|
||||||
|
'url',
|
||||||
|
'loop',
|
||||||
|
'volume',
|
||||||
|
'sort_order',
|
||||||
|
'is_enabled',
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
```
|
```
|
||||||
@ -459,10 +498,12 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Blocked:**
|
**Blocked:**
|
||||||
|
|
||||||
- Individual record access: `GET /api/projects/123` → 404
|
- Individual record access: `GET /api/projects/123` → 404
|
||||||
- CSV exports: `GET /api/projects?filetype=csv` → 404
|
- CSV exports: `GET /api/projects?filetype=csv` → 404
|
||||||
|
|
||||||
**Allowed:**
|
**Allowed:**
|
||||||
|
|
||||||
- List endpoints: `GET /api/projects/` → Continue
|
- List endpoints: `GET /api/projects/` → Continue
|
||||||
|
|
||||||
#### Function: sanitizePublicRuntimeListResponse(entityName)
|
#### Function: sanitizePublicRuntimeListResponse(entityName)
|
||||||
@ -491,27 +532,33 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Before Sanitization:**
|
**Before Sanitization:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"rows": [{
|
"rows": [
|
||||||
"id": "123",
|
{
|
||||||
"name": "My Tour",
|
"id": "123",
|
||||||
"slug": "my-tour",
|
"name": "My Tour",
|
||||||
"createdAt": "2024-01-01",
|
"slug": "my-tour",
|
||||||
"createdById": "user-456",
|
"createdAt": "2024-01-01",
|
||||||
"internalNotes": "sensitive data"
|
"createdById": "user-456",
|
||||||
}]
|
"internalNotes": "sensitive data"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**After Sanitization:**
|
**After Sanitization:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"rows": [{
|
"rows": [
|
||||||
"id": "123",
|
{
|
||||||
"name": "My Tour",
|
"id": "123",
|
||||||
"slug": "my-tour"
|
"name": "My Tour",
|
||||||
}]
|
"slug": "my-tour"
|
||||||
|
}
|
||||||
|
]
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -552,13 +599,15 @@ module.exports = processFileMiddleware;
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Configuration:**
|
**Configuration:**
|
||||||
| Setting | Value |
|
|
||||||
|---------|-------|
|
| Setting | Value |
|
||||||
| Storage | Memory (Buffer) |
|
| ---------- | --------------- |
|
||||||
| Field Name | `file` |
|
| Storage | Memory (Buffer) |
|
||||||
| Max Files | 1 (single) |
|
| Field Name | `file` |
|
||||||
|
| Max Files | 1 (single) |
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const upload = require('./middlewares/upload');
|
const upload = require('./middlewares/upload');
|
||||||
|
|
||||||
@ -729,8 +778,8 @@ Content-Type: multipart/form-data
|
|||||||
|
|
||||||
### Environment Variables
|
### Environment Variables
|
||||||
|
|
||||||
| Variable | Affects | Description |
|
| Variable | Affects | Description |
|
||||||
|----------|---------|-------------|
|
| ---------- | ------------- | ----------------------------- |
|
||||||
| `NODE_ENV` | Rate limiting | Skip localhost in development |
|
| `NODE_ENV` | Rate limiting | Skip localhost in development |
|
||||||
|
|
||||||
### Constants
|
### Constants
|
||||||
@ -749,8 +798,12 @@ const METHOD_MAP = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
|
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
|
||||||
'PROJECTS', 'TOUR_PAGES', 'PAGE_ELEMENTS',
|
'PROJECTS',
|
||||||
'PAGE_LINKS', 'TRANSITIONS', 'PROJECT_AUDIO_TRACKS',
|
'TOUR_PAGES',
|
||||||
|
'PAGE_ELEMENTS',
|
||||||
|
'PAGE_LINKS',
|
||||||
|
'TRANSITIONS',
|
||||||
|
'PROJECT_AUDIO_TRACKS',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// runtime-public.ts
|
// runtime-public.ts
|
||||||
@ -761,12 +814,13 @@ const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
| Package | Version | Purpose |
|
| Package | Version | Purpose |
|
||||||
|---------|---------|---------|
|
| -------- | -------- | --------------------------- |
|
||||||
| `multer` | ^1.4.5 | Multipart form data parsing |
|
| `multer` | ^1.4.5 | Multipart form data parsing |
|
||||||
| `util` | built-in | Promisify multer |
|
| `util` | built-in | Promisify multer |
|
||||||
|
|
||||||
**Internal Dependencies:**
|
**Internal Dependencies:**
|
||||||
|
|
||||||
- `../utils/logger` - Pino logger for rate limit logging
|
- `../utils/logger` - Pino logger for rate limit logging
|
||||||
- `../services/notifications/errors/validation` - ValidationError class
|
- `../services/notifications/errors/validation` - ValidationError class
|
||||||
- `../db/api/roles` - RolesDBApi for Public role
|
- `../db/api/roles` - RolesDBApi for Public role
|
||||||
@ -835,6 +889,7 @@ The Middleware module provides:
|
|||||||
5. **upload.ts** - Simple Multer-based file upload
|
5. **upload.ts** - Simple Multer-based file upload
|
||||||
|
|
||||||
**Key Features:**
|
**Key Features:**
|
||||||
|
|
||||||
- Configurable rate limiting per endpoint type
|
- Configurable rate limiting per endpoint type
|
||||||
- Role-based permission checking with method-to-CRUD mapping
|
- Role-based permission checking with method-to-CRUD mapping
|
||||||
- Public runtime access for production presentations
|
- Public runtime access for production presentations
|
||||||
|
|||||||
@ -107,7 +107,8 @@ const errors = {
|
|||||||
error: 'Email not recognized',
|
error: 'Email not recognized',
|
||||||
},
|
},
|
||||||
passwordUpdate: {
|
passwordUpdate: {
|
||||||
samePassword: "You can't use the same password. Please create new password",
|
samePassword:
|
||||||
|
"You can't use the same password. Please create new password",
|
||||||
},
|
},
|
||||||
userNotVerified: 'Sorry, your email has not been verified yet',
|
userNotVerified: 'Sorry, your email has not been verified yet',
|
||||||
emailAddressVerificationEmail: {
|
emailAddressVerificationEmail: {
|
||||||
@ -133,7 +134,8 @@ const errors = {
|
|||||||
errors: {
|
errors: {
|
||||||
invalidFileEmpty: 'The file is empty',
|
invalidFileEmpty: 'The file is empty',
|
||||||
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
|
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
|
||||||
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.',
|
invalidFileUpload:
|
||||||
|
'Invalid file. Make sure you are using the last version of the template.',
|
||||||
importHashRequired: 'Import hash is required',
|
importHashRequired: 'Import hash is required',
|
||||||
importHashExistent: 'Data has already been imported',
|
importHashExistent: 'Data has already been imported',
|
||||||
userEmailMissing: 'Some items in the CSV do not have an email',
|
userEmailMissing: 'Some items in the CSV do not have an email',
|
||||||
@ -173,14 +175,14 @@ module.exports = errors;
|
|||||||
|
|
||||||
### Message Categories
|
### Message Categories
|
||||||
|
|
||||||
| Category | Purpose | Example Key |
|
| Category | Purpose | Example Key |
|
||||||
|----------|---------|-------------|
|
| ---------- | -------------------------- | ---------------------------------- |
|
||||||
| `app` | Application metadata | `app.title` |
|
| `app` | Application metadata | `app.title` |
|
||||||
| `auth` | Authentication errors | `auth.userNotFound` |
|
| `auth` | Authentication errors | `auth.userNotFound` |
|
||||||
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
|
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
|
||||||
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
|
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
|
||||||
| `errors` | Generic error messages | `errors.validation.message` |
|
| `errors` | Generic error messages | `errors.validation.message` |
|
||||||
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
|
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -228,7 +230,7 @@ const getNotification = (key, ...args) => {
|
|||||||
const message = _get(errors, key);
|
const message = _get(errors, key);
|
||||||
|
|
||||||
if (!message) {
|
if (!message) {
|
||||||
return key; // Return raw key as fallback
|
return key; // Return raw key as fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
return format(message, args);
|
return format(message, args);
|
||||||
@ -252,8 +254,8 @@ getNotification('emails.invitation.subject', 'Tour Builder Platform');
|
|||||||
// → "You've been invited to Tour Builder Platform"
|
// → "You've been invited to Tour Builder Platform"
|
||||||
|
|
||||||
// Check if key exists
|
// Check if key exists
|
||||||
isNotification('auth.userNotFound'); // → true
|
isNotification('auth.userNotFound'); // → true
|
||||||
isNotification('custom.message'); // → false
|
isNotification('custom.message'); // → false
|
||||||
|
|
||||||
// Unknown key returns the key itself
|
// Unknown key returns the key itself
|
||||||
getNotification('unknown.key');
|
getNotification('unknown.key');
|
||||||
@ -290,10 +292,12 @@ module.exports = class ValidationError extends Error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Properties:**
|
**Properties:**
|
||||||
|
|
||||||
- `message` - Human-readable error message
|
- `message` - Human-readable error message
|
||||||
- `code` - HTTP status code (400)
|
- `code` - HTTP status code (400)
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const ValidationError = require('./notifications/errors/validation');
|
const ValidationError = require('./notifications/errors/validation');
|
||||||
|
|
||||||
@ -336,10 +340,12 @@ module.exports = class ForbiddenError extends Error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Properties:**
|
**Properties:**
|
||||||
|
|
||||||
- `message` - Human-readable error message
|
- `message` - Human-readable error message
|
||||||
- `code` - HTTP status code (403)
|
- `code` - HTTP status code (403)
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||||
|
|
||||||
@ -358,62 +364,62 @@ throw new ForbiddenError();
|
|||||||
|
|
||||||
### Authentication Messages (`auth.*`)
|
### Authentication Messages (`auth.*`)
|
||||||
|
|
||||||
| Key | Message | Used In |
|
| Key | Message | Used In |
|
||||||
|-----|---------|---------|
|
| ------------------------------------------------- | --------------------------------------------------- | ----------------------- |
|
||||||
| `auth.userDisabled` | "Your account is disabled" | signin |
|
| `auth.userDisabled` | "Your account is disabled" | signin |
|
||||||
| `auth.forbidden` | "Forbidden" | authorization failures |
|
| `auth.forbidden` | "Forbidden" | authorization failures |
|
||||||
| `auth.unauthorized` | "Unauthorized" | missing authentication |
|
| `auth.unauthorized` | "Unauthorized" | missing authentication |
|
||||||
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
|
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
|
||||||
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
|
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
|
||||||
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
|
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
|
||||||
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
|
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
|
||||||
| `auth.invalidEmail` | "Please provide a valid email" | signup |
|
| `auth.invalidEmail` | "Please provide a valid email" | signup |
|
||||||
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin |
|
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin |
|
||||||
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset |
|
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset |
|
||||||
| `auth.passwordReset.error` | "Email not recognized" | password reset request |
|
| `auth.passwordReset.error` | "Email not recognized" | password reset request |
|
||||||
| `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update |
|
| `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update |
|
||||||
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification |
|
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification |
|
||||||
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification |
|
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification |
|
||||||
|
|
||||||
### IAM Messages (`iam.errors.*`)
|
### IAM Messages (`iam.errors.*`)
|
||||||
|
|
||||||
| Key | Message | Used In |
|
| Key | Message | Used In |
|
||||||
|-----|---------|---------|
|
| ---------------------------------- | ------------------------------------------------ | --------------------- |
|
||||||
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
|
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
|
||||||
| `iam.errors.userNotFound` | "User not found" | user update/delete |
|
| `iam.errors.userNotFound` | "User not found" | user update/delete |
|
||||||
| `iam.errors.disablingHimself` | "You can't disable yourself" | user disable |
|
| `iam.errors.disablingHimself` | "You can't disable yourself" | user disable |
|
||||||
| `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke |
|
| `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke |
|
||||||
| `iam.errors.deletingHimself` | "You can't delete yourself" | user delete |
|
| `iam.errors.deletingHimself` | "You can't delete yourself" | user delete |
|
||||||
| `iam.errors.emailRequired` | "Email is required" | user creation |
|
| `iam.errors.emailRequired` | "Email is required" | user creation |
|
||||||
| `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update |
|
| `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update |
|
||||||
| `iam.errors.searchQueryRequired` | "Search query is required" | search |
|
| `iam.errors.searchQueryRequired` | "Search query is required" | search |
|
||||||
|
|
||||||
### Importer Messages (`importer.errors.*`)
|
### Importer Messages (`importer.errors.*`)
|
||||||
|
|
||||||
| Key | Message | Used In |
|
| Key | Message | Used In |
|
||||||
|-----|---------|---------|
|
| ------------------------------------ | -------------------------------------------- | ---------------- |
|
||||||
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
|
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
|
||||||
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
|
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
|
||||||
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
|
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
|
||||||
| `importer.errors.importHashRequired` | "Import hash is required" | bulk import |
|
| `importer.errors.importHashRequired` | "Import hash is required" | bulk import |
|
||||||
| `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import |
|
| `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import |
|
||||||
| `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import |
|
| `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import |
|
||||||
|
|
||||||
### Generic Messages (`errors.*`)
|
### Generic Messages (`errors.*`)
|
||||||
|
|
||||||
| Key | Message | Used In |
|
| Key | Message | Used In |
|
||||||
|-----|---------|---------|
|
| ------------------------------------ | -------------------------- | ----------------------- |
|
||||||
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
|
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
|
||||||
| `errors.validation.message` | "An error occurred" | ValidationError default |
|
| `errors.validation.message` | "An error occurred" | ValidationError default |
|
||||||
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
|
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
|
||||||
|
|
||||||
### Email Messages (`emails.*`)
|
### Email Messages (`emails.*`)
|
||||||
|
|
||||||
| Key | Message | Used In |
|
| Key | Message | Used In |
|
||||||
|-----|---------|---------|
|
| ----------------------------------------- | ----------------------------- | ------------------ |
|
||||||
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
|
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
|
||||||
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
|
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
|
||||||
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
|
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -421,27 +427,27 @@ throw new ForbiddenError();
|
|||||||
|
|
||||||
### Services Using Notifications
|
### Services Using Notifications
|
||||||
|
|
||||||
| Service | Errors Used | Common Keys |
|
| Service | Errors Used | Common Keys |
|
||||||
|---------|-------------|-------------|
|
| ------------------------- | ------------------------------- | ------------------------------------------------ |
|
||||||
| `auth.js` | ValidationError, ForbiddenError | auth.*, iam.* |
|
| `auth.js` | ValidationError, ForbiddenError | auth._, iam._ |
|
||||||
| `users.ts` | ValidationError | iam.errors.* |
|
| `users.ts` | ValidationError | iam.errors.* |
|
||||||
| `projects.ts` | ValidationError | projectsNotFound |
|
| `projects.ts` | ValidationError | projectsNotFound |
|
||||||
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
|
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
|
||||||
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
|
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
|
||||||
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
|
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
|
||||||
|
|
||||||
### Email Templates Using Notifications
|
### Email Templates Using Notifications
|
||||||
|
|
||||||
| Template | Helper Usage |
|
| Template | Helper Usage |
|
||||||
|----------|--------------|
|
| ------------------------ | ------------------------------------------------------------ |
|
||||||
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
|
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
|
||||||
| `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` |
|
| `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` |
|
||||||
| `invitation.js` | `getNotification('emails.invitation.subject')` |
|
| `invitation.js` | `getNotification('emails.invitation.subject')` |
|
||||||
|
|
||||||
### Middleware Using Notifications
|
### Middleware Using Notifications
|
||||||
|
|
||||||
| Middleware | Error Used | Purpose |
|
| Middleware | Error Used | Purpose |
|
||||||
|------------|------------|---------|
|
| ---------------------- | --------------- | --------------------------- |
|
||||||
| `check-permissions.ts` | ValidationError | Permission denied responses |
|
| `check-permissions.ts` | ValidationError | Permission denied responses |
|
||||||
|
|
||||||
### Service Factory Using Notifications
|
### Service Factory Using Notifications
|
||||||
@ -596,13 +602,13 @@ class ValidationError extends AppError {
|
|||||||
|
|
||||||
### When to Use Which
|
### When to Use Which
|
||||||
|
|
||||||
| Scenario | Use |
|
| Scenario | Use |
|
||||||
|----------|-----|
|
| ----------------------------- | -------------------------------------- |
|
||||||
| Service business logic errors | `notifications/errors/ValidationError` |
|
| Service business logic errors | `notifications/errors/ValidationError` |
|
||||||
| Authorization failures | `notifications/errors/ForbiddenError` |
|
| Authorization failures | `notifications/errors/ForbiddenError` |
|
||||||
| Email subject/body text | `getNotification()` |
|
| Email subject/body text | `getNotification()` |
|
||||||
| Low-level utility errors | `utils/errors.js` |
|
| Low-level utility errors | `utils/errors.js` |
|
||||||
| Errors needing details object | `utils/errors.js` |
|
| Errors needing details object | `utils/errors.js` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -619,7 +625,8 @@ const notifications = {
|
|||||||
errors: {
|
errors: {
|
||||||
notFound: 'Project not found',
|
notFound: 'Project not found',
|
||||||
slugTaken: 'A project with this slug already exists',
|
slugTaken: 'A project with this slug already exists',
|
||||||
invalidSlug: 'Project slug must contain only letters, numbers, and hyphens',
|
invalidSlug:
|
||||||
|
'Project slug must contain only letters, numbers, and hyphens',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@ -704,13 +711,13 @@ const { getNotification, isNotification } = require('./helpers');
|
|||||||
describe('getNotification', () => {
|
describe('getNotification', () => {
|
||||||
it('should return message for valid key', () => {
|
it('should return message for valid key', () => {
|
||||||
expect(getNotification('auth.userNotFound')).toBe(
|
expect(getNotification('auth.userNotFound')).toBe(
|
||||||
"Sorry, we don't recognize your credentials"
|
"Sorry, we don't recognize your credentials",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should substitute parameters', () => {
|
it('should substitute parameters', () => {
|
||||||
expect(getNotification('emails.invitation.subject', 'My App')).toBe(
|
expect(getNotification('emails.invitation.subject', 'My App')).toBe(
|
||||||
"You've been invited to My App"
|
"You've been invited to My App",
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -734,9 +741,9 @@ describe('isNotification', () => {
|
|||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
| Package | Version | Purpose |
|
| Package | Version | Purpose |
|
||||||
|---------|---------|---------|
|
| ------------ | ------- | --------------------------- |
|
||||||
| `lodash/get` | ^4.x | Deep object property access |
|
| `lodash/get` | ^4.x | Deep object property access |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -7,32 +7,33 @@ The Routes module defines all HTTP endpoints for the application. It uses a fact
|
|||||||
**Directory:** `src/routes/`
|
**Directory:** `src/routes/`
|
||||||
|
|
||||||
**Files (25 total):**
|
**Files (25 total):**
|
||||||
| File | Lines | Pattern | Description |
|
|
||||||
|------|-------|---------|-------------|
|
| File | Lines | Pattern | Description |
|
||||||
| `auth.ts` | 327 | Custom | Authentication endpoints |
|
| -------------------------------- | ----- | ----------- | ------------------------------------------------------------- |
|
||||||
| `file.ts` | 150 | Custom | File upload/download endpoints |
|
| `auth.ts` | 327 | Custom | Authentication endpoints |
|
||||||
| `publish.ts` | 107 | Custom | Publishing workflow endpoints |
|
| `file.ts` | 150 | Custom | File upload/download endpoints |
|
||||||
| `search.ts` | 64 | Custom | Global search endpoint |
|
| `publish.ts` | 107 | Custom | Publishing workflow endpoints |
|
||||||
| `runtime-context.ts` | 16 | Custom | Runtime context inspection |
|
| `search.ts` | 64 | Custom | Global search endpoint |
|
||||||
| `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint |
|
| `runtime-context.ts` | 16 | Custom | Runtime context inspection |
|
||||||
| `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID |
|
| `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint |
|
||||||
| `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status |
|
| `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID |
|
||||||
| `roles.ts` | 141 | Factory | Roles CRUD via factory |
|
| `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status |
|
||||||
| `permissions.ts` | 188 | Factory | Permissions CRUD via factory |
|
| `roles.ts` | 141 | Factory | Roles CRUD via factory |
|
||||||
| `assets.ts` | 155 | Factory | Assets CRUD via factory |
|
| `permissions.ts` | 188 | Factory | Permissions CRUD via factory |
|
||||||
| `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory |
|
| `assets.ts` | 155 | Factory | Assets CRUD via factory |
|
||||||
| `access_logs.ts` | 145 | Factory | Access logs CRUD via factory |
|
| `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory |
|
||||||
| `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory |
|
| `access_logs.ts` | 145 | Factory | Access logs CRUD via factory |
|
||||||
| `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory |
|
| `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory |
|
||||||
| `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults |
|
| `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory |
|
||||||
| `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults |
|
| `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults |
|
||||||
| `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides |
|
| `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults |
|
||||||
| `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides |
|
| `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides |
|
||||||
| `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory |
|
| `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides |
|
||||||
| `publish_events.ts` | 157 | Factory | Publish events CRUD via factory |
|
| `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory |
|
||||||
| `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory |
|
| `publish_events.ts` | 157 | Factory | Publish events CRUD via factory |
|
||||||
| `element_type_defaults.ts` | 12 | Factory | Element type defaults via factory |
|
| `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory |
|
||||||
| `project_element_defaults.ts` | 92 | Hybrid | Project element defaults CRUD + custom (reset, diff) |
|
| `element_type_defaults.ts` | 12 | Factory | Element type defaults via factory |
|
||||||
|
| `project_element_defaults.ts` | 92 | Hybrid | Project element defaults CRUD + custom (reset, diff) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -98,40 +99,43 @@ Generates standardized CRUD routes for entities.
|
|||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const router = createEntityRouter(
|
const router = createEntityRouter(
|
||||||
'tour_pages', // Entity name
|
'tour_pages', // Entity name
|
||||||
Tour_pagesService, // Service class
|
Tour_pagesService, // Service class
|
||||||
Tour_pagesDBApi, // Database API class
|
Tour_pagesDBApi, // Database API class
|
||||||
{
|
{
|
||||||
permissionEntity: 'tour_pages', // Permission entity name (optional)
|
permissionEntity: 'tour_pages', // Permission entity name (optional)
|
||||||
csvFields: ['id', 'name'], // CSV export fields (optional)
|
csvFields: ['id', 'name'], // CSV export fields (optional)
|
||||||
validation: { // Request validation overrides (optional)
|
validation: {
|
||||||
|
// Request validation overrides (optional)
|
||||||
create: customCreateSchema,
|
create: customCreateSchema,
|
||||||
update: customUpdateSchema,
|
update: customUpdateSchema,
|
||||||
},
|
},
|
||||||
customRoutes: (router, Service, DBApi) => { // Custom routes (optional)
|
customRoutes: (router, Service, DBApi) => {
|
||||||
|
// Custom routes (optional)
|
||||||
router.post('/custom', handler);
|
router.post('/custom', handler);
|
||||||
}
|
},
|
||||||
}
|
},
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Generated Endpoints
|
#### Generated Endpoints
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
|--------|------|-------------|
|
| -------- | --------------- | ------------------------------------- |
|
||||||
| `POST` | `/` | Create new item |
|
| `POST` | `/` | Create new item |
|
||||||
| `POST` | `/bulk-import` | Bulk import items |
|
| `POST` | `/bulk-import` | Bulk import items |
|
||||||
| `PUT` | `/:id` | Update item by ID |
|
| `PUT` | `/:id` | Update item by ID |
|
||||||
| `DELETE` | `/:id` | Delete item by ID |
|
| `DELETE` | `/:id` | Delete item by ID |
|
||||||
| `POST` | `/deleteByIds` | Delete multiple items |
|
| `POST` | `/deleteByIds` | Delete multiple items |
|
||||||
| `GET` | `/` | List items (with pagination, filters) |
|
| `GET` | `/` | List items (with pagination, filters) |
|
||||||
| `GET` | `/count` | Count items matching filters |
|
| `GET` | `/count` | Count items matching filters |
|
||||||
| `GET` | `/autocomplete` | Autocomplete search |
|
| `GET` | `/autocomplete` | Autocomplete search |
|
||||||
| `GET` | `/:id` | Get single item by ID |
|
| `GET` | `/:id` | Get single item by ID |
|
||||||
|
|
||||||
#### Factory Features
|
#### Factory Features
|
||||||
|
|
||||||
**Permission Checking:**
|
**Permission Checking:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
router.use(checkCrudPermissions(permissionEntity));
|
router.use(checkCrudPermissions(permissionEntity));
|
||||||
// Maps HTTP methods to permissions:
|
// Maps HTTP methods to permissions:
|
||||||
@ -142,6 +146,7 @@ router.use(checkCrudPermissions(permissionEntity));
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Request Validation:**
|
**Request Validation:**
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
import { validateRequest } from '../middlewares/validate-request.ts';
|
import { validateRequest } from '../middlewares/validate-request.ts';
|
||||||
import { crud as crudSchemas } from '../validators/request-schemas.ts';
|
import { crud as crudSchemas } from '../validators/request-schemas.ts';
|
||||||
@ -159,6 +164,7 @@ router.get(
|
|||||||
Factory CRUD routes validate request bodies, params, and common query controls before service/DB calls. List and count routes validate `limit`, `page`, `field`, `sort`, and `filetype` while keeping existing entity filter query parameters. Entity routers can pass `validation` overrides for stricter contracts, as `users` and `projects` do.
|
Factory CRUD routes validate request bodies, params, and common query controls before service/DB calls. List and count routes validate `limit`, `page`, `field`, `sort`, and `filetype` while keeping existing entity filter query parameters. Entity routers can pass `validation` overrides for stricter contracts, as `users` and `projects` do.
|
||||||
|
|
||||||
**CSV Export:**
|
**CSV Export:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// GET /?filetype=csv
|
// GET /?filetype=csv
|
||||||
if (filetype === 'csv') {
|
if (filetype === 'csv') {
|
||||||
@ -168,6 +174,7 @@ if (filetype === 'csv') {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Runtime Context:**
|
**Runtime Context:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const runtimeContext = req.runtimeContext;
|
const runtimeContext = req.runtimeContext;
|
||||||
const payload = await DBApi.findAll(req.query, {
|
const payload = await DBApi.findAll(req.query, {
|
||||||
@ -226,22 +233,22 @@ mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', ...
|
|||||||
|
|
||||||
Authentication and account management.
|
Authentication and account management.
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
| ------ | ---------------------------------------- | ---- | ------------------------- |
|
||||||
| POST | `/signin/local` | No | Email/password login |
|
| POST | `/signin/local` | No | Email/password login |
|
||||||
| POST | `/signup` | No | Register new user |
|
| POST | `/signup` | No | Register new user |
|
||||||
| GET | `/me` | JWT | Get current user |
|
| GET | `/me` | JWT | Get current user |
|
||||||
| PUT | `/password-reset` | No | Reset password with token |
|
| PUT | `/password-reset` | No | Reset password with token |
|
||||||
| PUT | `/password-update` | JWT | Change password |
|
| PUT | `/password-update` | JWT | Change password |
|
||||||
| PUT | `/profile` | JWT | Update user profile |
|
| PUT | `/profile` | JWT | Update user profile |
|
||||||
| PUT | `/verify-email` | No | Verify email with token |
|
| PUT | `/verify-email` | No | Verify email with token |
|
||||||
| POST | `/send-email-address-verification-email` | JWT | Resend verification |
|
| POST | `/send-email-address-verification-email` | JWT | Resend verification |
|
||||||
| POST | `/send-password-reset-email` | No | Send reset email |
|
| POST | `/send-password-reset-email` | No | Send reset email |
|
||||||
| GET | `/email-configured` | No | Check email config |
|
| GET | `/email-configured` | No | Check email config |
|
||||||
| GET | `/signin/google` | No | Google OAuth start |
|
| GET | `/signin/google` | No | Google OAuth start |
|
||||||
| GET | `/signin/google/callback` | No | Google OAuth callback |
|
| GET | `/signin/google/callback` | No | Google OAuth callback |
|
||||||
| GET | `/signin/microsoft` | No | Microsoft OAuth start |
|
| GET | `/signin/microsoft` | No | Microsoft OAuth start |
|
||||||
| GET | `/signin/microsoft/callback` | No | Microsoft OAuth callback |
|
| GET | `/signin/microsoft/callback` | No | Microsoft OAuth callback |
|
||||||
|
|
||||||
`auth.ts` is a typed ESM route boundary. It uses reusable request body/query contracts from `backend/src/types/auth-routes.ts`, the typed `backend/src/services/auth.ts` service, and the shared Passport helpers in `backend/src/auth/passport-middleware.ts`.
|
`auth.ts` is a typed ESM route boundary. It uses reusable request body/query contracts from `backend/src/types/auth-routes.ts`, the typed `backend/src/services/auth.ts` service, and the shared Passport helpers in `backend/src/auth/passport-middleware.ts`.
|
||||||
|
|
||||||
@ -253,17 +260,18 @@ File upload and download operations.
|
|||||||
|
|
||||||
`file.ts` is a typed ESM route boundary. It calls the typed unified storage facade in `backend/src/services/file.ts`, while provider contracts and request/response shapes live in reusable `backend/src/types/file.ts` contracts. S3 and GCloud use official SDK-provided types instead of local SDK declarations.
|
`file.ts` is a typed ESM route boundary. It calls the typed unified storage facade in `backend/src/services/file.ts`, while provider contracts and request/response shapes live in reusable `backend/src/types/file.ts` contracts. S3 and GCloud use official SDK-provided types instead of local SDK declarations.
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
| ------ | ------------------------------------------------ | ---- | -------------------------------- |
|
||||||
| GET | `/download` | No | Download file by privateUrl |
|
| GET | `/download` | No | Download file by privateUrl |
|
||||||
| POST | `/presign` | No | Generate presigned URLs (max 50) |
|
| POST | `/presign` | No | Generate presigned URLs (max 50) |
|
||||||
| POST | `/upload/:table/:field` | JWT | Legacy single file upload |
|
| POST | `/upload/:table/:field` | JWT | Legacy single file upload |
|
||||||
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
|
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
|
||||||
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
|
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
|
||||||
| PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk |
|
| PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk |
|
||||||
| POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload |
|
| POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload |
|
||||||
|
|
||||||
**Presigned URLs Request:**
|
**Presigned URLs Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"urls": ["assets/image.jpg", "assets/video.mp4"]
|
"urls": ["assets/image.jpg", "assets/video.mp4"]
|
||||||
@ -271,6 +279,7 @@ File upload and download operations.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Presigned URLs Response:**
|
**Presigned URLs Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"presignedUrls": {
|
"presignedUrls": {
|
||||||
@ -286,13 +295,14 @@ File upload and download operations.
|
|||||||
|
|
||||||
Publishing workflow for Dev → Stage → Production.
|
Publishing workflow for Dev → Stage → Production.
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
| ------ | ---------------- | ---- | --------------------------- |
|
||||||
| POST | `/` | JWT | Publish stage to production |
|
| POST | `/` | JWT | Publish stage to production |
|
||||||
| POST | `/publish` | JWT | Alias for publish |
|
| POST | `/publish` | JWT | Alias for publish |
|
||||||
| POST | `/save-to-stage` | JWT | Save dev to stage |
|
| POST | `/save-to-stage` | JWT | Save dev to stage |
|
||||||
|
|
||||||
**Publish Request:**
|
**Publish Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"projectId": "uuid",
|
"projectId": "uuid",
|
||||||
@ -302,6 +312,7 @@ Publishing workflow for Dev → Stage → Production.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Save to Stage Request:**
|
**Save to Stage Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"projectId": "uuid"
|
"projectId": "uuid"
|
||||||
@ -314,11 +325,12 @@ Publishing workflow for Dev → Stage → Production.
|
|||||||
|
|
||||||
Global full-text search across entities.
|
Global full-text search across entities.
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
| ------ | ---- | ---- | -------------------------- |
|
||||||
| POST | `/` | JWT | Search across all entities |
|
| POST | `/` | JWT | Search across all entities |
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"searchQuery": "my search term"
|
"searchQuery": "my search term"
|
||||||
@ -326,6 +338,7 @@ Global full-text search across entities.
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"users": [...],
|
"users": [...],
|
||||||
@ -338,11 +351,12 @@ Global full-text search across entities.
|
|||||||
|
|
||||||
Runtime context inspection for debugging.
|
Runtime context inspection for debugging.
|
||||||
|
|
||||||
| Method | Path | Auth | Description |
|
| Method | Path | Auth | Description |
|
||||||
|--------|------|------|-------------|
|
| ------ | ---- | ---- | --------------------------- |
|
||||||
| GET | `/` | No | Get current runtime context |
|
| GET | `/` | No | Get current runtime context |
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mode": "admin",
|
"mode": "admin",
|
||||||
@ -361,22 +375,25 @@ Runtime context inspection for debugging.
|
|||||||
Projects with factory CRUD plus a clone endpoint.
|
Projects with factory CRUD plus a clone endpoint.
|
||||||
|
|
||||||
**Standard CRUD Endpoints:**
|
**Standard CRUD Endpoints:**
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| POST | `/` | Create project |
|
| ------ | --------------- | ------------------------ |
|
||||||
| POST | `/bulk-import` | Bulk import projects |
|
| POST | `/` | Create project |
|
||||||
| PUT | `/:id` | Update project |
|
| POST | `/bulk-import` | Bulk import projects |
|
||||||
| DELETE | `/:id` | Delete project |
|
| PUT | `/:id` | Update project |
|
||||||
| POST | `/deleteByIds` | Delete multiple projects |
|
| DELETE | `/:id` | Delete project |
|
||||||
| GET | `/` | List projects |
|
| POST | `/deleteByIds` | Delete multiple projects |
|
||||||
| GET | `/count` | Count projects |
|
| GET | `/` | List projects |
|
||||||
| GET | `/autocomplete` | Autocomplete search |
|
| GET | `/count` | Count projects |
|
||||||
| GET | `/:id` | Get project by ID |
|
| GET | `/autocomplete` | Autocomplete search |
|
||||||
|
| GET | `/:id` | Get project by ID |
|
||||||
|
|
||||||
**Custom Endpoints:**
|
**Custom Endpoints:**
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| POST | `/:id/clone` | Clone project with all pages |
|
| ------ | ------------ | ---------------------------- |
|
||||||
|
| POST | `/:id/clone` | Clone project with all pages |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### tour_pages.ts (380 lines)
|
### tour_pages.ts (380 lines)
|
||||||
@ -384,30 +401,33 @@ Projects with factory CRUD plus a clone endpoint.
|
|||||||
Typed manual CRUD route for tour pages. In addition to standard create/update/delete/list/count/autocomplete/find-by-id operations, it owns page reorder, dev-page duplication, CSV export, and reverse-video status endpoints. The route uses reusable contracts from `backend/src/types/tour-pages.ts`.
|
Typed manual CRUD route for tour pages. In addition to standard create/update/delete/list/count/autocomplete/find-by-id operations, it owns page reorder, dev-page duplication, CSV export, and reverse-video status endpoints. The route uses reusable contracts from `backend/src/types/tour-pages.ts`.
|
||||||
|
|
||||||
**Schema Fields:**
|
**Schema Fields:**
|
||||||
|
|
||||||
- `source_key`, `name`, `slug`
|
- `source_key`, `name`, `slug`
|
||||||
- `background_image_url`, `background_video_url`, `background_audio_url`
|
- `background_image_url`, `background_video_url`, `background_audio_url`
|
||||||
- `ui_schema_json`, `sort_order`
|
- `ui_schema_json`, `sort_order`
|
||||||
|
|
||||||
**Endpoints:**
|
**Endpoints:**
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| POST | `/` | Create page |
|
| ------ | ----------------------- | -------------------------------------------------------------------- |
|
||||||
| POST | `/bulk-import` | Bulk import pages |
|
| POST | `/` | Create page |
|
||||||
| POST | `/reorder` | Reorder dev pages in a project/environment |
|
| POST | `/bulk-import` | Bulk import pages |
|
||||||
| POST | `/:id/duplicate` | Duplicate a dev page |
|
| POST | `/reorder` | Reorder dev pages in a project/environment |
|
||||||
| PUT | `/:id` | Update page |
|
| POST | `/:id/duplicate` | Duplicate a dev page |
|
||||||
| DELETE | `/:id` | Remove page |
|
| PUT | `/:id` | Update page |
|
||||||
| POST | `/deleteByIds` | Remove multiple pages |
|
| DELETE | `/:id` | Remove page |
|
||||||
| GET | `/` | List pages with reverse video URL population and optional CSV export |
|
| POST | `/deleteByIds` | Remove multiple pages |
|
||||||
| POST | `/reverse-video-status` | Check generated reverse-video variants |
|
| GET | `/` | List pages with reverse video URL population and optional CSV export |
|
||||||
| GET | `/count` | Count pages |
|
| POST | `/reverse-video-status` | Check generated reverse-video variants |
|
||||||
| GET | `/autocomplete` | Page autocomplete |
|
| GET | `/count` | Count pages |
|
||||||
| GET | `/:id` | Get one page with reverse video URL population |
|
| GET | `/autocomplete` | Page autocomplete |
|
||||||
|
| GET | `/:id` | Get one page with reverse video URL population |
|
||||||
|
|
||||||
- `POST /api/tour_pages/reorder` accepts `{ data: { projectId, environment,
|
- `POST /api/tour_pages/reorder` accepts `{ data: { projectId, environment,
|
||||||
orderedPageIds } }`, validates a complete page list for the project/dev
|
orderedPageIds } }`, validates a complete page list for the project/dev
|
||||||
environment, and updates only `sort_order`.
|
environment, and updates only `sort_order`.
|
||||||
- `POST /api/tour_pages/:id/duplicate` accepts `{ data: { projectId,
|
- `POST /api/tour_pages/:id/duplicate` accepts `{ data: { projectId,
|
||||||
environment, name, slug } }`, duplicates a dev page into a new independent dev
|
environment, name, slug } }`, duplicates a dev page into a new independent dev
|
||||||
page, appends it to the project order, deep-copies `ui_schema_json`, and
|
page, appends it to the project order, deep-copies `ui_schema_json`, and
|
||||||
regenerates inline element IDs.
|
regenerates inline element IDs.
|
||||||
- Constructor page deletion uses the standard `DELETE /api/tour_pages/:id`
|
- Constructor page deletion uses the standard `DELETE /api/tour_pages/:id`
|
||||||
@ -431,12 +451,13 @@ module.exports = createEntityRouter(
|
|||||||
Element_type_defaultsService,
|
Element_type_defaultsService,
|
||||||
Element_type_defaultsDBApi,
|
Element_type_defaultsDBApi,
|
||||||
{
|
{
|
||||||
permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions
|
permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
```
|
```
|
||||||
|
|
||||||
**URL Aliases:**
|
**URL Aliases:**
|
||||||
|
|
||||||
- `/api/element-type-defaults` (primary)
|
- `/api/element-type-defaults` (primary)
|
||||||
- `/api/ui-elements` (backwards compatibility)
|
- `/api/ui-elements` (backwards compatibility)
|
||||||
|
|
||||||
@ -462,21 +483,24 @@ baseRouter.get('/:id/diff', ...);
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Standard CRUD Endpoints:** (via factory)
|
**Standard CRUD Endpoints:** (via factory)
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| POST | `/` | Create project element default |
|
| ------ | ------ | --------------------------------- |
|
||||||
| PUT | `/:id` | Update project element default |
|
| POST | `/` | Create project element default |
|
||||||
| DELETE | `/:id` | Delete project element default |
|
| PUT | `/:id` | Update project element default |
|
||||||
| GET | `/` | List project element defaults |
|
| DELETE | `/:id` | Delete project element default |
|
||||||
| GET | `/:id` | Get project element default by ID |
|
| GET | `/` | List project element defaults |
|
||||||
|
| GET | `/:id` | Get project element default by ID |
|
||||||
|
|
||||||
**Custom Endpoints:**
|
**Custom Endpoints:**
|
||||||
| Method | Path | Description |
|
|
||||||
|--------|------|-------------|
|
| Method | Path | Description |
|
||||||
| POST | `/:id/reset` | Reset project element default to global |
|
| ------ | ------------ | ----------------------------------------- |
|
||||||
| GET | `/:id/diff` | Get diff from global element type default |
|
| POST | `/:id/reset` | Reset project element default to global |
|
||||||
|
| GET | `/:id/diff` | Get diff from global element type default |
|
||||||
|
|
||||||
**URL Alias:**
|
**URL Alias:**
|
||||||
|
|
||||||
- `/api/project-element-defaults` (primary)
|
- `/api/project-element-defaults` (primary)
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -486,17 +510,19 @@ baseRouter.get('/:id/diff', ...);
|
|||||||
### List Endpoint (GET /)
|
### List Endpoint (GET /)
|
||||||
|
|
||||||
**Query Parameters:**
|
**Query Parameters:**
|
||||||
| Param | Type | Description |
|
|
||||||
|-------|------|-------------|
|
| Param | Type | Description |
|
||||||
| `page` | number | Page number (0-indexed) |
|
| ------------------ | ------ | ------------------------------ |
|
||||||
| `limit` | number | Items per page |
|
| `page` | number | Page number (0-indexed) |
|
||||||
| `field` | string | Sort field |
|
| `limit` | number | Items per page |
|
||||||
| `sort` | string | Sort direction (`asc`/`desc`) |
|
| `field` | string | Sort field |
|
||||||
| `filetype` | string | Export format (`csv`) |
|
| `sort` | string | Sort direction (`asc`/`desc`) |
|
||||||
| `[fieldName]` | string | Filter by field value |
|
| `filetype` | string | Export format (`csv`) |
|
||||||
| `[fieldName]Range` | array | Filter by range `[start, end]` |
|
| `[fieldName]` | string | Filter by field value |
|
||||||
|
| `[fieldName]Range` | array | Filter by range `[start, end]` |
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"rows": [...],
|
"rows": [...],
|
||||||
@ -507,6 +533,7 @@ baseRouter.get('/:id/diff', ...);
|
|||||||
### Create Endpoint (POST /)
|
### Create Endpoint (POST /)
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": {
|
"data": {
|
||||||
@ -517,6 +544,7 @@ baseRouter.get('/:id/diff', ...);
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
@ -529,6 +557,7 @@ baseRouter.get('/:id/diff', ...);
|
|||||||
### Update Endpoint (PUT /:id)
|
### Update Endpoint (PUT /:id)
|
||||||
|
|
||||||
**Request:**
|
**Request:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"id": "uuid",
|
"id": "uuid",
|
||||||
@ -539,6 +568,7 @@ baseRouter.get('/:id/diff', ...);
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Response:**
|
**Response:**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
true
|
true
|
||||||
```
|
```
|
||||||
@ -546,11 +576,13 @@ true
|
|||||||
### Delete Endpoints
|
### Delete Endpoints
|
||||||
|
|
||||||
**Single Delete (DELETE /:id):**
|
**Single Delete (DELETE /:id):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
true
|
true
|
||||||
```
|
```
|
||||||
|
|
||||||
**Bulk Delete (POST /deleteByIds):**
|
**Bulk Delete (POST /deleteByIds):**
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"data": ["uuid1", "uuid2", "uuid3"]
|
"data": ["uuid1", "uuid2", "uuid3"]
|
||||||
@ -625,15 +657,16 @@ router.use('/', require('../helpers').commonErrorHandler);
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Error Response Mapping:**
|
**Error Response Mapping:**
|
||||||
| Status Code | Description |
|
|
||||||
|-------------|-------------|
|
| Status Code | Description |
|
||||||
| 400 | Bad Request |
|
| ----------- | --------------------- |
|
||||||
| 401 | Unauthorized |
|
| 400 | Bad Request |
|
||||||
| 403 | Forbidden |
|
| 401 | Unauthorized |
|
||||||
| 404 | Not Found |
|
| 403 | Forbidden |
|
||||||
| 409 | Conflict |
|
| 404 | Not Found |
|
||||||
| 422 | Unprocessable Entity |
|
| 409 | Conflict |
|
||||||
| 500 | Internal Server Error |
|
| 422 | Unprocessable Entity |
|
||||||
|
| 500 | Internal Server Error |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -641,49 +674,50 @@ router.use('/', require('../helpers').commonErrorHandler);
|
|||||||
|
|
||||||
### Public Routes (No Auth)
|
### Public Routes (No Auth)
|
||||||
|
|
||||||
| Route | Description |
|
| Route | Description |
|
||||||
|-------|-------------|
|
| -------------------------------- | ----------------------- |
|
||||||
| `GET /api/health` | Health check |
|
| `GET /api/health` | Health check |
|
||||||
| `POST /api/auth/signin/local` | Login |
|
| `POST /api/auth/signin/local` | Login |
|
||||||
| `GET /api/auth/signin/google` | Google OAuth |
|
| `GET /api/auth/signin/google` | Google OAuth |
|
||||||
| `GET /api/auth/signin/microsoft` | Microsoft OAuth |
|
| `GET /api/auth/signin/microsoft` | Microsoft OAuth |
|
||||||
| `GET /api/file/download` | File download |
|
| `GET /api/file/download` | File download |
|
||||||
| `POST /api/file/presign` | Generate presigned URLs |
|
| `POST /api/file/presign` | Generate presigned URLs |
|
||||||
| `GET /api/runtime-context` | Runtime context |
|
| `GET /api/runtime-context` | Runtime context |
|
||||||
|
|
||||||
### Runtime Public Routes (Production Environment)
|
### Runtime Public Routes (Production Environment)
|
||||||
|
|
||||||
| Route | Description |
|
| Route | Description |
|
||||||
|-------|-------------|
|
| ------------------------------- | ----------------------------- |
|
||||||
| `GET /api/projects` | List projects (sanitized) |
|
| `GET /api/projects` | List projects (sanitized) |
|
||||||
| `GET /api/tour_pages` | List tour pages (sanitized) |
|
| `GET /api/tour_pages` | List tour pages (sanitized) |
|
||||||
| `GET /api/project_audio_tracks` | List audio tracks (sanitized) |
|
| `GET /api/project_audio_tracks` | List audio tracks (sanitized) |
|
||||||
|
|
||||||
### Authenticated Routes (JWT Required)
|
### Authenticated Routes (JWT Required)
|
||||||
|
|
||||||
All other routes require JWT authentication via:
|
All other routes require JWT authentication via:
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
app.use('/api/users', jwtAuth, usersRoutes);
|
app.use('/api/users', jwtAuth, usersRoutes);
|
||||||
```
|
```
|
||||||
|
|
||||||
### Rate Limited Routes
|
### Rate Limited Routes
|
||||||
|
|
||||||
| Route | Limiter | Config |
|
| Route | Limiter | Config |
|
||||||
|-------|---------|--------|
|
| ------------------------------------- | -------------------- | -------- |
|
||||||
| `/api/auth/signin/local` | authLimiter | 10/15min |
|
| `/api/auth/signin/local` | authLimiter | 10/15min |
|
||||||
| `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour |
|
| `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour |
|
||||||
| `/api/file/upload*` | uploadLimiter | 10/min |
|
| `/api/file/upload*` | uploadLimiter | 10/min |
|
||||||
| `/api/file/download`, `/presign` | downloadLimiter | 200/min |
|
| `/api/file/download`, `/presign` | downloadLimiter | 200/min |
|
||||||
| `/api/search` | searchLimiter | 30/min |
|
| `/api/search` | searchLimiter | 30/min |
|
||||||
|
|
||||||
## Dependencies
|
## Dependencies
|
||||||
|
|
||||||
| Package | Purpose |
|
| Package | Purpose |
|
||||||
|---------|---------|
|
| ------------- | ------------------------ |
|
||||||
| `express` | Router and middleware |
|
| `express` | Router and middleware |
|
||||||
| `json2csv` | CSV export functionality |
|
| `json2csv` | CSV export functionality |
|
||||||
| `passport` | JWT authentication |
|
| `passport` | JWT authentication |
|
||||||
| `body-parser` | JSON body parsing |
|
| `body-parser` | JSON body parsing |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -750,6 +784,7 @@ The Routes module provides:
|
|||||||
10. **Error Handling** - Centralized via wrapAsync and commonErrorHandler
|
10. **Error Handling** - Centralized via wrapAsync and commonErrorHandler
|
||||||
|
|
||||||
**Route Statistics:**
|
**Route Statistics:**
|
||||||
|
|
||||||
- 26 route files
|
- 26 route files
|
||||||
- ~50+ unique endpoints
|
- ~50+ unique endpoints
|
||||||
- 11 factory-generated entity routers
|
- 11 factory-generated entity routers
|
||||||
|
|||||||
@ -46,18 +46,18 @@ The Services module implements the **business logic layer** of the backend appli
|
|||||||
|
|
||||||
Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling.
|
Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling.
|
||||||
|
|
||||||
| Service | File | Entity | LOC |
|
| Service | File | Entity | LOC |
|
||||||
| -------------------------- | ------------------------------- | ---------------------------------------------- | ------ |
|
| -------------------------- | ------------------------------- | --------------------------------------------------------------------------- | ------ |
|
||||||
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 |
|
| tour_pages | `tour_pages.ts` | Tour Pages (reverse video generation plus targeted back-transition refresh) | ~1,500 |
|
||||||
| permissions | `permissions.ts` | Permissions | 6 |
|
| permissions | `permissions.ts` | Permissions | 6 |
|
||||||
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
|
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
|
||||||
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
|
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
|
||||||
| publish_events | `publish_events.ts` | Publish Events | 6 |
|
| publish_events | `publish_events.ts` | Publish Events | 6 |
|
||||||
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
|
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
|
||||||
| access_logs | `access_logs.ts` | Access Logs | 6 |
|
| access_logs | `access_logs.ts` | Access Logs | 6 |
|
||||||
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
|
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
|
||||||
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
|
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
|
||||||
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
|
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
|
||||||
|
|
||||||
**Example - Factory Service:**
|
**Example - Factory Service:**
|
||||||
|
|
||||||
@ -565,6 +565,16 @@ Phase G: Clone tour_pages, audio_tracks, element_defaults
|
|||||||
- Primary assets: `assets/{projectId}/{uuid}.ext`
|
- Primary assets: `assets/{projectId}/{uuid}.ext`
|
||||||
- Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID)
|
- Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID)
|
||||||
|
|
||||||
|
**Tour page transition safeguards:**
|
||||||
|
|
||||||
|
- Before save-time auto-reverse validation, targeted back buttons refresh their
|
||||||
|
transition fields from the current incoming forward element.
|
||||||
|
- If the source page's forward transition was removed, stale back-button
|
||||||
|
`transitionVideoUrl`, `transitionReverseMode`, and `reverseVideoUrl` values
|
||||||
|
are cleared before validation so old heavy videos do not block unrelated saves.
|
||||||
|
- Project-wide reverse regeneration performs the same refresh before queuing
|
||||||
|
missing reversed videos.
|
||||||
|
|
||||||
**Error Handling:**
|
**Error Handling:**
|
||||||
|
|
||||||
- Failed file copies fall back to original storage path (cloned project still functional, shares assets with source)
|
- Failed file copies fall back to original storage path (cloned project still functional, shares assets with source)
|
||||||
|
|||||||
@ -5,6 +5,7 @@
|
|||||||
The Utilities module provides centralized helper functions, error handling, logging, environment validation, and i18n message management. Utilities are organized across several locations based on their domain.
|
The Utilities module provides centralized helper functions, error handling, logging, environment validation, and i18n message management. Utilities are organized across several locations based on their domain.
|
||||||
|
|
||||||
**Locations:**
|
**Locations:**
|
||||||
|
|
||||||
- `backend/src/utils/` - Core utilities (errors, logging, env validation, request context)
|
- `backend/src/utils/` - Core utilities (errors, logging, env validation, request context)
|
||||||
- `backend/src/helpers.ts` - Request helpers (async wrapper, error handler, JWT)
|
- `backend/src/helpers.ts` - Request helpers (async wrapper, error handler, JWT)
|
||||||
- `backend/src/db/utils.ts` - Database utilities
|
- `backend/src/db/utils.ts` - Database utilities
|
||||||
@ -47,7 +48,7 @@ class AppError extends Error {
|
|||||||
super(message);
|
super(message);
|
||||||
this.statusCode = statusCode;
|
this.statusCode = statusCode;
|
||||||
this.details = details;
|
this.details = details;
|
||||||
this.isOperational = true; // Distinguishes from programming errors
|
this.isOperational = true; // Distinguishes from programming errors
|
||||||
Error.captureStackTrace(this, this.constructor);
|
Error.captureStackTrace(this, this.constructor);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -55,16 +56,17 @@ class AppError extends Error {
|
|||||||
|
|
||||||
**Error Types:**
|
**Error Types:**
|
||||||
|
|
||||||
| Class | Status Code | Default Message | Usage |
|
| Class | Status Code | Default Message | Usage |
|
||||||
|-------|-------------|-----------------|-------|
|
| ------------------- | ----------- | ---------------------- | ------------------- |
|
||||||
| `AppError` | 500 | (custom) | Base class |
|
| `AppError` | 500 | (custom) | Base class |
|
||||||
| `NotFoundError` | 404 | `{resource} not found` | Missing resources |
|
| `NotFoundError` | 404 | `{resource} not found` | Missing resources |
|
||||||
| `ValidationError` | 400 | (custom) | Invalid input |
|
| `ValidationError` | 400 | (custom) | Invalid input |
|
||||||
| `ForbiddenError` | 403 | `Access denied` | Permission denied |
|
| `ForbiddenError` | 403 | `Access denied` | Permission denied |
|
||||||
| `UnauthorizedError` | 401 | `Unauthorized` | Auth required |
|
| `UnauthorizedError` | 401 | `Unauthorized` | Auth required |
|
||||||
| `ConflictError` | 409 | `Resource conflict` | Duplicate resources |
|
| `ConflictError` | 409 | `Resource conflict` | Duplicate resources |
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const { NotFoundError, ValidationError, ForbiddenError } = require('./utils');
|
const { NotFoundError, ValidationError, ForbiddenError } = require('./utils');
|
||||||
|
|
||||||
@ -110,6 +112,7 @@ const logger = pino({
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Log Levels:**
|
**Log Levels:**
|
||||||
|
|
||||||
- `fatal` - Unrecoverable errors
|
- `fatal` - Unrecoverable errors
|
||||||
- `error` - Errors requiring attention
|
- `error` - Errors requiring attention
|
||||||
- `warn` - Warning conditions (400-499 responses)
|
- `warn` - Warning conditions (400-499 responses)
|
||||||
@ -139,6 +142,7 @@ process.on('unhandledRejection', (reason) => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Request Logger Middleware:**
|
**Request Logger Middleware:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
function requestLogger(req, res, next) {
|
function requestLogger(req, res, next) {
|
||||||
// Generate or use existing request ID
|
// Generate or use existing request ID
|
||||||
@ -162,9 +166,15 @@ function requestLogger(req, res, next) {
|
|||||||
|
|
||||||
// Log level based on status code
|
// Log level based on status code
|
||||||
if (res.statusCode >= 500) {
|
if (res.statusCode >= 500) {
|
||||||
getRequestLogger(req)?.error(logData, 'Request completed with server error');
|
getRequestLogger(req)?.error(
|
||||||
|
logData,
|
||||||
|
'Request completed with server error',
|
||||||
|
);
|
||||||
} else if (res.statusCode >= 400) {
|
} else if (res.statusCode >= 400) {
|
||||||
getRequestLogger(req)?.warn(logData, 'Request completed with client error');
|
getRequestLogger(req)?.warn(
|
||||||
|
logData,
|
||||||
|
'Request completed with client error',
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
getRequestLogger(req)?.info(logData, 'Request completed');
|
getRequestLogger(req)?.info(logData, 'Request completed');
|
||||||
}
|
}
|
||||||
@ -177,6 +187,7 @@ function requestLogger(req, res, next) {
|
|||||||
**Log Output Examples:**
|
**Log Output Examples:**
|
||||||
|
|
||||||
Development (pino-pretty):
|
Development (pino-pretty):
|
||||||
|
|
||||||
```
|
```
|
||||||
[12:34:56.789] INFO (tour-builder-api): Request completed
|
[12:34:56.789] INFO (tour-builder-api): Request completed
|
||||||
requestId: "abc-123"
|
requestId: "abc-123"
|
||||||
@ -187,11 +198,24 @@ Development (pino-pretty):
|
|||||||
```
|
```
|
||||||
|
|
||||||
Production (JSON):
|
Production (JSON):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"level":30,"time":1711723456789,"service":"tour-builder-api","env":"production","requestId":"abc-123","method":"GET","url":"/api/users","status":200,"duration":45,"msg":"Request completed"}
|
{
|
||||||
|
"level": 30,
|
||||||
|
"time": 1711723456789,
|
||||||
|
"service": "tour-builder-api",
|
||||||
|
"env": "production",
|
||||||
|
"requestId": "abc-123",
|
||||||
|
"method": "GET",
|
||||||
|
"url": "/api/users",
|
||||||
|
"status": 200,
|
||||||
|
"duration": 45,
|
||||||
|
"msg": "Request completed"
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const { logger, requestLogger } = require('./utils/logger');
|
const { logger, requestLogger } = require('./utils/logger');
|
||||||
|
|
||||||
@ -223,6 +247,7 @@ routes/services should read through `getCurrentUser`, `getRuntimeContext`,
|
|||||||
Joi-based validation ensuring all required environment variables are present with correct types.
|
Joi-based validation ensuring all required environment variables are present with correct types.
|
||||||
|
|
||||||
**Schema Definition:**
|
**Schema Definition:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const Joi = require('joi');
|
const Joi = require('joi');
|
||||||
|
|
||||||
@ -275,15 +300,16 @@ const envSchema = Joi.object({
|
|||||||
LOG_LEVEL: Joi.string()
|
LOG_LEVEL: Joi.string()
|
||||||
.valid('fatal', 'error', 'warn', 'info', 'debug', 'trace')
|
.valid('fatal', 'error', 'warn', 'info', 'debug', 'trace')
|
||||||
.default('info'),
|
.default('info'),
|
||||||
}).unknown(true); // Allow additional env vars
|
}).unknown(true); // Allow additional env vars
|
||||||
```
|
```
|
||||||
|
|
||||||
**Validation Function:**
|
**Validation Function:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
function validateEnv() {
|
function validateEnv() {
|
||||||
const { error, value } = envSchema.validate(process.env, {
|
const { error, value } = envSchema.validate(process.env, {
|
||||||
abortEarly: false, // Report all errors
|
abortEarly: false, // Report all errors
|
||||||
stripUnknown: false, // Keep unknown vars
|
stripUnknown: false, // Keep unknown vars
|
||||||
});
|
});
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
@ -298,22 +324,22 @@ function validateEnv() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return value; // Returns validated/defaulted values
|
return value; // Returns validated/defaulted values
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Environment Variable Categories:**
|
**Environment Variable Categories:**
|
||||||
|
|
||||||
| Category | Variables | Required |
|
| Category | Variables | Required |
|
||||||
|----------|-----------|----------|
|
| ----------------- | ----------------------------------------------------------------------------------------------- | -------- |
|
||||||
| **Server** | `NODE_ENV`, `PORT` | Defaults |
|
| **Server** | `NODE_ENV`, `PORT` | Defaults |
|
||||||
| **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | Defaults |
|
| **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | Defaults |
|
||||||
| **Auth** | `SECRET_KEY`, `ADMIN_PASS`, `USER_PASS`, `ADMIN_EMAIL` | Defaults |
|
| **Auth** | `SECRET_KEY`, `ADMIN_PASS`, `USER_PASS`, `ADMIN_EMAIL` | Defaults |
|
||||||
| **OAuth** | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | Optional |
|
| **OAuth** | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | Optional |
|
||||||
| **AWS S3** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`, `AWS_S3_REGION`, `AWS_S3_PREFIX` | Optional |
|
| **AWS S3** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`, `AWS_S3_REGION`, `AWS_S3_PREFIX` | Optional |
|
||||||
| **Email** | `EMAIL_USER`, `EMAIL_PASS`, `EMAIL_TLS_REJECT_UNAUTHORIZED` | Optional |
|
| **Email** | `EMAIL_USER`, `EMAIL_PASS`, `EMAIL_TLS_REJECT_UNAUTHORIZED` | Optional |
|
||||||
| **External APIs** | `PEXELS_KEY` | Optional |
|
| **External APIs** | `PEXELS_KEY` | Optional |
|
||||||
| **Logging** | `LOG_LEVEL` | Defaults |
|
| **Logging** | `LOG_LEVEL` | Defaults |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -330,6 +356,7 @@ module.exports = {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Exported:**
|
**Exported:**
|
||||||
|
|
||||||
- `AppError`, `NotFoundError`, `ValidationError`, `ForbiddenError`, `UnauthorizedError`, `ConflictError`
|
- `AppError`, `NotFoundError`, `ValidationError`, `ForbiddenError`, `UnauthorizedError`, `ConflictError`
|
||||||
- `logger`, `requestLogger`, `registerProcessErrorHandlers`,
|
- `logger`, `requestLogger`, `registerProcessErrorHandlers`,
|
||||||
`exitAfterLogging`,
|
`exitAfterLogging`,
|
||||||
@ -382,12 +409,12 @@ module.exports = class Helpers {
|
|||||||
|
|
||||||
**Functions:**
|
**Functions:**
|
||||||
|
|
||||||
| Function | Purpose | Usage |
|
| Function | Purpose | Usage |
|
||||||
|----------|---------|-------|
|
| ----------------------------------------- | ---------------------------------------- | -------------------------- |
|
||||||
| `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers |
|
| `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers |
|
||||||
| `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware |
|
| `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware |
|
||||||
| `jwtSign(data)` | Creates JWT with 6h expiry | Auth service |
|
| `jwtSign(data)` | Creates JWT with 6h expiry | Auth service |
|
||||||
| `isUuidV4(value)` | Validates UUID v4 format | Route parameter validation |
|
| `isUuidV4(value)` | Validates UUID v4 format | Route parameter validation |
|
||||||
|
|
||||||
## Request Validation
|
## Request Validation
|
||||||
|
|
||||||
@ -433,18 +460,22 @@ Request validation errors return JSON:
|
|||||||
Service/domain `ValidationError` responses keep the legacy plain-text format unless they are raised by request validation middleware.
|
Service/domain `ValidationError` responses keep the legacy plain-text format unless they are raised by request validation middleware.
|
||||||
|
|
||||||
**Usage Pattern:**
|
**Usage Pattern:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
||||||
|
|
||||||
// Async route handler
|
// Async route handler
|
||||||
router.get('/users/:id', wrapAsync(async (req, res) => {
|
router.get(
|
||||||
if (!isUuidV4(req.params.id)) {
|
'/users/:id',
|
||||||
return res.status(400).send('Invalid ID format');
|
wrapAsync(async (req, res) => {
|
||||||
}
|
if (!isUuidV4(req.params.id)) {
|
||||||
|
return res.status(400).send('Invalid ID format');
|
||||||
|
}
|
||||||
|
|
||||||
const user = await UserService.findOne(req.params.id);
|
const user = await UserService.findOne(req.params.id);
|
||||||
res.json(user);
|
res.json(user);
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
// Register error handler at end of router
|
// Register error handler at end of router
|
||||||
router.use('/', commonErrorHandler);
|
router.use('/', commonErrorHandler);
|
||||||
@ -489,19 +520,21 @@ module.exports = class Utils {
|
|||||||
|
|
||||||
**Functions:**
|
**Functions:**
|
||||||
|
|
||||||
| Function | Purpose | Returns |
|
| Function | Purpose | Returns |
|
||||||
|----------|---------|---------|
|
| ----------------------------- | -------------------------------- | ---------------------- |
|
||||||
| `isValidUuid(value)` | Check if value is a valid UUID | `boolean` |
|
| `isValidUuid(value)` | Check if value is a valid UUID | `boolean` |
|
||||||
| `generateUuid()` | Generate a new UUID v4 | `string` |
|
| `generateUuid()` | Generate a new UUID v4 | `string` |
|
||||||
| `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` |
|
| `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` |
|
||||||
| `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause |
|
| `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause |
|
||||||
|
|
||||||
**UUID Validation Behavior:**
|
**UUID Validation Behavior:**
|
||||||
|
|
||||||
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
||||||
- Invalid UUIDs in relation filters (`?project=uuid|name`) → filtered out for ID search, kept for text search
|
- Invalid UUIDs in relation filters (`?project=uuid|name`) → filtered out for ID search, kept for text search
|
||||||
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
||||||
|
|
||||||
**Usage in DB API:**
|
**Usage in DB API:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const Utils = require('../utils');
|
const Utils = require('../utils');
|
||||||
|
|
||||||
@ -596,7 +629,8 @@ const errors = {
|
|||||||
errors: {
|
errors: {
|
||||||
invalidFileEmpty: 'The file is empty',
|
invalidFileEmpty: 'The file is empty',
|
||||||
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
|
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
|
||||||
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.',
|
invalidFileUpload:
|
||||||
|
'Invalid file. Make sure you are using the last version of the template.',
|
||||||
importHashRequired: 'Import hash is required',
|
importHashRequired: 'Import hash is required',
|
||||||
importHashExistent: 'Data has already been imported',
|
importHashExistent: 'Data has already been imported',
|
||||||
userEmailMissing: 'Some items in the CSV do not have an email',
|
userEmailMissing: 'Some items in the CSV do not have an email',
|
||||||
@ -628,14 +662,14 @@ const errors = {
|
|||||||
|
|
||||||
**Message Categories:**
|
**Message Categories:**
|
||||||
|
|
||||||
| Category | Purpose | Examples |
|
| Category | Purpose | Examples |
|
||||||
|----------|---------|----------|
|
| ---------- | ---------------------- | ----------------------------------------- |
|
||||||
| `app` | Application metadata | `app.title` |
|
| `app` | Application metadata | `app.title` |
|
||||||
| `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` |
|
| `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` |
|
||||||
| `iam` | User management errors | `iam.errors.userAlreadyExists` |
|
| `iam` | User management errors | `iam.errors.userAlreadyExists` |
|
||||||
| `importer` | Import/export errors | `importer.errors.invalidFileEmpty` |
|
| `importer` | Import/export errors | `importer.errors.invalidFileEmpty` |
|
||||||
| `errors` | Generic errors | `errors.forbidden.message` |
|
| `errors` | Generic errors | `errors.forbidden.message` |
|
||||||
| `emails` | Email templates | `emails.invitation.subject` |
|
| `emails` | Email templates | `emails.invitation.subject` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -670,6 +704,7 @@ const getNotification = (key, ...args) => {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const { getNotification, isNotification } = require('./helpers');
|
const { getNotification, isNotification } = require('./helpers');
|
||||||
|
|
||||||
@ -682,8 +717,8 @@ getNotification('emails.invitation.subject', 'Tour Builder');
|
|||||||
// → "You've been invited to Tour Builder"
|
// → "You've been invited to Tour Builder"
|
||||||
|
|
||||||
// Check existence
|
// Check existence
|
||||||
isNotification('auth.userDisabled'); // → true
|
isNotification('auth.userDisabled'); // → true
|
||||||
isNotification('unknown.key'); // → false
|
isNotification('unknown.key'); // → false
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@ -693,6 +728,7 @@ isNotification('unknown.key'); // → false
|
|||||||
i18n-aware error classes (legacy pattern, prefer `utils/errors.js`):
|
i18n-aware error classes (legacy pattern, prefer `utils/errors.js`):
|
||||||
|
|
||||||
**ForbiddenError:**
|
**ForbiddenError:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const { getNotification, isNotification } = require('../helpers');
|
const { getNotification, isNotification } = require('../helpers');
|
||||||
|
|
||||||
@ -713,6 +749,7 @@ module.exports = class ForbiddenError extends Error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**ValidationError:**
|
**ValidationError:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
module.exports = class ValidationError extends Error {
|
module.exports = class ValidationError extends Error {
|
||||||
constructor(messageCode) {
|
constructor(messageCode) {
|
||||||
@ -731,6 +768,7 @@ module.exports = class ValidationError extends Error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**Usage:**
|
**Usage:**
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
const ForbiddenError = require('./services/notifications/errors/forbidden');
|
const ForbiddenError = require('./services/notifications/errors/forbidden');
|
||||||
const ValidationError = require('./services/notifications/errors/validation');
|
const ValidationError = require('./services/notifications/errors/validation');
|
||||||
@ -740,7 +778,7 @@ throw new ForbiddenError('auth.forbidden');
|
|||||||
throw new ValidationError('iam.errors.emailRequired');
|
throw new ValidationError('iam.errors.emailRequired');
|
||||||
|
|
||||||
// With default message
|
// With default message
|
||||||
throw new ForbiddenError(); // → 'Forbidden'
|
throw new ForbiddenError(); // → 'Forbidden'
|
||||||
throw new ValidationError(); // → 'An error occurred'
|
throw new ValidationError(); // → 'An error occurred'
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -804,46 +842,46 @@ const { getNotification } = require('./services/notifications/helpers');
|
|||||||
|
|
||||||
### Error Class Selection
|
### Error Class Selection
|
||||||
|
|
||||||
| Scenario | Recommended Class |
|
| Scenario | Recommended Class |
|
||||||
|----------|-------------------|
|
| ------------------ | ------------------------------------------- |
|
||||||
| Resource not found | `NotFoundError` from `utils/errors.js` |
|
| Resource not found | `NotFoundError` from `utils/errors.js` |
|
||||||
| Invalid input | `ValidationError` from `utils/errors.js` |
|
| Invalid input | `ValidationError` from `utils/errors.js` |
|
||||||
| Permission denied | `ForbiddenError` from `utils/errors.js` |
|
| Permission denied | `ForbiddenError` from `utils/errors.js` |
|
||||||
| Auth required | `UnauthorizedError` from `utils/errors.js` |
|
| Auth required | `UnauthorizedError` from `utils/errors.js` |
|
||||||
| Duplicate resource | `ConflictError` from `utils/errors.js` |
|
| Duplicate resource | `ConflictError` from `utils/errors.js` |
|
||||||
| i18n error message | Legacy classes from `notifications/errors/` |
|
| i18n error message | Legacy classes from `notifications/errors/` |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Environment Variables Reference
|
## Environment Variables Reference
|
||||||
|
|
||||||
| Variable | Type | Default | Description |
|
| Variable | Type | Default | Description |
|
||||||
|----------|------|---------|-------------|
|
| ------------------------------- | ------ | -------------------------- | ------------------------------------------------ |
|
||||||
| `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` |
|
| `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` |
|
||||||
| `PORT` | number | `8080` | Server port |
|
| `PORT` | number | `8080` | Server port |
|
||||||
| `DB_HOST` | string | `localhost` | PostgreSQL host |
|
| `DB_HOST` | string | `localhost` | PostgreSQL host |
|
||||||
| `DB_PORT` | number | `5432` | PostgreSQL port |
|
| `DB_PORT` | number | `5432` | PostgreSQL port |
|
||||||
| `DB_NAME` | string | `db_tour_builder_platform` | Database name |
|
| `DB_NAME` | string | `db_tour_builder_platform` | Database name |
|
||||||
| `DB_USER` | string | `postgres` | Database user |
|
| `DB_USER` | string | `postgres` | Database user |
|
||||||
| `DB_PASS` | string | `` | Database password |
|
| `DB_PASS` | string | `` | Database password |
|
||||||
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
|
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
|
||||||
| `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email |
|
| `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email |
|
||||||
| `ADMIN_PASS` | string | `88dbeaf8` | Admin account password |
|
| `ADMIN_PASS` | string | `88dbeaf8` | Admin account password |
|
||||||
| `USER_PASS` | string | `c3baadeda5c6` | Default user password |
|
| `USER_PASS` | string | `c3baadeda5c6` | Default user password |
|
||||||
| `GOOGLE_CLIENT_ID` | string | `` | Google OAuth client ID |
|
| `GOOGLE_CLIENT_ID` | string | `` | Google OAuth client ID |
|
||||||
| `GOOGLE_CLIENT_SECRET` | string | `` | Google OAuth client secret |
|
| `GOOGLE_CLIENT_SECRET` | string | `` | Google OAuth client secret |
|
||||||
| `MS_CLIENT_ID` | string | `` | Microsoft OAuth client ID |
|
| `MS_CLIENT_ID` | string | `` | Microsoft OAuth client ID |
|
||||||
| `MS_CLIENT_SECRET` | string | `` | Microsoft OAuth client secret |
|
| `MS_CLIENT_SECRET` | string | `` | Microsoft OAuth client secret |
|
||||||
| `AWS_ACCESS_KEY_ID` | string | `` | AWS access key |
|
| `AWS_ACCESS_KEY_ID` | string | `` | AWS access key |
|
||||||
| `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key |
|
| `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key |
|
||||||
| `AWS_S3_BUCKET` | string | `` | S3 bucket name |
|
| `AWS_S3_BUCKET` | string | `` | S3 bucket name |
|
||||||
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
|
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
|
||||||
| `AWS_S3_PREFIX` | string | UUID | S3 key prefix |
|
| `AWS_S3_PREFIX` | string | UUID | S3 key prefix |
|
||||||
| `EMAIL_USER` | string | `` | SMTP username |
|
| `EMAIL_USER` | string | `` | SMTP username |
|
||||||
| `EMAIL_PASS` | string | `` | SMTP password |
|
| `EMAIL_PASS` | string | `` | SMTP password |
|
||||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation |
|
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation |
|
||||||
| `PEXELS_KEY` | string | `` | Pexels API key |
|
| `PEXELS_KEY` | string | `` | Pexels API key |
|
||||||
| `LOG_LEVEL` | string | `info` | Pino log level |
|
| `LOG_LEVEL` | string | `info` | Pino log level |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@ -5,13 +5,13 @@
|
|||||||
Backend tests use Node.js 24's built-in test runner and TypeScript test files.
|
Backend tests use Node.js 24's built-in test runner and TypeScript test files.
|
||||||
The suite is split into three layers:
|
The suite is split into three layers:
|
||||||
|
|
||||||
| Layer | Command | Location | Purpose |
|
| Layer | Command | Location | Purpose |
|
||||||
|-------|---------|----------|---------|
|
| ------------ | -------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||||
| Unit | `npm run test` | `backend/tests/*.test.ts` | Pure helpers, validators, policy decisions, service contracts, and file/session utilities |
|
| Unit | `npm run test` | `backend/tests/*.test.ts` | Pure helpers, validators, policy decisions, service contracts, and file/session utilities |
|
||||||
| Integration | `npm run test:integration` | `backend/tests/integration/*.test.ts` | Cross-module behavior such as DB-backed access policy and Express router factory contracts |
|
| Integration | `npm run test:integration` | `backend/tests/integration/*.test.ts` | Cross-module behavior such as DB-backed access policy and Express router factory contracts |
|
||||||
| E2E | `npm run test:e2e` | `backend/tests/e2e/*.test.ts` | Real HTTP request/response checks against local Express test apps |
|
| E2E | `npm run test:e2e` | `backend/tests/e2e/*.test.ts` | Real HTTP request/response checks against local Express test apps |
|
||||||
| Full suite | `npm run test:all` | all test folders | Runs unit, integration, and e2e in sequence |
|
| Full suite | `npm run test:all` | all test folders | Runs unit, integration, and e2e in sequence |
|
||||||
| Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite |
|
| Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite |
|
||||||
|
|
||||||
## Current Coverage
|
## Current Coverage
|
||||||
|
|
||||||
|
|||||||
@ -32,7 +32,9 @@ function toProjectPath(filePath: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isHistoricalMigration(projectPath: string): boolean {
|
function isHistoricalMigration(projectPath: string): boolean {
|
||||||
return projectPath.startsWith('src/db/migrations/') && projectPath.endsWith('.js');
|
return (
|
||||||
|
projectPath.startsWith('src/db/migrations/') && projectPath.endsWith('.js')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkJsBoundary(projectPath: string): BoundaryViolation | null {
|
function checkJsBoundary(projectPath: string): BoundaryViolation | null {
|
||||||
@ -44,7 +46,10 @@ function checkJsBoundary(projectPath: string): BoundaryViolation | null {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkTsBoundary(projectPath: string, source: string): BoundaryViolation | null {
|
function checkTsBoundary(
|
||||||
|
projectPath: string,
|
||||||
|
source: string,
|
||||||
|
): BoundaryViolation | null {
|
||||||
if (!commonJsPattern.test(source)) return null;
|
if (!commonJsPattern.test(source)) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -70,7 +75,9 @@ async function checkFile(filePath: string): Promise<BoundaryViolation | null> {
|
|||||||
|
|
||||||
async function main(): Promise<void> {
|
async function main(): Promise<void> {
|
||||||
const files = (
|
const files = (
|
||||||
await Promise.all(sourceRoots.map((root) => collectFiles(path.join(process.cwd(), root))))
|
await Promise.all(
|
||||||
|
sourceRoots.map((root) => collectFiles(path.join(process.cwd(), root))),
|
||||||
|
)
|
||||||
).flat();
|
).flat();
|
||||||
const checks = await Promise.all(files.map((file) => checkFile(file)));
|
const checks = await Promise.all(files.map((file) => checkFile(file)));
|
||||||
const violations = checks.filter((violation) => violation !== null);
|
const violations = checks.filter((violation) => violation !== null);
|
||||||
|
|||||||
@ -10,7 +10,10 @@ import config from '../config.ts';
|
|||||||
import db from '../db/models/index.ts';
|
import db from '../db/models/index.ts';
|
||||||
import UsersDBApi from '../db/api/users.ts';
|
import UsersDBApi from '../db/api/users.ts';
|
||||||
import { jwtSign } from '../helpers.ts';
|
import { jwtSign } from '../helpers.ts';
|
||||||
import { setCurrentUser, setSocialAuthToken } from '../utils/request-context.ts';
|
import {
|
||||||
|
setCurrentUser,
|
||||||
|
setSocialAuthToken,
|
||||||
|
} from '../utils/request-context.ts';
|
||||||
import type {
|
import type {
|
||||||
AuthTokenPayload,
|
AuthTokenPayload,
|
||||||
CurrentUser,
|
CurrentUser,
|
||||||
@ -111,9 +114,11 @@ async function socialStrategy(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate({
|
const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate(
|
||||||
where: { email, provider },
|
{
|
||||||
});
|
where: { email, provider },
|
||||||
|
},
|
||||||
|
);
|
||||||
const body: AuthTokenPayload['user'] = {
|
const body: AuthTokenPayload['user'] = {
|
||||||
id: user.id,
|
id: user.id,
|
||||||
email: user.email,
|
email: user.email,
|
||||||
@ -165,11 +170,7 @@ passport.use(
|
|||||||
secretOrKey: config.secret_key,
|
secretOrKey: config.secret_key,
|
||||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||||
},
|
},
|
||||||
(
|
(req: Request, token: unknown, done: VerifiedCallback) => {
|
||||||
req: Request,
|
|
||||||
token: unknown,
|
|
||||||
done: VerifiedCallback,
|
|
||||||
) => {
|
|
||||||
void verifyJwt(req, token, done);
|
void verifyJwt(req, token, done);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
@ -47,7 +47,9 @@ function authenticatePassport(
|
|||||||
const middleware: unknown = passport.authenticate(strategy, options);
|
const middleware: unknown = passport.authenticate(strategy, options);
|
||||||
|
|
||||||
if (!isRequestHandler(middleware)) {
|
if (!isRequestHandler(middleware)) {
|
||||||
throw new Error(`Passport ${strategy} authentication middleware is unavailable.`);
|
throw new Error(
|
||||||
|
`Passport ${strategy} authentication middleware is unavailable.`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return middleware;
|
return middleware;
|
||||||
|
|||||||
@ -73,7 +73,9 @@ class Asset_variantsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static override getFieldMapping(data: AssetVariantData): AssetVariantFieldMapping {
|
static override getFieldMapping(
|
||||||
|
data: AssetVariantData,
|
||||||
|
): AssetVariantFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
assetId: data.assetId || null,
|
assetId: data.assetId || null,
|
||||||
|
|||||||
@ -88,15 +88,12 @@ function isGenericDbModel(value: unknown): value is GenericDbModel {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return 'getTableName' in value && typeof value.getTableName === 'function';
|
||||||
'getTableName' in value &&
|
|
||||||
typeof value.getTableName === 'function'
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTransactionOptions(
|
function buildTransactionOptions(transaction: Transaction | undefined): {
|
||||||
transaction: Transaction | undefined,
|
transaction?: Transaction | undefined;
|
||||||
): { transaction?: Transaction | undefined } {
|
} {
|
||||||
const options: { transaction?: Transaction | undefined } = {};
|
const options: { transaction?: Transaction | undefined } = {};
|
||||||
if (transaction !== undefined) {
|
if (transaction !== undefined) {
|
||||||
options.transaction = transaction;
|
options.transaction = transaction;
|
||||||
@ -139,12 +136,17 @@ function addRangeFilter(where: DbData, field: string, range: unknown): void {
|
|||||||
addRangeBoundary(where, field, Op.lte, end);
|
addRangeBoundary(where, field, Op.lte, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilterString(filter: GenericDbListFilter, field: string): string | null {
|
function getFilterString(
|
||||||
|
filter: GenericDbListFilter,
|
||||||
|
field: string,
|
||||||
|
): string | null {
|
||||||
const value = filter[field];
|
const value = filter[field];
|
||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? value : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDbFindAllOptions(value: unknown): value is DbFindAllOptions<unknown> {
|
function isDbFindAllOptions(
|
||||||
|
value: unknown,
|
||||||
|
): value is DbFindAllOptions<unknown> {
|
||||||
return (
|
return (
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
('filter' in value ||
|
('filter' in value ||
|
||||||
@ -495,9 +497,7 @@ class GenericDBApi {
|
|||||||
return record;
|
return record;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async findBy(
|
static async findBy(options: DbFindByOptions): Promise<EntityRecord | null>;
|
||||||
options: DbFindByOptions,
|
|
||||||
): Promise<EntityRecord | null>;
|
|
||||||
static async findBy(
|
static async findBy(
|
||||||
where: unknown,
|
where: unknown,
|
||||||
options?: ServiceOptions & { include?: unknown[] },
|
options?: ServiceOptions & { include?: unknown[] },
|
||||||
@ -514,7 +514,9 @@ class GenericDBApi {
|
|||||||
const rawTransaction = hasWhereOption
|
const rawTransaction = hasWhereOption
|
||||||
? maybeOptions.transaction
|
? maybeOptions.transaction
|
||||||
: options.transaction;
|
: options.transaction;
|
||||||
const transaction = isTransaction(rawTransaction) ? rawTransaction : undefined;
|
const transaction = isTransaction(rawTransaction)
|
||||||
|
? rawTransaction
|
||||||
|
: undefined;
|
||||||
const include =
|
const include =
|
||||||
hasWhereOption && Array.isArray(maybeOptions.include)
|
hasWhereOption && Array.isArray(maybeOptions.include)
|
||||||
? maybeOptions.include
|
? maybeOptions.include
|
||||||
@ -681,7 +683,8 @@ class GenericDBApi {
|
|||||||
queryOptions.transaction = options.transaction;
|
queryOptions.transaction = options.transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { rows, count } = await this.getModel().findAndCountAll(queryOptions);
|
const { rows, count } =
|
||||||
|
await this.getModel().findAndCountAll(queryOptions);
|
||||||
return {
|
return {
|
||||||
rows,
|
rows,
|
||||||
count,
|
count,
|
||||||
|
|||||||
@ -33,7 +33,9 @@ function isMissingTableError(error: unknown): boolean {
|
|||||||
return original.code === '42P01';
|
return original.code === '42P01';
|
||||||
}
|
}
|
||||||
|
|
||||||
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null {
|
function stringifySettings(
|
||||||
|
value: ElementSettingsJson | string | null | undefined,
|
||||||
|
): string | null {
|
||||||
if (value === undefined || value === null) return null;
|
if (value === undefined || value === null) return null;
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === 'string') return value;
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
@ -437,7 +439,9 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
|||||||
return super.deleteByIds(options);
|
return super.deleteByIds(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static override async remove(options: EntityIdOptions): Promise<EntityRecord> {
|
static override async remove(
|
||||||
|
options: EntityIdOptions,
|
||||||
|
): Promise<EntityRecord> {
|
||||||
await this.ensureInitialized();
|
await this.ensureInitialized();
|
||||||
return super.remove(options);
|
return super.remove(options);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,7 +11,9 @@ import type {
|
|||||||
RelationFileRecord,
|
RelationFileRecord,
|
||||||
} from '../../types/index.ts';
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
function normalizeRelationFiles(rawFiles: RelationFileInput): RelationFileRecord[] {
|
function normalizeRelationFiles(
|
||||||
|
rawFiles: RelationFileInput,
|
||||||
|
): RelationFileRecord[] {
|
||||||
if (Array.isArray(rawFiles)) return rawFiles;
|
if (Array.isArray(rawFiles)) return rawFiles;
|
||||||
return rawFiles ? [rawFiles] : [];
|
return rawFiles ? [rawFiles] : [];
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,7 +34,9 @@ function isGlobalTransitionListFilter(
|
|||||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isDbFindAllOptions(value: unknown): value is DbFindAllOptions<unknown> {
|
function isDbFindAllOptions(
|
||||||
|
value: unknown,
|
||||||
|
): value is DbFindAllOptions<unknown> {
|
||||||
return (
|
return (
|
||||||
value !== null &&
|
value !== null &&
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
|
|||||||
@ -28,13 +28,17 @@ import type {
|
|||||||
|
|
||||||
const { Op } = db.Sequelize;
|
const { Op } = db.Sequelize;
|
||||||
|
|
||||||
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null {
|
function stringifySettings(
|
||||||
|
value: ElementSettingsJson | string | null | undefined,
|
||||||
|
): string | null {
|
||||||
if (value === undefined || value === null) return null;
|
if (value === undefined || value === null) return null;
|
||||||
if (typeof value === 'string') return value;
|
if (typeof value === 'string') return value;
|
||||||
return JSON.stringify(value);
|
return JSON.stringify(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseSettings(value: ElementSettingsJson | string | null | undefined): ElementSettingsJson {
|
function parseSettings(
|
||||||
|
value: ElementSettingsJson | string | null | undefined,
|
||||||
|
): ElementSettingsJson {
|
||||||
if (!value) return {};
|
if (!value) return {};
|
||||||
if (typeof value !== 'string') return value;
|
if (typeof value !== 'string') return value;
|
||||||
|
|
||||||
@ -84,7 +88,9 @@ function isQueryWhere(value: unknown): value is QueryWhere {
|
|||||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRangeFilter(value: unknown): value is ProjectElementDefaultsRangeFilter {
|
function isRangeFilter(
|
||||||
|
value: unknown,
|
||||||
|
): value is ProjectElementDefaultsRangeFilter {
|
||||||
return Array.isArray(value) && value.length === 2;
|
return Array.isArray(value) && value.length === 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -229,7 +235,8 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
|||||||
whereOrOptions: { id: string } | DbFindByOptions,
|
whereOrOptions: { id: string } | DbFindByOptions,
|
||||||
options: ProjectElementDefaultsOptions = {},
|
options: ProjectElementDefaultsOptions = {},
|
||||||
): Promise<ProjectElementDefaultRecord | null> {
|
): Promise<ProjectElementDefaultRecord | null> {
|
||||||
const where = 'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
|
const where =
|
||||||
|
'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
|
||||||
const findOptions: {
|
const findOptions: {
|
||||||
where: QueryWhere;
|
where: QueryWhere;
|
||||||
include?: unknown[];
|
include?: unknown[];
|
||||||
@ -271,7 +278,8 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
|||||||
const offset = Math.max(currentPage - 1, 0) * limit;
|
const offset = Math.max(currentPage - 1, 0) * limit;
|
||||||
const where: QueryWhere = {};
|
const where: QueryWhere = {};
|
||||||
|
|
||||||
const projectFilter = normalizedFilter.project || normalizedFilter.projectId;
|
const projectFilter =
|
||||||
|
normalizedFilter.project || normalizedFilter.projectId;
|
||||||
const terms = projectFilter ? projectFilter.split('|') : [];
|
const terms = projectFilter ? projectFilter.split('|') : [];
|
||||||
const validUuids = Utils.filterValidUuids(terms);
|
const validUuids = Utils.filterValidUuids(terms);
|
||||||
const include: RuntimeProjectInclude[] = [
|
const include: RuntimeProjectInclude[] = [
|
||||||
@ -398,17 +406,19 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const seenTypes = new Set<string>();
|
const seenTypes = new Set<string>();
|
||||||
const dedupedDefaults = globalDefaults.rows.filter(isGlobalElementDefaultRecord).filter((row) => {
|
const dedupedDefaults = globalDefaults.rows
|
||||||
if (seenTypes.has(row.element_type)) {
|
.filter(isGlobalElementDefaultRecord)
|
||||||
logger.warn(
|
.filter((row) => {
|
||||||
{ elementType: row.element_type },
|
if (seenTypes.has(row.element_type)) {
|
||||||
'Duplicate element_type in global defaults skipped',
|
logger.warn(
|
||||||
);
|
{ elementType: row.element_type },
|
||||||
return false;
|
'Duplicate element_type in global defaults skipped',
|
||||||
}
|
);
|
||||||
seenTypes.add(row.element_type);
|
return false;
|
||||||
return true;
|
}
|
||||||
});
|
seenTypes.add(row.element_type);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const currentUserId = options.currentUser?.id || null;
|
const currentUserId = options.currentUser?.id || null;
|
||||||
|
|||||||
@ -49,7 +49,10 @@ function normalizeFilter(
|
|||||||
return isProjectListFilter(filter.filter) ? filter.filter : {};
|
return isProjectListFilter(filter.filter) ? filter.filter : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilterString(filter: ProjectListFilter, field: string): string | null {
|
function getFilterString(
|
||||||
|
filter: ProjectListFilter,
|
||||||
|
field: string,
|
||||||
|
): string | null {
|
||||||
const value = filter[field];
|
const value = filter[field];
|
||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? value : null;
|
||||||
}
|
}
|
||||||
@ -69,7 +72,11 @@ function addRangeBoundary(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
|
function addRangeFilter(
|
||||||
|
where: QueryWhere,
|
||||||
|
field: string,
|
||||||
|
range: unknown,
|
||||||
|
): void {
|
||||||
if (!isRangeFilter(range)) return;
|
if (!isRangeFilter(range)) return;
|
||||||
|
|
||||||
const [start, end] = range;
|
const [start, end] = range;
|
||||||
@ -85,7 +92,8 @@ function getDefinedProjectFields(data: ProjectData): ProjectFieldMapping {
|
|||||||
description: 'description' in data ? data.description || null : undefined,
|
description: 'description' in data ? data.description || null : undefined,
|
||||||
logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
|
logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
|
||||||
favicon_url: 'favicon_url' in data ? data.favicon_url || null : undefined,
|
favicon_url: 'favicon_url' in data ? data.favicon_url || null : undefined,
|
||||||
og_image_url: 'og_image_url' in data ? data.og_image_url || null : undefined,
|
og_image_url:
|
||||||
|
'og_image_url' in data ? data.og_image_url || null : undefined,
|
||||||
design_width: 'design_width' in data ? data.design_width : undefined,
|
design_width: 'design_width' in data ? data.design_width : undefined,
|
||||||
design_height: 'design_height' in data ? data.design_height : undefined,
|
design_height: 'design_height' in data ? data.design_height : undefined,
|
||||||
production_presentation_visibility:
|
production_presentation_visibility:
|
||||||
|
|||||||
@ -90,7 +90,9 @@ class Publish_eventsDBApi extends GenericDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static override getFieldMapping(data: PublishEventData): PublishEventFieldMapping {
|
static override getFieldMapping(
|
||||||
|
data: PublishEventData,
|
||||||
|
): PublishEventFieldMapping {
|
||||||
return {
|
return {
|
||||||
id: data.id || undefined,
|
id: data.id || undefined,
|
||||||
title: data.title || null,
|
title: data.title || null,
|
||||||
|
|||||||
@ -28,7 +28,9 @@ function getRuntimeEnvironment(
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRuntimeProjectSlug(options: RuntimeFilterOptions = {}): string | null {
|
function getRuntimeProjectSlug(
|
||||||
|
options: RuntimeFilterOptions = {},
|
||||||
|
): string | null {
|
||||||
const runtimeContext = getRuntimeContext(options);
|
const runtimeContext = getRuntimeContext(options);
|
||||||
return runtimeContext?.headerProjectSlug ?? null;
|
return runtimeContext?.headerProjectSlug ?? null;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,7 +52,10 @@ function normalizeFilter(
|
|||||||
return isTourPageListQuery(filter.filter) ? filter.filter : {};
|
return isTourPageListQuery(filter.filter) ? filter.filter : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
function getFilterString(filter: TourPageListQuery, field: string): string | null {
|
function getFilterString(
|
||||||
|
filter: TourPageListQuery,
|
||||||
|
field: string,
|
||||||
|
): string | null {
|
||||||
const value = filter[field];
|
const value = filter[field];
|
||||||
return typeof value === 'string' ? value : null;
|
return typeof value === 'string' ? value : null;
|
||||||
}
|
}
|
||||||
@ -72,7 +75,11 @@ function addRangeBoundary(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
|
function addRangeFilter(
|
||||||
|
where: QueryWhere,
|
||||||
|
field: string,
|
||||||
|
range: unknown,
|
||||||
|
): void {
|
||||||
if (!isRangeFilter(range)) return;
|
if (!isRangeFilter(range)) return;
|
||||||
|
|
||||||
const [start, end] = range;
|
const [start, end] = range;
|
||||||
@ -83,7 +90,8 @@ function addRangeFilter(where: QueryWhere, field: string, range: unknown): void
|
|||||||
function getProjectId(data: TourPageData): string | null {
|
function getProjectId(data: TourPageData): string | null {
|
||||||
if (typeof data.projectId === 'string') return data.projectId;
|
if (typeof data.projectId === 'string') return data.projectId;
|
||||||
if (typeof data.project === 'string') return data.project;
|
if (typeof data.project === 'string') return data.project;
|
||||||
if (data.project && typeof data.project.id === 'string') return data.project.id;
|
if (data.project && typeof data.project.id === 'string')
|
||||||
|
return data.project.id;
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -116,7 +116,11 @@ function addRangeBoundary(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
|
function addRangeFilter(
|
||||||
|
where: QueryWhere,
|
||||||
|
field: string,
|
||||||
|
range: unknown,
|
||||||
|
): void {
|
||||||
if (!isRangeFilter(range)) return;
|
if (!isRangeFilter(range)) return;
|
||||||
|
|
||||||
const [start, end] = range;
|
const [start, end] = range;
|
||||||
@ -128,9 +132,9 @@ function getCurrentUserId(options?: ServiceOptions): string | null {
|
|||||||
return options?.currentUser?.id ?? null;
|
return options?.currentUser?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildTransactionOptions(
|
function buildTransactionOptions(transaction: Transaction | undefined): {
|
||||||
transaction: Transaction | undefined,
|
transaction?: Transaction | undefined;
|
||||||
): { transaction?: Transaction | undefined } {
|
} {
|
||||||
const options: { transaction?: Transaction | undefined } = {};
|
const options: { transaction?: Transaction | undefined } = {};
|
||||||
if (transaction !== undefined) {
|
if (transaction !== undefined) {
|
||||||
options.transaction = transaction;
|
options.transaction = transaction;
|
||||||
@ -173,7 +177,8 @@ function buildUserUpdatePayload(
|
|||||||
|
|
||||||
if (data.firstName !== undefined) updatePayload.firstName = data.firstName;
|
if (data.firstName !== undefined) updatePayload.firstName = data.firstName;
|
||||||
if (data.lastName !== undefined) updatePayload.lastName = data.lastName;
|
if (data.lastName !== undefined) updatePayload.lastName = data.lastName;
|
||||||
if (data.phoneNumber !== undefined) updatePayload.phoneNumber = data.phoneNumber;
|
if (data.phoneNumber !== undefined)
|
||||||
|
updatePayload.phoneNumber = data.phoneNumber;
|
||||||
if (data.email !== undefined) updatePayload.email = data.email;
|
if (data.email !== undefined) updatePayload.email = data.email;
|
||||||
if (data.disabled !== undefined) updatePayload.disabled = data.disabled;
|
if (data.disabled !== undefined) updatePayload.disabled = data.disabled;
|
||||||
if (data.password !== undefined && data.password !== null) {
|
if (data.password !== undefined && data.password !== null) {
|
||||||
@ -195,7 +200,8 @@ function buildUserUpdatePayload(
|
|||||||
updatePayload.passwordResetToken = data.passwordResetToken;
|
updatePayload.passwordResetToken = data.passwordResetToken;
|
||||||
}
|
}
|
||||||
if (data.passwordResetTokenExpiresAt !== undefined) {
|
if (data.passwordResetTokenExpiresAt !== undefined) {
|
||||||
updatePayload.passwordResetTokenExpiresAt = data.passwordResetTokenExpiresAt;
|
updatePayload.passwordResetTokenExpiresAt =
|
||||||
|
data.passwordResetTokenExpiresAt;
|
||||||
}
|
}
|
||||||
if (data.provider !== undefined) updatePayload.provider = data.provider;
|
if (data.provider !== undefined) updatePayload.provider = data.provider;
|
||||||
|
|
||||||
@ -255,7 +261,9 @@ class UsersDBApi {
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
static async create(options: Parameters<UsersDbApi['create']>[0]): Promise<UserRecord> {
|
static async create(
|
||||||
|
options: Parameters<UsersDbApi['create']>[0],
|
||||||
|
): Promise<UserRecord> {
|
||||||
assertCreateOptions(options, 'DBApi');
|
assertCreateOptions(options, 'DBApi');
|
||||||
|
|
||||||
const { data: userData, transaction } = options;
|
const { data: userData, transaction } = options;
|
||||||
@ -342,12 +350,7 @@ class UsersDBApi {
|
|||||||
updateOptions: Parameters<UsersDbApi['update']>[0],
|
updateOptions: Parameters<UsersDbApi['update']>[0],
|
||||||
): Promise<UserRecord> {
|
): Promise<UserRecord> {
|
||||||
assertUpdateOptions(updateOptions, 'DBApi');
|
assertUpdateOptions(updateOptions, 'DBApi');
|
||||||
const {
|
const { id, data, transaction, runtimeContext } = updateOptions;
|
||||||
id,
|
|
||||||
data,
|
|
||||||
transaction,
|
|
||||||
runtimeContext,
|
|
||||||
} = updateOptions;
|
|
||||||
const dbOptions: ServiceOptions = {};
|
const dbOptions: ServiceOptions = {};
|
||||||
if (transaction !== undefined) {
|
if (transaction !== undefined) {
|
||||||
dbOptions.transaction = transaction;
|
dbOptions.transaction = transaction;
|
||||||
@ -359,7 +362,10 @@ class UsersDBApi {
|
|||||||
dbOptions.runtimeContext = runtimeContext;
|
dbOptions.runtimeContext = runtimeContext;
|
||||||
}
|
}
|
||||||
|
|
||||||
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
|
const users = await db.users.findByPk(
|
||||||
|
id,
|
||||||
|
buildTransactionOptions(transaction),
|
||||||
|
);
|
||||||
if (!users) {
|
if (!users) {
|
||||||
throw new Error('UsersNotFound');
|
throw new Error('UsersNotFound');
|
||||||
}
|
}
|
||||||
@ -379,9 +385,10 @@ class UsersDBApi {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!data?.custom_permissions) {
|
if (!data?.custom_permissions) {
|
||||||
const existingPermissionIds = users.custom_permissions?.flatMap((item) =>
|
const existingPermissionIds =
|
||||||
item.id ? [item.id] : [],
|
users.custom_permissions?.flatMap((item) =>
|
||||||
) || [];
|
item.id ? [item.id] : [],
|
||||||
|
) || [];
|
||||||
if (existingPermissionIds.length) {
|
if (existingPermissionIds.length) {
|
||||||
data.custom_permissions = existingPermissionIds;
|
data.custom_permissions = existingPermissionIds;
|
||||||
}
|
}
|
||||||
@ -410,9 +417,12 @@ class UsersDBApi {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.custom_permissions !== undefined) {
|
if (data.custom_permissions !== undefined) {
|
||||||
await users.setCustom_permissions(normalizePermissionIds(data.custom_permissions) || [], {
|
await users.setCustom_permissions(
|
||||||
transaction,
|
normalizePermissionIds(data.custom_permissions) || [],
|
||||||
});
|
{
|
||||||
|
transaction,
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
await FileDBApi.replaceRelationFiles(
|
await FileDBApi.replaceRelationFiles(
|
||||||
@ -461,7 +471,10 @@ class UsersDBApi {
|
|||||||
const { id, transaction } = options;
|
const { id, transaction } = options;
|
||||||
const currentUserId = getCurrentUserId(options);
|
const currentUserId = getCurrentUserId(options);
|
||||||
|
|
||||||
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
|
const users = await db.users.findByPk(
|
||||||
|
id,
|
||||||
|
buildTransactionOptions(transaction),
|
||||||
|
);
|
||||||
if (!users) {
|
if (!users) {
|
||||||
throw new Error('UsersNotFound');
|
throw new Error('UsersNotFound');
|
||||||
}
|
}
|
||||||
@ -539,19 +552,21 @@ class UsersDBApi {
|
|||||||
...buildTransactionOptions(transaction),
|
...buildTransactionOptions(transaction),
|
||||||
});
|
});
|
||||||
|
|
||||||
output.allowed_private_production_project_ids = productionPresentationAccess
|
output.allowed_private_production_project_ids =
|
||||||
.flatMap((row: UserProductionPresentationAccessRecord) => {
|
productionPresentationAccess.flatMap(
|
||||||
const plain = row.get({ plain: true });
|
(row: UserProductionPresentationAccessRecord) => {
|
||||||
const project = plain.project;
|
const plain = row.get({ plain: true });
|
||||||
if (!project?.id || !project.name || !project.slug) return [];
|
const project = plain.project;
|
||||||
|
if (!project?.id || !project.name || !project.slug) return [];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: project.id,
|
id: project.id,
|
||||||
label: `${project.name} (${project.slug})`,
|
label: `${project.name} (${project.slug})`,
|
||||||
name: project.name,
|
name: project.name,
|
||||||
slug: project.slug,
|
slug: project.slug,
|
||||||
};
|
};
|
||||||
});
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return output;
|
return output;
|
||||||
}
|
}
|
||||||
@ -669,7 +684,11 @@ class UsersDBApi {
|
|||||||
'emailVerificationToken',
|
'emailVerificationToken',
|
||||||
normalizedFilter.emailVerificationToken,
|
normalizedFilter.emailVerificationToken,
|
||||||
);
|
);
|
||||||
addTextFilter(where, 'passwordResetToken', normalizedFilter.passwordResetToken);
|
addTextFilter(
|
||||||
|
where,
|
||||||
|
'passwordResetToken',
|
||||||
|
normalizedFilter.passwordResetToken,
|
||||||
|
);
|
||||||
addTextFilter(where, 'provider', normalizedFilter.provider);
|
addTextFilter(where, 'provider', normalizedFilter.provider);
|
||||||
addRangeFilter(
|
addRangeFilter(
|
||||||
where,
|
where,
|
||||||
@ -684,7 +703,8 @@ class UsersDBApi {
|
|||||||
|
|
||||||
if (normalizedFilter.active !== undefined) {
|
if (normalizedFilter.active !== undefined) {
|
||||||
where.active =
|
where.active =
|
||||||
normalizedFilter.active === true || normalizedFilter.active === 'true';
|
normalizedFilter.active === true ||
|
||||||
|
normalizedFilter.active === 'true';
|
||||||
}
|
}
|
||||||
|
|
||||||
if (normalizedFilter.disabled) {
|
if (normalizedFilter.disabled) {
|
||||||
@ -733,7 +753,7 @@ class UsersDBApi {
|
|||||||
typeof normalizedFilter.field === 'string' &&
|
typeof normalizedFilter.field === 'string' &&
|
||||||
this.SORTABLE_FIELDS.includes(normalizedFilter.field)
|
this.SORTABLE_FIELDS.includes(normalizedFilter.field)
|
||||||
? normalizedFilter.field
|
? normalizedFilter.field
|
||||||
: 'createdAt';
|
: 'createdAt';
|
||||||
const sortDirection =
|
const sortDirection =
|
||||||
String(normalizedFilter.sort || 'desc').toUpperCase() === 'ASC'
|
String(normalizedFilter.sort || 'desc').toUpperCase() === 'ASC'
|
||||||
? 'ASC'
|
? 'ASC'
|
||||||
@ -787,7 +807,9 @@ class UsersDBApi {
|
|||||||
const where: QueryWhere = {};
|
const where: QueryWhere = {};
|
||||||
|
|
||||||
if (query) {
|
if (query) {
|
||||||
const orConditions: unknown[] = [Utils.ilike('users', 'firstName', query)];
|
const orConditions: unknown[] = [
|
||||||
|
Utils.ilike('users', 'firstName', query),
|
||||||
|
];
|
||||||
|
|
||||||
if (Utils.isValidUuid(query)) {
|
if (Utils.isValidUuid(query)) {
|
||||||
orConditions.unshift({ id: query });
|
orConditions.unshift({ id: query });
|
||||||
@ -849,7 +871,10 @@ class UsersDBApi {
|
|||||||
const currentUserId = getCurrentUserId(options);
|
const currentUserId = getCurrentUserId(options);
|
||||||
const transaction = options.transaction;
|
const transaction = options.transaction;
|
||||||
|
|
||||||
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
|
const users = await db.users.findByPk(
|
||||||
|
id,
|
||||||
|
buildTransactionOptions(transaction),
|
||||||
|
);
|
||||||
if (!users) {
|
if (!users) {
|
||||||
throw new Error('UsersNotFound');
|
throw new Error('UsersNotFound');
|
||||||
}
|
}
|
||||||
@ -928,7 +953,10 @@ class UsersDBApi {
|
|||||||
const currentUserId = getCurrentUserId(options);
|
const currentUserId = getCurrentUserId(options);
|
||||||
const transaction = options.transaction;
|
const transaction = options.transaction;
|
||||||
|
|
||||||
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
|
const users = await db.users.findByPk(
|
||||||
|
id,
|
||||||
|
buildTransactionOptions(transaction),
|
||||||
|
);
|
||||||
if (!users) {
|
if (!users) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -66,7 +66,9 @@ type FunctionPropertyName =
|
|||||||
function isDatabaseConfigEnvironment(
|
function isDatabaseConfigEnvironment(
|
||||||
value: string,
|
value: string,
|
||||||
): value is keyof typeof dbConfig {
|
): value is keyof typeof dbConfig {
|
||||||
return value === 'development' || value === 'production' || value === 'dev_stage';
|
return (
|
||||||
|
value === 'development' || value === 'production' || value === 'dev_stage'
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getDatabaseConfig(): DatabaseEnvironmentConfig {
|
function getDatabaseConfig(): DatabaseEnvironmentConfig {
|
||||||
@ -115,7 +117,9 @@ function isSampleDataModel(value: object): value is SampleDataModel {
|
|||||||
return hasFunctionProperties(value, ['bulkCreate', 'count', 'findOne']);
|
return hasFunctionProperties(value, ['bulkCreate', 'count', 'findOne']);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProjectModel(value: object): value is ProjectModel & SampleDataModel {
|
function isProjectModel(
|
||||||
|
value: object,
|
||||||
|
): value is ProjectModel & SampleDataModel {
|
||||||
return hasFunctionProperties(value, [
|
return hasFunctionProperties(value, [
|
||||||
'bulkCreate',
|
'bulkCreate',
|
||||||
'count',
|
'count',
|
||||||
@ -127,9 +131,7 @@ function isProjectModel(value: object): value is ProjectModel & SampleDataModel
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isProjectCloneAssetModel(
|
function isProjectCloneAssetModel(value: object): value is DbModels['assets'] {
|
||||||
value: object,
|
|
||||||
): value is DbModels['assets'] {
|
|
||||||
return hasFunctionProperties(value, [
|
return hasFunctionProperties(value, [
|
||||||
'bulkCreate',
|
'bulkCreate',
|
||||||
'count',
|
'count',
|
||||||
@ -149,7 +151,9 @@ function isProjectCloneVariantModel(
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPublishEventModel(value: object): value is DbModels['publish_events'] {
|
function isPublishEventModel(
|
||||||
|
value: object,
|
||||||
|
): value is DbModels['publish_events'] {
|
||||||
return hasFunctionProperties(value, [
|
return hasFunctionProperties(value, [
|
||||||
'bulkCreate',
|
'bulkCreate',
|
||||||
'count',
|
'count',
|
||||||
@ -158,7 +162,9 @@ function isPublishEventModel(value: object): value is DbModels['publish_events']
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTourPageModel(value: object): value is TourPageModel & SampleDataModel {
|
function isTourPageModel(
|
||||||
|
value: object,
|
||||||
|
): value is TourPageModel & SampleDataModel {
|
||||||
return hasFunctionProperties(value, [
|
return hasFunctionProperties(value, [
|
||||||
'bulkCreate',
|
'bulkCreate',
|
||||||
'count',
|
'count',
|
||||||
@ -244,7 +250,12 @@ function isProductionPresentationAccessModel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isRoleModel(value: object): value is RoleModel {
|
function isRoleModel(value: object): value is RoleModel {
|
||||||
return hasFunctionProperties(value, ['create', 'findAll', 'findByPk', 'findOne']);
|
return hasFunctionProperties(value, [
|
||||||
|
'create',
|
||||||
|
'findAll',
|
||||||
|
'findByPk',
|
||||||
|
'findOne',
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isPermissionModel(value: object): value is PermissionModel {
|
function isPermissionModel(value: object): value is PermissionModel {
|
||||||
|
|||||||
@ -3,7 +3,10 @@ import type {
|
|||||||
SequelizeModelFactory,
|
SequelizeModelFactory,
|
||||||
} from '../../types/index.ts';
|
} from '../../types/index.ts';
|
||||||
|
|
||||||
const definePermissionsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
const definePermissionsModel: SequelizeModelFactory = (
|
||||||
|
sequelize,
|
||||||
|
DataTypes,
|
||||||
|
) => {
|
||||||
const permissions: SequelizeModel = sequelize.define(
|
const permissions: SequelizeModel = sequelize.define(
|
||||||
'permissions',
|
'permissions',
|
||||||
{
|
{
|
||||||
|
|||||||
@ -25,7 +25,9 @@ interface UsersSequelizeModel extends ModelStatic<UserModelInstance> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isKnownExternalProvider(provider: string): boolean {
|
function isKnownExternalProvider(provider: string): boolean {
|
||||||
return provider !== providers.LOCAL && Object.values(providers).includes(provider);
|
return (
|
||||||
|
provider !== providers.LOCAL && Object.values(providers).includes(provider)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function trimStringFields(user: UserModelInstance): UserModelInstance {
|
function trimStringFields(user: UserModelInstance): UserModelInstance {
|
||||||
|
|||||||
@ -6,11 +6,7 @@ import { Sequelize } from 'sequelize';
|
|||||||
import * as SequelizeModule from 'sequelize';
|
import * as SequelizeModule from 'sequelize';
|
||||||
import type { QueryInterface } from 'sequelize';
|
import type { QueryInterface } from 'sequelize';
|
||||||
import { SequelizeStorage, Umzug } from 'umzug';
|
import { SequelizeStorage, Umzug } from 'umzug';
|
||||||
import type {
|
import type { MigrationMeta, MigrationParams, RunnableMigration } from 'umzug';
|
||||||
MigrationMeta,
|
|
||||||
MigrationParams,
|
|
||||||
RunnableMigration,
|
|
||||||
} from 'umzug';
|
|
||||||
|
|
||||||
import '../load-env.ts';
|
import '../load-env.ts';
|
||||||
import dbConfig from './db-config.ts';
|
import dbConfig from './db-config.ts';
|
||||||
@ -120,7 +116,9 @@ function getModuleDefault(value: unknown): unknown {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isLegacyMigrationModule(value: unknown): value is LegacyMigrationModule {
|
function isLegacyMigrationModule(
|
||||||
|
value: unknown,
|
||||||
|
): value is LegacyMigrationModule {
|
||||||
return (
|
return (
|
||||||
isRecord(value) &&
|
isRecord(value) &&
|
||||||
typeof value.up === 'function' &&
|
typeof value.up === 'function' &&
|
||||||
@ -266,7 +264,10 @@ function createMigrators(sequelize: Sequelize): DbMigrators {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function printMigrationList(title: string, migrations: readonly MigrationMeta[]): void {
|
function printMigrationList(
|
||||||
|
title: string,
|
||||||
|
migrations: readonly MigrationMeta[],
|
||||||
|
): void {
|
||||||
console.log(`${title}: ${migrations.length}`);
|
console.log(`${title}: ${migrations.length}`);
|
||||||
for (const migration of migrations) {
|
for (const migration of migrations) {
|
||||||
console.log(`- ${migration.name}`);
|
console.log(`- ${migration.name}`);
|
||||||
@ -370,9 +371,7 @@ function selectMigrator(
|
|||||||
command: DbUmzugCommand,
|
command: DbUmzugCommand,
|
||||||
migrators: DbMigrators,
|
migrators: DbMigrators,
|
||||||
): Umzug<DbUmzugContext> {
|
): Umzug<DbUmzugContext> {
|
||||||
return command.startsWith('seed:')
|
return command.startsWith('seed:') ? migrators.seeders : migrators.migrations;
|
||||||
? migrators.seeders
|
|
||||||
: migrators.migrations;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function runCommand(command: DbUmzugCommand): Promise<void> {
|
async function runCommand(command: DbUmzugCommand): Promise<void> {
|
||||||
@ -410,7 +409,9 @@ async function main(): Promise<void> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const mainScriptUrl =
|
const mainScriptUrl =
|
||||||
process.argv[1] === undefined ? undefined : pathToFileURL(process.argv[1]).href;
|
process.argv[1] === undefined
|
||||||
|
? undefined
|
||||||
|
: pathToFileURL(process.argv[1]).href;
|
||||||
|
|
||||||
if (import.meta.url === mainScriptUrl) {
|
if (import.meta.url === mainScriptUrl) {
|
||||||
void main();
|
void main();
|
||||||
|
|||||||
@ -19,9 +19,8 @@ export default class Utils {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static ilike(model: string, column: string, value: string): Where {
|
static ilike(model: string, column: string, value: string): Where {
|
||||||
return where(
|
return where(fn('lower', col(`${model}.${column}`)), {
|
||||||
fn('lower', col(`${model}.${column}`)),
|
[Op.like]: `%${value}%`.toLowerCase(),
|
||||||
{ [Op.like]: `%${value}%`.toLowerCase() },
|
});
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,10 +12,7 @@ import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
|
|||||||
import { validateRequest } from '../middlewares/validate-request.ts';
|
import { validateRequest } from '../middlewares/validate-request.ts';
|
||||||
import { crud as crudSchemas } from '../validators/request-schemas.ts';
|
import { crud as crudSchemas } from '../validators/request-schemas.ts';
|
||||||
import { logger } from '../utils/logger.ts';
|
import { logger } from '../utils/logger.ts';
|
||||||
import {
|
import { getCurrentUser, getRuntimeContext } from '../utils/request-context.ts';
|
||||||
getCurrentUser,
|
|
||||||
getRuntimeContext,
|
|
||||||
} from '../utils/request-context.ts';
|
|
||||||
import type {
|
import type {
|
||||||
EntityRouterDbApi,
|
EntityRouterDbApi,
|
||||||
EntityRouterOptions,
|
EntityRouterOptions,
|
||||||
@ -69,7 +66,9 @@ function hasRawAttributes(value: unknown): value is {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function isStringArray(value: unknown): value is string[] {
|
function isStringArray(value: unknown): value is string[] {
|
||||||
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
return (
|
||||||
|
Array.isArray(value) && value.every((item) => typeof item === 'string')
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clampLimit(value: unknown, options: ClampLimitOptions): number {
|
function clampLimit(value: unknown, options: ClampLimitOptions): number {
|
||||||
@ -83,7 +82,8 @@ function clampLimit(value: unknown, options: ClampLimitOptions): number {
|
|||||||
|
|
||||||
function getSortableFields(DBApi: EntityRouterDbApi): readonly string[] {
|
function getSortableFields(DBApi: EntityRouterDbApi): readonly string[] {
|
||||||
if (isStringArray(DBApi.SORTABLE_FIELDS)) return DBApi.SORTABLE_FIELDS;
|
if (isStringArray(DBApi.SORTABLE_FIELDS)) return DBApi.SORTABLE_FIELDS;
|
||||||
if (hasRawAttributes(DBApi.MODEL)) return Object.keys(DBApi.MODEL.rawAttributes);
|
if (hasRawAttributes(DBApi.MODEL))
|
||||||
|
return Object.keys(DBApi.MODEL.rawAttributes);
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -115,10 +115,7 @@ function normalizeQuery(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sortableFields = getSortableFields(DBApi);
|
const sortableFields = getSortableFields(DBApi);
|
||||||
if (
|
if (typeof query.field === 'string' && sortableFields.includes(query.field)) {
|
||||||
typeof query.field === 'string' &&
|
|
||||||
sortableFields.includes(query.field)
|
|
||||||
) {
|
|
||||||
normalized.field = query.field;
|
normalized.field = query.field;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -278,20 +275,22 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
|
|||||||
router.post(
|
router.post(
|
||||||
'/',
|
'/',
|
||||||
validateRequest(schemaFor(options.validation, 'create')),
|
validateRequest(schemaFor(options.validation, 'create')),
|
||||||
wrapAsync<never, unknown, EntityDataRequestBody<TCreate>>(async (req, res) => {
|
wrapAsync<never, unknown, EntityDataRequestBody<TCreate>>(
|
||||||
const referer =
|
async (req, res) => {
|
||||||
req.headers.referer ||
|
const referer =
|
||||||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
req.headers.referer ||
|
||||||
const link = new URL(referer);
|
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||||
const payload = await Service.create(
|
const link = new URL(referer);
|
||||||
buildCreateOptions(req.body.data, {
|
const payload = await Service.create(
|
||||||
currentUser: getCurrentUser(req),
|
buildCreateOptions(req.body.data, {
|
||||||
runtimeContext: getRuntimeContext(req),
|
currentUser: getCurrentUser(req),
|
||||||
host: link.origin,
|
runtimeContext: getRuntimeContext(req),
|
||||||
}),
|
host: link.origin,
|
||||||
);
|
}),
|
||||||
res.status(200).send(payload);
|
);
|
||||||
}),
|
res.status(200).send(payload);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
router.post(
|
router.post(
|
||||||
@ -307,14 +306,14 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
|
|||||||
validateRequest(schemaFor(options.validation, 'update')),
|
validateRequest(schemaFor(options.validation, 'update')),
|
||||||
wrapAsync<{ id: string }, unknown, RouteEntityDataRequestBody<TUpdate>>(
|
wrapAsync<{ id: string }, unknown, RouteEntityDataRequestBody<TUpdate>>(
|
||||||
async (req, res) => {
|
async (req, res) => {
|
||||||
assertRouteIdMatchesBody(req);
|
assertRouteIdMatchesBody(req);
|
||||||
await Service.update(
|
await Service.update(
|
||||||
buildUpdateOptions(req.params.id, req.body.data, {
|
buildUpdateOptions(req.params.id, req.body.data, {
|
||||||
currentUser: getCurrentUser(req),
|
currentUser: getCurrentUser(req),
|
||||||
runtimeContext: getRuntimeContext(req),
|
runtimeContext: getRuntimeContext(req),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
res.status(200).send(true);
|
res.status(200).send(true);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -336,15 +335,17 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
|
|||||||
router.post(
|
router.post(
|
||||||
'/deleteByIds',
|
'/deleteByIds',
|
||||||
validateRequest(schemaFor(options.validation, 'deleteByIds')),
|
validateRequest(schemaFor(options.validation, 'deleteByIds')),
|
||||||
wrapAsync<never, unknown, EntityDeleteByIdsRequestBody>(async (req, res) => {
|
wrapAsync<never, unknown, EntityDeleteByIdsRequestBody>(
|
||||||
await Service.deleteByIds(
|
async (req, res) => {
|
||||||
buildDeleteByIdsOptions(req.body.data, {
|
await Service.deleteByIds(
|
||||||
currentUser: getCurrentUser(req),
|
buildDeleteByIdsOptions(req.body.data, {
|
||||||
runtimeContext: getRuntimeContext(req),
|
currentUser: getCurrentUser(req),
|
||||||
}),
|
runtimeContext: getRuntimeContext(req),
|
||||||
);
|
}),
|
||||||
res.status(200).send(true);
|
);
|
||||||
}),
|
res.status(200).send(true);
|
||||||
|
},
|
||||||
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
router.get(
|
router.get(
|
||||||
@ -367,10 +368,8 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fields = options.csvFields || DBApi.CSV_FIELDS || [
|
const fields = options.csvFields ||
|
||||||
'id',
|
DBApi.CSV_FIELDS || ['id', 'createdAt'];
|
||||||
'createdAt',
|
|
||||||
];
|
|
||||||
try {
|
try {
|
||||||
const csv = parse(payload.rows, { fields: [...fields] });
|
const csv = parse(payload.rows, { fields: [...fields] });
|
||||||
res.status(200).attachment('export.csv').send(csv);
|
res.status(200).attachment('export.csv').send(csv);
|
||||||
|
|||||||
@ -5,10 +5,7 @@ import type {
|
|||||||
RequestHandler,
|
RequestHandler,
|
||||||
Response,
|
Response,
|
||||||
} from 'express';
|
} from 'express';
|
||||||
import type {
|
import type { ParamsDictionary, Query } from 'express-serve-static-core';
|
||||||
ParamsDictionary,
|
|
||||||
Query,
|
|
||||||
} from 'express-serve-static-core';
|
|
||||||
import jwt from 'jsonwebtoken';
|
import jwt from 'jsonwebtoken';
|
||||||
|
|
||||||
import config from './config.ts';
|
import config from './config.ts';
|
||||||
|
|||||||
@ -7,7 +7,10 @@ import helmet from 'helmet';
|
|||||||
import * as swaggerUI from 'swagger-ui-express';
|
import * as swaggerUI from 'swagger-ui-express';
|
||||||
|
|
||||||
import './auth/auth.ts';
|
import './auth/auth.ts';
|
||||||
import { authenticateJwt, authenticateJwtWithCallback } from './auth/passport-middleware.ts';
|
import {
|
||||||
|
authenticateJwt,
|
||||||
|
authenticateJwtWithCallback,
|
||||||
|
} from './auth/passport-middleware.ts';
|
||||||
import config from './config.ts';
|
import config from './config.ts';
|
||||||
import db from './db/models/index.ts';
|
import db from './db/models/index.ts';
|
||||||
import { wrapAsync } from './helpers.ts';
|
import { wrapAsync } from './helpers.ts';
|
||||||
@ -86,7 +89,10 @@ function getExpressRouter(name: string, router: unknown): ExpressRouter {
|
|||||||
return router;
|
return router;
|
||||||
}
|
}
|
||||||
|
|
||||||
const accessLogsRoutes = getExpressRouter('access_logs', accessLogsRoutesModule);
|
const accessLogsRoutes = getExpressRouter(
|
||||||
|
'access_logs',
|
||||||
|
accessLogsRoutesModule,
|
||||||
|
);
|
||||||
const assetVariantsRoutes = getExpressRouter(
|
const assetVariantsRoutes = getExpressRouter(
|
||||||
'asset_variants',
|
'asset_variants',
|
||||||
assetVariantsRoutesModule,
|
assetVariantsRoutesModule,
|
||||||
@ -106,7 +112,10 @@ const globalUiControlDefaultsRoutes = getExpressRouter(
|
|||||||
'global_ui_control_defaults',
|
'global_ui_control_defaults',
|
||||||
globalUiControlDefaultsRoutesModule,
|
globalUiControlDefaultsRoutesModule,
|
||||||
);
|
);
|
||||||
const permissionsRoutes = getExpressRouter('permissions', permissionsRoutesModule);
|
const permissionsRoutes = getExpressRouter(
|
||||||
|
'permissions',
|
||||||
|
permissionsRoutesModule,
|
||||||
|
);
|
||||||
const presignedUrlRequestsRoutes = getExpressRouter(
|
const presignedUrlRequestsRoutes = getExpressRouter(
|
||||||
'presigned_url_requests',
|
'presigned_url_requests',
|
||||||
presignedUrlRequestsRoutesModule,
|
presignedUrlRequestsRoutesModule,
|
||||||
@ -133,10 +142,16 @@ const projectUiControlSettingsRoutes = getExpressRouter(
|
|||||||
);
|
);
|
||||||
const projectsRoutes = getExpressRouter('projects', projectsRoutesModule);
|
const projectsRoutes = getExpressRouter('projects', projectsRoutesModule);
|
||||||
const publishRoutes = getExpressRouter('publish', publishRoutesModule);
|
const publishRoutes = getExpressRouter('publish', publishRoutesModule);
|
||||||
const publishEventsRoutes = getExpressRouter('publish_events', publishEventsRoutesModule);
|
const publishEventsRoutes = getExpressRouter(
|
||||||
|
'publish_events',
|
||||||
|
publishEventsRoutesModule,
|
||||||
|
);
|
||||||
const pwaCachesRoutes = getExpressRouter('pwa_caches', pwaCachesRoutesModule);
|
const pwaCachesRoutes = getExpressRouter('pwa_caches', pwaCachesRoutesModule);
|
||||||
const rolesRoutes = getExpressRouter('roles', rolesRoutesModule);
|
const rolesRoutes = getExpressRouter('roles', rolesRoutesModule);
|
||||||
const runtimeAccessRoutes = getExpressRouter('runtime-access', runtimeAccessRoutesModule);
|
const runtimeAccessRoutes = getExpressRouter(
|
||||||
|
'runtime-access',
|
||||||
|
runtimeAccessRoutesModule,
|
||||||
|
);
|
||||||
const runtimeContextRoutes = getExpressRouter(
|
const runtimeContextRoutes = getExpressRouter(
|
||||||
'runtime-context',
|
'runtime-context',
|
||||||
runtimeContextRoutesModule,
|
runtimeContextRoutesModule,
|
||||||
@ -149,11 +164,7 @@ const specs = createOpenApiDocument({
|
|||||||
serverUrl: config.server.swaggerServerUrl,
|
serverUrl: config.server.swaggerServerUrl,
|
||||||
});
|
});
|
||||||
|
|
||||||
app.use(
|
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs));
|
||||||
'/api-docs',
|
|
||||||
swaggerUI.serve,
|
|
||||||
swaggerUI.setup(specs),
|
|
||||||
);
|
|
||||||
|
|
||||||
app.enable('trust proxy');
|
app.enable('trust proxy');
|
||||||
app.use(
|
app.use(
|
||||||
@ -184,104 +195,105 @@ app.use(bodyParser.json({ limit: '50mb' }));
|
|||||||
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
|
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
|
||||||
app.use(runtimeContextMiddleware);
|
app.use(runtimeContextMiddleware);
|
||||||
|
|
||||||
const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(async (
|
const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(
|
||||||
req,
|
async (req, res, next) => {
|
||||||
res,
|
try {
|
||||||
next,
|
const runtimeContext = getRuntimeContext(req);
|
||||||
) => {
|
const headerEnvironment = runtimeContext?.headerEnvironment;
|
||||||
try {
|
const headerProjectSlug = runtimeContext?.headerProjectSlug;
|
||||||
const runtimeContext = getRuntimeContext(req);
|
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
||||||
const headerEnvironment = runtimeContext?.headerEnvironment;
|
const hasAuthHeader = Boolean(req.headers.authorization);
|
||||||
const headerProjectSlug = runtimeContext?.headerProjectSlug;
|
|
||||||
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
|
||||||
const hasAuthHeader = Boolean(req.headers.authorization);
|
|
||||||
|
|
||||||
// Only production is public. Stage requires authentication (workspace for review).
|
// Only production is public. Stage requires authentication (workspace for review).
|
||||||
const isPublicEnvironment = headerEnvironment === 'production';
|
const isPublicEnvironment = headerEnvironment === 'production';
|
||||||
|
|
||||||
if (!isPublicEnvironment || !isReadOnlyRequest) {
|
if (!isPublicEnvironment || !isReadOnlyRequest) {
|
||||||
setRuntimePublicRequest(req, false);
|
setRuntimePublicRequest(req, false);
|
||||||
jwtAuth(req, res, next);
|
jwtAuth(req, res, next);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isPrivateProductionPresentation =
|
const isPrivateProductionPresentation =
|
||||||
await RuntimePresentationAccessService.isPrivateProductionPresentation(
|
await RuntimePresentationAccessService.isPrivateProductionPresentation(
|
||||||
headerProjectSlug,
|
headerProjectSlug,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isPrivateProductionPresentation) {
|
if (!isPrivateProductionPresentation) {
|
||||||
setRuntimePublicRequest(req, true);
|
setRuntimePublicRequest(req, true);
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!hasAuthHeader) {
|
if (!hasAuthHeader) {
|
||||||
setRuntimePublicRequest(req, false);
|
setRuntimePublicRequest(req, false);
|
||||||
res.status(401).send({ message: 'Authentication required' });
|
res.status(401).send({ message: 'Authentication required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const privatePresentationAuth = authenticateJwtWithCallback(
|
const privatePresentationAuth = authenticateJwtWithCallback(
|
||||||
async (error, user) => {
|
async (error, user) => {
|
||||||
if (error) return next(error);
|
if (error) return next(error);
|
||||||
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
setRuntimePublicRequest(req, false);
|
|
||||||
res.status(401).send({ message: 'Authentication required' });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentUser(req, user);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const canAccess =
|
|
||||||
await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation(
|
|
||||||
user,
|
|
||||||
headerProjectSlug,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canAccess) {
|
|
||||||
setRuntimePublicRequest(req, false);
|
setRuntimePublicRequest(req, false);
|
||||||
res.status(403).send({ message: 'Presentation access denied' });
|
res.status(401).send({ message: 'Authentication required' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRuntimePublicRequest(req, true);
|
setCurrentUser(req, user);
|
||||||
return next();
|
|
||||||
} catch (accessError) {
|
|
||||||
return next(accessError);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
privatePresentationAuth(req, res, next);
|
try {
|
||||||
} catch (error) {
|
const canAccess =
|
||||||
return next(error);
|
await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation(
|
||||||
}
|
user,
|
||||||
});
|
headerProjectSlug,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!canAccess) {
|
||||||
|
setRuntimePublicRequest(req, false);
|
||||||
|
res.status(403).send({ message: 'Presentation access denied' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRuntimePublicRequest(req, true);
|
||||||
|
return next();
|
||||||
|
} catch (accessError) {
|
||||||
|
return next(accessError);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
privatePresentationAuth(req, res, next);
|
||||||
|
} catch (error) {
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Health check endpoint (no auth required)
|
// Health check endpoint (no auth required)
|
||||||
app.get('/api/health', wrapAsync(async (_req, res) => {
|
app.get(
|
||||||
const health: HealthResponse = {
|
'/api/health',
|
||||||
status: 'ok',
|
wrapAsync(async (_req, res) => {
|
||||||
timestamp: new Date().toISOString(),
|
const health: HealthResponse = {
|
||||||
uptime: process.uptime(),
|
status: 'ok',
|
||||||
environment: config.server.env,
|
timestamp: new Date().toISOString(),
|
||||||
};
|
uptime: process.uptime(),
|
||||||
|
environment: config.server.env,
|
||||||
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await db.sequelize.authenticate();
|
await db.sequelize.authenticate();
|
||||||
health.database = 'connected';
|
health.database = 'connected';
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
health.status = 'degraded';
|
health.status = 'degraded';
|
||||||
health.database = 'disconnected';
|
health.database = 'disconnected';
|
||||||
health.databaseError =
|
health.databaseError =
|
||||||
error instanceof Error ? error.message : 'Unknown database error';
|
error instanceof Error ? error.message : 'Unknown database error';
|
||||||
}
|
}
|
||||||
|
|
||||||
const statusCode = health.status === 'ok' ? 200 : 503;
|
const statusCode = health.status === 'ok' ? 200 : 503;
|
||||||
res.status(statusCode).json(health);
|
res.status(statusCode).json(health);
|
||||||
}));
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
app.use('/api/auth', authRoutes);
|
app.use('/api/auth', authRoutes);
|
||||||
app.use('/api/runtime-context', runtimeContextRoutes);
|
app.use('/api/runtime-context', runtimeContextRoutes);
|
||||||
@ -333,11 +345,7 @@ app.use('/api/access_logs', jwtAuth, accessLogsRoutes);
|
|||||||
app.use('/api/element-type-defaults', jwtAuth, elementTypeDefaultsRoutes);
|
app.use('/api/element-type-defaults', jwtAuth, elementTypeDefaultsRoutes);
|
||||||
// Backwards compatibility alias for old API endpoint
|
// Backwards compatibility alias for old API endpoint
|
||||||
app.use('/api/ui-elements', jwtAuth, elementTypeDefaultsRoutes);
|
app.use('/api/ui-elements', jwtAuth, elementTypeDefaultsRoutes);
|
||||||
app.use(
|
app.use('/api/project-element-defaults', jwtAuth, projectElementDefaultsRoutes);
|
||||||
'/api/project-element-defaults',
|
|
||||||
jwtAuth,
|
|
||||||
projectElementDefaultsRoutes,
|
|
||||||
);
|
|
||||||
// Global transition defaults - routes handle their own auth (GET public, PUT protected)
|
// Global transition defaults - routes handle their own auth (GET public, PUT protected)
|
||||||
app.use('/api/global-transition-defaults', globalTransitionDefaultsRoutes);
|
app.use('/api/global-transition-defaults', globalTransitionDefaultsRoutes);
|
||||||
|
|
||||||
@ -402,10 +410,7 @@ app.use(appErrorHandler);
|
|||||||
const PORT = config.server.port;
|
const PORT = config.server.port;
|
||||||
|
|
||||||
const server = app.listen(PORT, () => {
|
const server = app.listen(PORT, () => {
|
||||||
logger.info(
|
logger.info({ port: PORT, env: config.server.env }, 'Server started');
|
||||||
{ port: PORT, env: config.server.env },
|
|
||||||
'Server started',
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||||
|
|||||||
@ -82,9 +82,7 @@ async function checkPermissionRequest(
|
|||||||
|
|
||||||
if (!effectiveRole) {
|
if (!effectiveRole) {
|
||||||
return next(
|
return next(
|
||||||
new Error(
|
new Error('Internal Server Error: Could not determine effective role.'),
|
||||||
'Internal Server Error: Could not determine effective role.',
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,8 +150,7 @@ function getCrudPermissionName(
|
|||||||
permissionNameOverride?: string,
|
permissionNameOverride?: string,
|
||||||
): string {
|
): string {
|
||||||
return (
|
return (
|
||||||
permissionNameOverride ||
|
permissionNameOverride || `${METHOD_MAP[method]}_${name.toUpperCase()}`
|
||||||
`${METHOD_MAP[method]}_${name.toUpperCase()}`
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -143,9 +143,13 @@ async function handleProjectSettingsReadOrAuth(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
handlePrivateProductionReadAuth(req, res, next, user, runtimeProjectSlug).catch(
|
handlePrivateProductionReadAuth(
|
||||||
|
req,
|
||||||
|
res,
|
||||||
next,
|
next,
|
||||||
);
|
user,
|
||||||
|
runtimeProjectSlug,
|
||||||
|
).catch(next);
|
||||||
});
|
});
|
||||||
|
|
||||||
privateReadAuth(req, res, next);
|
privateReadAuth(req, res, next);
|
||||||
|
|||||||
@ -54,7 +54,9 @@ setInterval(() => {
|
|||||||
* @param {Function} [options.skip] - Skip rate limiting for certain requests (req) => boolean
|
* @param {Function} [options.skip] - Skip rate limiting for certain requests (req) => boolean
|
||||||
* @returns {Function} Express middleware
|
* @returns {Function} Express middleware
|
||||||
*/
|
*/
|
||||||
const createRateLimiter = (options: RateLimiterOptions = {}): RequestHandler => {
|
const createRateLimiter = (
|
||||||
|
options: RateLimiterOptions = {},
|
||||||
|
): RequestHandler => {
|
||||||
const {
|
const {
|
||||||
keyPrefix = 'rate-limit',
|
keyPrefix = 'rate-limit',
|
||||||
windowMs = 15 * 60 * 1000, // 15 minutes
|
windowMs = 15 * 60 * 1000, // 15 minutes
|
||||||
|
|||||||
@ -88,7 +88,9 @@ function hasPlainGetter(
|
|||||||
return typeof value.get === 'function';
|
return typeof value.get === 'function';
|
||||||
}
|
}
|
||||||
|
|
||||||
function toPlainRecord(value: RuntimePublicPlainRecord): RuntimePublicPlainRecord {
|
function toPlainRecord(
|
||||||
|
value: RuntimePublicPlainRecord,
|
||||||
|
): RuntimePublicPlainRecord {
|
||||||
return hasPlainGetter(value) ? value.get({ plain: true }) : value;
|
return hasPlainGetter(value) ? value.get({ plain: true }) : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -107,7 +109,9 @@ function matchesPublicRuntimePath(
|
|||||||
: requestPath === pattern;
|
: requestPath === pattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAllowedPaths(entityName: string): readonly RuntimePublicPathPattern[] {
|
function getAllowedPaths(
|
||||||
|
entityName: string,
|
||||||
|
): readonly RuntimePublicPathPattern[] {
|
||||||
return PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] ?? ['/'];
|
return PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] ?? ['/'];
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -198,4 +202,7 @@ const sanitizePublicRuntimeListResponse =
|
|||||||
next();
|
next();
|
||||||
};
|
};
|
||||||
|
|
||||||
export { blockNonPublicRuntimeListEndpoints, sanitizePublicRuntimeListResponse };
|
export {
|
||||||
|
blockNonPublicRuntimeListEndpoints,
|
||||||
|
sanitizePublicRuntimeListResponse,
|
||||||
|
};
|
||||||
|
|||||||
@ -8,7 +8,11 @@ import type {
|
|||||||
RequestSchemaMap,
|
RequestSchemaMap,
|
||||||
} from '../types/index.ts';
|
} from '../types/index.ts';
|
||||||
|
|
||||||
const VALID_REQUEST_PARTS: RequestValidationPart[] = ['params', 'query', 'body'];
|
const VALID_REQUEST_PARTS: RequestValidationPart[] = [
|
||||||
|
'params',
|
||||||
|
'query',
|
||||||
|
'body',
|
||||||
|
];
|
||||||
|
|
||||||
interface RequestPartsTarget {
|
interface RequestPartsTarget {
|
||||||
params: unknown;
|
params: unknown;
|
||||||
|
|||||||
@ -233,7 +233,10 @@ const booleanResponse = jsonResponse('Boolean success response', {
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
});
|
});
|
||||||
|
|
||||||
const successObjectResponse = jsonResponse('Success response', ref('SuccessResponse'));
|
const successObjectResponse = jsonResponse(
|
||||||
|
'Success response',
|
||||||
|
ref('SuccessResponse'),
|
||||||
|
);
|
||||||
|
|
||||||
const errorResponses = {
|
const errorResponses = {
|
||||||
400: { $ref: '#/components/responses/BadRequestError' },
|
400: { $ref: '#/components/responses/BadRequestError' },
|
||||||
@ -293,8 +296,7 @@ const crudPaths = (resource: CrudResource): OpenApiPaths => {
|
|||||||
get: {
|
get: {
|
||||||
tags: [resource.tag],
|
tags: [resource.tag],
|
||||||
summary: `List ${resource.tag} items`,
|
summary: `List ${resource.tag} items`,
|
||||||
description:
|
description: `${readDescription}. Supports pagination, sorting, entity filters, and CSV export through filetype=csv.`,
|
||||||
`${readDescription}. Supports pagination, sorting, entity filters, and CSV export through filetype=csv.`,
|
|
||||||
security: readSecurity,
|
security: readSecurity,
|
||||||
parameters: [...commonReadParameters, ...listParameters],
|
parameters: [...commonReadParameters, ...listParameters],
|
||||||
responses: {
|
responses: {
|
||||||
@ -634,7 +636,10 @@ const schemas: Record<string, OpenApiSchema> = {
|
|||||||
properties: {
|
properties: {
|
||||||
id: uuidSchema,
|
id: uuidSchema,
|
||||||
name: nullable({ type: 'string' }),
|
name: nullable({ type: 'string' }),
|
||||||
asset_type: { type: 'string', enum: ['image', 'video', 'audio', 'embed'] },
|
asset_type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['image', 'video', 'audio', 'embed'],
|
||||||
|
},
|
||||||
type: { type: 'string', enum: ['general', 'background', 'transition'] },
|
type: { type: 'string', enum: ['general', 'background', 'transition'] },
|
||||||
cdn_url: nullable({ type: 'string' }),
|
cdn_url: nullable({ type: 'string' }),
|
||||||
storage_key: nullable({ type: 'string' }),
|
storage_key: nullable({ type: 'string' }),
|
||||||
@ -774,7 +779,10 @@ const schemas: Record<string, OpenApiSchema> = {
|
|||||||
target_environment: { type: 'string', enum: runtimeEnvironmentValues },
|
target_environment: { type: 'string', enum: runtimeEnvironmentValues },
|
||||||
started_at: nullable(dateTimeSchema),
|
started_at: nullable(dateTimeSchema),
|
||||||
finished_at: nullable(dateTimeSchema),
|
finished_at: nullable(dateTimeSchema),
|
||||||
status: { type: 'string', enum: ['queued', 'running', 'success', 'failed'] },
|
status: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['queued', 'running', 'success', 'failed'],
|
||||||
|
},
|
||||||
error_message: nullable({ type: 'string' }),
|
error_message: nullable({ type: 'string' }),
|
||||||
pages_count: nullable({ type: 'integer' }),
|
pages_count: nullable({ type: 'integer' }),
|
||||||
assets_count: nullable({ type: 'integer' }),
|
assets_count: nullable({ type: 'integer' }),
|
||||||
@ -809,7 +817,10 @@ const schemas: Record<string, OpenApiSchema> = {
|
|||||||
id: uuidSchema,
|
id: uuidSchema,
|
||||||
project: uuidSchema,
|
project: uuidSchema,
|
||||||
environment: { type: 'string', enum: runtimeEnvironmentValues },
|
environment: { type: 'string', enum: runtimeEnvironmentValues },
|
||||||
asset_type: { type: 'string', enum: ['image', 'video', 'audio', 'embed'] },
|
asset_type: {
|
||||||
|
type: 'string',
|
||||||
|
enum: ['image', 'video', 'audio', 'embed'],
|
||||||
|
},
|
||||||
url: nullable({ type: 'string' }),
|
url: nullable({ type: 'string' }),
|
||||||
mime_type: nullable({ type: 'string' }),
|
mime_type: nullable({ type: 'string' }),
|
||||||
size_mb: nullable({ type: 'number' }),
|
size_mb: nullable({ type: 'number' }),
|
||||||
@ -1530,7 +1541,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
requestBody: jsonRequest(ref('ReverseVideoStatusRequest')),
|
requestBody: jsonRequest(ref('ReverseVideoStatusRequest')),
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Reverse video status map', ref('ReverseVideoStatusResponse')),
|
200: jsonResponse(
|
||||||
|
'Reverse video status map',
|
||||||
|
ref('ReverseVideoStatusResponse'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1542,7 +1556,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
parameters: [idParameter],
|
parameters: [idParameter],
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Reset project element default', ref('ProjectElementDefault')),
|
200: jsonResponse(
|
||||||
|
'Reset project element default',
|
||||||
|
ref('ProjectElementDefault'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1554,7 +1571,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
parameters: [idParameter],
|
parameters: [idParameter],
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Project element default diff', ref('ProjectElementDefaultDiff')),
|
200: jsonResponse(
|
||||||
|
'Project element default diff',
|
||||||
|
ref('ProjectElementDefaultDiff'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1565,7 +1585,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
summary: 'Get singleton global transition defaults',
|
summary: 'Get singleton global transition defaults',
|
||||||
security: [],
|
security: [],
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Global transition defaults', ref('GlobalTransitionDefault')),
|
200: jsonResponse(
|
||||||
|
'Global transition defaults',
|
||||||
|
ref('GlobalTransitionDefault'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1577,7 +1600,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
security: [],
|
security: [],
|
||||||
parameters: [idParameter],
|
parameters: [idParameter],
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Global transition defaults', ref('GlobalTransitionDefault')),
|
200: jsonResponse(
|
||||||
|
'Global transition defaults',
|
||||||
|
ref('GlobalTransitionDefault'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1600,7 +1626,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
parameters: listParameters,
|
parameters: listParameters,
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Paginated list', paginatedSchema('ProjectTransitionSetting')),
|
200: jsonResponse(
|
||||||
|
'Paginated list',
|
||||||
|
paginatedSchema('ProjectTransitionSetting'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1608,7 +1637,9 @@ const customPaths: OpenApiPaths = {
|
|||||||
tags: ['ProjectTransitionSettings'],
|
tags: ['ProjectTransitionSettings'],
|
||||||
summary: 'Create project transition settings',
|
summary: 'Create project transition settings',
|
||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')),
|
requestBody: jsonRequest(
|
||||||
|
entityEnvelopeSchema('ProjectTransitionSetting'),
|
||||||
|
),
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Created settings', ref('ProjectTransitionSetting')),
|
200: jsonResponse('Created settings', ref('ProjectTransitionSetting')),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
@ -1631,7 +1662,9 @@ const customPaths: OpenApiPaths = {
|
|||||||
summary: 'Update project transition settings by ID',
|
summary: 'Update project transition settings by ID',
|
||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
parameters: [idParameter],
|
parameters: [idParameter],
|
||||||
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')),
|
requestBody: jsonRequest(
|
||||||
|
entityEnvelopeSchema('ProjectTransitionSetting'),
|
||||||
|
),
|
||||||
responses: {
|
responses: {
|
||||||
200: booleanResponse,
|
200: booleanResponse,
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
@ -1664,7 +1697,9 @@ const customPaths: OpenApiPaths = {
|
|||||||
summary: 'Upsert project transition settings for project environment',
|
summary: 'Upsert project transition settings for project environment',
|
||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
parameters: [projectIdParameter, environmentParameter],
|
parameters: [projectIdParameter, environmentParameter],
|
||||||
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')),
|
requestBody: jsonRequest(
|
||||||
|
entityEnvelopeSchema('ProjectTransitionSetting'),
|
||||||
|
),
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Settings', ref('ProjectTransitionSetting')),
|
200: jsonResponse('Settings', ref('ProjectTransitionSetting')),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
@ -1687,7 +1722,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
summary: 'Get singleton global UI-control defaults',
|
summary: 'Get singleton global UI-control defaults',
|
||||||
security: [],
|
security: [],
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')),
|
200: jsonResponse(
|
||||||
|
'Global UI-control defaults',
|
||||||
|
ref('GlobalUiControlDefaults'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1699,7 +1737,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
security: [],
|
security: [],
|
||||||
parameters: [idParameter],
|
parameters: [idParameter],
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')),
|
200: jsonResponse(
|
||||||
|
'Global UI-control defaults',
|
||||||
|
ref('GlobalUiControlDefaults'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1710,7 +1751,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
parameters: [idParameter],
|
parameters: [idParameter],
|
||||||
requestBody: jsonRequest(entityEnvelopeSchema('GlobalUiControlDefaults')),
|
requestBody: jsonRequest(entityEnvelopeSchema('GlobalUiControlDefaults')),
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')),
|
200: jsonResponse(
|
||||||
|
'Global UI-control defaults',
|
||||||
|
ref('GlobalUiControlDefaults'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1722,7 +1766,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
parameters: listParameters,
|
parameters: listParameters,
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Paginated list', paginatedSchema('ProjectUiControlSettings')),
|
200: jsonResponse(
|
||||||
|
'Paginated list',
|
||||||
|
paginatedSchema('ProjectUiControlSettings'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1743,7 +1790,9 @@ const customPaths: OpenApiPaths = {
|
|||||||
summary: 'Upsert project UI-control settings for project environment',
|
summary: 'Upsert project UI-control settings for project environment',
|
||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
parameters: [projectIdParameter, environmentParameter],
|
parameters: [projectIdParameter, environmentParameter],
|
||||||
requestBody: jsonRequest(entityEnvelopeSchema('ProjectUiControlSettings')),
|
requestBody: jsonRequest(
|
||||||
|
entityEnvelopeSchema('ProjectUiControlSettings'),
|
||||||
|
),
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Settings', ref('ProjectUiControlSettings')),
|
200: jsonResponse('Settings', ref('ProjectUiControlSettings')),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
@ -1822,7 +1871,10 @@ const customPaths: OpenApiPaths = {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Presentation access metadata', ref('RuntimePresentationAccess')),
|
200: jsonResponse(
|
||||||
|
'Presentation access metadata',
|
||||||
|
ref('RuntimePresentationAccess'),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1830,10 +1882,14 @@ const customPaths: OpenApiPaths = {
|
|||||||
'/api/runtime-access/private-production-presentations': {
|
'/api/runtime-access/private-production-presentations': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['RuntimeAccess'],
|
tags: ['RuntimeAccess'],
|
||||||
summary: 'List private production presentations for user-management grants',
|
summary:
|
||||||
|
'List private production presentations for user-management grants',
|
||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Private production presentations', arrayOf(ref('Project'))),
|
200: jsonResponse(
|
||||||
|
'Private production presentations',
|
||||||
|
arrayOf(ref('Project')),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1841,10 +1897,14 @@ const customPaths: OpenApiPaths = {
|
|||||||
'/api/runtime-access/private-production-presentations/autocomplete': {
|
'/api/runtime-access/private-production-presentations/autocomplete': {
|
||||||
get: {
|
get: {
|
||||||
tags: ['RuntimeAccess'],
|
tags: ['RuntimeAccess'],
|
||||||
summary: 'Autocomplete private production presentations for user-management grants',
|
summary:
|
||||||
|
'Autocomplete private production presentations for user-management grants',
|
||||||
security: bearerSecurity,
|
security: bearerSecurity,
|
||||||
responses: {
|
responses: {
|
||||||
200: jsonResponse('Private production presentations', arrayOf(ref('Project'))),
|
200: jsonResponse(
|
||||||
|
'Private production presentations',
|
||||||
|
arrayOf(ref('Project')),
|
||||||
|
),
|
||||||
...errorResponses,
|
...errorResponses,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@ -1904,7 +1964,9 @@ function buildCrudPaths(): OpenApiPaths {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createOpenApiDocument(options: OpenApiDocumentOptions): OpenApiDocument {
|
function createOpenApiDocument(
|
||||||
|
options: OpenApiDocumentOptions,
|
||||||
|
): OpenApiDocument {
|
||||||
return {
|
return {
|
||||||
openapi: '3.0.0',
|
openapi: '3.0.0',
|
||||||
info: {
|
info: {
|
||||||
@ -1942,7 +2004,10 @@ function createOpenApiDocument(options: OpenApiDocumentOptions): OpenApiDocument
|
|||||||
),
|
),
|
||||||
ForbiddenError: jsonResponse('Permission denied', ref('ErrorResponse')),
|
ForbiddenError: jsonResponse('Permission denied', ref('ErrorResponse')),
|
||||||
NotFoundError: jsonResponse('Resource not found', ref('ErrorResponse')),
|
NotFoundError: jsonResponse('Resource not found', ref('ErrorResponse')),
|
||||||
RateLimitError: jsonResponse('Rate limit exceeded', ref('ErrorResponse')),
|
RateLimitError: jsonResponse(
|
||||||
|
'Rate limit exceeded',
|
||||||
|
ref('ErrorResponse'),
|
||||||
|
),
|
||||||
ServerError: jsonResponse('Server error', ref('ErrorResponse')),
|
ServerError: jsonResponse('Server error', ref('ErrorResponse')),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@ -56,7 +56,7 @@ function getRequestHost(req: Request): string {
|
|||||||
const uiUrl = safeParseUrl(config.uiUrl);
|
const uiUrl = safeParseUrl(config.uiUrl);
|
||||||
const fallbackHost = uiUrl
|
const fallbackHost = uiUrl
|
||||||
? uiUrl.origin
|
? uiUrl.origin
|
||||||
: config.backUrl ?? 'http://localhost:3000';
|
: (config.backUrl ?? 'http://localhost:3000');
|
||||||
const origin = safeParseUrl(req.headers.origin);
|
const origin = safeParseUrl(req.headers.origin);
|
||||||
const referer = safeParseUrl(req.headers.referer);
|
const referer = safeParseUrl(req.headers.referer);
|
||||||
|
|
||||||
@ -159,10 +159,7 @@ router.post(
|
|||||||
signinLimiter,
|
signinLimiter,
|
||||||
validateRequest(authSchemas.signinLocal),
|
validateRequest(authSchemas.signinLocal),
|
||||||
wrapAsync(async (req: Request<never, string, SigninLocalBody>, res) => {
|
wrapAsync(async (req: Request<never, string, SigninLocalBody>, res) => {
|
||||||
const payload = await AuthService.signin(
|
const payload = await AuthService.signin(req.body.email, req.body.password);
|
||||||
req.body.email,
|
|
||||||
req.body.password,
|
|
||||||
);
|
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
@ -253,10 +250,14 @@ router.post(
|
|||||||
validateRequest(authSchemas.sendPasswordResetEmail),
|
validateRequest(authSchemas.sendPasswordResetEmail),
|
||||||
wrapAsync(
|
wrapAsync(
|
||||||
async (req: Request<never, boolean, SendPasswordResetEmailBody>, res) => {
|
async (req: Request<never, boolean, SendPasswordResetEmailBody>, res) => {
|
||||||
const host = getRequestHost(req);
|
const host = getRequestHost(req);
|
||||||
await AuthService.sendPasswordResetEmail(req.body.email, 'register', host);
|
await AuthService.sendPasswordResetEmail(
|
||||||
const payload = true;
|
req.body.email,
|
||||||
res.status(200).send(payload);
|
'register',
|
||||||
|
host,
|
||||||
|
);
|
||||||
|
const payload = true;
|
||||||
|
res.status(200).send(payload);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -12,7 +12,10 @@ import {
|
|||||||
useUpdatePermissionForProjectEnvironmentReset,
|
useUpdatePermissionForProjectEnvironmentReset,
|
||||||
} from '../middlewares/project-settings-runtime-auth.ts';
|
} from '../middlewares/project-settings-runtime-auth.ts';
|
||||||
import ProjectTransitionSettingsService from '../services/project_transition_settings.ts';
|
import ProjectTransitionSettingsService from '../services/project_transition_settings.ts';
|
||||||
import type { RouteMessageResponse, RouteSuccessResponse } from '../types/index.ts';
|
import type {
|
||||||
|
RouteMessageResponse,
|
||||||
|
RouteSuccessResponse,
|
||||||
|
} from '../types/index.ts';
|
||||||
import {
|
import {
|
||||||
assertBodyIdMatchesRouteId,
|
assertBodyIdMatchesRouteId,
|
||||||
isEntityDataRequestBody,
|
isEntityDataRequestBody,
|
||||||
@ -41,7 +44,9 @@ router.get(
|
|||||||
requireProductionProjectSettingsReadOrAuth,
|
requireProductionProjectSettingsReadOrAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
const response: RouteMessageResponse = {
|
||||||
|
message: 'Invalid route params',
|
||||||
|
};
|
||||||
return res.status(400).send(response);
|
return res.status(400).send(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +66,9 @@ router.put(
|
|||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
const response: RouteMessageResponse = {
|
||||||
|
message: 'Invalid route params',
|
||||||
|
};
|
||||||
return res.status(400).send(response);
|
return res.status(400).send(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -91,7 +98,9 @@ router.delete(
|
|||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
const response: RouteMessageResponse = {
|
||||||
|
message: 'Invalid route params',
|
||||||
|
};
|
||||||
return res.status(400).send(response);
|
return res.status(400).send(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -35,7 +35,9 @@ router.get(
|
|||||||
requireProductionProjectSettingsReadOrAuth,
|
requireProductionProjectSettingsReadOrAuth,
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
const response: RouteMessageResponse = {
|
||||||
|
message: 'Invalid route params',
|
||||||
|
};
|
||||||
return res.status(400).send(response);
|
return res.status(400).send(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,14 +57,14 @@ router.put(
|
|||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
const response: RouteMessageResponse = {
|
||||||
|
message: 'Invalid route params',
|
||||||
|
};
|
||||||
return res.status(400).send(response);
|
return res.status(400).send(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
const body: unknown = req.body;
|
const body: unknown = req.body;
|
||||||
const data: unknown = isEntityDataRequestBody(body)
|
const data: unknown = isEntityDataRequestBody(body) ? body.data : {};
|
||||||
? body.data
|
|
||||||
: {};
|
|
||||||
|
|
||||||
if (!isProjectUiControlSettingsData(data)) {
|
if (!isProjectUiControlSettingsData(data)) {
|
||||||
const response: RouteMessageResponse = {
|
const response: RouteMessageResponse = {
|
||||||
@ -87,7 +89,9 @@ router.delete(
|
|||||||
'/project/:projectId/env/:environment',
|
'/project/:projectId/env/:environment',
|
||||||
wrapAsync(async (req, res) => {
|
wrapAsync(async (req, res) => {
|
||||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
const response: RouteMessageResponse = {
|
||||||
|
message: 'Invalid route params',
|
||||||
|
};
|
||||||
return res.status(400).send(response);
|
return res.status(400).send(response);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,10 +2,7 @@ import express from 'express';
|
|||||||
import { parse } from 'json2csv';
|
import { parse } from 'json2csv';
|
||||||
|
|
||||||
import Tour_pagesDBApi from '../db/api/tour_pages.ts';
|
import Tour_pagesDBApi from '../db/api/tour_pages.ts';
|
||||||
import {
|
import { wrapAsync, commonErrorHandler } from '../helpers.ts';
|
||||||
wrapAsync,
|
|
||||||
commonErrorHandler,
|
|
||||||
} from '../helpers.ts';
|
|
||||||
import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
|
import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
|
||||||
import { validateRequest } from '../middlewares/validate-request.ts';
|
import { validateRequest } from '../middlewares/validate-request.ts';
|
||||||
import Tour_pagesService from '../services/tour_pages.ts';
|
import Tour_pagesService from '../services/tour_pages.ts';
|
||||||
|
|||||||
@ -51,12 +51,12 @@ const originalGetByIdHandler = originalGetById?.route.stack[0];
|
|||||||
if (originalGetByIdHandler) {
|
if (originalGetByIdHandler) {
|
||||||
originalGetByIdHandler.handle = wrapAsync(
|
originalGetByIdHandler.handle = wrapAsync(
|
||||||
async (req: Request<{ id: string }>, res) => {
|
async (req: Request<{ id: string }>, res) => {
|
||||||
// Call original handler with a custom response
|
// Call original handler with a custom response
|
||||||
const payload = await UsersDBApi.findBy({ id: req.params.id });
|
const payload = await UsersDBApi.findBy({ id: req.params.id });
|
||||||
if (payload) {
|
if (payload) {
|
||||||
delete payload.password;
|
delete payload.password;
|
||||||
}
|
}
|
||||||
res.status(200).send(payload);
|
res.status(200).send(payload);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,7 +33,9 @@ export default class AccessPolicy {
|
|||||||
return user?.app_role?.name || user?.role?.name || null;
|
return user?.app_role?.name || user?.role?.name || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getStandaloneRoleName(role: RoleRecord | null | undefined): string | null {
|
static getStandaloneRoleName(
|
||||||
|
role: RoleRecord | null | undefined,
|
||||||
|
): string | null {
|
||||||
return role?.name || null;
|
return role?.name || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -105,7 +107,9 @@ export default class AccessPolicy {
|
|||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
if (!user || !permission) return Promise.resolve(false);
|
if (!user || !permission) return Promise.resolve(false);
|
||||||
if (this.isPublicUser(user)) return Promise.resolve(false);
|
if (this.isPublicUser(user)) return Promise.resolve(false);
|
||||||
return Promise.resolve(this.getEffectivePermissionNames(user).has(permission));
|
return Promise.resolve(
|
||||||
|
this.getEffectivePermissionNames(user).has(permission),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
static isPublicUser(user: AccessPolicyUser): boolean {
|
static isPublicUser(user: AccessPolicyUser): boolean {
|
||||||
|
|||||||
@ -61,7 +61,9 @@ const ALLOWED_EMBED_DOMAINS = [
|
|||||||
'360stories.com',
|
'360stories.com',
|
||||||
];
|
];
|
||||||
|
|
||||||
function extractEmbedUrl(embedCode: string | null | undefined): AssetEmbedUrlResult {
|
function extractEmbedUrl(
|
||||||
|
embedCode: string | null | undefined,
|
||||||
|
): AssetEmbedUrlResult {
|
||||||
if (!embedCode?.trim()) {
|
if (!embedCode?.trim()) {
|
||||||
throw new ValidationError('Embed code is required');
|
throw new ValidationError('Embed code is required');
|
||||||
}
|
}
|
||||||
@ -175,7 +177,9 @@ function buildUpdateServiceOptions(
|
|||||||
return serviceOptions;
|
return serviceOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOptions {
|
function buildAssetFindByOptions(
|
||||||
|
options: AssetUpdateOptions,
|
||||||
|
): AssetFindByOptions {
|
||||||
const findByOptions: AssetFindByOptions = {};
|
const findByOptions: AssetFindByOptions = {};
|
||||||
|
|
||||||
if (options.transaction !== undefined) {
|
if (options.transaction !== undefined) {
|
||||||
@ -188,9 +192,7 @@ function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOption
|
|||||||
return findByOptions;
|
return findByOptions;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCurrentUserId(
|
function getCurrentUserId(options: ServiceOptions): string | null {
|
||||||
options: ServiceOptions,
|
|
||||||
): string | null {
|
|
||||||
return options.currentUser?.id ?? null;
|
return options.currentUser?.id ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,52 +1,52 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
.email-container {
|
.email-container {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.email-header {
|
.email-header {
|
||||||
background-color: #3498db;
|
background-color: #3498db;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.email-body {
|
.email-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.email-footer {
|
.email-footer {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: #f7fafc;
|
background-color: #f7fafc;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.link-primary {
|
.link-primary {
|
||||||
color: #3498db;
|
color: #3498db;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="email-container">
|
<div class="email-container">
|
||||||
<div class="email-header">
|
<div class="email-header">Verify your email for {appTitle}!</div>
|
||||||
Verify your email for {appTitle}!
|
<div class="email-body">
|
||||||
</div>
|
|
||||||
<div class="email-body">
|
|
||||||
<p>Hello,</p>
|
<p>Hello,</p>
|
||||||
<p>Follow this link to verify your email address.</p>
|
<p>Follow this link to verify your email address.</p>
|
||||||
<p>If you didn't ask to verify this address, you can ignore this email.</p>
|
<p>
|
||||||
|
If you didn't ask to verify this address, you can ignore this email.
|
||||||
|
</p>
|
||||||
<p><a href="{signupUrl}" class="link-primary">{signupUrl}</a></p>
|
<p><a href="{signupUrl}" class="link-primary">{signupUrl}</a></p>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-footer">
|
<div class="email-footer">
|
||||||
Thanks,<br/>
|
Thanks,<br />
|
||||||
The {appTitle} Team
|
The {appTitle} Team
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</body>
|
||||||
</body>
|
</html>
|
||||||
</html>
|
|
||||||
|
|||||||
@ -1,55 +1,56 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
.email-container {
|
.email-container {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.email-header {
|
.email-header {
|
||||||
background-color: #3498db;
|
background-color: #3498db;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.email-body {
|
.email-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.email-footer {
|
.email-footer {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: #f7fafc;
|
background-color: #f7fafc;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background-color: #3498db;
|
background-color: #3498db;
|
||||||
color: #fff!important;
|
color: #fff !important;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="email-container">
|
<div class="email-container">
|
||||||
<div class="email-header">
|
<div class="email-header">Welcome to {appTitle}!</div>
|
||||||
Welcome to {appTitle}!
|
<div class="email-body">
|
||||||
</div>
|
|
||||||
<div class="email-body">
|
|
||||||
<p>Hello,</p>
|
<p>Hello,</p>
|
||||||
<p>You've been invited to join {appTitle}. Please click the button below to set up your account.</p>
|
<p>
|
||||||
|
You've been invited to join {appTitle}. Please click the button below
|
||||||
|
to set up your account.
|
||||||
|
</p>
|
||||||
<a href="{signupUrl}" class="btn-primary">Set up account</a>
|
<a href="{signupUrl}" class="btn-primary">Set up account</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="email-footer">
|
<div class="email-footer">
|
||||||
Thanks,<br/>
|
Thanks,<br />
|
||||||
The {appTitle} Team
|
The {appTitle} Team
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</body>
|
||||||
</body>
|
</html>
|
||||||
</html>
|
|
||||||
|
|||||||
@ -1,52 +1,55 @@
|
|||||||
<!DOCTYPE html>
|
<!doctype html>
|
||||||
<html>
|
<html>
|
||||||
<head>
|
<head>
|
||||||
<style>
|
<style>
|
||||||
.email-container {
|
.email-container {
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
margin: auto;
|
margin: auto;
|
||||||
background-color: #ffffff;
|
background-color: #ffffff;
|
||||||
border: 1px solid #e2e8f0;
|
border: 1px solid #e2e8f0;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.email-header {
|
.email-header {
|
||||||
background-color: #3498db;
|
background-color: #3498db;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
.email-body {
|
.email-body {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
}
|
}
|
||||||
.email-footer {
|
.email-footer {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background-color: #f7fafc;
|
background-color: #f7fafc;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #4a5568;
|
color: #4a5568;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
.link-primary {
|
.link-primary {
|
||||||
color: #3498db;
|
color: #3498db;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="email-container">
|
<div class="email-container">
|
||||||
<div class="email-header">
|
<div class="email-header">Reset your password for {appTitle}</div>
|
||||||
Reset your password for {appTitle}
|
<div class="email-body">
|
||||||
</div>
|
|
||||||
<div class="email-body">
|
|
||||||
<p>Hello,</p>
|
<p>Hello,</p>
|
||||||
<p>Follow this link to reset your {appTitle} password for your {accountName} account.</p>
|
<p>
|
||||||
|
Follow this link to reset your {appTitle} password for your
|
||||||
|
{accountName} account.
|
||||||
|
</p>
|
||||||
<p><a href="{resetUrl}" class="link-primary">{resetUrl}</a></p>
|
<p><a href="{resetUrl}" class="link-primary">{resetUrl}</a></p>
|
||||||
<p>If you didn't ask to reset your password, you can ignore this email.</p>
|
<p>
|
||||||
</div>
|
If you didn't ask to reset your password, you can ignore this email.
|
||||||
<div class="email-footer">
|
</p>
|
||||||
Thanks,<br/>
|
</div>
|
||||||
|
<div class="email-footer">
|
||||||
|
Thanks,<br />
|
||||||
The {appTitle} Team
|
The {appTitle} Team
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</body>
|
||||||
</body>
|
</html>
|
||||||
</html>
|
|
||||||
|
|||||||
@ -178,10 +178,7 @@ export default class LocalStorageProvider extends BaseStorageProvider {
|
|||||||
return { key: destinationKey };
|
return { key: destinationKey };
|
||||||
}
|
}
|
||||||
|
|
||||||
override getSignedUrl(
|
override getSignedUrl(key: string, _expiresIn: number): Promise<string> {
|
||||||
key: string,
|
|
||||||
_expiresIn: number,
|
|
||||||
): Promise<string> {
|
|
||||||
return Promise.resolve(`/uploads/${key}`);
|
return Promise.resolve(`/uploads/${key}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -177,10 +177,7 @@ export default class S3StorageProvider extends BaseStorageProvider {
|
|||||||
return 503;
|
return 503;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (error instanceof S3ServiceException && error.$metadata.httpStatusCode) {
|
||||||
error instanceof S3ServiceException &&
|
|
||||||
error.$metadata.httpStatusCode
|
|
||||||
) {
|
|
||||||
return error.$metadata.httpStatusCode;
|
return error.$metadata.httpStatusCode;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -193,11 +190,11 @@ export default class S3StorageProvider extends BaseStorageProvider {
|
|||||||
|
|
||||||
return Boolean(
|
return Boolean(
|
||||||
(errorName && RETRYABLE_ERRORS.has(errorName)) ||
|
(errorName && RETRYABLE_ERRORS.has(errorName)) ||
|
||||||
(errorCode && RETRYABLE_ERRORS.has(errorCode)) ||
|
(errorCode && RETRYABLE_ERRORS.has(errorCode)) ||
|
||||||
(error instanceof S3ServiceException &&
|
(error instanceof S3ServiceException &&
|
||||||
error.$metadata.httpStatusCode !== undefined &&
|
error.$metadata.httpStatusCode !== undefined &&
|
||||||
error.$metadata.httpStatusCode >= 500 &&
|
error.$metadata.httpStatusCode >= 500 &&
|
||||||
error.$metadata.httpStatusCode < 600),
|
error.$metadata.httpStatusCode < 600),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -411,10 +408,7 @@ export default class S3StorageProvider extends BaseStorageProvider {
|
|||||||
return keys;
|
return keys;
|
||||||
}
|
}
|
||||||
|
|
||||||
override async getSignedUrl(
|
override async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||||
key: string,
|
|
||||||
expiresIn = 3600,
|
|
||||||
): Promise<string> {
|
|
||||||
const fullKey = this.buildKey(key);
|
const fullKey = this.buildKey(key);
|
||||||
|
|
||||||
const command = new GetObjectCommand({
|
const command = new GetObjectCommand({
|
||||||
|
|||||||
@ -61,7 +61,9 @@ const isUploadSessionChunkMeta = (
|
|||||||
const isUploadSessionUploadedChunks = (
|
const isUploadSessionUploadedChunks = (
|
||||||
value: unknown,
|
value: unknown,
|
||||||
): value is UploadSessionUploadedChunks => {
|
): value is UploadSessionUploadedChunks => {
|
||||||
return isRecord(value) && Object.values(value).every(isUploadSessionChunkMeta);
|
return (
|
||||||
|
isRecord(value) && Object.values(value).every(isUploadSessionChunkMeta)
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isNullableString = (value: unknown): value is string | null => {
|
const isNullableString = (value: unknown): value is string | null => {
|
||||||
@ -160,7 +162,11 @@ export default class UploadSessionManager {
|
|||||||
return sessionId;
|
return sessionId;
|
||||||
}
|
}
|
||||||
|
|
||||||
saveChunk(sessionId: string, chunkIndex: number, data: Buffer): Promise<void> {
|
saveChunk(
|
||||||
|
sessionId: string,
|
||||||
|
chunkIndex: number,
|
||||||
|
data: Buffer,
|
||||||
|
): Promise<void> {
|
||||||
const chunkPath = this.getChunkPath(sessionId, chunkIndex);
|
const chunkPath = this.getChunkPath(sessionId, chunkIndex);
|
||||||
ensureDirectoryExistence(chunkPath);
|
ensureDirectoryExistence(chunkPath);
|
||||||
fs.writeFileSync(chunkPath, data);
|
fs.writeFileSync(chunkPath, data);
|
||||||
|
|||||||
@ -147,10 +147,7 @@ export default class ProjectAudioTracksService {
|
|||||||
|
|
||||||
const results = await parseCsvRows(req.file.buffer);
|
const results = await parseCsvRows(req.file.buffer);
|
||||||
|
|
||||||
logger.debug(
|
logger.debug({ rows: results.length }, 'Project audio tracks CSV parsed');
|
||||||
{ rows: results.length },
|
|
||||||
'Project audio tracks CSV parsed',
|
|
||||||
);
|
|
||||||
|
|
||||||
const bulkImportOptions = buildContextOptions({
|
const bulkImportOptions = buildContextOptions({
|
||||||
currentUser: getCurrentUser(req),
|
currentUser: getCurrentUser(req),
|
||||||
|
|||||||
@ -12,9 +12,9 @@ import type {
|
|||||||
} from '../types/index.ts';
|
} from '../types/index.ts';
|
||||||
import type { Transaction } from 'sequelize';
|
import type { Transaction } from 'sequelize';
|
||||||
|
|
||||||
function buildTransactionOptions(
|
function buildTransactionOptions(transaction: Transaction | undefined): {
|
||||||
transaction: Transaction | undefined,
|
transaction?: Transaction;
|
||||||
): { transaction?: Transaction } {
|
} {
|
||||||
return transaction ? { transaction } : {};
|
return transaction ? { transaction } : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -536,7 +536,8 @@ export default class ProjectsService extends BaseProjectsService {
|
|||||||
if (sourceAsset.mime_type !== undefined) {
|
if (sourceAsset.mime_type !== undefined) {
|
||||||
payload.mime_type = sourceAsset.mime_type;
|
payload.mime_type = sourceAsset.mime_type;
|
||||||
}
|
}
|
||||||
if (sourceAsset.size_mb !== undefined) payload.size_mb = sourceAsset.size_mb;
|
if (sourceAsset.size_mb !== undefined)
|
||||||
|
payload.size_mb = sourceAsset.size_mb;
|
||||||
if (sourceAsset.width_px !== undefined) {
|
if (sourceAsset.width_px !== undefined) {
|
||||||
payload.width_px = sourceAsset.width_px;
|
payload.width_px = sourceAsset.width_px;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -162,7 +162,11 @@ export default class PublishService {
|
|||||||
{ transaction },
|
{ transaction },
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.copyStageToProduction(projectId, currentUser, transaction);
|
return this.copyStageToProduction(
|
||||||
|
projectId,
|
||||||
|
currentUser,
|
||||||
|
transaction,
|
||||||
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@ -102,7 +102,9 @@ const columnsInt: Partial<Record<SearchTableName, readonly string[]>> = {
|
|||||||
|
|
||||||
const SEARCH_LIMIT_PER_TABLE = 50;
|
const SEARCH_LIMIT_PER_TABLE = 50;
|
||||||
|
|
||||||
function hasPermissionMethod(value: unknown): value is RoleWithPermissionMethod {
|
function hasPermissionMethod(
|
||||||
|
value: unknown,
|
||||||
|
): value is RoleWithPermissionMethod {
|
||||||
return (
|
return (
|
||||||
value !== null &&
|
value !== null &&
|
||||||
typeof value === 'object' &&
|
typeof value === 'object' &&
|
||||||
|
|||||||
@ -165,69 +165,79 @@ const regenerateElementInstanceIds = (
|
|||||||
if (!uiSchema || typeof uiSchema !== 'object') return uiSchema;
|
if (!uiSchema || typeof uiSchema !== 'object') return uiSchema;
|
||||||
|
|
||||||
const parsedClone = parseUnknownJson(JSON.stringify(uiSchema));
|
const parsedClone = parseUnknownJson(JSON.stringify(uiSchema));
|
||||||
const clonedSchema = isStructuredUiSchema(parsedClone) ? parsedClone : uiSchema;
|
const clonedSchema = isStructuredUiSchema(parsedClone)
|
||||||
|
? parsedClone
|
||||||
|
: uiSchema;
|
||||||
if (!Array.isArray(clonedSchema.elements)) return clonedSchema;
|
if (!Array.isArray(clonedSchema.elements)) return clonedSchema;
|
||||||
|
|
||||||
clonedSchema.elements = clonedSchema.elements.map((element): TourPageElement => {
|
clonedSchema.elements = clonedSchema.elements.map(
|
||||||
const clonedElement: TourPageElement = {
|
(element): TourPageElement => {
|
||||||
...element,
|
const clonedElement: TourPageElement = {
|
||||||
id: createLocalElementId(),
|
...element,
|
||||||
};
|
id: createLocalElementId(),
|
||||||
|
};
|
||||||
|
|
||||||
const galleryCards = regenerateNestedItemIds(clonedElement.galleryCards);
|
const galleryCards = regenerateNestedItemIds(clonedElement.galleryCards);
|
||||||
if (galleryCards !== undefined) {
|
if (galleryCards !== undefined) {
|
||||||
clonedElement.galleryCards = galleryCards;
|
clonedElement.galleryCards = galleryCards;
|
||||||
}
|
}
|
||||||
|
|
||||||
const galleryInfoSpans = regenerateNestedItemIds(
|
const galleryInfoSpans = regenerateNestedItemIds(
|
||||||
clonedElement.galleryInfoSpans,
|
clonedElement.galleryInfoSpans,
|
||||||
);
|
|
||||||
if (galleryInfoSpans !== undefined) {
|
|
||||||
clonedElement.galleryInfoSpans = galleryInfoSpans;
|
|
||||||
}
|
|
||||||
|
|
||||||
const carouselSlides = regenerateNestedItemIds(clonedElement.carouselSlides);
|
|
||||||
if (carouselSlides !== undefined) {
|
|
||||||
clonedElement.carouselSlides = carouselSlides;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Array.isArray(clonedElement.infoPanelSections)) {
|
|
||||||
clonedElement.infoPanelSections = clonedElement.infoPanelSections.map(
|
|
||||||
(section) => {
|
|
||||||
const clonedSection = {
|
|
||||||
...section,
|
|
||||||
id: `section-${createLocalElementId()}`,
|
|
||||||
};
|
|
||||||
const spans = regenerateNestedItemIds(section.spans);
|
|
||||||
if (spans !== undefined) {
|
|
||||||
clonedSection.spans = spans;
|
|
||||||
}
|
|
||||||
const images = regenerateNestedItemIds(section.images);
|
|
||||||
if (images !== undefined) {
|
|
||||||
clonedSection.images = images;
|
|
||||||
}
|
|
||||||
return clonedSection;
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
}
|
if (galleryInfoSpans !== undefined) {
|
||||||
|
clonedElement.galleryInfoSpans = galleryInfoSpans;
|
||||||
|
}
|
||||||
|
|
||||||
return clonedElement;
|
const carouselSlides = regenerateNestedItemIds(
|
||||||
});
|
clonedElement.carouselSlides,
|
||||||
|
);
|
||||||
|
if (carouselSlides !== undefined) {
|
||||||
|
clonedElement.carouselSlides = carouselSlides;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(clonedElement.infoPanelSections)) {
|
||||||
|
clonedElement.infoPanelSections = clonedElement.infoPanelSections.map(
|
||||||
|
(section) => {
|
||||||
|
const clonedSection = {
|
||||||
|
...section,
|
||||||
|
id: `section-${createLocalElementId()}`,
|
||||||
|
};
|
||||||
|
const spans = regenerateNestedItemIds(section.spans);
|
||||||
|
if (spans !== undefined) {
|
||||||
|
clonedSection.spans = spans;
|
||||||
|
}
|
||||||
|
const images = regenerateNestedItemIds(section.images);
|
||||||
|
if (images !== undefined) {
|
||||||
|
clonedSection.images = images;
|
||||||
|
}
|
||||||
|
return clonedSection;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return clonedElement;
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
return clonedSchema;
|
return clonedSchema;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Create base service from factory
|
// Create base service from factory
|
||||||
const BaseService = createEntityService<TourPageRecord, TourPageData, TourPageData>(
|
const BaseService = createEntityService<
|
||||||
Tour_pagesDBApi,
|
TourPageRecord,
|
||||||
{
|
TourPageData,
|
||||||
|
TourPageData
|
||||||
|
>(Tour_pagesDBApi, {
|
||||||
entityName: 'tour_pages',
|
entityName: 'tour_pages',
|
||||||
},
|
});
|
||||||
);
|
|
||||||
|
|
||||||
const buildCreateServiceOptions = (
|
const buildCreateServiceOptions = (
|
||||||
options: TourPageCreateOptions,
|
options: TourPageCreateOptions,
|
||||||
): Pick<TourPageCreateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> => {
|
): Pick<
|
||||||
|
TourPageCreateOptions,
|
||||||
|
'currentUser' | 'transaction' | 'runtimeContext'
|
||||||
|
> => {
|
||||||
const serviceOptions: Pick<
|
const serviceOptions: Pick<
|
||||||
TourPageCreateOptions,
|
TourPageCreateOptions,
|
||||||
'currentUser' | 'transaction' | 'runtimeContext'
|
'currentUser' | 'transaction' | 'runtimeContext'
|
||||||
@ -248,7 +258,10 @@ const buildCreateServiceOptions = (
|
|||||||
|
|
||||||
const buildUpdateServiceOptions = (
|
const buildUpdateServiceOptions = (
|
||||||
options: TourPageUpdateOptions,
|
options: TourPageUpdateOptions,
|
||||||
): Pick<TourPageUpdateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> => {
|
): Pick<
|
||||||
|
TourPageUpdateOptions,
|
||||||
|
'currentUser' | 'transaction' | 'runtimeContext'
|
||||||
|
> => {
|
||||||
const serviceOptions: Pick<
|
const serviceOptions: Pick<
|
||||||
TourPageUpdateOptions,
|
TourPageUpdateOptions,
|
||||||
'currentUser' | 'transaction' | 'runtimeContext'
|
'currentUser' | 'transaction' | 'runtimeContext'
|
||||||
@ -270,8 +283,10 @@ const buildUpdateServiceOptions = (
|
|||||||
const buildFindByOptions = (
|
const buildFindByOptions = (
|
||||||
options: TourPageUpdateOptions,
|
options: TourPageUpdateOptions,
|
||||||
): { transaction?: Transaction; runtimeContext?: RuntimeContext } => {
|
): { transaction?: Transaction; runtimeContext?: RuntimeContext } => {
|
||||||
const findByOptions: { transaction?: Transaction; runtimeContext?: RuntimeContext } =
|
const findByOptions: {
|
||||||
{};
|
transaction?: Transaction;
|
||||||
|
runtimeContext?: RuntimeContext;
|
||||||
|
} = {};
|
||||||
|
|
||||||
if (options.transaction !== undefined) {
|
if (options.transaction !== undefined) {
|
||||||
findByOptions.transaction = options.transaction;
|
findByOptions.transaction = options.transaction;
|
||||||
@ -317,6 +332,10 @@ const toPlainTourPage = (
|
|||||||
return page;
|
return page;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getStringValue = (value: unknown): string => {
|
||||||
|
return typeof value === 'string' ? value.trim() : '';
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tour Pages Service with reversed video generation
|
* Tour Pages Service with reversed video generation
|
||||||
*/
|
*/
|
||||||
@ -325,7 +344,9 @@ class TourPagesService extends BaseService {
|
|||||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
|
||||||
}
|
}
|
||||||
|
|
||||||
static getAssetSizeBytes(asset: AssetRecord | null | undefined): number | null {
|
static getAssetSizeBytes(
|
||||||
|
asset: AssetRecord | null | undefined,
|
||||||
|
): number | null {
|
||||||
if (!asset || asset.size_mb == null) return null;
|
if (!asset || asset.size_mb == null) return null;
|
||||||
|
|
||||||
const sizeMb = Number(asset.size_mb);
|
const sizeMb = Number(asset.size_mb);
|
||||||
@ -740,7 +761,16 @@ class TourPagesService extends BaseService {
|
|||||||
static isBackElement(element: TourPageElement): boolean {
|
static isBackElement(element: TourPageElement): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
element.type === 'navigation_prev' ||
|
element.type === 'navigation_prev' ||
|
||||||
(element.type?.startsWith?.('navigation') && element.navType === 'back'),
|
(element.type?.startsWith?.('navigation') && element.navType === 'back'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isForwardNavigationElement(element: TourPageElement): boolean {
|
||||||
|
return Boolean(
|
||||||
|
element.type === 'navigation_next' ||
|
||||||
|
(element.type?.startsWith?.('navigation') &&
|
||||||
|
element.navType !== 'back' &&
|
||||||
|
element.type !== 'navigation_prev'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -753,14 +783,183 @@ class TourPagesService extends BaseService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isForward =
|
|
||||||
element.type === 'navigation_next' ||
|
|
||||||
(element.type?.startsWith?.('navigation') &&
|
|
||||||
element.navType !== 'back' &&
|
|
||||||
element.type !== 'navigation_prev');
|
|
||||||
// Check for target (slug or legacy ID) and transition video
|
// Check for target (slug or legacy ID) and transition video
|
||||||
const hasTarget = element.targetPageSlug || element.targetPageId;
|
const hasTarget = element.targetPageSlug || element.targetPageId;
|
||||||
return Boolean(isForward && hasTarget && element.transitionVideoUrl);
|
return Boolean(
|
||||||
|
TourPagesService.isForwardNavigationElement(element) &&
|
||||||
|
hasTarget &&
|
||||||
|
element.transitionVideoUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static isForwardElementTargetingPage(
|
||||||
|
element: TourPageElement,
|
||||||
|
page: Pick<TourPageData, 'id' | 'slug'>,
|
||||||
|
): boolean {
|
||||||
|
if (element.navigationTargetMode === 'external_url') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!TourPagesService.isForwardNavigationElement(element)) return false;
|
||||||
|
|
||||||
|
const targetSlug = getStringValue(element.targetPageSlug);
|
||||||
|
const targetId = getStringValue(element.targetPageId);
|
||||||
|
const pageSlug = getStringValue(page.slug);
|
||||||
|
const pageId = getStringValue(page.id);
|
||||||
|
|
||||||
|
return Boolean(
|
||||||
|
(pageSlug && targetSlug === pageSlug) || (pageId && targetId === pageId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static findBestIncomingForwardElement(
|
||||||
|
sourcePage: TourPageRecord,
|
||||||
|
currentPage: Pick<TourPageData, 'id' | 'slug'>,
|
||||||
|
): TourPageElement | null {
|
||||||
|
const uiSchema = parseStructuredUiSchema(sourcePage.ui_schema_json);
|
||||||
|
if (!uiSchema?.elements || !Array.isArray(uiSchema.elements)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = uiSchema.elements.filter((element) =>
|
||||||
|
TourPagesService.isForwardElementTargetingPage(element, currentPage),
|
||||||
|
);
|
||||||
|
|
||||||
|
const scoreCandidate = (element: TourPageElement): number => {
|
||||||
|
const hasTransition = Boolean(getStringValue(element.transitionVideoUrl));
|
||||||
|
const hasReverse = Boolean(getStringValue(element.reverseVideoUrl));
|
||||||
|
|
||||||
|
if (hasTransition && hasReverse) return 3;
|
||||||
|
if (hasTransition) return 2;
|
||||||
|
if (hasReverse) return 1;
|
||||||
|
return 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
return candidates.reduce<TourPageElement | null>((best, candidate) => {
|
||||||
|
if (!best) return candidate;
|
||||||
|
return scoreCandidate(candidate) > scoreCandidate(best)
|
||||||
|
? candidate
|
||||||
|
: best;
|
||||||
|
}, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
static applyIncomingTransitionToBackElement(
|
||||||
|
backElement: TourPageElement,
|
||||||
|
incomingElement: TourPageElement | null,
|
||||||
|
): boolean {
|
||||||
|
const nextTransitionVideoUrl = getStringValue(
|
||||||
|
incomingElement?.transitionVideoUrl,
|
||||||
|
);
|
||||||
|
const nextReverseMode = nextTransitionVideoUrl
|
||||||
|
? incomingElement?.transitionReverseMode === 'separate_video'
|
||||||
|
? 'separate_video'
|
||||||
|
: 'auto_reverse'
|
||||||
|
: '';
|
||||||
|
const nextReverseVideoUrl = nextTransitionVideoUrl
|
||||||
|
? getStringValue(incomingElement?.reverseVideoUrl)
|
||||||
|
: '';
|
||||||
|
|
||||||
|
const currentTransitionVideoUrl = getStringValue(
|
||||||
|
backElement.transitionVideoUrl,
|
||||||
|
);
|
||||||
|
const currentReverseMode = getStringValue(
|
||||||
|
backElement.transitionReverseMode,
|
||||||
|
);
|
||||||
|
const currentReverseVideoUrl = getStringValue(backElement.reverseVideoUrl);
|
||||||
|
|
||||||
|
if (
|
||||||
|
currentTransitionVideoUrl === nextTransitionVideoUrl &&
|
||||||
|
currentReverseMode === nextReverseMode &&
|
||||||
|
currentReverseVideoUrl === nextReverseVideoUrl
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
backElement.transitionVideoUrl = nextTransitionVideoUrl;
|
||||||
|
backElement.transitionReverseMode = nextReverseMode;
|
||||||
|
backElement.reverseVideoUrl = nextReverseVideoUrl;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
static refreshBackNavigationTransitionSources(
|
||||||
|
uiSchema: TourPageStructuredUiSchema,
|
||||||
|
pages: TourPageRecord[],
|
||||||
|
currentPage: Pick<TourPageData, 'id' | 'slug'>,
|
||||||
|
): boolean {
|
||||||
|
if (!uiSchema.elements || !Array.isArray(uiSchema.elements)) return false;
|
||||||
|
|
||||||
|
let wasModified = false;
|
||||||
|
|
||||||
|
for (const element of uiSchema.elements) {
|
||||||
|
if (!TourPagesService.isBackElement(element)) continue;
|
||||||
|
|
||||||
|
const sourcePageSlug = getStringValue(element.targetPageSlug);
|
||||||
|
const sourcePageId = getStringValue(element.targetPageId);
|
||||||
|
|
||||||
|
if (!sourcePageSlug && !sourcePageId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePage = pages.find(
|
||||||
|
(page) =>
|
||||||
|
(sourcePageSlug && page.slug === sourcePageSlug) ||
|
||||||
|
(sourcePageId && page.id === sourcePageId),
|
||||||
|
);
|
||||||
|
|
||||||
|
const incomingElement = sourcePage
|
||||||
|
? TourPagesService.findBestIncomingForwardElement(
|
||||||
|
sourcePage,
|
||||||
|
currentPage,
|
||||||
|
)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (
|
||||||
|
TourPagesService.applyIncomingTransitionToBackElement(
|
||||||
|
element,
|
||||||
|
incomingElement,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
wasModified = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return wasModified;
|
||||||
|
}
|
||||||
|
|
||||||
|
static async refreshBackNavigationTransitionSourcesForPage(
|
||||||
|
data: TourPageData,
|
||||||
|
uiSchema: TourPageStructuredUiSchema,
|
||||||
|
projectId: string | null | undefined,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const currentPageSlug = getStringValue(data.slug);
|
||||||
|
const currentPageId = getStringValue(data.id);
|
||||||
|
if (!projectId || (!currentPageSlug && !currentPageId)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasTargetedBackNavigation = uiSchema.elements?.some(
|
||||||
|
(element) =>
|
||||||
|
TourPagesService.isBackElement(element) &&
|
||||||
|
(getStringValue(element.targetPageSlug) ||
|
||||||
|
getStringValue(element.targetPageId)),
|
||||||
|
);
|
||||||
|
if (!hasTargetedBackNavigation) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { rows: pages } = await Tour_pagesDBApi.findAll(
|
||||||
|
{
|
||||||
|
projectId,
|
||||||
|
environment: data.environment || 'dev',
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
);
|
||||||
|
|
||||||
|
return TourPagesService.refreshBackNavigationTransitionSources(
|
||||||
|
uiSchema,
|
||||||
|
pages,
|
||||||
|
{ id: currentPageId, slug: currentPageSlug },
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -802,8 +1001,14 @@ class TourPagesService extends BaseService {
|
|||||||
'Processing reversed videos for navigation elements',
|
'Processing reversed videos for navigation elements',
|
||||||
);
|
);
|
||||||
|
|
||||||
|
let wasModified =
|
||||||
|
await TourPagesService.refreshBackNavigationTransitionSourcesForPage(
|
||||||
|
data,
|
||||||
|
uiSchema,
|
||||||
|
projectId,
|
||||||
|
);
|
||||||
|
|
||||||
const storageKeysToValidate = new Set<string>();
|
const storageKeysToValidate = new Set<string>();
|
||||||
let wasModified = false;
|
|
||||||
|
|
||||||
for (const element of uiSchema.elements) {
|
for (const element of uiSchema.elements) {
|
||||||
const isBack = TourPagesService.isBackElement(element);
|
const isBack = TourPagesService.isBackElement(element);
|
||||||
@ -972,38 +1177,39 @@ class TourPagesService extends BaseService {
|
|||||||
|
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
const log = logger.child({
|
const log = logger.child({
|
||||||
projectId,
|
|
||||||
storageKey,
|
|
||||||
pageId,
|
|
||||||
operation: 'singleReverseGeneration',
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
log.info('Starting background reversed variant generation');
|
|
||||||
const reversedUrl = await TourPagesService.getOrGenerateReversedVariant(
|
|
||||||
storageKey,
|
|
||||||
currentUser,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!reversedUrl) {
|
|
||||||
log.warn(
|
|
||||||
'Background reversed variant generation finished without result',
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await TourPagesService.applyReversedUrlToProjectElements(
|
|
||||||
projectId,
|
projectId,
|
||||||
storageKey,
|
storageKey,
|
||||||
reversedUrl,
|
pageId,
|
||||||
currentUser,
|
operation: 'singleReverseGeneration',
|
||||||
);
|
});
|
||||||
} catch (err) {
|
|
||||||
log.error({ err }, 'Background reversed generation failed');
|
try {
|
||||||
} finally {
|
log.info('Starting background reversed variant generation');
|
||||||
singleReverseGenerationInProgress.delete(taskKey);
|
const reversedUrl =
|
||||||
}
|
await TourPagesService.getOrGenerateReversedVariant(
|
||||||
|
storageKey,
|
||||||
|
currentUser,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!reversedUrl) {
|
||||||
|
log.warn(
|
||||||
|
'Background reversed variant generation finished without result',
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await TourPagesService.applyReversedUrlToProjectElements(
|
||||||
|
projectId,
|
||||||
|
storageKey,
|
||||||
|
reversedUrl,
|
||||||
|
currentUser,
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
log.error({ err }, 'Background reversed generation failed');
|
||||||
|
} finally {
|
||||||
|
singleReverseGenerationInProgress.delete(taskKey);
|
||||||
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1027,20 +1233,20 @@ class TourPagesService extends BaseService {
|
|||||||
|
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await TourPagesService.regenerateProjectReversedVideos(
|
await TourPagesService.regenerateProjectReversedVideos(
|
||||||
projectId,
|
projectId,
|
||||||
currentUser,
|
currentUser,
|
||||||
excludePageId,
|
excludePageId,
|
||||||
);
|
);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ err, projectId },
|
{ err, projectId },
|
||||||
'Background project regeneration failed',
|
'Background project regeneration failed',
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
projectRegenInProgress.delete(projectId);
|
projectRegenInProgress.delete(projectId);
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@ -1197,9 +1403,13 @@ class TourPagesService extends BaseService {
|
|||||||
// Upload reversed video to storage
|
// Upload reversed video to storage
|
||||||
const reversedKey = `assets/${asset.id}/reversed.mp4`;
|
const reversedKey = `assets/${asset.id}/reversed.mp4`;
|
||||||
|
|
||||||
const result = await FileService.uploadBuffer(reversedKey, reversedBuffer, {
|
const result = await FileService.uploadBuffer(
|
||||||
contentType: 'video/mp4',
|
reversedKey,
|
||||||
});
|
reversedBuffer,
|
||||||
|
{
|
||||||
|
contentType: 'video/mp4',
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
// Create variant record
|
// Create variant record
|
||||||
await Asset_variantsDBApi.create({
|
await Asset_variantsDBApi.create({
|
||||||
@ -1266,7 +1476,12 @@ class TourPagesService extends BaseService {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
let pageModified = false;
|
let pageModified =
|
||||||
|
TourPagesService.refreshBackNavigationTransitionSources(
|
||||||
|
uiSchema,
|
||||||
|
pages,
|
||||||
|
{ id: page.id, slug: page.slug },
|
||||||
|
);
|
||||||
|
|
||||||
for (const element of uiSchema.elements) {
|
for (const element of uiSchema.elements) {
|
||||||
// Process both forward elements AND back elements with their own transition
|
// Process both forward elements AND back elements with their own transition
|
||||||
@ -1274,7 +1489,7 @@ class TourPagesService extends BaseService {
|
|||||||
TourPagesService.isForwardElementWithTarget(element);
|
TourPagesService.isForwardElementWithTarget(element);
|
||||||
const isBackWithTransition = Boolean(
|
const isBackWithTransition = Boolean(
|
||||||
TourPagesService.isBackElement(element) &&
|
TourPagesService.isBackElement(element) &&
|
||||||
element.transitionVideoUrl,
|
element.transitionVideoUrl,
|
||||||
);
|
);
|
||||||
|
|
||||||
log.debug(
|
log.debug(
|
||||||
|
|||||||
@ -33,12 +33,9 @@ const BaseUsersService = createEntityService<
|
|||||||
UserData,
|
UserData,
|
||||||
UserListFilter,
|
UserListFilter,
|
||||||
UserAutocompleteOption
|
UserAutocompleteOption
|
||||||
>(
|
>(UsersDBApi, {
|
||||||
UsersDBApi,
|
entityName: 'Users',
|
||||||
{
|
});
|
||||||
entityName: 'Users',
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildRuntimeOptions = (
|
const buildRuntimeOptions = (
|
||||||
transaction: Transaction,
|
transaction: Transaction,
|
||||||
@ -230,7 +227,9 @@ export default class UsersService extends BaseUsersService {
|
|||||||
await this.createProductionPresentationAccessForPublicUser(options);
|
await this.createProductionPresentationAccessForPublicUser(options);
|
||||||
}
|
}
|
||||||
|
|
||||||
static override async create(options: UserCreateOptions): Promise<UserRecord> {
|
static override async create(
|
||||||
|
options: UserCreateOptions,
|
||||||
|
): Promise<UserRecord> {
|
||||||
assertCreateOptions(options, 'Service');
|
assertCreateOptions(options, 'Service');
|
||||||
const {
|
const {
|
||||||
data,
|
data,
|
||||||
@ -319,7 +318,9 @@ export default class UsersService extends BaseUsersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static override async update(options: UserUpdateOptions): Promise<UserRecord> {
|
static override async update(
|
||||||
|
options: UserUpdateOptions,
|
||||||
|
): Promise<UserRecord> {
|
||||||
assertUpdateOptions(options, 'Service');
|
assertUpdateOptions(options, 'Service');
|
||||||
const {
|
const {
|
||||||
id,
|
id,
|
||||||
@ -405,7 +406,9 @@ export default class UsersService extends BaseUsersService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
static override async remove(options: UserRemoveOptions): Promise<UserRecord> {
|
static override async remove(
|
||||||
|
options: UserRemoveOptions,
|
||||||
|
): Promise<UserRecord> {
|
||||||
assertIdOptions(options, 'Service', 'remove');
|
assertIdOptions(options, 'Service', 'remove');
|
||||||
const { id, currentUser, transaction, runtimeContext } = options;
|
const { id, currentUser, transaction, runtimeContext } = options;
|
||||||
|
|
||||||
|
|||||||
@ -20,7 +20,8 @@ import { logger } from '../utils/logger.ts';
|
|||||||
|
|
||||||
const loadCommonJsModule = createRequire(import.meta.url);
|
const loadCommonJsModule = createRequire(import.meta.url);
|
||||||
const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static');
|
const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static');
|
||||||
const ffmpegPath = typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null;
|
const ffmpegPath =
|
||||||
|
typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null;
|
||||||
|
|
||||||
let ffmpegQueueTail: Promise<unknown> = Promise.resolve();
|
let ffmpegQueueTail: Promise<unknown> = Promise.resolve();
|
||||||
let queuedFfmpegJobs = 0;
|
let queuedFfmpegJobs = 0;
|
||||||
@ -47,9 +48,11 @@ interface MediaProbeStream {
|
|||||||
|
|
||||||
interface MediaProbeOutput {
|
interface MediaProbeOutput {
|
||||||
streams?: MediaProbeStream[] | undefined;
|
streams?: MediaProbeStream[] | undefined;
|
||||||
format?: {
|
format?:
|
||||||
duration?: string | number | undefined;
|
| {
|
||||||
} | undefined;
|
duration?: string | number | undefined;
|
||||||
|
}
|
||||||
|
| undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessResult {
|
interface ProcessResult {
|
||||||
@ -360,7 +363,9 @@ async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> {
|
|||||||
}
|
}
|
||||||
metadata = parsedMetadata;
|
metadata = parsedMetadata;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw new Error(`Failed to parse FFprobe metadata: ${toError(error).message}`);
|
throw new Error(
|
||||||
|
`Failed to parse FFprobe metadata: ${toError(error).message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const streams = metadata.streams ?? [];
|
const streams = metadata.streams ?? [];
|
||||||
|
|||||||
@ -21,7 +21,9 @@ export interface ProductionPresentationProject {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface RoleWithPermissionLoader extends RoleRecord {
|
export interface RoleWithPermissionLoader extends RoleRecord {
|
||||||
getPermissions?: () => Promise<ReadonlyArray<PermissionRecord | PermissionName>>;
|
getPermissions?: () => Promise<
|
||||||
|
ReadonlyArray<PermissionRecord | PermissionName>
|
||||||
|
>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AccessPolicyUser = CurrentUser | null | undefined;
|
export type AccessPolicyUser = CurrentUser | null | undefined;
|
||||||
|
|||||||
@ -145,16 +145,19 @@ export interface InvalidAssetMimeValidationResult {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AssetMimeValidationResult =
|
export type AssetMimeValidationResult =
|
||||||
| ValidAssetMimeValidationResult
|
ValidAssetMimeValidationResult | InvalidAssetMimeValidationResult;
|
||||||
| InvalidAssetMimeValidationResult;
|
|
||||||
|
|
||||||
export type AssetFindByOptions = Pick<
|
export type AssetFindByOptions = Pick<
|
||||||
UpdateOptions<AssetData>,
|
UpdateOptions<AssetData>,
|
||||||
'transaction' | 'runtimeContext'
|
'transaction' | 'runtimeContext'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
export interface AssetsDbApi
|
export interface AssetsDbApi extends EntityDbApi<
|
||||||
extends EntityDbApi<AssetRecord, AssetData, AssetData, AssetListFilter> {
|
AssetRecord,
|
||||||
|
AssetData,
|
||||||
|
AssetData,
|
||||||
|
AssetListFilter
|
||||||
|
> {
|
||||||
findBy(options: DbFindByOptions): Promise<AssetRecord | null>;
|
findBy(options: DbFindByOptions): Promise<AssetRecord | null>;
|
||||||
findBy(
|
findBy(
|
||||||
where: { id?: string; storage_key?: string },
|
where: { id?: string; storage_key?: string },
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import type SMTPConnection from 'nodemailer/lib/smtp-connection/index.js';
|
import type SMTPConnection from 'nodemailer/lib/smtp-connection/index.js';
|
||||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js';
|
import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js';
|
||||||
|
|
||||||
export interface BackendEmailConfig
|
export interface BackendEmailConfig extends Omit<
|
||||||
extends Omit<SMTPTransport.Options, 'auth'> {
|
SMTPTransport.Options,
|
||||||
|
'auth'
|
||||||
|
> {
|
||||||
from: string;
|
from: string;
|
||||||
auth: SMTPConnection.Credentials;
|
auth: SMTPConnection.Credentials;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -103,8 +103,9 @@ export interface DbFindAllOptions<TFilter> extends ServiceOptions {
|
|||||||
offset?: number;
|
offset?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface DbFindByOptions<TWhere extends WhereOptions = WhereOptions>
|
export interface DbFindByOptions<
|
||||||
extends ServiceOptions {
|
TWhere extends WhereOptions = WhereOptions,
|
||||||
|
> extends ServiceOptions {
|
||||||
where: TWhere;
|
where: TWhere;
|
||||||
include?: Includeable[];
|
include?: Includeable[];
|
||||||
}
|
}
|
||||||
@ -135,13 +136,20 @@ export interface EntityDbApi<
|
|||||||
remove(options: EntityIdOptions): Promise<TEntity>;
|
remove(options: EntityIdOptions): Promise<TEntity>;
|
||||||
deleteByIds(options: DeleteByIdsOptions): Promise<TEntity[]>;
|
deleteByIds(options: DeleteByIdsOptions): Promise<TEntity[]>;
|
||||||
findBy(options: DbFindByOptions): Promise<TEntity | null>;
|
findBy(options: DbFindByOptions): Promise<TEntity | null>;
|
||||||
findAll(options: DbFindAllOptions<TFilter>): Promise<PaginatedResult<TEntity>>;
|
findAll(
|
||||||
|
options: DbFindAllOptions<TFilter>,
|
||||||
|
): Promise<PaginatedResult<TEntity>>;
|
||||||
findAllAutocomplete(options: AutocompleteOptions): Promise<TAutocomplete[]>;
|
findAllAutocomplete(options: AutocompleteOptions): Promise<TAutocomplete[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SingletonDbApi<TEntity extends EntityRecord, TUpdate>
|
export interface SingletonDbApi<
|
||||||
extends EntityDbApi<TEntity, TUpdate, TUpdate, unknown> {
|
TEntity extends EntityRecord,
|
||||||
|
TUpdate,
|
||||||
|
> extends EntityDbApi<TEntity, TUpdate, TUpdate, unknown> {
|
||||||
findOne(options?: ServiceOptions): Promise<TEntity | null>;
|
findOne(options?: ServiceOptions): Promise<TEntity | null>;
|
||||||
findBy(options: DbFindByOptions): Promise<TEntity | null>;
|
findBy(options: DbFindByOptions): Promise<TEntity | null>;
|
||||||
findBy(where: { id: string }, options?: ServiceOptions): Promise<TEntity | null>;
|
findBy(
|
||||||
|
where: { id: string },
|
||||||
|
options?: ServiceOptions,
|
||||||
|
): Promise<TEntity | null>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,8 +11,7 @@ export interface SequelizeCliStorageOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface DatabaseEnvironmentConfig
|
export interface DatabaseEnvironmentConfig
|
||||||
extends Options,
|
extends Options, SequelizeCliStorageOptions {
|
||||||
SequelizeCliStorageOptions {
|
|
||||||
dialect: 'postgres';
|
dialect: 'postgres';
|
||||||
use_env_variable?: string;
|
use_env_variable?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -461,9 +461,7 @@ export interface RoleModel {
|
|||||||
data: { name: string },
|
data: { name: string },
|
||||||
options: { transaction: Transaction },
|
options: { transaction: Transaction },
|
||||||
): Promise<RoleModelRecord>;
|
): Promise<RoleModelRecord>;
|
||||||
findOne(options: {
|
findOne(options: { where: { name: string } }): Promise<UserPublicRole | null>;
|
||||||
where: { name: string };
|
|
||||||
}): Promise<UserPublicRole | null>;
|
|
||||||
findAll(options: {
|
findAll(options: {
|
||||||
where: { name: 'Public' };
|
where: { name: 'Public' };
|
||||||
include: readonly [{ association: 'permissions' }];
|
include: readonly [{ association: 'permissions' }];
|
||||||
|
|||||||
@ -76,13 +76,12 @@ export interface ElementTypeDefaultsModel {
|
|||||||
}): Promise<ElementTypeDefaultsModelRecord | null>;
|
}): Promise<ElementTypeDefaultsModelRecord | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ElementTypeDefaultsDbApi
|
export interface ElementTypeDefaultsDbApi extends EntityDbApi<
|
||||||
extends EntityDbApi<
|
ElementTypeDefaultsRecord,
|
||||||
ElementTypeDefaultsRecord,
|
ElementTypeDefaultsData,
|
||||||
ElementTypeDefaultsData,
|
ElementTypeDefaultsData,
|
||||||
ElementTypeDefaultsData,
|
unknown
|
||||||
unknown
|
> {
|
||||||
> {
|
|
||||||
ensureInitialized(): Promise<void>;
|
ensureInitialized(): Promise<void>;
|
||||||
bulkImport(
|
bulkImport(
|
||||||
data: ElementTypeDefaultsData[],
|
data: ElementTypeDefaultsData[],
|
||||||
|
|||||||
@ -47,8 +47,10 @@ export interface EntityRouterQuery {
|
|||||||
offset?: unknown;
|
offset?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface NormalizedEntityRouterQuery
|
export interface NormalizedEntityRouterQuery extends Omit<
|
||||||
extends Omit<EntityRouterQuery, 'limit' | 'page' | 'sort' | 'field'> {
|
EntityRouterQuery,
|
||||||
|
'limit' | 'page' | 'sort' | 'field'
|
||||||
|
> {
|
||||||
limit: number;
|
limit: number;
|
||||||
page: number;
|
page: number;
|
||||||
sort?: EntityRouterSortDirection;
|
sort?: EntityRouterSortDirection;
|
||||||
|
|||||||
@ -34,12 +34,12 @@ export interface EntityServiceConstructor<
|
|||||||
TFilter = unknown,
|
TFilter = unknown,
|
||||||
TAutocomplete extends EntityRecord | { id: string } = TEntity,
|
TAutocomplete extends EntityRecord | { id: string } = TEntity,
|
||||||
> extends EntityServiceClass<
|
> extends EntityServiceClass<
|
||||||
TEntity,
|
TEntity,
|
||||||
TCreate,
|
TCreate,
|
||||||
TUpdate,
|
TUpdate,
|
||||||
TFilter,
|
TFilter,
|
||||||
TAutocomplete
|
TAutocomplete
|
||||||
> {
|
> {
|
||||||
new (): object;
|
new (): object;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -58,7 +58,10 @@ export interface EntityServiceDbApi<
|
|||||||
readonly __filterType?: TFilter;
|
readonly __filterType?: TFilter;
|
||||||
readonly __autocompleteType?: TAutocomplete;
|
readonly __autocompleteType?: TAutocomplete;
|
||||||
create(options: CreateOptions<unknown>): Promise<TEntity>;
|
create(options: CreateOptions<unknown>): Promise<TEntity>;
|
||||||
bulkImport(rows: readonly unknown[], options: BulkImportOptions): Promise<unknown>;
|
bulkImport(
|
||||||
|
rows: readonly unknown[],
|
||||||
|
options: BulkImportOptions,
|
||||||
|
): Promise<unknown>;
|
||||||
update(options: UpdateOptions<unknown>): Promise<TEntity>;
|
update(options: UpdateOptions<unknown>): Promise<TEntity>;
|
||||||
findBy(
|
findBy(
|
||||||
where: { id: string },
|
where: { id: string },
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
export type NodeEnvironment =
|
export type NodeEnvironment =
|
||||||
| 'development'
|
'development' | 'test' | 'production' | 'dev_stage';
|
||||||
| 'test'
|
|
||||||
| 'production'
|
|
||||||
| 'dev_stage';
|
|
||||||
|
|
||||||
export interface ValidatedEnvironment {
|
export interface ValidatedEnvironment {
|
||||||
NODE_ENV: NodeEnvironment;
|
NODE_ENV: NodeEnvironment;
|
||||||
|
|||||||
@ -231,7 +231,9 @@ export interface FileServiceFacade {
|
|||||||
req: FileServiceRequest,
|
req: FileServiceRequest,
|
||||||
res: FileServiceResponse,
|
res: FileServiceResponse,
|
||||||
): Promise<unknown>;
|
): Promise<unknown>;
|
||||||
generatePresignedUrls(urls: readonly string[]): Promise<Record<string, string>>;
|
generatePresignedUrls(
|
||||||
|
urls: readonly string[],
|
||||||
|
): Promise<Record<string, string>>;
|
||||||
isValidPath(urlPath: unknown): urlPath is string;
|
isValidPath(urlPath: unknown): urlPath is string;
|
||||||
createErrorResponse(
|
createErrorResponse(
|
||||||
message: string,
|
message: string,
|
||||||
@ -266,10 +268,7 @@ export interface FileDbOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type RelationFileInput =
|
export type RelationFileInput =
|
||||||
| RelationFileRecord
|
RelationFileRecord | RelationFileRecord[] | null | undefined;
|
||||||
| RelationFileRecord[]
|
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
export interface FileModelCreatePayload {
|
export interface FileModelCreatePayload {
|
||||||
belongsTo: string;
|
belongsTo: string;
|
||||||
@ -364,7 +363,10 @@ export interface UploadSessionChunkMeta {
|
|||||||
uploadedAt: string;
|
uploadedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type UploadSessionUploadedChunks = Record<string, UploadSessionChunkMeta>;
|
export type UploadSessionUploadedChunks = Record<
|
||||||
|
string,
|
||||||
|
UploadSessionChunkMeta
|
||||||
|
>;
|
||||||
|
|
||||||
export interface UploadSessionMeta {
|
export interface UploadSessionMeta {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
|
|||||||
@ -1,15 +1,8 @@
|
|||||||
export type TransitionType =
|
export type TransitionType =
|
||||||
| 'fade'
|
'fade' | 'slide-left' | 'slide-right' | 'zoom' | 'none';
|
||||||
| 'slide-left'
|
|
||||||
| 'slide-right'
|
|
||||||
| 'zoom'
|
|
||||||
| 'none';
|
|
||||||
|
|
||||||
export type TransitionEasing =
|
export type TransitionEasing =
|
||||||
| 'ease-in-out'
|
'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
|
||||||
| 'ease-in'
|
|
||||||
| 'ease-out'
|
|
||||||
| 'linear';
|
|
||||||
|
|
||||||
export interface GlobalTransitionDefaultsData {
|
export interface GlobalTransitionDefaultsData {
|
||||||
id?: string;
|
id?: string;
|
||||||
@ -19,8 +12,7 @@ export interface GlobalTransitionDefaultsData {
|
|||||||
overlay_color?: string;
|
overlay_color?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface GlobalTransitionDefaultsRecord
|
export interface GlobalTransitionDefaultsRecord extends Required<GlobalTransitionDefaultsData> {
|
||||||
extends Required<GlobalTransitionDefaultsData> {
|
|
||||||
id: string;
|
id: string;
|
||||||
createdAt?: Date;
|
createdAt?: Date;
|
||||||
updatedAt?: Date;
|
updatedAt?: Date;
|
||||||
@ -86,13 +78,11 @@ export interface GlobalUiControlDefaultsModelRecord {
|
|||||||
export interface GlobalUiControlDefaultsModel {
|
export interface GlobalUiControlDefaultsModel {
|
||||||
count(): Promise<number>;
|
count(): Promise<number>;
|
||||||
sync(): Promise<unknown>;
|
sync(): Promise<unknown>;
|
||||||
create(
|
create(data: {
|
||||||
data: {
|
settings_json: GlobalUiControlSettingsJson;
|
||||||
settings_json: GlobalUiControlSettingsJson;
|
createdAt: Date;
|
||||||
createdAt: Date;
|
updatedAt: Date;
|
||||||
updatedAt: Date;
|
}): Promise<GlobalUiControlDefaultsModelRecord>;
|
||||||
},
|
|
||||||
): Promise<GlobalUiControlDefaultsModelRecord>;
|
|
||||||
findOne(options: {
|
findOne(options: {
|
||||||
transaction?: unknown;
|
transaction?: unknown;
|
||||||
}): Promise<GlobalUiControlDefaultsModelRecord | null>;
|
}): Promise<GlobalUiControlDefaultsModelRecord | null>;
|
||||||
|
|||||||
@ -1,8 +1,5 @@
|
|||||||
import type { NextFunction, Request, Response } from 'express';
|
import type { NextFunction, Request, Response } from 'express';
|
||||||
import type {
|
import type { ParamsDictionary, Query } from 'express-serve-static-core';
|
||||||
ParamsDictionary,
|
|
||||||
Query,
|
|
||||||
} from 'express-serve-static-core';
|
|
||||||
|
|
||||||
import type { RequestValidationDetail } from './validation.ts';
|
import type { RequestValidationDetail } from './validation.ts';
|
||||||
|
|
||||||
@ -32,7 +29,11 @@ export interface RouteIdRequestLike {
|
|||||||
body: unknown;
|
body: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RouteIdRequest = Request<{ id: string }, unknown, RouteIdRequestBody>;
|
export type RouteIdRequest = Request<
|
||||||
|
{ id: string },
|
||||||
|
unknown,
|
||||||
|
RouteIdRequestBody
|
||||||
|
>;
|
||||||
|
|
||||||
export interface RouteIdRequestBody {
|
export interface RouteIdRequestBody {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@ -12,8 +12,7 @@ export interface SortOptions<TSortField extends string = string> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ListQueryOptions<TFilter, TSortField extends string = string>
|
export interface ListQueryOptions<TFilter, TSortField extends string = string>
|
||||||
extends PaginationOptions,
|
extends PaginationOptions, SortOptions<TSortField> {
|
||||||
SortOptions<TSortField> {
|
|
||||||
filter?: TFilter;
|
filter?: TFilter;
|
||||||
query?: string;
|
query?: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -124,9 +124,7 @@ export interface ProjectAudioTracksDbApi {
|
|||||||
where: { id: string },
|
where: { id: string },
|
||||||
options?: ProjectAudioTrackRuntimeOptions,
|
options?: ProjectAudioTrackRuntimeOptions,
|
||||||
): Promise<ProjectAudioTrackRecord | null>;
|
): Promise<ProjectAudioTrackRecord | null>;
|
||||||
findBy(
|
findBy(options: DbFindByOptions): Promise<ProjectAudioTrackRecord | null>;
|
||||||
options: DbFindByOptions,
|
|
||||||
): Promise<ProjectAudioTrackRecord | null>;
|
|
||||||
findAll(
|
findAll(
|
||||||
filter?: ProjectAudioTrackListFilter,
|
filter?: ProjectAudioTrackListFilter,
|
||||||
options?: ProjectAudioTrackRuntimeOptions,
|
options?: ProjectAudioTrackRuntimeOptions,
|
||||||
|
|||||||
@ -103,8 +103,7 @@ export interface ProjectElementDefaultsOptions extends ServiceOptions {
|
|||||||
countOnly?: boolean;
|
countOnly?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectElementDefaultsModelRecord
|
export interface ProjectElementDefaultsModelRecord extends ProjectElementDefaultRecord {
|
||||||
extends ProjectElementDefaultRecord {
|
|
||||||
update(
|
update(
|
||||||
data: Partial<ProjectElementDefaultRecord> & {
|
data: Partial<ProjectElementDefaultRecord> & {
|
||||||
updatedById?: string | null;
|
updatedById?: string | null;
|
||||||
@ -155,13 +154,12 @@ export interface ProjectElementDefaultsModel {
|
|||||||
}): Promise<PaginatedResult<ProjectElementDefaultRecord>>;
|
}): Promise<PaginatedResult<ProjectElementDefaultRecord>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectElementDefaultsDbApi
|
export interface ProjectElementDefaultsDbApi extends EntityDbApi<
|
||||||
extends EntityDbApi<
|
ProjectElementDefaultRecord,
|
||||||
ProjectElementDefaultRecord,
|
ProjectElementDefaultsData,
|
||||||
ProjectElementDefaultsData,
|
ProjectElementDefaultsData,
|
||||||
ProjectElementDefaultsData,
|
ProjectElementDefaultsListFilter
|
||||||
ProjectElementDefaultsListFilter
|
> {
|
||||||
> {
|
|
||||||
findByElementType(
|
findByElementType(
|
||||||
projectId: string,
|
projectId: string,
|
||||||
elementType: string,
|
elementType: string,
|
||||||
|
|||||||
@ -6,10 +6,7 @@ import type {
|
|||||||
} from './index.ts';
|
} from './index.ts';
|
||||||
|
|
||||||
export type ProjectMembershipAccessLevel =
|
export type ProjectMembershipAccessLevel =
|
||||||
| 'owner'
|
'owner' | 'editor' | 'reviewer' | 'viewer';
|
||||||
| 'editor'
|
|
||||||
| 'reviewer'
|
|
||||||
| 'viewer';
|
|
||||||
|
|
||||||
export interface ProjectMembershipData {
|
export interface ProjectMembershipData {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@ -18,10 +18,7 @@ import type { Transaction } from 'sequelize';
|
|||||||
|
|
||||||
export type ProjectTransitionType = 'fade' | 'none' | 'video';
|
export type ProjectTransitionType = 'fade' | 'none' | 'video';
|
||||||
export type ProjectTransitionEasing =
|
export type ProjectTransitionEasing =
|
||||||
| 'ease-in-out'
|
'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
|
||||||
| 'ease-in'
|
|
||||||
| 'ease-out'
|
|
||||||
| 'linear';
|
|
||||||
|
|
||||||
export interface ProjectTransitionSettingsData {
|
export interface ProjectTransitionSettingsData {
|
||||||
id?: string;
|
id?: string;
|
||||||
|
|||||||
@ -39,8 +39,7 @@ export interface ProjectUiControlSettingsRuntimeOptions {
|
|||||||
transaction?: Transaction;
|
transaction?: Transaction;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectUiControlSettingsUpsertOptions
|
export interface ProjectUiControlSettingsUpsertOptions extends ProjectUiControlSettingsRuntimeOptions {
|
||||||
extends ProjectUiControlSettingsRuntimeOptions {
|
|
||||||
currentUser?: CurrentUser | null;
|
currentUser?: CurrentUser | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,10 +1,18 @@
|
|||||||
import type { Transaction } from 'sequelize';
|
import type { Transaction } from 'sequelize';
|
||||||
|
|
||||||
import type { CurrentUser } from './auth.ts';
|
import type { CurrentUser } from './auth.ts';
|
||||||
import type { DbFindAllOptions, DbFindByOptions, EntityDbApi } from './db-api.ts';
|
import type {
|
||||||
|
DbFindAllOptions,
|
||||||
|
DbFindByOptions,
|
||||||
|
EntityDbApi,
|
||||||
|
} from './db-api.ts';
|
||||||
import type { PaginatedResult } from './pagination.ts';
|
import type { PaginatedResult } from './pagination.ts';
|
||||||
import type { QueryWhere } from './runtime.ts';
|
import type { QueryWhere } from './runtime.ts';
|
||||||
import type { CreateOptions, ServiceOptions, UpdateOptions } from './service-options.ts';
|
import type {
|
||||||
|
CreateOptions,
|
||||||
|
ServiceOptions,
|
||||||
|
UpdateOptions,
|
||||||
|
} from './service-options.ts';
|
||||||
|
|
||||||
export type ProjectProductionPresentationVisibility = 'public' | 'private';
|
export type ProjectProductionPresentationVisibility = 'public' | 'private';
|
||||||
|
|
||||||
@ -38,8 +46,7 @@ export interface ProjectFieldMapping {
|
|||||||
design_width: number | null | undefined;
|
design_width: number | null | undefined;
|
||||||
design_height: number | null | undefined;
|
design_height: number | null | undefined;
|
||||||
production_presentation_visibility:
|
production_presentation_visibility:
|
||||||
| ProjectProductionPresentationVisibility
|
ProjectProductionPresentationVisibility | undefined;
|
||||||
| undefined;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProjectCreatePayload extends ProjectFieldMapping {
|
export interface ProjectCreatePayload extends ProjectFieldMapping {
|
||||||
@ -233,14 +240,20 @@ export interface ProjectCloneCreateOptions extends ProjectCloneTransactionOption
|
|||||||
|
|
||||||
export type ProjectCloneCurrentUser = Pick<CurrentUser, 'id'>;
|
export type ProjectCloneCurrentUser = Pick<CurrentUser, 'id'>;
|
||||||
|
|
||||||
export interface ProjectsDbApi
|
export interface ProjectsDbApi extends EntityDbApi<
|
||||||
extends EntityDbApi<ProjectRecord, ProjectData, ProjectData, ProjectListFilter> {
|
ProjectRecord,
|
||||||
|
ProjectData,
|
||||||
|
ProjectData,
|
||||||
|
ProjectListFilter
|
||||||
|
> {
|
||||||
findBy(options: DbFindByOptions): Promise<ProjectRecord | null>;
|
findBy(options: DbFindByOptions): Promise<ProjectRecord | null>;
|
||||||
findBy(
|
findBy(
|
||||||
where: { id: string },
|
where: { id: string },
|
||||||
options?: ProjectFindAllOptions,
|
options?: ProjectFindAllOptions,
|
||||||
): Promise<ProjectRecord | null>;
|
): Promise<ProjectRecord | null>;
|
||||||
findAll(options: DbFindAllOptions<unknown>): Promise<PaginatedResult<ProjectRecord>>;
|
findAll(
|
||||||
|
options: DbFindAllOptions<unknown>,
|
||||||
|
): Promise<PaginatedResult<ProjectRecord>>;
|
||||||
findAll(
|
findAll(
|
||||||
filter?: ProjectListFilter,
|
filter?: ProjectListFilter,
|
||||||
options?: ProjectFindAllOptions,
|
options?: ProjectFindAllOptions,
|
||||||
|
|||||||
@ -33,7 +33,10 @@ export interface SaveToStageResult {
|
|||||||
|
|
||||||
export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed';
|
export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed';
|
||||||
|
|
||||||
export type PublishSourceEnvironment = Extract<RuntimeEnvironment, 'dev' | 'stage'>;
|
export type PublishSourceEnvironment = Extract<
|
||||||
|
RuntimeEnvironment,
|
||||||
|
'dev' | 'stage'
|
||||||
|
>;
|
||||||
|
|
||||||
export type PublishTargetEnvironment = Extract<
|
export type PublishTargetEnvironment = Extract<
|
||||||
RuntimeEnvironment,
|
RuntimeEnvironment,
|
||||||
|
|||||||
@ -2,6 +2,8 @@ import type Joi from 'joi';
|
|||||||
|
|
||||||
import type { RequestValidationPart } from './validation.ts';
|
import type { RequestValidationPart } from './validation.ts';
|
||||||
|
|
||||||
export type RequestSchemaMap = Partial<Record<RequestValidationPart, Joi.Schema>>;
|
export type RequestSchemaMap = Partial<
|
||||||
|
Record<RequestValidationPart, Joi.Schema>
|
||||||
|
>;
|
||||||
export type RequestSchemaGroup = Record<string, RequestSchemaMap>;
|
export type RequestSchemaGroup = Record<string, RequestSchemaMap>;
|
||||||
export type RequestSchemaCatalog = Record<string, RequestSchemaGroup>;
|
export type RequestSchemaCatalog = Record<string, RequestSchemaGroup>;
|
||||||
|
|||||||
@ -1,4 +1,7 @@
|
|||||||
import type { AccessPolicyOptions, ProductionPresentationProject } from './access-policy.ts';
|
import type {
|
||||||
|
AccessPolicyOptions,
|
||||||
|
ProductionPresentationProject,
|
||||||
|
} from './access-policy.ts';
|
||||||
|
|
||||||
export interface PrivateProductionPresentationOption {
|
export interface PrivateProductionPresentationOption {
|
||||||
id: string;
|
id: string;
|
||||||
@ -16,16 +19,17 @@ export interface ProductionPresentationAccessGrantPlain {
|
|||||||
project?: ProductionPresentationAccessProject | null;
|
project?: ProductionPresentationAccessProject | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProductionPresentationAccessGrantRow
|
export interface ProductionPresentationAccessGrantRow extends ProductionPresentationAccessGrantPlain {
|
||||||
extends ProductionPresentationAccessGrantPlain {
|
|
||||||
get?: (options: { plain: true }) => ProductionPresentationAccessGrantPlain;
|
get?: (options: { plain: true }) => ProductionPresentationAccessGrantPlain;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PrivateProductionProjectRow
|
export interface PrivateProductionProjectRow extends Pick<
|
||||||
extends Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'> {
|
ProductionPresentationProject,
|
||||||
get?: (
|
'id' | 'name' | 'slug'
|
||||||
options: { plain: true },
|
> {
|
||||||
) => Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'>;
|
get?: (options: {
|
||||||
|
plain: true;
|
||||||
|
}) => Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimePresentationAccessOptions = AccessPolicyOptions;
|
export type RuntimePresentationAccessOptions = AccessPolicyOptions;
|
||||||
|
|||||||
@ -15,8 +15,7 @@ export interface UnknownRuntimeContext {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type RuntimeContextInspectionResponse =
|
export type RuntimeContextInspectionResponse =
|
||||||
| RuntimeContext
|
RuntimeContext | UnknownRuntimeContext;
|
||||||
| UnknownRuntimeContext;
|
|
||||||
|
|
||||||
export interface RuntimeFilterOptions {
|
export interface RuntimeFilterOptions {
|
||||||
runtimeContext?: RuntimeContext | null;
|
runtimeContext?: RuntimeContext | null;
|
||||||
|
|||||||
@ -68,8 +68,7 @@ export interface TourPageSequelizeRecord extends TourPageRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TourPageMaybeSequelizeRecord =
|
export type TourPageMaybeSequelizeRecord =
|
||||||
| TourPageRecord
|
TourPageRecord | TourPageSequelizeRecord;
|
||||||
| TourPageSequelizeRecord;
|
|
||||||
|
|
||||||
export interface TourPageProjectRef {
|
export interface TourPageProjectRef {
|
||||||
id: string;
|
id: string;
|
||||||
@ -121,8 +120,7 @@ export interface TourPageRecord extends TourPageData {
|
|||||||
|
|
||||||
export type TourPageCreateBody = EntityDataRequestBody<TourPageData>;
|
export type TourPageCreateBody = EntityDataRequestBody<TourPageData>;
|
||||||
|
|
||||||
export interface TourPageUpdateBody
|
export interface TourPageUpdateBody extends EntityDataRequestBody<TourPageData> {
|
||||||
extends EntityDataRequestBody<TourPageData> {
|
|
||||||
id?: string;
|
id?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -194,12 +192,11 @@ export interface TourPageReverseGenerationTask {
|
|||||||
pageId?: string | null | undefined;
|
pageId?: string | null | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TourPagesDbApi
|
export interface TourPagesDbApi extends EntityDbApi<
|
||||||
extends EntityDbApi<
|
TourPageRecord,
|
||||||
TourPageRecord,
|
TourPageData,
|
||||||
TourPageData,
|
TourPageData,
|
||||||
TourPageData,
|
TourPageListQuery
|
||||||
TourPageListQuery
|
|
||||||
> {
|
> {
|
||||||
readonly CSV_FIELDS: readonly string[];
|
readonly CSV_FIELDS: readonly string[];
|
||||||
findBy(options: DbFindByOptions): Promise<TourPageRecord | null>;
|
findBy(options: DbFindByOptions): Promise<TourPageRecord | null>;
|
||||||
@ -207,12 +204,16 @@ export interface TourPagesDbApi
|
|||||||
where: { id: string },
|
where: { id: string },
|
||||||
options?: ServiceOptions,
|
options?: ServiceOptions,
|
||||||
): Promise<TourPageRecord | null>;
|
): Promise<TourPageRecord | null>;
|
||||||
findAll(options: DbFindAllOptions<TourPageListQuery>): Promise<TourPageListResult>;
|
findAll(
|
||||||
|
options: DbFindAllOptions<TourPageListQuery>,
|
||||||
|
): Promise<TourPageListResult>;
|
||||||
findAll(
|
findAll(
|
||||||
filter?: TourPageListQuery,
|
filter?: TourPageListQuery,
|
||||||
options?: TourPageFindAllOptions,
|
options?: TourPageFindAllOptions,
|
||||||
): Promise<TourPageListResult>;
|
): Promise<TourPageListResult>;
|
||||||
findAllAutocomplete(options: TourPageAutocompleteOptions): Promise<TourPageRecord[]>;
|
findAllAutocomplete(
|
||||||
|
options: TourPageAutocompleteOptions,
|
||||||
|
): Promise<TourPageRecord[]>;
|
||||||
findAllAutocomplete(options: AutocompleteOptions): Promise<TourPageRecord[]>;
|
findAllAutocomplete(options: AutocompleteOptions): Promise<TourPageRecord[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -2,8 +2,15 @@ import type { RequestHandler } from 'express';
|
|||||||
import type { Transaction } from 'sequelize';
|
import type { Transaction } from 'sequelize';
|
||||||
|
|
||||||
import type { PermissionRecord, RoleRecord } from './auth.ts';
|
import type { PermissionRecord, RoleRecord } from './auth.ts';
|
||||||
import type { AutocompleteOptions, DeleteByIdsOptions } from './service-options.ts';
|
import type {
|
||||||
import type { DbFindAllOptions, DbFindByOptions, EntityDbApi } from './db-api.ts';
|
AutocompleteOptions,
|
||||||
|
DeleteByIdsOptions,
|
||||||
|
} from './service-options.ts';
|
||||||
|
import type {
|
||||||
|
DbFindAllOptions,
|
||||||
|
DbFindByOptions,
|
||||||
|
EntityDbApi,
|
||||||
|
} from './db-api.ts';
|
||||||
import type { EntityDataRequestBody } from './http.ts';
|
import type { EntityDataRequestBody } from './http.ts';
|
||||||
import type { PaginatedResult } from './pagination.ts';
|
import type { PaginatedResult } from './pagination.ts';
|
||||||
import type { QueryWhere } from './runtime.ts';
|
import type { QueryWhere } from './runtime.ts';
|
||||||
@ -157,15 +164,10 @@ export interface UserFindAllOptions extends ServiceOptions {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type UserSelectableIdInput =
|
export type UserSelectableIdInput =
|
||||||
| string
|
string | { id?: string | null; value?: string | null } | null | undefined;
|
||||||
| { id?: string | null; value?: string | null }
|
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
export type UserSelectableIdArrayInput =
|
export type UserSelectableIdArrayInput =
|
||||||
| Array<UserSelectableIdInput>
|
Array<UserSelectableIdInput> | null | undefined;
|
||||||
| null
|
|
||||||
| undefined;
|
|
||||||
|
|
||||||
export interface UserAccessMutationOptions {
|
export interface UserAccessMutationOptions {
|
||||||
user: Pick<UserRecord, 'id'> | null | undefined;
|
user: Pick<UserRecord, 'id'> | null | undefined;
|
||||||
@ -287,19 +289,15 @@ export interface UserModelApi {
|
|||||||
): Promise<PaginatedResult<UserModelRecord>>;
|
): Promise<PaginatedResult<UserModelRecord>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UsersDbApi
|
export interface UsersDbApi extends EntityDbApi<
|
||||||
extends EntityDbApi<
|
UserRecord,
|
||||||
UserRecord,
|
UserData,
|
||||||
UserData,
|
UserData,
|
||||||
UserData,
|
UserListFilter,
|
||||||
UserListFilter,
|
UserAutocompleteOption
|
||||||
UserAutocompleteOption
|
> {
|
||||||
> {
|
|
||||||
create(options: CreateOptions<UserData>): Promise<UserRecord>;
|
create(options: CreateOptions<UserData>): Promise<UserRecord>;
|
||||||
bulkImport(
|
bulkImport(data: UserData[], options?: ServiceOptions): Promise<UserRecord[]>;
|
||||||
data: UserData[],
|
|
||||||
options?: ServiceOptions,
|
|
||||||
): Promise<UserRecord[]>;
|
|
||||||
update(options: UpdateOptions<UserData>): Promise<UserRecord>;
|
update(options: UpdateOptions<UserData>): Promise<UserRecord>;
|
||||||
deleteByIds(options: DeleteByIdsOptions): Promise<UserRecord[]>;
|
deleteByIds(options: DeleteByIdsOptions): Promise<UserRecord[]>;
|
||||||
remove(options: EntityIdOptions): Promise<UserRecord>;
|
remove(options: EntityIdOptions): Promise<UserRecord>;
|
||||||
@ -312,7 +310,9 @@ export interface UsersDbApi
|
|||||||
where: UserFindByWhere,
|
where: UserFindByWhere,
|
||||||
options?: ServiceOptions,
|
options?: ServiceOptions,
|
||||||
): Promise<UserRecord | null>;
|
): Promise<UserRecord | null>;
|
||||||
findAll(options: DbFindAllOptions<unknown>): Promise<PaginatedResult<UserRecord>>;
|
findAll(
|
||||||
|
options: DbFindAllOptions<unknown>,
|
||||||
|
): Promise<PaginatedResult<UserRecord>>;
|
||||||
findAll(
|
findAll(
|
||||||
filter?: UserListFilter,
|
filter?: UserListFilter,
|
||||||
options?: UserFindAllOptions,
|
options?: UserFindAllOptions,
|
||||||
|
|||||||
@ -64,10 +64,7 @@ const envSchema = Joi.object({
|
|||||||
.integer()
|
.integer()
|
||||||
.positive()
|
.positive()
|
||||||
.default(3),
|
.default(3),
|
||||||
FFMPEG_BREAKER_COOLDOWN_MS: Joi.number()
|
FFMPEG_BREAKER_COOLDOWN_MS: Joi.number().integer().positive().default(120000),
|
||||||
.integer()
|
|
||||||
.positive()
|
|
||||||
.default(120000),
|
|
||||||
FFMPEG_BREAKER_SUCCESS_THRESHOLD: Joi.number()
|
FFMPEG_BREAKER_SUCCESS_THRESHOLD: Joi.number()
|
||||||
.integer()
|
.integer()
|
||||||
.positive()
|
.positive()
|
||||||
@ -191,19 +188,11 @@ function toValidatedEnvironment(
|
|||||||
'AWS_S3_CONNECTION_TIMEOUT',
|
'AWS_S3_CONNECTION_TIMEOUT',
|
||||||
5000,
|
5000,
|
||||||
),
|
),
|
||||||
AWS_S3_REQUEST_TIMEOUT: readNumber(
|
AWS_S3_REQUEST_TIMEOUT: readNumber(values, 'AWS_S3_REQUEST_TIMEOUT', 30000),
|
||||||
values,
|
|
||||||
'AWS_S3_REQUEST_TIMEOUT',
|
|
||||||
30000,
|
|
||||||
),
|
|
||||||
AWS_S3_MAX_ATTEMPTS: readNumber(values, 'AWS_S3_MAX_ATTEMPTS', 3),
|
AWS_S3_MAX_ATTEMPTS: readNumber(values, 'AWS_S3_MAX_ATTEMPTS', 3),
|
||||||
AWS_S3_MAX_SOCKETS: readNumber(values, 'AWS_S3_MAX_SOCKETS', 50),
|
AWS_S3_MAX_SOCKETS: readNumber(values, 'AWS_S3_MAX_SOCKETS', 50),
|
||||||
AWS_S3_KEEP_ALIVE: isEnvBooleanString(s3KeepAlive) ? s3KeepAlive : 'true',
|
AWS_S3_KEEP_ALIVE: isEnvBooleanString(s3KeepAlive) ? s3KeepAlive : 'true',
|
||||||
AWS_S3_PRESIGN_EXPIRY: readNumber(
|
AWS_S3_PRESIGN_EXPIRY: readNumber(values, 'AWS_S3_PRESIGN_EXPIRY', 3600),
|
||||||
values,
|
|
||||||
'AWS_S3_PRESIGN_EXPIRY',
|
|
||||||
3600,
|
|
||||||
),
|
|
||||||
FILE_STORAGE_PROVIDER:
|
FILE_STORAGE_PROVIDER:
|
||||||
fileStorageProvider === 's3' ||
|
fileStorageProvider === 's3' ||
|
||||||
fileStorageProvider === 'gcloud' ||
|
fileStorageProvider === 'gcloud' ||
|
||||||
@ -268,9 +257,9 @@ function toValidatedEnvironment(
|
|||||||
function validateEnv(): ValidatedEnvironment {
|
function validateEnv(): ValidatedEnvironment {
|
||||||
const result: Joi.ValidationResult<Record<string, unknown>> =
|
const result: Joi.ValidationResult<Record<string, unknown>> =
|
||||||
envSchema.validate(process.env, {
|
envSchema.validate(process.env, {
|
||||||
abortEarly: false,
|
abortEarly: false,
|
||||||
stripUnknown: false,
|
stripUnknown: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (result.error) {
|
if (result.error) {
|
||||||
const messages = result.error.details.map(
|
const messages = result.error.details.map(
|
||||||
|
|||||||
@ -44,7 +44,8 @@ function isGlobalTransitionDefaultsData(
|
|||||||
transitionTypes.has(value.transition_type))) &&
|
transitionTypes.has(value.transition_type))) &&
|
||||||
isOptionalNumber(value.duration_ms) &&
|
isOptionalNumber(value.duration_ms) &&
|
||||||
(value.easing === undefined ||
|
(value.easing === undefined ||
|
||||||
(typeof value.easing === 'string' && transitionEasings.has(value.easing))) &&
|
(typeof value.easing === 'string' &&
|
||||||
|
transitionEasings.has(value.easing))) &&
|
||||||
isOptionalString(value.overlay_color)
|
isOptionalString(value.overlay_color)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -45,9 +45,7 @@ function normalizeLoggedError(reason: unknown): Error {
|
|||||||
if (reason instanceof Error) return reason;
|
if (reason instanceof Error) return reason;
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
typeof reason === 'string'
|
typeof reason === 'string' ? reason : 'Non-Error value thrown or rejected';
|
||||||
? reason
|
|
||||||
: 'Non-Error value thrown or rejected';
|
|
||||||
|
|
||||||
return new Error(message, { cause: reason });
|
return new Error(message, { cause: reason });
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,9 @@ function readQueryString(query: unknown, key: string): string | undefined {
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getProjectSettingsListFilter(query: unknown): ProjectSettingsListFilter {
|
function getProjectSettingsListFilter(
|
||||||
|
query: unknown,
|
||||||
|
): ProjectSettingsListFilter {
|
||||||
const filter: ProjectSettingsListFilter = {};
|
const filter: ProjectSettingsListFilter = {};
|
||||||
const id = readQueryString(query, 'id');
|
const id = readQueryString(query, 'id');
|
||||||
const project = readQueryString(query, 'project');
|
const project = readQueryString(query, 'project');
|
||||||
@ -66,9 +68,7 @@ function readQueryRange(
|
|||||||
|
|
||||||
if (Array.isArray(value)) {
|
if (Array.isArray(value)) {
|
||||||
const first = value.find((item) => typeof item === 'string');
|
const first = value.find((item) => typeof item === 'string');
|
||||||
const second = value
|
const second = value.slice(1).find((item) => typeof item === 'string');
|
||||||
.slice(1)
|
|
||||||
.find((item) => typeof item === 'string');
|
|
||||||
return [first, second];
|
return [first, second];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,9 +10,7 @@ function hasStringId(value: unknown): value is { id: string } {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function isEntityDataRequestBody(
|
function isEntityDataRequestBody(body: unknown): body is EntityDataRequestBody {
|
||||||
body: unknown,
|
|
||||||
): body is EntityDataRequestBody {
|
|
||||||
return body !== null && typeof body === 'object' && 'data' in body;
|
return body !== null && typeof body === 'object' && 'data' in body;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -71,9 +71,7 @@ export function setPermissionNameOverride(
|
|||||||
permissionNameOverride;
|
permissionNameOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getPermissionNameOverride(
|
export function getPermissionNameOverride(req: Request): string | undefined {
|
||||||
req: Request,
|
|
||||||
): string | undefined {
|
|
||||||
return getRequestContext(req).permissionNameOverride;
|
return getRequestContext(req).permissionNameOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -63,11 +63,15 @@ void test('internal users with permissions can use admin api', () => {
|
|||||||
|
|
||||||
void test('platform-wide roles are explicit', () => {
|
void test('platform-wide roles are explicit', () => {
|
||||||
assert.equal(
|
assert.equal(
|
||||||
AccessPolicy.isPlatformWideRole(userWithRole('admin-1', { name: 'Administrator' })),
|
AccessPolicy.isPlatformWideRole(
|
||||||
|
userWithRole('admin-1', { name: 'Administrator' }),
|
||||||
|
),
|
||||||
true,
|
true,
|
||||||
);
|
);
|
||||||
assert.equal(
|
assert.equal(
|
||||||
AccessPolicy.isPlatformWideRole(userWithRole('designer-1', { name: 'Tour Designer' })),
|
AccessPolicy.isPlatformWideRole(
|
||||||
|
userWithRole('designer-1', { name: 'Tour Designer' }),
|
||||||
|
),
|
||||||
false,
|
false,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user