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
|
||||
class AssetsDBApi extends GenericDBApi {
|
||||
// Required: Define the Sequelize model
|
||||
static get MODEL() { return db.assets; }
|
||||
static get MODEL() {
|
||||
return db.assets;
|
||||
}
|
||||
|
||||
// Configurable behavior via static getters
|
||||
static get SEARCHABLE_FIELDS() { return ['name', 'cdn_url']; }
|
||||
static get RANGE_FIELDS() { return ['size_mb', 'width_px']; }
|
||||
static get ENUM_FIELDS() { return ['asset_type', 'is_public']; }
|
||||
static get JSON_FIELDS() { return ['settings_json']; }
|
||||
static get FIELD_DEFAULTS() { return { type: { default: 'general' } }; }
|
||||
static get ASSOCIATIONS() { return [{ field: 'project', setter: 'setProject' }]; }
|
||||
static get FIND_BY_INCLUDES() { return [{ association: 'project' }]; }
|
||||
static get FIND_ALL_INCLUDES() { return [{ model: db.projects, as: 'project' }]; }
|
||||
static get SEARCHABLE_FIELDS() {
|
||||
return ['name', 'cdn_url'];
|
||||
}
|
||||
static get RANGE_FIELDS() {
|
||||
return ['size_mb', 'width_px'];
|
||||
}
|
||||
static get ENUM_FIELDS() {
|
||||
return ['asset_type', 'is_public'];
|
||||
}
|
||||
static get JSON_FIELDS() {
|
||||
return ['settings_json'];
|
||||
}
|
||||
static get FIELD_DEFAULTS() {
|
||||
return { type: { default: 'general' } };
|
||||
}
|
||||
static get ASSOCIATIONS() {
|
||||
return [{ field: 'project', setter: 'setProject' }];
|
||||
}
|
||||
static get FIND_BY_INCLUDES() {
|
||||
return [{ association: 'project' }];
|
||||
}
|
||||
static get FIND_ALL_INCLUDES() {
|
||||
return [{ model: db.projects, as: 'project' }];
|
||||
}
|
||||
|
||||
// Custom field transformation
|
||||
static getFieldMapping(data) {
|
||||
@ -253,6 +271,7 @@ BaseStorageProvider (abstract)
|
||||
The storage provider base, S3 provider, and local provider are migrated TS/ESM modules. The S3 implementation uses official AWS SDK v3 types; shared provider-domain contracts are in `src/types/file.ts`.
|
||||
|
||||
Interface:
|
||||
|
||||
- `upload(key, data, options)` → `{ key, url }`
|
||||
- `download(key)` → `{ body, contentType }`
|
||||
- `delete(key)` → `void`
|
||||
@ -292,16 +311,17 @@ Application bootstrap:
|
||||
|
||||
```javascript
|
||||
// Key route mounting patterns
|
||||
app.use('/api/auth', authRoutes); // No JWT required
|
||||
app.use('/api/users', jwtAuth, usersRoutes); // JWT required
|
||||
app.use('/api/auth', authRoutes); // No JWT required
|
||||
app.use('/api/users', jwtAuth, usersRoutes); // JWT required
|
||||
|
||||
// Runtime public routes (production content accessible without auth)
|
||||
const mountRuntimeEntityRoute = (path, entityName, router) => {
|
||||
app.use(path,
|
||||
requireRuntimeReadOrAuth, // JWT or public production
|
||||
app.use(
|
||||
path,
|
||||
requireRuntimeReadOrAuth, // JWT or public production
|
||||
blockNonPublicRuntimeListEndpoints, // Block non-list endpoints
|
||||
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
|
||||
router
|
||||
router,
|
||||
);
|
||||
};
|
||||
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
|
||||
@ -312,27 +332,27 @@ mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
|
||||
|
||||
**Factory-Generated Routes** provide standard CRUD:
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/` | Create record |
|
||||
| POST | `/bulk-import` | Bulk import from CSV |
|
||||
| PUT | `/:id` | Update record |
|
||||
| DELETE | `/:id` | Delete record |
|
||||
| POST | `/deleteByIds` | Bulk delete |
|
||||
| GET | `/` | List with pagination & filters |
|
||||
| GET | `/count` | Count only |
|
||||
| GET | `/autocomplete` | Autocomplete search |
|
||||
| GET | `/:id` | Get single record |
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------- | ------------------------------ |
|
||||
| POST | `/` | Create record |
|
||||
| POST | `/bulk-import` | Bulk import from CSV |
|
||||
| PUT | `/:id` | Update record |
|
||||
| DELETE | `/:id` | Delete record |
|
||||
| POST | `/deleteByIds` | Bulk delete |
|
||||
| GET | `/` | List with pagination & filters |
|
||||
| GET | `/count` | Count only |
|
||||
| GET | `/autocomplete` | Autocomplete search |
|
||||
| GET | `/:id` | Get single record |
|
||||
|
||||
**Custom Routes** (auth, file, publish, search, runtime-context):
|
||||
|
||||
| Route | Endpoints |
|
||||
|-------|-----------|
|
||||
| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth |
|
||||
| `/api/file` | upload, download, presign, upload-sessions (chunked) |
|
||||
| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) |
|
||||
| `/api/search` | Global full-text search |
|
||||
| `/api/runtime-context` | Runtime environment detection |
|
||||
| Route | Endpoints |
|
||||
| ---------------------- | ------------------------------------------------------------------------ |
|
||||
| `/api/auth` | signin, signup, me, password-reset, verify-email, Google/Microsoft OAuth |
|
||||
| `/api/file` | upload, download, presign, upload-sessions (chunked) |
|
||||
| `/api/publish` | publish (stage→production), save-to-stage (dev→stage) |
|
||||
| `/api/search` | Global full-text search |
|
||||
| `/api/runtime-context` | Runtime environment detection |
|
||||
|
||||
### Service Layer
|
||||
|
||||
@ -356,6 +376,7 @@ static async create({ data, currentUser, transaction: externalTransaction, runti
|
||||
**Publish Service** (`services/publish.ts`):
|
||||
|
||||
Implements the dev→stage→production workflow with:
|
||||
|
||||
- Transaction locking to prevent concurrent publishes
|
||||
- Source key tracking for content lineage
|
||||
- Bulk copy operations for pages and audio tracks
|
||||
@ -364,14 +385,14 @@ Implements the dev→stage→production workflow with:
|
||||
|
||||
**Query Building** in `findAll()`:
|
||||
|
||||
| Filter Type | Example | SQL |
|
||||
|-------------|---------|-----|
|
||||
| Text search | `?name=foo` | `name ILIKE '%foo%'` |
|
||||
| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` |
|
||||
| Enum | `?asset_type=image` | `asset_type = 'image'` |
|
||||
| Relation | `?project=uuid` | JOIN with projects table |
|
||||
| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` |
|
||||
| Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` |
|
||||
| Filter Type | Example | SQL |
|
||||
| ----------- | ----------------------- | --------------------------------- |
|
||||
| Text search | `?name=foo` | `name ILIKE '%foo%'` |
|
||||
| Range | `?size_mbRange=[0,100]` | `size_mb >= 0 AND size_mb <= 100` |
|
||||
| Enum | `?asset_type=image` | `asset_type = 'image'` |
|
||||
| Relation | `?project=uuid` | JOIN with projects table |
|
||||
| Sort | `?field=name&sort=asc` | `ORDER BY name ASC` |
|
||||
| Pagination | `?page=1&limit=10` | `OFFSET 0 LIMIT 10` |
|
||||
|
||||
---
|
||||
|
||||
@ -402,6 +423,7 @@ Implements the dev→stage→production workflow with:
|
||||
4. Fallback to Public role for unauthenticated
|
||||
|
||||
**Permission Naming Convention**:
|
||||
|
||||
- `CREATE_<ENTITY>` - Create records
|
||||
- `READ_<ENTITY>` - Read records
|
||||
- `UPDATE_<ENTITY>` - Modify records
|
||||
@ -420,15 +442,16 @@ For production content accessible without authentication:
|
||||
|
||||
```javascript
|
||||
const requireRuntimeReadOrAuth = (req, res, next) => {
|
||||
const isPublicEnvironment = req.runtimeContext?.headerEnvironment === 'production';
|
||||
const isPublicEnvironment =
|
||||
req.runtimeContext?.headerEnvironment === 'production';
|
||||
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
||||
|
||||
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
|
||||
req.isRuntimePublicRequest = true;
|
||||
return next(); // Allow without JWT
|
||||
return next(); // Allow without JWT
|
||||
}
|
||||
|
||||
return jwtAuth(req, res, next); // Require JWT
|
||||
return jwtAuth(req, res, next); // Require JWT
|
||||
};
|
||||
```
|
||||
|
||||
@ -438,16 +461,17 @@ const requireRuntimeReadOrAuth = (req, res, next) => {
|
||||
|
||||
Pre-configured limiters (`middlewares/rateLimiter.ts`):
|
||||
|
||||
| Limiter | Window | Max Requests | Use Case |
|
||||
|---------|--------|--------------|----------|
|
||||
| `authLimiter` | 15 min | 10 | Authentication endpoints |
|
||||
| `passwordResetLimiter` | 1 hour | 5 | Password reset |
|
||||
| `apiLimiter` | 1 min | 100 | General API |
|
||||
| `uploadLimiter` | 1 min | 10 | File uploads |
|
||||
| `downloadLimiter` | 1 min | 200 | File downloads |
|
||||
| `searchLimiter` | 1 min | 30 | Search queries |
|
||||
| Limiter | Window | Max Requests | Use Case |
|
||||
| ---------------------- | ------ | ------------ | ------------------------ |
|
||||
| `authLimiter` | 15 min | 10 | Authentication endpoints |
|
||||
| `passwordResetLimiter` | 1 hour | 5 | Password reset |
|
||||
| `apiLimiter` | 1 min | 100 | General API |
|
||||
| `uploadLimiter` | 1 min | 10 | File uploads |
|
||||
| `downloadLimiter` | 1 min | 200 | File downloads |
|
||||
| `searchLimiter` | 1 min | 30 | Search queries |
|
||||
|
||||
Headers returned:
|
||||
|
||||
- `X-RateLimit-Limit`: Maximum requests
|
||||
- `X-RateLimit-Remaining`: Remaining requests
|
||||
- `X-RateLimit-Reset`: Reset time (ISO timestamp)
|
||||
@ -460,24 +484,26 @@ Headers returned:
|
||||
**Storage Provider Selection**:
|
||||
|
||||
```javascript
|
||||
const provider = config.fileStorage.provider ||
|
||||
const provider =
|
||||
config.fileStorage.provider ||
|
||||
(hasS3Credentials ? 's3' : hasGCloudCredentials ? 'gcloud' : 'local');
|
||||
```
|
||||
|
||||
**S3 Operations**:
|
||||
|
||||
| Operation | Method | Description |
|
||||
|-----------|--------|-------------|
|
||||
| Upload | `upload(key, data, options)` | Put object with metadata |
|
||||
| Download | `download(key)` | Get object stream |
|
||||
| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL |
|
||||
| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects |
|
||||
| Check | `exists(key)` | Head object |
|
||||
| List | `list(prefix)` | List objects with prefix |
|
||||
| Operation | Method | Description |
|
||||
| --------- | ---------------------------------- | ------------------------ |
|
||||
| Upload | `upload(key, data, options)` | Put object with metadata |
|
||||
| Download | `download(key)` | Get object stream |
|
||||
| Presign | `getSignedUrl(key, expiresIn)` | Generate presigned URL |
|
||||
| Delete | `delete(key)` / `deleteMany(keys)` | Remove objects |
|
||||
| Check | `exists(key)` | Head object |
|
||||
| List | `list(prefix)` | List objects with prefix |
|
||||
|
||||
**Chunked Uploads** (`UploadSessionManager`):
|
||||
|
||||
For large files, supports multipart upload sessions:
|
||||
|
||||
1. `POST /upload-sessions/init` - Create session
|
||||
2. `POST /upload-sessions/:id/chunk` - Upload chunk
|
||||
3. `POST /upload-sessions/:id/finalize` - Complete upload
|
||||
@ -498,11 +524,21 @@ class AppError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
class NotFoundError extends AppError { statusCode = 404 }
|
||||
class ValidationError extends AppError { statusCode = 400 }
|
||||
class ForbiddenError extends AppError { statusCode = 403 }
|
||||
class UnauthorizedError extends AppError { statusCode = 401 }
|
||||
class ConflictError extends AppError { statusCode = 409 }
|
||||
class NotFoundError extends AppError {
|
||||
statusCode = 404;
|
||||
}
|
||||
class ValidationError extends AppError {
|
||||
statusCode = 400;
|
||||
}
|
||||
class ForbiddenError extends AppError {
|
||||
statusCode = 403;
|
||||
}
|
||||
class UnauthorizedError extends AppError {
|
||||
statusCode = 401;
|
||||
}
|
||||
class ConflictError extends AppError {
|
||||
statusCode = 409;
|
||||
}
|
||||
```
|
||||
|
||||
**Async Handler** (`helpers.ts`):
|
||||
@ -563,12 +599,15 @@ function requestLogger(req, res, next) {
|
||||
res.setHeader('X-Request-Id', requestId);
|
||||
|
||||
res.on('finish', () => {
|
||||
req.log.info({
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
duration: Date.now() - start,
|
||||
}, 'Request completed');
|
||||
req.log.info(
|
||||
{
|
||||
method: req.method,
|
||||
url: req.originalUrl,
|
||||
status: res.statusCode,
|
||||
duration: Date.now() - start,
|
||||
},
|
||||
'Request completed',
|
||||
);
|
||||
});
|
||||
}
|
||||
```
|
||||
@ -579,30 +618,30 @@ function requestLogger(req, res, next) {
|
||||
|
||||
**Environment Variables** (`config.ts`):
|
||||
|
||||
| Variable | Description | Default |
|
||||
|----------|-------------|---------|
|
||||
| `SECRET_KEY` | JWT signing key | UUID-based default |
|
||||
| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` |
|
||||
| `ADMIN_PASS` | Admin user password | Generated |
|
||||
| `AWS_S3_BUCKET` | S3 bucket name | - |
|
||||
| `AWS_S3_REGION` | S3 region | `us-east-1` |
|
||||
| `AWS_ACCESS_KEY_ID` | AWS access key | - |
|
||||
| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - |
|
||||
| `MS_CLIENT_ID` | Microsoft OAuth client ID | - |
|
||||
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - |
|
||||
| `EMAIL_USER` | SMTP username | - |
|
||||
| `EMAIL_PASS` | SMTP password | - |
|
||||
| `LOG_LEVEL` | Logging level | `info` |
|
||||
| Variable | Description | Default |
|
||||
| ----------------------- | ----------------------------- | --------------------- |
|
||||
| `SECRET_KEY` | JWT signing key | UUID-based default |
|
||||
| `ADMIN_EMAIL` | Admin user email | `admin@flatlogic.com` |
|
||||
| `ADMIN_PASS` | Admin user password | Generated |
|
||||
| `AWS_S3_BUCKET` | S3 bucket name | - |
|
||||
| `AWS_S3_REGION` | S3 region | `us-east-1` |
|
||||
| `AWS_ACCESS_KEY_ID` | AWS access key | - |
|
||||
| `AWS_SECRET_ACCESS_KEY` | AWS secret key | - |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID | - |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret | - |
|
||||
| `MS_CLIENT_ID` | Microsoft OAuth client ID | - |
|
||||
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret | - |
|
||||
| `EMAIL_USER` | SMTP username | - |
|
||||
| `EMAIL_PASS` | SMTP password | - |
|
||||
| `LOG_LEVEL` | Logging level | `info` |
|
||||
|
||||
**Database Configuration** (`db/db-config.ts`):
|
||||
|
||||
| Environment | Database | Logging |
|
||||
|-------------|----------|---------|
|
||||
| `production` | `DB_*` env vars | Disabled |
|
||||
| `development` | `db_tour_builder_platform` | Console |
|
||||
| `dev_stage` | `DB_*` env vars | Console |
|
||||
| Environment | Database | Logging |
|
||||
| ------------- | -------------------------- | -------- |
|
||||
| `production` | `DB_*` env vars | Disabled |
|
||||
| `development` | `db_tour_builder_platform` | Console |
|
||||
| `dev_stage` | `DB_*` env vars | Console |
|
||||
|
||||
---
|
||||
|
||||
@ -647,23 +686,23 @@ GET /api/health
|
||||
|
||||
## Key Implementation Files
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/index.ts` | Application entry, middleware setup, route mounting |
|
||||
| `src/config.ts` | Environment configuration |
|
||||
| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 |
|
||||
| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) |
|
||||
| `src/factories/router.factory.ts` | Route generator for entities |
|
||||
| `src/factories/service.factory.ts` | Service generator for entities |
|
||||
| `src/db/api/base.api.ts` | GenericDBApi base class |
|
||||
| `src/middlewares/check-permissions.ts` | RBAC permission checking |
|
||||
| `src/middlewares/rateLimiter.ts` | Rate limiting configuration |
|
||||
| `src/middlewares/runtime-context.ts` | Runtime environment detection |
|
||||
| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization |
|
||||
| `src/services/publish.ts` | Publishing workflow service |
|
||||
| File | Purpose |
|
||||
| ---------------------------------------- | --------------------------------------------------------- |
|
||||
| `src/index.ts` | Application entry, middleware setup, route mounting |
|
||||
| `src/config.ts` | Environment configuration |
|
||||
| `src/helpers.ts` | wrapAsync, commonErrorHandler, jwtSign, isUuidV4 |
|
||||
| `src/auth/auth.ts` | Passport strategies (JWT, Google, Microsoft) |
|
||||
| `src/factories/router.factory.ts` | Route generator for entities |
|
||||
| `src/factories/service.factory.ts` | Service generator for entities |
|
||||
| `src/db/api/base.api.ts` | GenericDBApi base class |
|
||||
| `src/middlewares/check-permissions.ts` | RBAC permission checking |
|
||||
| `src/middlewares/rateLimiter.ts` | Rate limiting configuration |
|
||||
| `src/middlewares/runtime-context.ts` | Runtime environment detection |
|
||||
| `src/middlewares/runtime-public.ts` | Public runtime access control & field sanitization |
|
||||
| `src/services/publish.ts` | Publishing workflow service |
|
||||
| `src/services/file/S3StorageProvider.ts` | S3 storage implementation using official AWS SDK v3 types |
|
||||
| `src/utils/logger.ts` | Pino logger configuration |
|
||||
| `src/utils/errors.ts` | Error class definitions |
|
||||
| `src/utils/logger.ts` | Pino logger configuration |
|
||||
| `src/utils/errors.ts` | Error class definitions |
|
||||
|
||||
---
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -5,14 +5,15 @@
|
||||
The Auth module provides comprehensive authentication and authorization for the application. It supports local email/password authentication, OAuth 2.0 (Google, Microsoft), JWT-based session management, email verification, and password reset flows.
|
||||
|
||||
**Files:**
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `src/auth/auth.ts` | Passport.js strategy configurations (JWT, Google, Microsoft) |
|
||||
| `src/services/auth.ts` | Auth business logic (signin, password reset/update, email verification) |
|
||||
| `src/routes/auth.ts` | REST API endpoints for authentication |
|
||||
| `src/helpers.ts` | JWT signing utility (`jwtSign`) |
|
||||
| `src/db/api/users.js` | User database operations (tokens, password updates) |
|
||||
| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters |
|
||||
|
||||
| File | Purpose |
|
||||
| -------------------------------- | ----------------------------------------------------------------------- |
|
||||
| `src/auth/auth.ts` | Passport.js strategy configurations (JWT, Google, Microsoft) |
|
||||
| `src/services/auth.ts` | Auth business logic (signin, password reset/update, email verification) |
|
||||
| `src/routes/auth.ts` | REST API endpoints for authentication |
|
||||
| `src/helpers.ts` | JWT signing utility (`jwtSign`) |
|
||||
| `src/db/api/users.js` | User database operations (tokens, password updates) |
|
||||
| `src/middlewares/rateLimiter.js` | Auth-specific rate limiters |
|
||||
|
||||
---
|
||||
|
||||
@ -103,26 +104,31 @@ The Auth module provides comprehensive authentication and authorization for the
|
||||
Used for API authentication on all protected routes.
|
||||
|
||||
**Configuration (auth/auth.ts):**
|
||||
|
||||
```javascript
|
||||
passport.use(
|
||||
new JWTstrategy({
|
||||
passReqToCallback: true,
|
||||
secretOrKey: config.secret_key,
|
||||
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
|
||||
}, async (req, token, done) => {
|
||||
const user = await UsersDBApi.findBy({ email: token.user.email });
|
||||
new JWTstrategy(
|
||||
{
|
||||
passReqToCallback: true,
|
||||
secretOrKey: config.secret_key,
|
||||
jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken(),
|
||||
},
|
||||
async (req, token, done) => {
|
||||
const user = await UsersDBApi.findBy({ email: token.user.email });
|
||||
|
||||
if (user && user.disabled) {
|
||||
return done(new Error(`User '${user.email}' is disabled`));
|
||||
}
|
||||
if (user && user.disabled) {
|
||||
return done(new Error(`User '${user.email}' is disabled`));
|
||||
}
|
||||
|
||||
req.currentUser = user;
|
||||
return done(null, user);
|
||||
})
|
||||
req.currentUser = user;
|
||||
return done(null, user);
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**Token Structure:**
|
||||
|
||||
```javascript
|
||||
{
|
||||
user: {
|
||||
@ -135,6 +141,7 @@ passport.use(
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
// Protect route with JWT
|
||||
router.get('/me', passport.authenticate('jwt', { session: false }), handler);
|
||||
@ -146,23 +153,28 @@ const currentUser = req.currentUser;
|
||||
### 2. Google OAuth Strategy
|
||||
|
||||
**Configuration (auth/auth.ts):**
|
||||
|
||||
```javascript
|
||||
passport.use(
|
||||
new GoogleStrategy({
|
||||
clientID: config.google.clientId,
|
||||
clientSecret: config.google.clientSecret,
|
||||
callbackURL: config.apiUrl + '/auth/signin/google/callback',
|
||||
passReqToCallback: true,
|
||||
}, (request, accessToken, refreshToken, profile, done) => {
|
||||
socialStrategy(profile.email, profile, providers.GOOGLE, done);
|
||||
})
|
||||
new GoogleStrategy(
|
||||
{
|
||||
clientID: config.google.clientId,
|
||||
clientSecret: config.google.clientSecret,
|
||||
callbackURL: config.apiUrl + '/auth/signin/google/callback',
|
||||
passReqToCallback: true,
|
||||
},
|
||||
(request, accessToken, refreshToken, profile, done) => {
|
||||
socialStrategy(profile.email, profile, providers.GOOGLE, done);
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
|
||||
| Variable | Description |
|
||||
| ---------------------- | -------------------------- |
|
||||
| `GOOGLE_CLIENT_ID` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | Google OAuth client secret |
|
||||
|
||||
**OAuth Scopes:** `profile`, `email`
|
||||
@ -170,24 +182,29 @@ passport.use(
|
||||
### 3. Microsoft OAuth Strategy
|
||||
|
||||
**Configuration (auth/auth.ts):**
|
||||
|
||||
```javascript
|
||||
passport.use(
|
||||
new MicrosoftStrategy({
|
||||
clientID: config.microsoft.clientId,
|
||||
clientSecret: config.microsoft.clientSecret,
|
||||
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
|
||||
passReqToCallback: true,
|
||||
}, (request, accessToken, refreshToken, profile, done) => {
|
||||
const email = profile._json.mail || profile._json.userPrincipalName;
|
||||
socialStrategy(email, profile, providers.MICROSOFT, done);
|
||||
})
|
||||
new MicrosoftStrategy(
|
||||
{
|
||||
clientID: config.microsoft.clientId,
|
||||
clientSecret: config.microsoft.clientSecret,
|
||||
callbackURL: config.apiUrl + '/auth/signin/microsoft/callback',
|
||||
passReqToCallback: true,
|
||||
},
|
||||
(request, accessToken, refreshToken, profile, done) => {
|
||||
const email = profile._json.mail || profile._json.userPrincipalName;
|
||||
socialStrategy(email, profile, providers.MICROSOFT, done);
|
||||
},
|
||||
),
|
||||
);
|
||||
```
|
||||
|
||||
**Environment Variables:**
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `MS_CLIENT_ID` | Microsoft OAuth client ID |
|
||||
|
||||
| Variable | Description |
|
||||
| ------------------ | ----------------------------- |
|
||||
| `MS_CLIENT_ID` | Microsoft OAuth client ID |
|
||||
| `MS_CLIENT_SECRET` | Microsoft OAuth client secret |
|
||||
|
||||
**OAuth Scopes:** `https://graph.microsoft.com/user.read`, `openid`
|
||||
@ -222,13 +239,13 @@ Core authentication business logic.
|
||||
|
||||
```typescript
|
||||
class Auth {
|
||||
static async signin(email, password)
|
||||
static async verifyEmail(token, options)
|
||||
static async passwordUpdate(currentPassword, newPassword, options)
|
||||
static async passwordReset(token, password, options)
|
||||
static async sendEmailAddressVerificationEmail(email, host)
|
||||
static async sendPasswordResetEmail(email, type, host)
|
||||
static async updateProfile(data, currentUser)
|
||||
static async signin(email, password);
|
||||
static async verifyEmail(token, options);
|
||||
static async passwordUpdate(currentPassword, newPassword, options);
|
||||
static async passwordReset(token, password, options);
|
||||
static async sendEmailAddressVerificationEmail(email, host);
|
||||
static async sendPasswordResetEmail(email, type, host);
|
||||
static async updateProfile(data, currentUser);
|
||||
}
|
||||
```
|
||||
|
||||
@ -237,6 +254,7 @@ class Auth {
|
||||
Registers a new user or updates password for existing unverified user.
|
||||
|
||||
**Flow:**
|
||||
|
||||
```
|
||||
1. Check if user exists by email
|
||||
├── User exists with authenticationUid → Error: emailAlreadyInUse
|
||||
@ -257,6 +275,7 @@ Registers a new user or updates password for existing unverified user.
|
||||
Authenticates user with email and password.
|
||||
|
||||
**Flow:**
|
||||
|
||||
```
|
||||
1. Find user by email
|
||||
└── Not found → Error: userNotFound
|
||||
@ -278,6 +297,7 @@ Authenticates user with email and password.
|
||||
Verifies user email address using token.
|
||||
|
||||
**Flow:**
|
||||
|
||||
```
|
||||
1. Find user by email verification token
|
||||
└── Not found or expired → Error: invalidToken
|
||||
@ -290,6 +310,7 @@ Verifies user email address using token.
|
||||
Updates password for authenticated user.
|
||||
|
||||
**Flow:**
|
||||
|
||||
```
|
||||
1. Verify currentUser exists
|
||||
└── Not authenticated → ForbiddenError
|
||||
@ -305,6 +326,7 @@ Updates password for authenticated user.
|
||||
Resets password using reset token.
|
||||
|
||||
**Flow:**
|
||||
|
||||
```
|
||||
1. Find user by password reset token
|
||||
└── Not found or expired → Error: invalidToken
|
||||
@ -320,27 +342,28 @@ REST API endpoints for authentication.
|
||||
|
||||
#### Endpoints Overview
|
||||
|
||||
| Method | Path | Auth | Rate Limit | Description |
|
||||
|--------|------|------|------------|-------------|
|
||||
| POST | `/signin/local` | No | authLimiter | Login with email/password |
|
||||
| GET | `/me` | JWT | - | Get current user |
|
||||
| PUT | `/password-reset` | No | - | Reset password with token |
|
||||
| PUT | `/password-update` | JWT | - | Change password |
|
||||
| PUT | `/profile` | JWT | - | Update user profile |
|
||||
| PUT | `/verify-email` | No | - | Verify email with token |
|
||||
| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email |
|
||||
| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email |
|
||||
| GET | `/email-configured` | No | - | Check if email is configured |
|
||||
| GET | `/signin/google` | No | - | Initiate Google OAuth |
|
||||
| GET | `/signin/google/callback` | No | - | Google OAuth callback |
|
||||
| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth |
|
||||
| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback |
|
||||
| Method | Path | Auth | Rate Limit | Description |
|
||||
| ------ | ---------------------------------------- | ---- | -------------------- | ---------------------------- |
|
||||
| POST | `/signin/local` | No | authLimiter | Login with email/password |
|
||||
| GET | `/me` | JWT | - | Get current user |
|
||||
| PUT | `/password-reset` | No | - | Reset password with token |
|
||||
| PUT | `/password-update` | JWT | - | Change password |
|
||||
| PUT | `/profile` | JWT | - | Update user profile |
|
||||
| PUT | `/verify-email` | No | - | Verify email with token |
|
||||
| POST | `/send-email-address-verification-email` | JWT | - | Resend verification email |
|
||||
| POST | `/send-password-reset-email` | No | passwordResetLimiter | Send password reset email |
|
||||
| GET | `/email-configured` | No | - | Check if email is configured |
|
||||
| GET | `/signin/google` | No | - | Initiate Google OAuth |
|
||||
| GET | `/signin/google/callback` | No | - | Google OAuth callback |
|
||||
| GET | `/signin/microsoft` | No | - | Initiate Microsoft OAuth |
|
||||
| GET | `/signin/microsoft/callback` | No | - | Microsoft OAuth callback |
|
||||
|
||||
#### POST /api/auth/signin/local
|
||||
|
||||
Login with email and password.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"email": "user@example.com",
|
||||
@ -349,18 +372,20 @@ Login with email and password.
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
| Code | Message | Cause |
|
||||
|------|---------|-------|
|
||||
| 400 | `auth.userNotFound` | User doesn't exist |
|
||||
| 400 | `auth.userDisabled` | User account is disabled |
|
||||
| 400 | `auth.wrongPassword` | Invalid password |
|
||||
| 400 | `auth.userNotVerified` | Email not verified |
|
||||
| 429 | Too Many Requests | Rate limit exceeded |
|
||||
|
||||
| Code | Message | Cause |
|
||||
| ---- | ---------------------- | ------------------------ |
|
||||
| 400 | `auth.userNotFound` | User doesn't exist |
|
||||
| 400 | `auth.userDisabled` | User account is disabled |
|
||||
| 400 | `auth.wrongPassword` | Invalid password |
|
||||
| 400 | `auth.userNotVerified` | Email not verified |
|
||||
| 429 | Too Many Requests | Rate limit exceeded |
|
||||
|
||||
#### Self-Registration
|
||||
|
||||
@ -373,11 +398,13 @@ invitation/setup link.
|
||||
Get current authenticated user.
|
||||
|
||||
**Headers:**
|
||||
|
||||
```
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
@ -404,6 +431,7 @@ Authorization: Bearer <JWT_TOKEN>
|
||||
Reset password using token from email.
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"token": "abc123...",
|
||||
@ -412,6 +440,7 @@ Reset password using token from email.
|
||||
```
|
||||
|
||||
**Response (200):**
|
||||
|
||||
```json
|
||||
{ "success": true }
|
||||
```
|
||||
@ -421,11 +450,13 @@ Reset password using token from email.
|
||||
Change password for authenticated user.
|
||||
|
||||
**Headers:**
|
||||
|
||||
```
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
```
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"currentPassword": "oldPassword123",
|
||||
@ -434,22 +465,25 @@ Authorization: Bearer <JWT_TOKEN>
|
||||
```
|
||||
|
||||
**Errors:**
|
||||
| Code | Message | Cause |
|
||||
|------|---------|-------|
|
||||
| 400 | `auth.wrongPassword` | Current password incorrect |
|
||||
| 400 | `auth.passwordUpdate.samePassword` | New password same as old |
|
||||
| 403 | Forbidden | Not authenticated |
|
||||
|
||||
| Code | Message | Cause |
|
||||
| ---- | ---------------------------------- | -------------------------- |
|
||||
| 400 | `auth.wrongPassword` | Current password incorrect |
|
||||
| 400 | `auth.passwordUpdate.samePassword` | New password same as old |
|
||||
| 403 | Forbidden | Not authenticated |
|
||||
|
||||
#### PUT /api/auth/profile
|
||||
|
||||
Update user profile.
|
||||
|
||||
**Headers:**
|
||||
|
||||
```
|
||||
Authorization: Bearer <JWT_TOKEN>
|
||||
```
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"profile": {
|
||||
@ -463,18 +497,22 @@ Authorization: Bearer <JWT_TOKEN>
|
||||
#### OAuth Endpoints
|
||||
|
||||
**GET /api/auth/signin/google**
|
||||
|
||||
- Redirects to Google OAuth consent screen
|
||||
- Query param: `app` (passed as state)
|
||||
|
||||
**GET /api/auth/signin/google/callback**
|
||||
|
||||
- Handles Google OAuth callback
|
||||
- Redirects to: `{uiUrl}/login?token={jwt}`
|
||||
|
||||
**GET /api/auth/signin/microsoft**
|
||||
|
||||
- Redirects to Microsoft OAuth consent screen
|
||||
- Query param: `app` (passed as state)
|
||||
|
||||
**GET /api/auth/signin/microsoft/callback**
|
||||
|
||||
- Handles Microsoft OAuth callback
|
||||
- Redirects to: `{uiUrl}/login?token={jwt}`
|
||||
|
||||
@ -495,11 +533,12 @@ static jwtSign(data) {
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
| Setting | Value | Description |
|
||||
|---------|-------|-------------|
|
||||
|
||||
| Setting | Value | Description |
|
||||
| ---------- | ------------------- | ------------------------- |
|
||||
| Secret Key | `config.secret_key` | From `SECRET_KEY` env var |
|
||||
| Expiration | `6h` | Token valid for 6 hours |
|
||||
| Algorithm | `HS256` | Default HMAC SHA-256 |
|
||||
| Expiration | `6h` | Token valid for 6 hours |
|
||||
| Algorithm | `HS256` | Default HMAC SHA-256 |
|
||||
|
||||
---
|
||||
|
||||
@ -549,17 +588,19 @@ static async generateEmailVerificationToken(email, options) {
|
||||
```
|
||||
|
||||
**Token Properties:**
|
||||
| Property | Value |
|
||||
|----------|-------|
|
||||
| Length | 40 hex characters |
|
||||
| Expiry | 24 hours |
|
||||
| Storage | `emailVerificationToken` column |
|
||||
|
||||
| Property | Value |
|
||||
| -------- | ------------------------------- |
|
||||
| Length | 40 hex characters |
|
||||
| Expiry | 24 hours |
|
||||
| Storage | `emailVerificationToken` column |
|
||||
|
||||
#### Method: generatePasswordResetToken(email)
|
||||
|
||||
Generates secure token for password reset.
|
||||
|
||||
Same implementation as `generateEmailVerificationToken` but stores in:
|
||||
|
||||
- `passwordResetToken`
|
||||
- `passwordResetTokenExpiresAt`
|
||||
|
||||
@ -610,11 +651,11 @@ const authLimiter = createRateLimiter({
|
||||
});
|
||||
```
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Window | 15 minutes |
|
||||
| Max Requests | 10 |
|
||||
| Applied To | `/signin/local` |
|
||||
| Setting | Value |
|
||||
| ------------ | --------------- |
|
||||
| Window | 15 minutes |
|
||||
| Max Requests | 10 |
|
||||
| Applied To | `/signin/local` |
|
||||
|
||||
### Signup Limiter
|
||||
|
||||
@ -631,11 +672,11 @@ const passwordResetLimiter = createRateLimiter({
|
||||
});
|
||||
```
|
||||
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Window | 1 hour |
|
||||
| Max Requests | 5 |
|
||||
| Applied To | `/send-password-reset-email` |
|
||||
| Setting | Value |
|
||||
| ------------ | ---------------------------- |
|
||||
| Window | 1 hour |
|
||||
| Max Requests | 5 |
|
||||
| Applied To | `/send-password-reset-email` |
|
||||
|
||||
### Rate Limit Response
|
||||
|
||||
@ -648,6 +689,7 @@ const passwordResetLimiter = createRateLimiter({
|
||||
```
|
||||
|
||||
**Headers:**
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 10
|
||||
X-RateLimit-Remaining: 0
|
||||
@ -668,15 +710,16 @@ bcrypt: {
|
||||
}
|
||||
```
|
||||
|
||||
| Setting | Value | Security Impact |
|
||||
|---------|-------|-----------------|
|
||||
| Algorithm | bcrypt | Industry standard |
|
||||
| Salt Rounds | 12 | ~200ms hash time |
|
||||
| Salt | Auto-generated | Per-password unique |
|
||||
| Setting | Value | Security Impact |
|
||||
| ----------- | -------------- | ------------------- |
|
||||
| Algorithm | bcrypt | Industry standard |
|
||||
| Salt Rounds | 12 | ~200ms hash time |
|
||||
| Salt | Auto-generated | Per-password unique |
|
||||
|
||||
### Password Validation
|
||||
|
||||
Passwords are:
|
||||
|
||||
1. Hashed before storage (never stored in plain text)
|
||||
2. Compared using `bcrypt.compare()` (timing-attack safe)
|
||||
3. Required for local authentication
|
||||
@ -696,6 +739,7 @@ if (EmailSender.isConfigured) {
|
||||
```
|
||||
|
||||
When email is NOT configured:
|
||||
|
||||
- Signup succeeds without verification email
|
||||
- Users are auto-verified on signin
|
||||
- Password reset emails not sent
|
||||
@ -705,6 +749,7 @@ When email is NOT configured:
|
||||
**Email Class:** `EmailAddressVerificationEmail`
|
||||
|
||||
**Link Format:**
|
||||
|
||||
```
|
||||
{host}/verify-email?token={token}
|
||||
```
|
||||
@ -712,10 +757,12 @@ When email is NOT configured:
|
||||
### Password Reset Email
|
||||
|
||||
**Email Classes:**
|
||||
|
||||
- `PasswordResetEmail` - Standard reset
|
||||
- `InvitationEmail` - New user invitation
|
||||
|
||||
**Link Format:**
|
||||
|
||||
```
|
||||
{host}/password-reset?token={token}
|
||||
```
|
||||
@ -726,16 +773,16 @@ When email is NOT configured:
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret |
|
||||
| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret |
|
||||
| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID |
|
||||
| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret |
|
||||
| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email |
|
||||
| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password |
|
||||
| `USER_PASS` | No | `c3baadeda5c6` | Default user password |
|
||||
| Variable | Required | Default | Description |
|
||||
| ---------------------- | -------- | -------------------------------------- | ----------------------------- |
|
||||
| `SECRET_KEY` | Yes | `88dbeaf8-e906-405e-9e41-c3baadeda5c6` | JWT signing secret |
|
||||
| `GOOGLE_CLIENT_ID` | No | - | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | No | - | Google OAuth client secret |
|
||||
| `MS_CLIENT_ID` | No | - | Microsoft OAuth client ID |
|
||||
| `MS_CLIENT_SECRET` | No | - | Microsoft OAuth client secret |
|
||||
| `ADMIN_EMAIL` | No | `admin@flatlogic.com` | Default admin email |
|
||||
| `ADMIN_PASS` | No | `88dbeaf8` | Default admin password |
|
||||
| `USER_PASS` | No | `c3baadeda5c6` | Default user password |
|
||||
|
||||
### config.ts Settings
|
||||
|
||||
@ -862,18 +909,18 @@ When email is NOT configured:
|
||||
|
||||
## Error Codes
|
||||
|
||||
| Error Key | HTTP Status | Description |
|
||||
|-----------|-------------|-------------|
|
||||
| `auth.userNotFound` | 400 | User with email doesn't exist |
|
||||
| `auth.userDisabled` | 400 | User account is disabled |
|
||||
| `auth.wrongPassword` | 400 | Password doesn't match |
|
||||
| `auth.userNotVerified` | 400 | Email not verified |
|
||||
| `auth.emailAlreadyInUse` | 400 | Email already registered |
|
||||
| `auth.passwordUpdate.samePassword` | 400 | New password same as current |
|
||||
| `auth.passwordReset.error` | 400 | Token generation failed |
|
||||
| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token |
|
||||
| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed |
|
||||
| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token |
|
||||
| Error Key | HTTP Status | Description |
|
||||
| ------------------------------------------------- | ----------- | ------------------------------ |
|
||||
| `auth.userNotFound` | 400 | User with email doesn't exist |
|
||||
| `auth.userDisabled` | 400 | User account is disabled |
|
||||
| `auth.wrongPassword` | 400 | Password doesn't match |
|
||||
| `auth.userNotVerified` | 400 | Email not verified |
|
||||
| `auth.emailAlreadyInUse` | 400 | Email already registered |
|
||||
| `auth.passwordUpdate.samePassword` | 400 | New password same as current |
|
||||
| `auth.passwordReset.error` | 400 | Token generation failed |
|
||||
| `auth.passwordReset.invalidToken` | 400 | Invalid or expired reset token |
|
||||
| `auth.emailAddressVerificationEmail.error` | 400 | Verification email failed |
|
||||
| `auth.emailAddressVerificationEmail.invalidToken` | 400 | Invalid verification token |
|
||||
|
||||
---
|
||||
|
||||
@ -913,18 +960,18 @@ Use the authenticated Users API/UI to create invited users.
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `passport` | ^0.6.0 | Authentication middleware |
|
||||
| `passport-jwt` | ^4.0.0 | JWT strategy for Passport |
|
||||
| `passport-google-oauth2` | ^0.2.0 | Google OAuth strategy |
|
||||
| `passport-microsoft` | ^2.0.0 | Microsoft OAuth strategy |
|
||||
| `@types/passport-jwt` | ^4.0.1 | Maintained TypeScript definitions for JWT Passport strategy |
|
||||
| `@types/passport-google-oauth2` | ^0.1.10 | Maintained TypeScript definitions for Google OAuth Passport strategy |
|
||||
| `@types/passport-microsoft` | ^2.1.1 | Maintained TypeScript definitions for Microsoft Passport strategy |
|
||||
| `jsonwebtoken` | ^9.0.0 | JWT sign/verify |
|
||||
| `bcrypt` | ^5.1.0 | Password hashing |
|
||||
| `crypto` | built-in | Token generation |
|
||||
| Package | Version | Purpose |
|
||||
| ------------------------------- | -------- | -------------------------------------------------------------------- |
|
||||
| `passport` | ^0.6.0 | Authentication middleware |
|
||||
| `passport-jwt` | ^4.0.0 | JWT strategy for Passport |
|
||||
| `passport-google-oauth2` | ^0.2.0 | Google OAuth strategy |
|
||||
| `passport-microsoft` | ^2.0.0 | Microsoft OAuth strategy |
|
||||
| `@types/passport-jwt` | ^4.0.1 | Maintained TypeScript definitions for JWT Passport strategy |
|
||||
| `@types/passport-google-oauth2` | ^0.1.10 | Maintained TypeScript definitions for Google OAuth Passport strategy |
|
||||
| `@types/passport-microsoft` | ^2.1.1 | Maintained TypeScript definitions for Microsoft Passport strategy |
|
||||
| `jsonwebtoken` | ^9.0.0 | JWT sign/verify |
|
||||
| `bcrypt` | ^5.1.0 | Password hashing |
|
||||
| `crypto` | built-in | Token generation |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -4,13 +4,13 @@ The Core module provides the foundational components of the backend application:
|
||||
|
||||
## Overview
|
||||
|
||||
| File | Purpose | Lines |
|
||||
|------|---------|-------|
|
||||
| `src/index.ts` | Application entry point, Express setup, middleware, route mounting | varies |
|
||||
| `src/config.ts` | Environment configuration and settings | varies |
|
||||
| `src/helpers.js` | Utility functions (wrapAsync, JWT, validation) | 32 |
|
||||
| `src/types/` | Shared strict TypeScript contracts for migrated backend code | varies |
|
||||
| `src/load-env.ts` | Central backend `.env` bootstrap for app and DB entrypoints | varies |
|
||||
| File | Purpose | Lines |
|
||||
| ----------------- | ------------------------------------------------------------------ | ------ |
|
||||
| `src/index.ts` | Application entry point, Express setup, middleware, route mounting | varies |
|
||||
| `src/config.ts` | Environment configuration and settings | varies |
|
||||
| `src/helpers.js` | Utility functions (wrapAsync, JWT, validation) | 32 |
|
||||
| `src/types/` | Shared strict TypeScript contracts for migrated backend code | varies |
|
||||
| `src/load-env.ts` | Central backend `.env` bootstrap for app and DB entrypoints | varies |
|
||||
|
||||
---
|
||||
|
||||
@ -29,11 +29,18 @@ import express from 'express';
|
||||
import helmet from 'helmet';
|
||||
import * as swaggerUI from 'swagger-ui-express';
|
||||
|
||||
import { authenticateJwt, authenticateJwtWithCallback } from './auth/passport-middleware.ts';
|
||||
import {
|
||||
authenticateJwt,
|
||||
authenticateJwtWithCallback,
|
||||
} from './auth/passport-middleware.ts';
|
||||
import config from './config.ts';
|
||||
import { wrapAsync } from './helpers.ts';
|
||||
import { runtimeContextMiddleware } from './middlewares/runtime-context.ts';
|
||||
import { downloadLimiter, searchLimiter, uploadLimiter } from './middlewares/rateLimiter.ts';
|
||||
import {
|
||||
downloadLimiter,
|
||||
searchLimiter,
|
||||
uploadLimiter,
|
||||
} from './middlewares/rateLimiter.ts';
|
||||
import { createOpenApiDocument } from './openapi/document.ts';
|
||||
import {
|
||||
exitAfterLogging,
|
||||
@ -159,21 +166,25 @@ app.use('/api/file', fileRoutes) // File download/presign (partial)
|
||||
#### Protected Routes (JWT Required)
|
||||
|
||||
```javascript
|
||||
app.use('/api/users', jwtAuth, usersRoutes)
|
||||
app.use('/api/roles', jwtAuth, rolesRoutes)
|
||||
app.use('/api/permissions', jwtAuth, permissionsRoutes)
|
||||
app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes)
|
||||
app.use('/api/assets', jwtAuth, assetsRoutes)
|
||||
app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes)
|
||||
app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes)
|
||||
app.use('/api/publish_events', jwtAuth, publish_eventsRoutes)
|
||||
app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes)
|
||||
app.use('/api/access_logs', jwtAuth, access_logsRoutes)
|
||||
app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes)
|
||||
app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes) // Alias
|
||||
app.use('/api/project-element-defaults', jwtAuth, project_element_defaultsRoutes)
|
||||
app.use('/api/publish', jwtAuth, publishRoutes)
|
||||
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes)
|
||||
app.use('/api/users', jwtAuth, usersRoutes);
|
||||
app.use('/api/roles', jwtAuth, rolesRoutes);
|
||||
app.use('/api/permissions', jwtAuth, permissionsRoutes);
|
||||
app.use('/api/project_memberships', jwtAuth, project_membershipsRoutes);
|
||||
app.use('/api/assets', jwtAuth, assetsRoutes);
|
||||
app.use('/api/asset_variants', jwtAuth, asset_variantsRoutes);
|
||||
app.use('/api/presigned_url_requests', jwtAuth, presigned_url_requestsRoutes);
|
||||
app.use('/api/publish_events', jwtAuth, publish_eventsRoutes);
|
||||
app.use('/api/pwa_caches', jwtAuth, pwa_cachesRoutes);
|
||||
app.use('/api/access_logs', jwtAuth, access_logsRoutes);
|
||||
app.use('/api/element-type-defaults', jwtAuth, element_type_defaultsRoutes);
|
||||
app.use('/api/ui-elements', jwtAuth, element_type_defaultsRoutes); // Alias
|
||||
app.use(
|
||||
'/api/project-element-defaults',
|
||||
jwtAuth,
|
||||
project_element_defaultsRoutes,
|
||||
);
|
||||
app.use('/api/publish', jwtAuth, publishRoutes);
|
||||
app.use('/api/search', jwtAuth, searchLimiter, searchRoutes);
|
||||
```
|
||||
|
||||
#### Runtime Public Routes (Production Content Without Auth)
|
||||
@ -182,9 +193,13 @@ app.use('/api/search', jwtAuth, searchLimiter, searchRoutes)
|
||||
// These routes use requireRuntimeReadOrAuth middleware
|
||||
// Allows unauthenticated GET requests in production environment
|
||||
|
||||
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes)
|
||||
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes)
|
||||
mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', project_audio_tracksRoutes)
|
||||
mountRuntimeEntityRoute('/api/projects', 'projects', projectsRoutes);
|
||||
mountRuntimeEntityRoute('/api/tour_pages', 'tour_pages', tour_pagesRoutes);
|
||||
mountRuntimeEntityRoute(
|
||||
'/api/project_audio_tracks',
|
||||
'project_audio_tracks',
|
||||
project_audio_tracksRoutes,
|
||||
);
|
||||
```
|
||||
|
||||
### Key Functions
|
||||
@ -204,11 +219,11 @@ const requireRuntimeReadOrAuth = (req, res, next) => {
|
||||
|
||||
if (isPublicEnvironment && isReadOnlyRequest && !hasAuthHeader) {
|
||||
req.isRuntimePublicRequest = true;
|
||||
return next(); // Allow without JWT
|
||||
return next(); // Allow without JWT
|
||||
}
|
||||
|
||||
req.isRuntimePublicRequest = false;
|
||||
return jwtAuth(req, res, next); // Require JWT
|
||||
return jwtAuth(req, res, next); // Require JWT
|
||||
};
|
||||
```
|
||||
|
||||
@ -220,9 +235,9 @@ Helper to mount routes with runtime public access middleware stack:
|
||||
const mountRuntimeEntityRoute = (path, entityName, router) => {
|
||||
app.use(
|
||||
path,
|
||||
requireRuntimeReadOrAuth, // JWT or public production
|
||||
blockNonPublicRuntimeListEndpoints, // Block non-list for public
|
||||
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
|
||||
requireRuntimeReadOrAuth, // JWT or public production
|
||||
blockNonPublicRuntimeListEndpoints, // Block non-list for public
|
||||
sanitizePublicRuntimeListResponse(entityName), // Filter sensitive fields
|
||||
router,
|
||||
);
|
||||
};
|
||||
@ -309,10 +324,7 @@ breaker rejections use status `503` instead of being collapsed to `500`.
|
||||
const PORT = config.server.port;
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(
|
||||
{ port: PORT, env: config.server.env },
|
||||
'Server started',
|
||||
);
|
||||
logger.info({ port: PORT, env: config.server.env }, 'Server started');
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
@ -454,33 +466,32 @@ const config = {
|
||||
port: serverPort,
|
||||
swaggerServerUrl,
|
||||
},
|
||||
|
||||
};
|
||||
```
|
||||
|
||||
### Environment Variables Reference
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` |
|
||||
| `PORT` | number | `8080` | Server port |
|
||||
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
|
||||
| `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email |
|
||||
| `ADMIN_PASS` | string | Generated | Admin user password |
|
||||
| `USER_PASS` | string | Generated | Default user password |
|
||||
| `AWS_S3_BUCKET` | string | - | S3 bucket name |
|
||||
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
|
||||
| `AWS_ACCESS_KEY_ID` | string | - | AWS access key |
|
||||
| `AWS_SECRET_ACCESS_KEY` | string | - | AWS secret key |
|
||||
| `AWS_S3_PREFIX` | string | Hash | S3 key prefix |
|
||||
| `GOOGLE_CLIENT_ID` | string | - | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | string | - | Google OAuth client secret |
|
||||
| `MS_CLIENT_ID` | string | - | Microsoft OAuth client ID |
|
||||
| `MS_CLIENT_SECRET` | string | - | Microsoft OAuth client secret |
|
||||
| `EMAIL_USER` | string | - | SMTP username |
|
||||
| `EMAIL_PASS` | string | - | SMTP password |
|
||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS validation |
|
||||
| `LOG_LEVEL` | string | `info` | Pino log level |
|
||||
| Variable | Type | Default | Description |
|
||||
| ------------------------------- | ------ | --------------------- | ------------------------------------------------------------- |
|
||||
| `NODE_ENV` | string | `development` | Environment: `development`, `production`, `dev_stage`, `test` |
|
||||
| `PORT` | number | `8080` | Server port |
|
||||
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
|
||||
| `ADMIN_EMAIL` | string | `admin@flatlogic.com` | Admin user email |
|
||||
| `ADMIN_PASS` | string | Generated | Admin user password |
|
||||
| `USER_PASS` | string | Generated | Default user password |
|
||||
| `AWS_S3_BUCKET` | string | - | S3 bucket name |
|
||||
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
|
||||
| `AWS_ACCESS_KEY_ID` | string | - | AWS access key |
|
||||
| `AWS_SECRET_ACCESS_KEY` | string | - | AWS secret key |
|
||||
| `AWS_S3_PREFIX` | string | Hash | S3 key prefix |
|
||||
| `GOOGLE_CLIENT_ID` | string | - | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | string | - | Google OAuth client secret |
|
||||
| `MS_CLIENT_ID` | string | - | Microsoft OAuth client ID |
|
||||
| `MS_CLIENT_SECRET` | string | - | Microsoft OAuth client secret |
|
||||
| `EMAIL_USER` | string | - | SMTP username |
|
||||
| `EMAIL_PASS` | string | - | SMTP password |
|
||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS validation |
|
||||
| `LOG_LEVEL` | string | `info` | Pino log level |
|
||||
|
||||
### Environment Validation
|
||||
|
||||
@ -520,7 +531,7 @@ function validateEnv() {
|
||||
logger.error({ errors: messages }, 'Environment validation failed');
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
process.exit(1); // Fatal in production
|
||||
process.exit(1); // Fatal in production
|
||||
} else {
|
||||
logger.warn('Continuing with default values in non-production mode');
|
||||
}
|
||||
@ -617,10 +628,13 @@ router.get('/', async (req, res, next) => {
|
||||
// With wrapAsync - cleaner code
|
||||
const wrapAsync = require('../helpers').wrapAsync;
|
||||
|
||||
router.get('/', wrapAsync(async (req, res) => {
|
||||
const data = await Service.findAll();
|
||||
res.json(data);
|
||||
}));
|
||||
router.get(
|
||||
'/',
|
||||
wrapAsync(async (req, res) => {
|
||||
const data = await Service.findAll();
|
||||
res.json(data);
|
||||
}),
|
||||
);
|
||||
```
|
||||
|
||||
#### commonErrorHandler
|
||||
@ -634,10 +648,10 @@ router.use('/', commonErrorHandler);
|
||||
// Errors with code/status are returned as-is
|
||||
const error = new Error('Not found');
|
||||
error.code = 404;
|
||||
throw error; // → 404 "Not found"
|
||||
throw error; // → 404 "Not found"
|
||||
|
||||
// Unknown errors return 500
|
||||
throw new Error('Database connection failed'); // → 500 "Internal server error"
|
||||
throw new Error('Database connection failed'); // → 500 "Internal server error"
|
||||
```
|
||||
|
||||
#### jwtSign
|
||||
@ -663,10 +677,10 @@ const token = jwtSign({
|
||||
const { isUuidV4 } = require('./helpers');
|
||||
|
||||
// Validate UUID format
|
||||
isUuidV4('550e8400-e29b-41d4-a716-446655440000'); // true
|
||||
isUuidV4('550e8400-e29b-31d4-a716-446655440000'); // false (version 3)
|
||||
isUuidV4('not-a-uuid'); // false
|
||||
isUuidV4(''); // false
|
||||
isUuidV4('550e8400-e29b-41d4-a716-446655440000'); // true
|
||||
isUuidV4('550e8400-e29b-31d4-a716-446655440000'); // false (version 3)
|
||||
isUuidV4('not-a-uuid'); // false
|
||||
isUuidV4(''); // false
|
||||
```
|
||||
|
||||
---
|
||||
@ -767,12 +781,12 @@ External Dependencies:
|
||||
|
||||
## Server Modes
|
||||
|
||||
| NODE_ENV | Port | Database | Swagger | Description |
|
||||
|----------|------|----------|---------|-------------|
|
||||
| `development` | 8080 | Local | localhost:8080 | Legacy local development |
|
||||
| `dev_stage` | 3000 | Remote | localhost:3000 | Staging preview |
|
||||
| `production` | 8080 | Remote | Disabled | Production deployment |
|
||||
| `test` | 8080 | Test DB | Disabled | Automated testing |
|
||||
| NODE_ENV | Port | Database | Swagger | Description |
|
||||
| ------------- | ---- | -------- | -------------- | ------------------------ |
|
||||
| `development` | 8080 | Local | localhost:8080 | Legacy local development |
|
||||
| `dev_stage` | 3000 | Remote | localhost:3000 | Staging preview |
|
||||
| `production` | 8080 | Remote | Disabled | Production deployment |
|
||||
| `test` | 8080 | Test DB | Disabled | Automated testing |
|
||||
|
||||
**Standard VM note:** the VM PM2 setup runs the backend with
|
||||
`NODE_ENV=dev_stage`, so the backend listens on port `3000`. The frontend runs
|
||||
|
||||
@ -8,30 +8,30 @@ The DB API module provides the data access layer that sits between services and
|
||||
|
||||
**Files:** 20 files (1 base class + 18 entity APIs + 1 utility)
|
||||
|
||||
| File | Class/Purpose | LOC | Extends GenericDBApi |
|
||||
|------|---------------|-----|---------------------|
|
||||
| `base.api.ts` | `GenericDBApi` - Base class | 726 | - |
|
||||
| `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) |
|
||||
| `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes |
|
||||
| `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes |
|
||||
| `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes |
|
||||
| `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes |
|
||||
| `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes |
|
||||
| `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes |
|
||||
| `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes |
|
||||
| `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes |
|
||||
| `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes |
|
||||
| `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | Yes |
|
||||
| `project_transition_settings.ts` | `Project_transition_settingsDBApi` - Project transition settings | ~277 | Yes |
|
||||
| `global_transition_defaults.ts` | `Global_transition_defaultsDBApi` - Global transition defaults | ~155 | Yes |
|
||||
| `global_ui_control_defaults.ts` | `Global_ui_control_defaultsDBApi` - Global UI control defaults | ~160 | Yes |
|
||||
| `project_ui_control_settings.ts` | `Project_ui_control_settingsDBApi` - Project UI control settings | ~150 | Yes |
|
||||
| `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes |
|
||||
| `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes |
|
||||
| `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes |
|
||||
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
|
||||
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
|
||||
| `runtime-context.ts` | Runtime context helpers | 57 | - |
|
||||
| File | Class/Purpose | LOC | Extends GenericDBApi |
|
||||
| -------------------------------- | ---------------------------------------------------------------- | ---- | -------------------- |
|
||||
| `base.api.ts` | `GenericDBApi` - Base class | 726 | - |
|
||||
| `users.ts` | `UsersDBApi` - User accounts | 979 | No (custom) |
|
||||
| `projects.ts` | `ProjectsDBApi` - Projects | ~320 | Yes |
|
||||
| `tour_pages.ts` | `Tour_pagesDBApi` - Tour pages | ~350 | Yes |
|
||||
| `assets.ts` | `AssetsDBApi` - Media assets | ~92 | Yes |
|
||||
| `asset_variants.ts` | `Asset_variantsDBApi` - Asset variants | 82 | Yes |
|
||||
| `roles.ts` | `RolesDBApi` - RBAC roles | 71 | Yes |
|
||||
| `permissions.ts` | `PermissionsDBApi` - RBAC permissions | 53 | Yes |
|
||||
| `project_memberships.ts` | `Project_membershipsDBApi` - Team access | 86 | Yes |
|
||||
| `element_type_defaults.ts` | `Element_type_defaultsDBApi` - Global defaults | ~409 | Yes |
|
||||
| `project_element_defaults.ts` | `Project_element_defaultsDBApi` - Project defaults | ~410 | Yes |
|
||||
| `project_audio_tracks.ts` | `Project_audio_tracksDBApi` - Audio tracks | ~199 | Yes |
|
||||
| `project_transition_settings.ts` | `Project_transition_settingsDBApi` - Project transition settings | ~277 | Yes |
|
||||
| `global_transition_defaults.ts` | `Global_transition_defaultsDBApi` - Global transition defaults | ~155 | Yes |
|
||||
| `global_ui_control_defaults.ts` | `Global_ui_control_defaultsDBApi` - Global UI control defaults | ~160 | Yes |
|
||||
| `project_ui_control_settings.ts` | `Project_ui_control_settingsDBApi` - Project UI control settings | ~150 | Yes |
|
||||
| `publish_events.ts` | `Publish_eventsDBApi` - Publishing history | 101 | Yes |
|
||||
| `pwa_caches.ts` | `Pwa_cachesDBApi` - PWA manifests | 76 | Yes |
|
||||
| `access_logs.ts` | `Access_logsDBApi` - Audit trail | 88 | Yes |
|
||||
| `presigned_url_requests.ts` | `Presigned_url_requestsDBApi` - S3 URL audit | 90 | Yes |
|
||||
| `file.ts` | `FileDBApi` - Polymorphic file attachments | ~95 | No (custom) |
|
||||
| `runtime-context.ts` | Runtime context helpers | 57 | - |
|
||||
|
||||
---
|
||||
|
||||
@ -111,23 +111,23 @@ The base class provides a Template Method pattern where subclasses configure beh
|
||||
|
||||
### Static Getters (Configuration)
|
||||
|
||||
| Getter | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `MODEL` | Model | (required) | Sequelize model reference |
|
||||
| `TABLE_NAME` | string | From MODEL | Database table name |
|
||||
| `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search |
|
||||
| `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) |
|
||||
| `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering |
|
||||
| `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) |
|
||||
| `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs |
|
||||
| `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters |
|
||||
| `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() |
|
||||
| `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() |
|
||||
| `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export |
|
||||
| `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete |
|
||||
| `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify |
|
||||
| `FIELD_DEFAULTS` | object | `{}` | Default values for fields |
|
||||
| `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations |
|
||||
| Getter | Type | Default | Description |
|
||||
| -------------------- | -------- | --------------------- | ------------------------------------------------ |
|
||||
| `MODEL` | Model | (required) | Sequelize model reference |
|
||||
| `TABLE_NAME` | string | From MODEL | Database table name |
|
||||
| `SEARCHABLE_FIELDS` | string[] | `[]` | Fields for ILIKE text search |
|
||||
| `RANGE_FIELDS` | string[] | `[]` | Fields for range queries (min/max) |
|
||||
| `ENUM_FIELDS` | string[] | `[]` | Fields for exact match filtering |
|
||||
| `UUID_FIELDS` | string[] | `[]` | UUID foreign key fields (validated before query) |
|
||||
| `RELATION_FILTERS` | object[] | `[]` | Related entity filter configs |
|
||||
| `ASSOCIATIONS` | object[] | `[]` | M:N or belongsTo setters |
|
||||
| `FIND_BY_INCLUDES` | object[] | `[]` | Includes for findBy() |
|
||||
| `FIND_ALL_INCLUDES` | object[] | `[]` | Includes for findAll() |
|
||||
| `CSV_FIELDS` | string[] | `['id', 'createdAt']` | Fields for CSV export |
|
||||
| `AUTOCOMPLETE_FIELD` | string | `'name'` | Field for autocomplete |
|
||||
| `JSON_FIELDS` | string[] | `[]` | Fields to auto-stringify |
|
||||
| `FIELD_DEFAULTS` | object | `{}` | Default values for fields |
|
||||
| `FIELD_TRANSFORMERS` | object | `{}` | Custom field transformations |
|
||||
|
||||
### Methods
|
||||
|
||||
@ -320,14 +320,14 @@ static async findAll(filter = {}, options = {}) {
|
||||
|
||||
#### Other Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `bulkImport(data, options)` | Bulk create with timestamps offset |
|
||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records |
|
||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record |
|
||||
| `findBy(where, options)` | Find single record by criteria |
|
||||
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
|
||||
| `toCSV(rows)` | Convert rows to CSV string |
|
||||
| Method | Description |
|
||||
| ---------------------------------------------------------------- | ---------------------------------- |
|
||||
| `bulkImport(data, options)` | Bulk create with timestamps offset |
|
||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple records |
|
||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single record |
|
||||
| `findBy(where, options)` | Find single record by criteria |
|
||||
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
|
||||
| `toCSV(rows)` | Convert rows to CSV string |
|
||||
|
||||
---
|
||||
|
||||
@ -421,13 +421,25 @@ Extend `GenericDBApi` with minimal configuration. Only override static getters a
|
||||
|
||||
```typescript
|
||||
class PermissionsDBApi extends GenericDBApi {
|
||||
static override get MODEL(): unknown { return db.permissions; }
|
||||
static override get TABLE_NAME(): string { return 'permissions'; }
|
||||
static override get SEARCHABLE_FIELDS(): string[] { return ['name']; }
|
||||
static override get CSV_FIELDS(): string[] { return ['id', 'name', 'createdAt']; }
|
||||
static override get AUTOCOMPLETE_FIELD(): string { return 'name'; }
|
||||
static override get MODEL(): unknown {
|
||||
return db.permissions;
|
||||
}
|
||||
static override get TABLE_NAME(): string {
|
||||
return 'permissions';
|
||||
}
|
||||
static override get SEARCHABLE_FIELDS(): string[] {
|
||||
return ['name'];
|
||||
}
|
||||
static override get CSV_FIELDS(): string[] {
|
||||
return ['id', 'name', 'createdAt'];
|
||||
}
|
||||
static override get AUTOCOMPLETE_FIELD(): string {
|
||||
return 'name';
|
||||
}
|
||||
|
||||
static override getFieldMapping(data: PermissionData): PermissionFieldMapping {
|
||||
static override getFieldMapping(
|
||||
data: PermissionData,
|
||||
): PermissionFieldMapping {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
name: data.name || null,
|
||||
@ -437,6 +449,7 @@ class PermissionsDBApi extends GenericDBApi {
|
||||
```
|
||||
|
||||
**Entities using this pattern:**
|
||||
|
||||
- `PermissionsDBApi`
|
||||
- `AssetsDBApi`
|
||||
- `Asset_variantsDBApi`
|
||||
@ -542,6 +555,7 @@ class ProjectsDBApi extends GenericDBApi {
|
||||
```
|
||||
|
||||
**Entities using this pattern:**
|
||||
|
||||
- `Tour_pagesDBApi` - Environment filtering via `applyRuntimeEnvironment()`
|
||||
- `ProjectsDBApi` - Slug filtering with ID bypass and auto-snapshot on create
|
||||
- `Project_audio_tracksDBApi` - Environment filtering
|
||||
@ -642,6 +656,7 @@ Don't extend `GenericDBApi` due to significantly different requirements.
|
||||
**Example: UsersDBApi**
|
||||
|
||||
Complex user management with:
|
||||
|
||||
- Password hashing (bcrypt)
|
||||
- File avatar handling
|
||||
- Token generation for email verification and password reset
|
||||
@ -651,13 +666,16 @@ Complex user management with:
|
||||
class UsersDBApi {
|
||||
static async create(options: UserCreateOptions): Promise<UserRecord> {
|
||||
const { data, currentUser = { id: null }, transaction } = options;
|
||||
const users = await db.users.create({
|
||||
firstName: data.firstName || null,
|
||||
lastName: data.lastName || null,
|
||||
email: data.email || null,
|
||||
password: data.password || null, // Already hashed by service
|
||||
// ...
|
||||
}, { transaction });
|
||||
const users = await db.users.create(
|
||||
{
|
||||
firstName: data.firstName || null,
|
||||
lastName: data.lastName || null,
|
||||
email: data.email || null,
|
||||
password: data.password || null, // Already hashed by service
|
||||
// ...
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
// Auto-assign default role
|
||||
if (!data.app_role) {
|
||||
@ -698,9 +716,11 @@ class UsersDBApi {
|
||||
}
|
||||
|
||||
static async _generateToken(keyNames, email, options) {
|
||||
const users = await db.users.findOne({ where: { email: email.toLowerCase() } });
|
||||
const users = await db.users.findOne({
|
||||
where: { email: email.toLowerCase() },
|
||||
});
|
||||
const token = crypto.randomBytes(20).toString('hex');
|
||||
const tokenExpiresAt = Date.now() + (24 * 60 * 60 * 1000); // 24 hours
|
||||
const tokenExpiresAt = Date.now() + 24 * 60 * 60 * 1000; // 24 hours
|
||||
|
||||
await users.update({
|
||||
[keyNames[0]]: token,
|
||||
@ -719,7 +739,7 @@ class UsersDBApi {
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Example: FileDBApi**
|
||||
@ -733,7 +753,11 @@ export default class FileDBApi {
|
||||
assert(relation.belongsToColumn);
|
||||
assert(relation.belongsToId);
|
||||
|
||||
const files = Array.isArray(rawFiles) ? rawFiles : rawFiles ? [rawFiles] : [];
|
||||
const files = Array.isArray(rawFiles)
|
||||
? rawFiles
|
||||
: rawFiles
|
||||
? [rawFiles]
|
||||
: [];
|
||||
|
||||
await this._removeLegacyFiles(relation, files, options);
|
||||
await this._addFiles(relation, files, options);
|
||||
@ -743,14 +767,17 @@ export default class FileDBApi {
|
||||
const inexistentFiles = files.filter((file) => !!file.new);
|
||||
|
||||
for (const file of inexistentFiles) {
|
||||
await db.file.create({
|
||||
belongsTo: relation.belongsTo,
|
||||
belongsToColumn: relation.belongsToColumn,
|
||||
belongsToId: relation.belongsToId,
|
||||
name: file.name,
|
||||
publicUrl: file.publicUrl,
|
||||
privateUrl: file.privateUrl,
|
||||
}, { transaction: options.transaction });
|
||||
await db.file.create(
|
||||
{
|
||||
belongsTo: relation.belongsTo,
|
||||
belongsToColumn: relation.belongsToColumn,
|
||||
belongsToId: relation.belongsToId,
|
||||
name: file.name,
|
||||
publicUrl: file.publicUrl,
|
||||
privateUrl: file.privateUrl,
|
||||
},
|
||||
{ transaction: options.transaction },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -769,7 +796,7 @@ export default class FileDBApi {
|
||||
await file.destroy({ transaction: options.transaction });
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
@ -875,14 +902,15 @@ module.exports = class Utils {
|
||||
|
||||
**UUID Utility Functions:**
|
||||
|
||||
| Function | Purpose | Returns |
|
||||
|----------|---------|---------|
|
||||
| `isValidUuid(value)` | Check if valid UUID | `boolean` |
|
||||
| `generateUuid()` | Create new UUID v4 | `string` |
|
||||
| `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` |
|
||||
| `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause |
|
||||
| Function | Purpose | Returns |
|
||||
| ----------------------------- | -------------------------------- | ---------------------- |
|
||||
| `isValidUuid(value)` | Check if valid UUID | `boolean` |
|
||||
| `generateUuid()` | Create new UUID v4 | `string` |
|
||||
| `filterValidUuids(values)` | Filter array to valid UUIDs only | `string[]` |
|
||||
| `ilike(model, column, value)` | Case-insensitive search | Sequelize where clause |
|
||||
|
||||
**UUID Validation Behavior:**
|
||||
|
||||
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
||||
- Invalid UUID in relation filter (`?project=uuid|name`) → filters out invalid UUIDs for ID search, keeps all terms for text search
|
||||
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
||||
@ -895,8 +923,12 @@ module.exports = class Utils {
|
||||
|
||||
```javascript
|
||||
class AssetsDBApi extends GenericDBApi {
|
||||
static get MODEL() { return db.assets; }
|
||||
static get TABLE_NAME() { return 'assets'; }
|
||||
static get MODEL() {
|
||||
return db.assets;
|
||||
}
|
||||
static get TABLE_NAME() {
|
||||
return 'assets';
|
||||
}
|
||||
|
||||
static get SEARCHABLE_FIELDS() {
|
||||
return ['name', 'cdn_url', 'storage_key', 'mime_type', 'checksum'];
|
||||
@ -928,7 +960,12 @@ class AssetsDBApi extends GenericDBApi {
|
||||
|
||||
static get RELATION_FILTERS() {
|
||||
return [
|
||||
{ filterKey: 'project', model: db.projects, as: 'project', searchField: 'name' },
|
||||
{
|
||||
filterKey: 'project',
|
||||
model: db.projects,
|
||||
as: 'project',
|
||||
searchField: 'name',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@ -958,6 +995,7 @@ class AssetsDBApi extends GenericDBApi {
|
||||
**Two ways to handle foreign keys:**
|
||||
|
||||
1. **Direct field mapping** (preferred for programmatic use):
|
||||
|
||||
```javascript
|
||||
// In getFieldMapping()
|
||||
static getFieldMapping(data) {
|
||||
@ -973,6 +1011,7 @@ await Asset_variantsDBApi.create({ assetId: asset.id, ... });
|
||||
```
|
||||
|
||||
2. **Via ASSOCIATIONS setter** (used by frontend forms):
|
||||
|
||||
```javascript
|
||||
// ASSOCIATIONS config uses 'asset' (relation name)
|
||||
static get ASSOCIATIONS() {
|
||||
@ -996,28 +1035,30 @@ undefined source instance and fail before the foreign key can be saved.
|
||||
|
||||
```typescript
|
||||
class RolesDBApi extends GenericDBApi {
|
||||
static override get MODEL(): unknown { return db.roles; }
|
||||
static override get MODEL(): unknown {
|
||||
return db.roles;
|
||||
}
|
||||
|
||||
static override get ASSOCIATIONS(): RoleAssociationConfig[] {
|
||||
return [{ field: 'permissions', setter: 'setPermissions', isArray: true }];
|
||||
}
|
||||
|
||||
static override get FIND_BY_INCLUDES(): unknown[] {
|
||||
return [
|
||||
{ association: 'users_app_role' },
|
||||
{ association: 'permissions' },
|
||||
];
|
||||
return [{ association: 'users_app_role' }, { association: 'permissions' }];
|
||||
}
|
||||
|
||||
static override get FIND_ALL_INCLUDES(): unknown[] {
|
||||
return [
|
||||
{ model: db.permissions, as: 'permissions', required: false },
|
||||
];
|
||||
return [{ model: db.permissions, as: 'permissions', required: false }];
|
||||
}
|
||||
|
||||
static get RELATION_FILTERS() {
|
||||
return [
|
||||
{ filterKey: 'permissions', model: db.permissions, as: 'permissions_filter', searchField: 'name' },
|
||||
{
|
||||
filterKey: 'permissions',
|
||||
model: db.permissions,
|
||||
as: 'permissions_filter',
|
||||
searchField: 'name',
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
@ -1060,18 +1101,18 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
||||
|
||||
## API Summary Table
|
||||
|
||||
| API | Pattern | Getters | Custom Methods | Notes |
|
||||
|-----|---------|---------|----------------|-------|
|
||||
| `PermissionsDBApi` | Simple | 6 | 0 | Minimal config |
|
||||
| `AssetsDBApi` | Simple | 9 | 0 | With associations |
|
||||
| `RolesDBApi` | Simple | 10 | 0 | M:N permissions |
|
||||
| `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups |
|
||||
| `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering |
|
||||
| `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering |
|
||||
| `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding |
|
||||
| `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff |
|
||||
| `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files |
|
||||
| `FileDBApi` | Fully custom | - | 3 | Polymorphic files |
|
||||
| API | Pattern | Getters | Custom Methods | Notes |
|
||||
| ------------------------------- | ------------- | ------- | -------------- | ----------------------------------------------------------- |
|
||||
| `PermissionsDBApi` | Simple | 6 | 0 | Minimal config |
|
||||
| `AssetsDBApi` | Simple | 9 | 0 | With associations |
|
||||
| `RolesDBApi` | Simple | 10 | 0 | M:N permissions |
|
||||
| `ProjectsDBApi` | Runtime-aware | 10 | 3 | Auto-snapshot on create, slug filter skipped for ID lookups |
|
||||
| `Tour_pagesDBApi` | Runtime-aware | 9 | 0 | Environment filtering |
|
||||
| `Project_audio_tracksDBApi` | Runtime-aware | 8 | 0 | Environment filtering |
|
||||
| `Element_type_defaultsDBApi` | Self-init | 9 | 1 | Default seeding |
|
||||
| `Project_element_defaultsDBApi` | Extended | 10 | 4 | Snapshot, reset, diff |
|
||||
| `UsersDBApi` | Fully custom | - | 12 | Auth, tokens, files |
|
||||
| `FileDBApi` | Fully custom | - | 3 | Polymorphic files |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -7,6 +7,7 @@ The DB Config module manages database connection settings, environment validatio
|
||||
**Location:** `backend/src/db/`
|
||||
|
||||
**Key Files:**
|
||||
|
||||
- `db-config.ts` - Typed ESM database connection settings per environment
|
||||
- `umzug.ts` - Typed Umzug runner for migrations, seeders, create/drop
|
||||
- `utils.ts` - Database utility functions
|
||||
@ -14,6 +15,7 @@ The DB Config module manages database connection settings, environment validatio
|
||||
- `reset.ts` - Database reset script
|
||||
|
||||
**Related Files:**
|
||||
|
||||
- `backend/src/config.ts` - Application configuration
|
||||
- `backend/src/utils/env-validation.ts` - Environment variable validation
|
||||
|
||||
@ -58,14 +60,14 @@ env var is absent or invalid, the `port` property is omitted.
|
||||
|
||||
### Environment Comparison
|
||||
|
||||
| Setting | Production | Development | Dev Stage |
|
||||
|---------|------------|-------------|-----------|
|
||||
| **Dialect** | postgres | postgres | postgres |
|
||||
| **Credentials** | Env vars | Hardcoded | Env vars |
|
||||
| **Logging** | Disabled | Pino debug | Pino debug |
|
||||
| **Host** | Env var | localhost | Env var |
|
||||
| Setting | Production | Development | Dev Stage |
|
||||
| --------------------- | ------------- | ------------- | ------------- |
|
||||
| **Dialect** | postgres | postgres | postgres |
|
||||
| **Credentials** | Env vars | Hardcoded | Env vars |
|
||||
| **Logging** | Disabled | Pino debug | Pino debug |
|
||||
| **Host** | Env var | localhost | Env var |
|
||||
| **Migration Storage** | SequelizeMeta | SequelizeMeta | SequelizeMeta |
|
||||
| **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData |
|
||||
| **Seeder Storage** | SequelizeData | SequelizeData | SequelizeData |
|
||||
|
||||
---
|
||||
|
||||
@ -73,14 +75,14 @@ env var is absent or invalid, the `port` property is omitted.
|
||||
|
||||
### Database Variables
|
||||
|
||||
| Variable | Required | Default | Description |
|
||||
|----------|----------|---------|-------------|
|
||||
| `NODE_ENV` | No | development | Environment selection |
|
||||
| `DB_HOST` | Prod/Stage | localhost | Database host |
|
||||
| `DB_PORT` | Prod/Stage | 5432 | Database port |
|
||||
| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name |
|
||||
| `DB_USER` | Prod/Stage | postgres | Database username |
|
||||
| `DB_PASS` | Prod/Stage | (empty) | Database password |
|
||||
| Variable | Required | Default | Description |
|
||||
| ---------- | ---------- | ------------------------ | --------------------- |
|
||||
| `NODE_ENV` | No | development | Environment selection |
|
||||
| `DB_HOST` | Prod/Stage | localhost | Database host |
|
||||
| `DB_PORT` | Prod/Stage | 5432 | Database port |
|
||||
| `DB_NAME` | Prod/Stage | db_tour_builder_platform | Database name |
|
||||
| `DB_USER` | Prod/Stage | postgres | Database username |
|
||||
| `DB_PASS` | Prod/Stage | (empty) | Database password |
|
||||
|
||||
### Environment Validation
|
||||
|
||||
@ -112,47 +114,47 @@ const envSchema = Joi.object({
|
||||
|
||||
### Complete Environment Variable Schema
|
||||
|
||||
| Category | Variable | Validation | Default |
|
||||
|----------|----------|------------|---------|
|
||||
| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development |
|
||||
| | PORT | number | 8080 |
|
||||
| **Database** | DB_HOST | string | localhost |
|
||||
| | DB_PORT | number | 5432 |
|
||||
| | DB_NAME | string | db_tour_builder_platform |
|
||||
| | DB_USER | string | postgres |
|
||||
| | DB_PASS | string (allow empty) | (empty) |
|
||||
| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) |
|
||||
| | ADMIN_PASS | string | 88dbeaf8 |
|
||||
| | USER_PASS | string | c3baadeda5c6 |
|
||||
| | ADMIN_EMAIL | email | admin@flatlogic.com |
|
||||
| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) |
|
||||
| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) |
|
||||
| | MS_CLIENT_ID | string (allow empty) | (empty) |
|
||||
| | MS_CLIENT_SECRET | string (allow empty) | (empty) |
|
||||
| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) |
|
||||
| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) |
|
||||
| | AWS_S3_BUCKET | string (allow empty) | (empty) |
|
||||
| | AWS_S3_REGION | string | us-east-1 |
|
||||
| | AWS_S3_PREFIX | string | (default hash) |
|
||||
| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 |
|
||||
| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 |
|
||||
| | AWS_S3_MAX_ATTEMPTS | number | 3 |
|
||||
| | AWS_S3_MAX_SOCKETS | number | 50 |
|
||||
| | AWS_S3_KEEP_ALIVE | boolean string | true |
|
||||
| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 |
|
||||
| **Email** | EMAIL_USER | string (allow empty) | (empty) |
|
||||
| | EMAIL_PASS | string (allow empty) | (empty) |
|
||||
| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true |
|
||||
| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) |
|
||||
| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info |
|
||||
| Category | Variable | Validation | Default |
|
||||
| ----------------- | ----------------------------- | ---------------------------------------------- | ------------------------ |
|
||||
| **Server** | NODE_ENV | enum: development, test, production, dev_stage | development |
|
||||
| | PORT | number | 8080 |
|
||||
| **Database** | DB_HOST | string | localhost |
|
||||
| | DB_PORT | number | 5432 |
|
||||
| | DB_NAME | string | db_tour_builder_platform |
|
||||
| | DB_USER | string | postgres |
|
||||
| | DB_PASS | string (allow empty) | (empty) |
|
||||
| **Auth** | SECRET_KEY | string, min 16 chars | (default UUID) |
|
||||
| | ADMIN_PASS | string | 88dbeaf8 |
|
||||
| | USER_PASS | string | c3baadeda5c6 |
|
||||
| | ADMIN_EMAIL | email | admin@flatlogic.com |
|
||||
| **OAuth** | GOOGLE_CLIENT_ID | string (allow empty) | (empty) |
|
||||
| | GOOGLE_CLIENT_SECRET | string (allow empty) | (empty) |
|
||||
| | MS_CLIENT_ID | string (allow empty) | (empty) |
|
||||
| | MS_CLIENT_SECRET | string (allow empty) | (empty) |
|
||||
| **AWS S3** | AWS_ACCESS_KEY_ID | string (allow empty) | (empty) |
|
||||
| | AWS_SECRET_ACCESS_KEY | string (allow empty) | (empty) |
|
||||
| | AWS_S3_BUCKET | string (allow empty) | (empty) |
|
||||
| | AWS_S3_REGION | string | us-east-1 |
|
||||
| | AWS_S3_PREFIX | string | (default hash) |
|
||||
| | AWS_S3_CONNECTION_TIMEOUT | number (ms) | 5000 |
|
||||
| | AWS_S3_REQUEST_TIMEOUT | number (ms) | 30000 |
|
||||
| | AWS_S3_MAX_ATTEMPTS | number | 3 |
|
||||
| | AWS_S3_MAX_SOCKETS | number | 50 |
|
||||
| | AWS_S3_KEEP_ALIVE | boolean string | true |
|
||||
| | AWS_S3_PRESIGN_EXPIRY | number (seconds) | 3600 |
|
||||
| **Email** | EMAIL_USER | string (allow empty) | (empty) |
|
||||
| | EMAIL_PASS | string (allow empty) | (empty) |
|
||||
| | EMAIL_TLS_REJECT_UNAUTHORIZED | enum: true, false | true |
|
||||
| **External APIs** | PEXELS_KEY | string (allow empty) | (empty) |
|
||||
| **Logging** | LOG_LEVEL | enum: fatal, error, warn, info, debug, trace | info |
|
||||
|
||||
### Validation Behavior
|
||||
|
||||
```javascript
|
||||
function validateEnv() {
|
||||
const { error, value } = envSchema.validate(process.env, {
|
||||
abortEarly: false, // Report all errors, not just first
|
||||
stripUnknown: false, // Keep unknown env vars
|
||||
abortEarly: false, // Report all errors, not just first
|
||||
stripUnknown: false, // Keep unknown env vars
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@ -160,7 +162,7 @@ function validateEnv() {
|
||||
logger.error({ errors: messages }, 'Environment validation failed');
|
||||
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
process.exit(1); // Fatal in production
|
||||
process.exit(1); // Fatal in production
|
||||
} else {
|
||||
logger.warn('Continuing with default values in non-production mode');
|
||||
}
|
||||
@ -178,13 +180,13 @@ The database command entrypoint is `backend/src/db/umzug.ts`.
|
||||
|
||||
### Runtime Paths
|
||||
|
||||
| Setting | Path |
|
||||
|---------|------|
|
||||
| Config | `src/db/db-config.ts` |
|
||||
| Runner | `src/db/umzug.ts` |
|
||||
| Models | `src/db/models/` |
|
||||
| Seeders | `src/db/seeders/` |
|
||||
| Migrations | `src/db/migrations/` |
|
||||
| Setting | Path |
|
||||
| ---------- | --------------------- |
|
||||
| Config | `src/db/db-config.ts` |
|
||||
| Runner | `src/db/umzug.ts` |
|
||||
| Models | `src/db/models/` |
|
||||
| Seeders | `src/db/seeders/` |
|
||||
| Migrations | `src/db/migrations/` |
|
||||
|
||||
---
|
||||
|
||||
@ -245,11 +247,11 @@ through `src/db/models/index.ts`, whose typed facade is provided by
|
||||
import Utils from '../db/utils.ts';
|
||||
|
||||
// UUID validation
|
||||
Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true
|
||||
Utils.isValidUuid('not-a-uuid'); // false
|
||||
Utils.isValidUuid('550e8400-e29b-41d4-a716-446655440000'); // true
|
||||
Utils.isValidUuid('not-a-uuid'); // false
|
||||
|
||||
// Generate new UUID
|
||||
const id = Utils.generateUuid(); // Returns new UUID v4
|
||||
const id = Utils.generateUuid(); // Returns new UUID v4
|
||||
|
||||
// Filter array to valid UUIDs only
|
||||
const validIds = Utils.filterValidUuids(['uuid1', 'invalid', 'uuid2']);
|
||||
@ -260,7 +262,7 @@ const where = {
|
||||
Utils.ilike('users', 'firstName', searchTerm),
|
||||
Utils.ilike('users', 'lastName', searchTerm),
|
||||
Utils.ilike('users', 'email', searchTerm),
|
||||
]
|
||||
],
|
||||
};
|
||||
```
|
||||
|
||||
@ -276,7 +278,9 @@ Synchronizes models to database schema using Sequelize's `alter` mode.
|
||||
async function syncDatabase() {
|
||||
// Safety check - never run in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
console.error('ERROR: sync.ts should not be run in production. Use migrations instead.');
|
||||
console.error(
|
||||
'ERROR: sync.ts should not be run in production. Use migrations instead.',
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
@ -293,17 +297,18 @@ async function syncDatabase() {
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
node src/db/sync.ts
|
||||
```
|
||||
|
||||
**Sync Modes:**
|
||||
|
||||
| Mode | Description | Use Case |
|
||||
|------|-------------|----------|
|
||||
| `{ force: true }` | Drop and recreate all tables | Fresh start |
|
||||
| `{ alter: true }` | Modify tables to match models | Development |
|
||||
| (none) | Create only missing tables | Safe default |
|
||||
| Mode | Description | Use Case |
|
||||
| ----------------- | ----------------------------- | ------------ |
|
||||
| `{ force: true }` | Drop and recreate all tables | Fresh start |
|
||||
| `{ alter: true }` | Modify tables to match models | Development |
|
||||
| (none) | Create only missing tables | Safe default |
|
||||
|
||||
### reset.ts
|
||||
|
||||
@ -324,6 +329,7 @@ db.sequelize
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```bash
|
||||
node src/db/reset.ts
|
||||
```
|
||||
@ -397,45 +403,46 @@ const config = {
|
||||
|
||||
### Storage Configuration
|
||||
|
||||
| Provider | Variables | Purpose |
|
||||
|----------|-----------|---------|
|
||||
| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage |
|
||||
| **GCloud** | (hardcoded bucket) | Legacy support |
|
||||
| **Local** | uploadDir (os.tmpdir()) | Development fallback |
|
||||
| Provider | Variables | Purpose |
|
||||
| ---------- | ---------------------------------------------------------------------- | -------------------- |
|
||||
| **AWS S3** | AWS_S3_BUCKET, AWS_S3_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY | File storage |
|
||||
| **GCloud** | (hardcoded bucket) | Legacy support |
|
||||
| **Local** | uploadDir (os.tmpdir()) | Development fallback |
|
||||
|
||||
### S3 Performance Tuning
|
||||
|
||||
| Variable | Default | Description |
|
||||
|----------|---------|-------------|
|
||||
| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout |
|
||||
| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout |
|
||||
| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure |
|
||||
| AWS_S3_MAX_SOCKETS | 50 | Connection pool size |
|
||||
| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections |
|
||||
| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) |
|
||||
| Variable | Default | Description |
|
||||
| ------------------------- | ------- | ------------------------------- |
|
||||
| AWS_S3_CONNECTION_TIMEOUT | 5000ms | TCP connection timeout |
|
||||
| AWS_S3_REQUEST_TIMEOUT | 30000ms | Total request timeout |
|
||||
| AWS_S3_MAX_ATTEMPTS | 3 | Retry attempts on failure |
|
||||
| AWS_S3_MAX_SOCKETS | 50 | Connection pool size |
|
||||
| AWS_S3_KEEP_ALIVE | true | Reuse TCP connections |
|
||||
| AWS_S3_PRESIGN_EXPIRY | 3600s | Presigned URL validity (1 hour) |
|
||||
|
||||
### Security Configuration
|
||||
|
||||
| Setting | Value | Purpose |
|
||||
|---------|-------|---------|
|
||||
| bcrypt.saltRounds | 12 | Password hashing strength |
|
||||
| SECRET_KEY | 16+ char string | JWT signing key |
|
||||
| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation |
|
||||
| Setting | Value | Purpose |
|
||||
| ----------------------------- | --------------- | -------------------------- |
|
||||
| bcrypt.saltRounds | 12 | Password hashing strength |
|
||||
| SECRET_KEY | 16+ char string | JWT signing key |
|
||||
| EMAIL_TLS_REJECT_UNAUTHORIZED | true/false | TLS certificate validation |
|
||||
|
||||
### URL Configuration
|
||||
|
||||
| URL | Development | Production |
|
||||
|-----|-------------|------------|
|
||||
| apiUrl | http://localhost:3000/api | (remote)/api |
|
||||
| swaggerUrl | http://localhost:3000 | (remote) |
|
||||
| uiUrl | http://localhost:3001/# | (remote)/# |
|
||||
| backUrl | http://localhost:3001 | (remote) |
|
||||
| URL | Development | Production |
|
||||
| ---------- | ------------------------- | ------------ |
|
||||
| apiUrl | http://localhost:3000/api | (remote)/api |
|
||||
| swaggerUrl | http://localhost:3000 | (remote) |
|
||||
| uiUrl | http://localhost:3001/# | (remote)/# |
|
||||
| backUrl | http://localhost:3001 | (remote) |
|
||||
|
||||
---
|
||||
|
||||
## Running Commands
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run start-dev
|
||||
@ -447,6 +454,7 @@ DB config selection; when `NODE_ENV` is absent it defaults to `dev_stage`, which
|
||||
matches the standard VM backend flow and listens on port `3000`.
|
||||
|
||||
### VM / Dev Stage
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run start
|
||||
@ -465,12 +473,14 @@ flow loads `.env` through `src/load-env.ts` and defaults missing `NODE_ENV` to
|
||||
## Best Practices
|
||||
|
||||
### 1. Never Commit Secrets
|
||||
|
||||
```bash
|
||||
# .env file should be in .gitignore
|
||||
# Use environment variables in deployment
|
||||
```
|
||||
|
||||
### 2. Use Migrations in Production
|
||||
|
||||
```javascript
|
||||
// Never use sync.ts or reset.ts in production
|
||||
if (process.env.NODE_ENV === 'production') {
|
||||
@ -479,13 +489,15 @@ if (process.env.NODE_ENV === 'production') {
|
||||
```
|
||||
|
||||
### 3. Validate Environment Early
|
||||
|
||||
```javascript
|
||||
// config.ts loads validation at import time
|
||||
import { validateEnv } from './utils/env-validation.ts';
|
||||
validateEnv(); // Called before app starts
|
||||
validateEnv(); // Called before app starts
|
||||
```
|
||||
|
||||
### 4. Environment-Specific Logging
|
||||
|
||||
```javascript
|
||||
// Production: logging disabled (performance)
|
||||
// Development/dev_stage: SQL logs use structured Pino debug entries
|
||||
|
||||
@ -65,15 +65,16 @@ backend/
|
||||
`backend/src/db/umzug.ts` owns migration and seeder execution. It uses official
|
||||
Umzug types, `SequelizeStorage`, and the existing storage tables:
|
||||
|
||||
| Flow | Files | Storage Table | Stored Names |
|
||||
|------|-------|---------------|--------------|
|
||||
| Migrations | `src/db/migrations/*.js` | `SequelizeMeta` | `*.js` |
|
||||
| Seeders | `src/db/seeders/*.ts` in source, `dist/src/db/seeders/*.js` in build | `SequelizeData` | stable `*.js` names |
|
||||
| Flow | Files | Storage Table | Stored Names |
|
||||
| ---------- | -------------------------------------------------------------------- | --------------- | ------------------- |
|
||||
| Migrations | `src/db/migrations/*.js` | `SequelizeMeta` | `*.js` |
|
||||
| Seeders | `src/db/seeders/*.ts` in source, `dist/src/db/seeders/*.js` in build | `SequelizeData` | stable `*.js` names |
|
||||
|
||||
Seeder files are typed ESM source, and the runner stores stable execution names
|
||||
so already executed seeders are not treated as pending.
|
||||
|
||||
### NPM Scripts
|
||||
|
||||
```bash
|
||||
# Run pending migrations
|
||||
npm run db:migrate
|
||||
@ -99,6 +100,7 @@ npm run db:seed
|
||||
## Migration File Structure
|
||||
|
||||
### Standard Template
|
||||
|
||||
```javascript
|
||||
'use strict';
|
||||
|
||||
@ -122,6 +124,7 @@ Use one project-wide migration template for new schema changes. Do not modify
|
||||
already applied migration files to match newer style choices.
|
||||
|
||||
### Naming Convention
|
||||
|
||||
```
|
||||
YYYYMMDDHHMMSS-descriptive-name.js
|
||||
|
||||
@ -138,6 +141,7 @@ Examples:
|
||||
## Migration Patterns
|
||||
|
||||
### 1. Transaction Wrapper Pattern
|
||||
|
||||
**Purpose:** Ensure atomic operations - all changes succeed or all fail.
|
||||
|
||||
```javascript
|
||||
@ -164,6 +168,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
### 2. Idempotent Check Pattern
|
||||
|
||||
**Purpose:** Safely re-run migrations without errors.
|
||||
|
||||
```javascript
|
||||
@ -188,6 +193,7 @@ await queryInterface.addColumn('tableName', 'columnName', { ... });
|
||||
---
|
||||
|
||||
### 3. Helper Function Pattern
|
||||
|
||||
**Purpose:** Reduce repetition for bulk operations.
|
||||
|
||||
```javascript
|
||||
@ -196,14 +202,19 @@ module.exports = {
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
|
||||
// Define reusable helper
|
||||
const addForeignKey = async (tableName, columnName, references, onDelete) => {
|
||||
const addForeignKey = async (
|
||||
tableName,
|
||||
columnName,
|
||||
references,
|
||||
onDelete,
|
||||
) => {
|
||||
const constraintName = `${tableName}_${columnName}_fkey`;
|
||||
|
||||
// Check existence
|
||||
const [results] = await queryInterface.sequelize.query(
|
||||
`SELECT constraint_name FROM information_schema.table_constraints
|
||||
WHERE table_name = '${tableName}' AND constraint_name = '${constraintName}'`,
|
||||
{ transaction }
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
if (results.length === 0) {
|
||||
@ -221,8 +232,18 @@ module.exports = {
|
||||
};
|
||||
|
||||
// Use helper multiple times
|
||||
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
|
||||
await addForeignKey('tour_pages', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
|
||||
await addForeignKey(
|
||||
'assets',
|
||||
'projectId',
|
||||
{ table: 'projects', field: 'id' },
|
||||
'CASCADE',
|
||||
);
|
||||
await addForeignKey(
|
||||
'tour_pages',
|
||||
'projectId',
|
||||
{ table: 'projects', field: 'id' },
|
||||
'CASCADE',
|
||||
);
|
||||
// ... more FKs
|
||||
},
|
||||
};
|
||||
@ -233,6 +254,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
### 4. Safe Table Drop Pattern
|
||||
|
||||
**Purpose:** Prevent accidental data loss when dropping tables.
|
||||
|
||||
```javascript
|
||||
@ -270,6 +292,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
### 5. ENUM to TEXT Conversion Pattern
|
||||
|
||||
**Purpose:** Convert restrictive ENUMs to flexible TEXT while preserving data.
|
||||
|
||||
```javascript
|
||||
@ -279,33 +302,45 @@ module.exports = {
|
||||
|
||||
try {
|
||||
// 1. Create temporary TEXT column
|
||||
await queryInterface.addColumn('table', 'column_text', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
}, { transaction });
|
||||
await queryInterface.addColumn(
|
||||
'table',
|
||||
'column_text',
|
||||
{
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: true,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
// 2. Copy ENUM values to TEXT
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE table SET column_text = column::TEXT`,
|
||||
{ transaction }
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
// 3. Drop old ENUM column
|
||||
await queryInterface.removeColumn('table', 'column', { transaction });
|
||||
|
||||
// 4. Rename TEXT column
|
||||
await queryInterface.renameColumn('table', 'column_text', 'column', { transaction });
|
||||
await queryInterface.renameColumn('table', 'column_text', 'column', {
|
||||
transaction,
|
||||
});
|
||||
|
||||
// 5. Add NOT NULL constraint
|
||||
await queryInterface.changeColumn('table', 'column', {
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
}, { transaction });
|
||||
await queryInterface.changeColumn(
|
||||
'table',
|
||||
'column',
|
||||
{
|
||||
type: Sequelize.TEXT,
|
||||
allowNull: false,
|
||||
},
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
// 6. Drop ENUM type
|
||||
await queryInterface.sequelize.query(
|
||||
`DROP TYPE IF EXISTS "enum_table_column"`,
|
||||
{ transaction }
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
await transaction.commit();
|
||||
@ -330,6 +365,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
### 6. Data Backfill Pattern
|
||||
|
||||
**Purpose:** Populate new tables/columns with data from existing records.
|
||||
|
||||
```javascript
|
||||
@ -383,6 +419,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
### 7. Cross-Environment Data Copy Pattern
|
||||
|
||||
**Purpose:** Copy content between environments (dev → stage → production).
|
||||
|
||||
```javascript
|
||||
@ -390,14 +427,14 @@ module.exports = {
|
||||
async up(queryInterface, Sequelize) {
|
||||
const projects = await queryInterface.sequelize.query(
|
||||
`SELECT id FROM projects WHERE "deletedAt" IS NULL`,
|
||||
{ type: Sequelize.QueryTypes.SELECT }
|
||||
{ type: Sequelize.QueryTypes.SELECT },
|
||||
);
|
||||
|
||||
for (const project of projects) {
|
||||
// Check if target environment already has content
|
||||
const [stageCheck] = await queryInterface.sequelize.query(
|
||||
`SELECT COUNT(*)::int as count FROM tour_pages
|
||||
WHERE "projectId" = '${project.id}' AND environment = 'stage'`
|
||||
WHERE "projectId" = '${project.id}' AND environment = 'stage'`,
|
||||
);
|
||||
|
||||
if (stageCheck?.count > 0) continue;
|
||||
@ -433,7 +470,7 @@ module.exports = {
|
||||
async down(queryInterface) {
|
||||
// Delete records with source_key (created by migration)
|
||||
await queryInterface.sequelize.query(
|
||||
`DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL`
|
||||
`DELETE FROM tour_pages WHERE environment = 'stage' AND source_key IS NOT NULL`,
|
||||
);
|
||||
},
|
||||
};
|
||||
@ -444,6 +481,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
### 8. JSON Field Transformation Pattern
|
||||
|
||||
**Purpose:** Transform data stored in JSON columns.
|
||||
|
||||
```javascript
|
||||
@ -456,24 +494,27 @@ module.exports = {
|
||||
const [records] = await queryInterface.sequelize.query(
|
||||
`SELECT id, "projectId", environment, slug, json_column
|
||||
FROM table_name WHERE json_column IS NOT NULL`,
|
||||
{ transaction }
|
||||
{ transaction },
|
||||
);
|
||||
|
||||
// Build lookup maps for ID → slug transformations
|
||||
const slugById = new Map();
|
||||
records.forEach(r => slugById.set(r.id, { projectId: r.projectId, slug: r.slug }));
|
||||
records.forEach((r) =>
|
||||
slugById.set(r.id, { projectId: r.projectId, slug: r.slug }),
|
||||
);
|
||||
|
||||
// Transform each record
|
||||
for (const record of records) {
|
||||
const jsonData = typeof record.json_column === 'string'
|
||||
? JSON.parse(record.json_column)
|
||||
: record.json_column;
|
||||
const jsonData =
|
||||
typeof record.json_column === 'string'
|
||||
? JSON.parse(record.json_column)
|
||||
: record.json_column;
|
||||
|
||||
let hasChanges = false;
|
||||
|
||||
// Transform JSON structure
|
||||
if (jsonData.elements) {
|
||||
jsonData.elements.forEach(element => {
|
||||
jsonData.elements.forEach((element) => {
|
||||
if (element.targetPageId) {
|
||||
const target = slugById.get(element.targetPageId);
|
||||
if (target) {
|
||||
@ -490,8 +531,8 @@ module.exports = {
|
||||
`UPDATE table_name SET json_column = :json WHERE id = :id`,
|
||||
{
|
||||
replacements: { json: JSON.stringify(jsonData), id: record.id },
|
||||
transaction
|
||||
}
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -510,6 +551,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
### 9. Constraint Enforcement Pattern
|
||||
|
||||
**Purpose:** Add NOT NULL constraints after fixing existing NULL values.
|
||||
|
||||
```javascript
|
||||
@ -517,7 +559,7 @@ module.exports = {
|
||||
async up(queryInterface) {
|
||||
// First, fix any NULL values
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE table_name SET column = 'default' WHERE column IS NULL`
|
||||
`UPDATE table_name SET column = 'default' WHERE column IS NULL`,
|
||||
);
|
||||
|
||||
// Then add NOT NULL constraint with default
|
||||
@ -543,6 +585,7 @@ module.exports = {
|
||||
---
|
||||
|
||||
### 10. Safe Down Migration Pattern
|
||||
|
||||
**Purpose:** Handle cases where down migration isn't meaningful.
|
||||
|
||||
```javascript
|
||||
@ -554,7 +597,9 @@ module.exports = {
|
||||
|
||||
async down(_queryInterface, _Sequelize) {
|
||||
// This migration only adds missing data, not destructive
|
||||
console.log('No down migration needed - this migration only adds missing data.');
|
||||
console.log(
|
||||
'No down migration needed - this migration only adds missing data.',
|
||||
);
|
||||
},
|
||||
};
|
||||
```
|
||||
@ -566,60 +611,74 @@ module.exports = {
|
||||
## Migration Categories
|
||||
|
||||
### Schema Changes
|
||||
| Migration | Description |
|
||||
|-----------|-------------|
|
||||
| `add-foreign-key-constraints` | Add FK constraints to all model associations |
|
||||
| `create-project-element-defaults` | Create new table with indexes |
|
||||
| `drop-page-elements-table` | Drop unused table |
|
||||
| `drop-page-links-table` | Drop unused table |
|
||||
| `drop-transitions-table` | Drop unused table |
|
||||
|
||||
| Migration | Description |
|
||||
| --------------------------------- | -------------------------------------------- |
|
||||
| `add-foreign-key-constraints` | Add FK constraints to all model associations |
|
||||
| `create-project-element-defaults` | Create new table with indexes |
|
||||
| `drop-page-elements-table` | Drop unused table |
|
||||
| `drop-page-links-table` | Drop unused table |
|
||||
| `drop-transitions-table` | Drop unused table |
|
||||
|
||||
### Column Modifications
|
||||
| Migration | Description |
|
||||
|-----------|-------------|
|
||||
| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` |
|
||||
| `remove-project-phase-column` | Remove redundant `phase` column |
|
||||
| `remove-entry-page-slug-column` | Remove unused column |
|
||||
| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility |
|
||||
| `enforce-environment-not-null` | Add NOT NULL constraint |
|
||||
| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` |
|
||||
| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages |
|
||||
| `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table |
|
||||
| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table |
|
||||
|
||||
| Migration | Description |
|
||||
| ------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `remove-redundant-deletion-columns` | Remove `is_deleted`, `deleted_at_time` |
|
||||
| `remove-project-phase-column` | Remove redundant `phase` column |
|
||||
| `remove-entry-page-slug-column` | Remove unused column |
|
||||
| `convert-element-type-enum-to-text` | ENUM → TEXT for flexibility |
|
||||
| `enforce-environment-not-null` | Add NOT NULL constraint |
|
||||
| `remove-unused-theme-columns-from-projects` | Remove `theme_config_json`, `custom_css_json`, `cdn_base_url` |
|
||||
| `add-background-video-settings` | Add video playback settings (autoplay, loop, muted, start/end time) to tour_pages |
|
||||
| `add-design-dimensions-to-projects` | Add `design_width`, `design_height` to projects table |
|
||||
| `add-design-dimensions-to-tour-pages` | Add `design_width`, `design_height` to tour_pages table |
|
||||
|
||||
### Table Renames
|
||||
| Migration | Description |
|
||||
|-----------|-------------|
|
||||
|
||||
| Migration | Description |
|
||||
| --------------------------------------------- | ------------------ |
|
||||
| `rename-ui-elements-to-element-type-defaults` | Rename for clarity |
|
||||
|
||||
### Data Migrations
|
||||
| Migration | Description |
|
||||
|-----------|-------------|
|
||||
| `backfill-project-element-defaults` | Populate new table for existing projects |
|
||||
| `copy-dev-to-stage` | Initialize stage environment |
|
||||
| `convert-targetpageid-to-slug` | Transform JSON navigation references |
|
||||
| `fix-project-audio-tracks-environment` | Fix environment values |
|
||||
| `add-missing-element-type-defaults` | Insert missing default rows |
|
||||
| `sync-all-element-type-defaults` | Full sync of all 11 element types |
|
||||
|
||||
| Migration | Description |
|
||||
| ---------------------------------------- | ---------------------------------------------------------- |
|
||||
| `backfill-project-element-defaults` | Populate new table for existing projects |
|
||||
| `copy-dev-to-stage` | Initialize stage environment |
|
||||
| `convert-targetpageid-to-slug` | Transform JSON navigation references |
|
||||
| `fix-project-audio-tracks-environment` | Fix environment values |
|
||||
| `add-missing-element-type-defaults` | Insert missing default rows |
|
||||
| `sync-all-element-type-defaults` | Full sync of all 11 element types |
|
||||
| `remove-duplicate-element-type-defaults` | Remove duplicate records created during earlier migrations |
|
||||
| `cleanup-invalid-element-type-defaults` | Clean up invalid entries and ensure data integrity |
|
||||
| `cleanup-invalid-element-type-defaults` | Clean up invalid entries and ensure data integrity |
|
||||
|
||||
---
|
||||
|
||||
## Foreign Key Strategies
|
||||
|
||||
| Strategy | When to Use | Example |
|
||||
|----------|-------------|---------|
|
||||
| `CASCADE` | Delete child when parent deleted | `assets.projectId → projects.id` |
|
||||
| `SET NULL` | Preserve record, nullify FK | `publish_events.userId → users.id` (audit trail) |
|
||||
| `SET NULL` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` |
|
||||
| Strategy | When to Use | Example |
|
||||
| ------------------------------ | -------------------------------- | ------------------------------------------------ |
|
||||
| `CASCADE` | Delete child when parent deleted | `assets.projectId → projects.id` |
|
||||
| `SET NULL` | Preserve record, nullify FK | `publish_events.userId → users.id` (audit trail) |
|
||||
| `SET NULL` + `allowNull: true` | Optional FK | `users.app_roleId → roles.id` |
|
||||
|
||||
```javascript
|
||||
// CASCADE - delete assets when project is deleted
|
||||
await addForeignKey('assets', 'projectId', { table: 'projects', field: 'id' }, 'CASCADE');
|
||||
await addForeignKey(
|
||||
'assets',
|
||||
'projectId',
|
||||
{ table: 'projects', field: 'id' },
|
||||
'CASCADE',
|
||||
);
|
||||
|
||||
// SET NULL - preserve audit log when user is deleted
|
||||
await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'SET NULL');
|
||||
await addForeignKey(
|
||||
'access_logs',
|
||||
'userId',
|
||||
{ table: 'users', field: 'id' },
|
||||
'SET NULL',
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
@ -627,6 +686,7 @@ await addForeignKey('access_logs', 'userId', { table: 'users', field: 'id' }, 'S
|
||||
## Best Practices
|
||||
|
||||
### 1. Always Use Transactions
|
||||
|
||||
```javascript
|
||||
const transaction = await queryInterface.sequelize.transaction();
|
||||
try {
|
||||
@ -639,20 +699,23 @@ try {
|
||||
```
|
||||
|
||||
### 2. Check Before Modify
|
||||
|
||||
```javascript
|
||||
// Always check existence before adding/removing
|
||||
const tableExists = await queryInterface.sequelize.query(
|
||||
`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')`
|
||||
`SELECT EXISTS (SELECT FROM information_schema.tables WHERE table_name = 'name')`,
|
||||
);
|
||||
```
|
||||
|
||||
### 3. Log Progress
|
||||
|
||||
```javascript
|
||||
console.log(`Migrating project ${projectId}: ${addedCount} records added`);
|
||||
console.log('Migration complete: All foreign keys added');
|
||||
```
|
||||
|
||||
### 4. Safe Drops
|
||||
|
||||
```javascript
|
||||
// Never drop non-empty tables silently
|
||||
if (count > 0) {
|
||||
@ -661,6 +724,7 @@ if (count > 0) {
|
||||
```
|
||||
|
||||
### 5. Reversible Operations
|
||||
|
||||
```javascript
|
||||
// Down migration should restore previous state
|
||||
async down(queryInterface, Sequelize) {
|
||||
@ -671,16 +735,17 @@ async down(queryInterface, Sequelize) {
|
||||
```
|
||||
|
||||
### 6. Use Parameterized Queries
|
||||
|
||||
```javascript
|
||||
// Good - prevents SQL injection
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE table SET column = :value WHERE id = :id`,
|
||||
{ replacements: { value: 'safe', id: record.id } }
|
||||
{ replacements: { value: 'safe', id: record.id } },
|
||||
);
|
||||
|
||||
// Avoid - SQL injection risk
|
||||
await queryInterface.sequelize.query(
|
||||
`UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'`
|
||||
`UPDATE table SET column = '${unsafeValue}' WHERE id = '${unsafeId}'`,
|
||||
);
|
||||
```
|
||||
|
||||
@ -689,13 +754,16 @@ await queryInterface.sequelize.query(
|
||||
## Running Migrations
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:migrate
|
||||
```
|
||||
|
||||
### Server Startup
|
||||
|
||||
Migrations run automatically via `npm start`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
@ -705,11 +773,13 @@ Migrations run automatically via `npm start`:
|
||||
```
|
||||
|
||||
### Migration Status
|
||||
|
||||
```bash
|
||||
npm run db:migrate:status
|
||||
```
|
||||
|
||||
### Undo Migrations
|
||||
|
||||
```bash
|
||||
# Undo last migration
|
||||
npm run db:migrate:undo
|
||||
@ -728,32 +798,32 @@ explicit rollback/backup plan.
|
||||
|
||||
## Current Migration Inventory
|
||||
|
||||
| # | Timestamp | Name | Type |
|
||||
|---|-----------|------|------|
|
||||
| 1 | 20260319000001 | add-foreign-key-constraints | Schema |
|
||||
| 2 | 20260319000002 | remove-redundant-deletion-columns | Column |
|
||||
| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename |
|
||||
| 4 | 20260326000002 | convert-element-type-enum-to-text | Column |
|
||||
| 5 | 20260326000003 | create-project-element-defaults | Schema |
|
||||
| 6 | 20260326000004 | backfill-project-element-defaults | Data |
|
||||
| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data |
|
||||
| 8 | 20260326000006 | copy-dev-to-stage | Data |
|
||||
| 9 | 20260326043002 | enforce-environment-not-null | Column |
|
||||
| 10 | 20260326050442 | remove-project-phase-column | Column |
|
||||
| 11 | 20260326054410 | remove-entry-page-slug-column | Column |
|
||||
| 12 | 20260326060000 | convert-targetpageid-to-slug | Data |
|
||||
| 13 | 20260326060001 | drop-page-elements-table | Schema |
|
||||
| 14 | 20260326060002 | drop-page-links-table | Schema |
|
||||
| 15 | 20260326060003 | drop-transitions-table | Schema |
|
||||
| 16 | 20260326171017 | add-missing-element-type-defaults | Data |
|
||||
| 17 | 20260327000001 | sync-all-element-type-defaults | Data |
|
||||
| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column |
|
||||
| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data |
|
||||
| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data |
|
||||
| 21 | 20260403000001 | add-background-video-settings | Column |
|
||||
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
|
||||
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column |
|
||||
| 24 | 20260605000001 | add-background-audio-settings | Column |
|
||||
| # | Timestamp | Name | Type |
|
||||
| --- | -------------- | ------------------------------------------- | ------ |
|
||||
| 1 | 20260319000001 | add-foreign-key-constraints | Schema |
|
||||
| 2 | 20260319000002 | remove-redundant-deletion-columns | Column |
|
||||
| 3 | 20260326000001 | rename-ui-elements-to-element-type-defaults | Rename |
|
||||
| 4 | 20260326000002 | convert-element-type-enum-to-text | Column |
|
||||
| 5 | 20260326000003 | create-project-element-defaults | Schema |
|
||||
| 6 | 20260326000004 | backfill-project-element-defaults | Data |
|
||||
| 7 | 20260326000005 | fix-project-audio-tracks-environment | Data |
|
||||
| 8 | 20260326000006 | copy-dev-to-stage | Data |
|
||||
| 9 | 20260326043002 | enforce-environment-not-null | Column |
|
||||
| 10 | 20260326050442 | remove-project-phase-column | Column |
|
||||
| 11 | 20260326054410 | remove-entry-page-slug-column | Column |
|
||||
| 12 | 20260326060000 | convert-targetpageid-to-slug | Data |
|
||||
| 13 | 20260326060001 | drop-page-elements-table | Schema |
|
||||
| 14 | 20260326060002 | drop-page-links-table | Schema |
|
||||
| 15 | 20260326060003 | drop-transitions-table | Schema |
|
||||
| 16 | 20260326171017 | add-missing-element-type-defaults | Data |
|
||||
| 17 | 20260327000001 | sync-all-element-type-defaults | Data |
|
||||
| 18 | 20260331024423 | remove-unused-theme-columns-from-projects | Column |
|
||||
| 19 | 20260331054340 | remove-duplicate-element-type-defaults | Data |
|
||||
| 20 | 20260331063424 | cleanup-invalid-element-type-defaults | Data |
|
||||
| 21 | 20260403000001 | add-background-video-settings | Column |
|
||||
| 22 | 20260409000001 | add-design-dimensions-to-projects | Column |
|
||||
| 23 | 20260409111309 | add-design-dimensions-to-tour-pages | Column |
|
||||
| 24 | 20260605000001 | add-background-audio-settings | Column |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -10,31 +10,31 @@ The DB Models module defines the Sequelize ORM models that map to PostgreSQL dat
|
||||
the backend TS/ESM migration, model entries have a typed `.ts` source plus a
|
||||
typed ESM source file. There is no model-level CommonJS compatibility facade.
|
||||
|
||||
| File | Model | Purpose | LOC |
|
||||
|------|-------|---------|-----|
|
||||
| `index.ts` | - | ESM entrypoint re-exporting `loader.ts` | 1 |
|
||||
| `loader.ts` | - | Typed model registry and Sequelize initialization | 128 |
|
||||
| `users.ts` + `.js` bridge | `users` | User accounts with authentication | 246 |
|
||||
| `projects.ts` + `.js` bridge | `projects` | Virtual tour projects | 211 |
|
||||
| `production_presentation_access.ts` + `.js` bridge | `production_presentation_access` | Customer grants for private production presentations | 67 |
|
||||
| `tour_pages.ts` + `.js` bridge | `tour_pages` | Individual tour pages with UI schema | 131 |
|
||||
| `assets.ts` + `.js` bridge | `assets` | Uploaded media files | 169 |
|
||||
| `asset_variants.ts` + `.js` bridge | `asset_variants` | Asset size/format variants | 103 |
|
||||
| `roles.ts` + `roles.js` bridge | `roles` | RBAC roles | 85 |
|
||||
| `permissions.ts` + `permissions.js` bridge | `permissions` | RBAC permissions | 52 |
|
||||
| `project_memberships.ts` + `.js` bridge | `project_memberships` | User-project access | 89 |
|
||||
| `publish_events.ts` + `.js` bridge | `publish_events` | Publishing history | 148 |
|
||||
| `pwa_caches.ts` + `.js` bridge | `pwa_caches` | PWA offline cache manifests | 84 |
|
||||
| `access_logs.ts` + `.js` bridge | `access_logs` | Activity audit trail | 105 |
|
||||
| `element_type_defaults.ts` + `.js` bridge | `element_type_defaults` | Global UI element defaults | 91 |
|
||||
| `project_element_defaults.ts` + `.js` bridge | `project_element_defaults` | Project-specific element defaults | 101 |
|
||||
| `project_audio_tracks.ts` + `.js` bridge | `project_audio_tracks` | Background audio tracks | 103 |
|
||||
| `project_transition_settings.ts` + `.js` bridge | `project_transition_settings` | Environment-aware CSS transition settings | 95 |
|
||||
| `global_transition_defaults.ts` + `.js` bridge | `global_transition_defaults` | Platform defaults for CSS page transitions | 65 |
|
||||
| `global_ui_control_defaults.ts` + `.js` bridge | `global_ui_control_defaults` | Platform defaults for fullscreen, sound, and offline controls | 33 |
|
||||
| `project_ui_control_settings.ts` + `.js` bridge | `project_ui_control_settings` | Project/environment overrides for global UI controls | 61 |
|
||||
| `presigned_url_requests.ts` + `.js` bridge | `presigned_url_requests` | S3 presigned URL audit | 118 |
|
||||
| `file.ts` + `.js` bridge | `file` | Generic file attachments | 53 |
|
||||
| File | Model | Purpose | LOC |
|
||||
| -------------------------------------------------- | -------------------------------- | ------------------------------------------------------------- | --- |
|
||||
| `index.ts` | - | ESM entrypoint re-exporting `loader.ts` | 1 |
|
||||
| `loader.ts` | - | Typed model registry and Sequelize initialization | 128 |
|
||||
| `users.ts` + `.js` bridge | `users` | User accounts with authentication | 246 |
|
||||
| `projects.ts` + `.js` bridge | `projects` | Virtual tour projects | 211 |
|
||||
| `production_presentation_access.ts` + `.js` bridge | `production_presentation_access` | Customer grants for private production presentations | 67 |
|
||||
| `tour_pages.ts` + `.js` bridge | `tour_pages` | Individual tour pages with UI schema | 131 |
|
||||
| `assets.ts` + `.js` bridge | `assets` | Uploaded media files | 169 |
|
||||
| `asset_variants.ts` + `.js` bridge | `asset_variants` | Asset size/format variants | 103 |
|
||||
| `roles.ts` + `roles.js` bridge | `roles` | RBAC roles | 85 |
|
||||
| `permissions.ts` + `permissions.js` bridge | `permissions` | RBAC permissions | 52 |
|
||||
| `project_memberships.ts` + `.js` bridge | `project_memberships` | User-project access | 89 |
|
||||
| `publish_events.ts` + `.js` bridge | `publish_events` | Publishing history | 148 |
|
||||
| `pwa_caches.ts` + `.js` bridge | `pwa_caches` | PWA offline cache manifests | 84 |
|
||||
| `access_logs.ts` + `.js` bridge | `access_logs` | Activity audit trail | 105 |
|
||||
| `element_type_defaults.ts` + `.js` bridge | `element_type_defaults` | Global UI element defaults | 91 |
|
||||
| `project_element_defaults.ts` + `.js` bridge | `project_element_defaults` | Project-specific element defaults | 101 |
|
||||
| `project_audio_tracks.ts` + `.js` bridge | `project_audio_tracks` | Background audio tracks | 103 |
|
||||
| `project_transition_settings.ts` + `.js` bridge | `project_transition_settings` | Environment-aware CSS transition settings | 95 |
|
||||
| `global_transition_defaults.ts` + `.js` bridge | `global_transition_defaults` | Platform defaults for CSS page transitions | 65 |
|
||||
| `global_ui_control_defaults.ts` + `.js` bridge | `global_ui_control_defaults` | Platform defaults for fullscreen, sound, and offline controls | 33 |
|
||||
| `project_ui_control_settings.ts` + `.js` bridge | `project_ui_control_settings` | Project/environment overrides for global UI controls | 61 |
|
||||
| `presigned_url_requests.ts` + `.js` bridge | `presigned_url_requests` | S3 presigned URL audit | 118 |
|
||||
| `file.ts` + `.js` bridge | `file` | Generic file attachments | 53 |
|
||||
|
||||
---
|
||||
|
||||
@ -128,6 +128,7 @@ bridge or immutable migration.
|
||||
## Model Loader
|
||||
|
||||
**Locations:**
|
||||
|
||||
- `backend/src/db/models/loader.ts`
|
||||
- `backend/src/db/models/index.ts`
|
||||
- `backend/src/types/db-models.ts`
|
||||
@ -143,11 +144,11 @@ for service-specific model calls.
|
||||
|
||||
**Location:** `backend/src/db/db-config.ts`
|
||||
|
||||
| Environment | Database | Logging | Notes |
|
||||
|-------------|----------|---------|-------|
|
||||
| `production` | From env vars | Disabled | Live production |
|
||||
| `development` | `db_tour_builder_platform` | Console | Local dev |
|
||||
| `dev_stage` | From env vars | Console | Staging server |
|
||||
| Environment | Database | Logging | Notes |
|
||||
| ------------- | -------------------------- | -------- | --------------- |
|
||||
| `production` | From env vars | Disabled | Live production |
|
||||
| `development` | `db_tour_builder_platform` | Console | Local dev |
|
||||
| `dev_stage` | From env vars | Console | Staging server |
|
||||
|
||||
---
|
||||
|
||||
@ -167,13 +168,13 @@ All models share these Sequelize options:
|
||||
|
||||
### Common Fields
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `id` | `UUID` | Primary key (auto-generated UUIDv4) |
|
||||
| Field | Type | Description |
|
||||
| ------------ | ------------- | ----------------------------------------- |
|
||||
| `id` | `UUID` | Primary key (auto-generated UUIDv4) |
|
||||
| `importHash` | `STRING(255)` | Unique hash for bulk import deduplication |
|
||||
| `createdAt` | `DATE` | Auto-managed creation timestamp |
|
||||
| `updatedAt` | `DATE` | Auto-managed update timestamp |
|
||||
| `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) |
|
||||
| `createdAt` | `DATE` | Auto-managed creation timestamp |
|
||||
| `updatedAt` | `DATE` | Auto-managed update timestamp |
|
||||
| `deletedAt` | `DATE` | Soft delete timestamp (paranoid mode) |
|
||||
|
||||
### Common Associations
|
||||
|
||||
@ -193,40 +194,49 @@ db.MODEL.belongsTo(db.users, { as: 'updatedBy' });
|
||||
|
||||
**Purpose:** User accounts for authentication and authorization.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `firstName` | TEXT | Yes | - | Trimmed |
|
||||
| `lastName` | TEXT | Yes | - | Trimmed |
|
||||
| `phoneNumber` | TEXT | Yes | - | - |
|
||||
| `email` | TEXT | No | - | isEmail, notEmpty, unique |
|
||||
| `password` | TEXT | No | - | Hashed with bcrypt |
|
||||
| `disabled` | BOOLEAN | No | false | - |
|
||||
| `emailVerified` | BOOLEAN | No | false | - |
|
||||
| `emailVerificationToken` | TEXT | Yes | - | - |
|
||||
| `emailVerificationTokenExpiresAt` | DATE | Yes | - | - |
|
||||
| `passwordResetToken` | TEXT | Yes | - | - |
|
||||
| `passwordResetTokenExpiresAt` | DATE | Yes | - | - |
|
||||
| `provider` | TEXT | No | 'local' | OAuth provider |
|
||||
| `app_roleId` | UUID | Yes | - | FK to roles |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| --------------------------------- | ------- | -------- | ------- | ------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `firstName` | TEXT | Yes | - | Trimmed |
|
||||
| `lastName` | TEXT | Yes | - | Trimmed |
|
||||
| `phoneNumber` | TEXT | Yes | - | - |
|
||||
| `email` | TEXT | No | - | isEmail, notEmpty, unique |
|
||||
| `password` | TEXT | No | - | Hashed with bcrypt |
|
||||
| `disabled` | BOOLEAN | No | false | - |
|
||||
| `emailVerified` | BOOLEAN | No | false | - |
|
||||
| `emailVerificationToken` | TEXT | Yes | - | - |
|
||||
| `emailVerificationTokenExpiresAt` | DATE | Yes | - | - |
|
||||
| `passwordResetToken` | TEXT | Yes | - | - |
|
||||
| `passwordResetTokenExpiresAt` | DATE | Yes | - | - |
|
||||
| `provider` | TEXT | No | 'local' | OAuth provider |
|
||||
| `app_roleId` | UUID | Yes | - | FK to roles |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `email` (unique)
|
||||
- `app_roleId`
|
||||
- `deletedAt`
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
users.belongsTo(roles, { as: 'app_role' });
|
||||
users.belongsToMany(permissions, { as: 'custom_permissions', through: 'usersCustom_permissionsPermissions' });
|
||||
users.belongsToMany(permissions, {
|
||||
as: 'custom_permissions',
|
||||
through: 'usersCustom_permissionsPermissions',
|
||||
});
|
||||
users.hasMany(project_memberships, { as: 'project_memberships_user' });
|
||||
users.hasMany(presigned_url_requests, { as: 'presigned_url_requests_user' });
|
||||
users.hasMany(publish_events, { as: 'publish_events_user' });
|
||||
users.hasMany(access_logs, { as: 'access_logs_user' });
|
||||
users.hasMany(file, { as: 'avatar', scope: { belongsTo: 'users', belongsToColumn: 'avatar' } });
|
||||
users.hasMany(file, {
|
||||
as: 'avatar',
|
||||
scope: { belongsTo: 'users', belongsToColumn: 'avatar' },
|
||||
});
|
||||
```
|
||||
|
||||
**Hooks:**
|
||||
|
||||
```javascript
|
||||
users.beforeCreate((user) => {
|
||||
// Trim string fields
|
||||
@ -245,34 +255,60 @@ users.beforeUpdate((user) => {
|
||||
|
||||
**Purpose:** Virtual tour projects container.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||
| `slug` | TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores |
|
||||
| `description` | TEXT | Yes | - | - |
|
||||
| `logo_url` | TEXT | Yes | - | - |
|
||||
| `favicon_url` | TEXT | Yes | - | - |
|
||||
| `og_image_url` | TEXT | Yes | - | - |
|
||||
| `production_presentation_visibility` | ENUM | No | public | public, private |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ------------------------------------ | ---- | -------- | ------- | --------------------------------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||
| `slug` | TEXT | No | - | notEmpty, unique, alphanumeric + dashes/underscores |
|
||||
| `description` | TEXT | Yes | - | - |
|
||||
| `logo_url` | TEXT | Yes | - | - |
|
||||
| `favicon_url` | TEXT | Yes | - | - |
|
||||
| `og_image_url` | TEXT | Yes | - | - |
|
||||
| `production_presentation_visibility` | ENUM | No | public | public, private |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `slug` (unique)
|
||||
- `deletedAt`
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
projects.hasMany(project_memberships, { as: 'project_memberships_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(project_memberships, {
|
||||
as: 'project_memberships_project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
projects.hasMany(assets, { as: 'assets_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(presigned_url_requests, { as: 'presigned_url_requests_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(presigned_url_requests, {
|
||||
as: 'presigned_url_requests_project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
projects.hasMany(tour_pages, { as: 'tour_pages_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(project_audio_tracks, { as: 'project_audio_tracks_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(project_transition_settings, { as: 'project_transition_settings_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(publish_events, { as: 'publish_events_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(project_audio_tracks, {
|
||||
as: 'project_audio_tracks_project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
projects.hasMany(project_transition_settings, {
|
||||
as: 'project_transition_settings_project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
projects.hasMany(publish_events, {
|
||||
as: 'publish_events_project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
projects.hasMany(pwa_caches, { as: 'pwa_caches_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(access_logs, { as: 'access_logs_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(project_element_defaults, { as: 'project_element_defaults_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(production_presentation_access, { as: 'production_presentation_access_project', onDelete: 'CASCADE' });
|
||||
projects.hasMany(access_logs, {
|
||||
as: 'access_logs_project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
projects.hasMany(project_element_defaults, {
|
||||
as: 'project_element_defaults_project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
projects.hasMany(production_presentation_access, {
|
||||
as: 'production_presentation_access_project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
@ -282,26 +318,40 @@ projects.hasMany(production_presentation_access, { as: 'production_presentation_
|
||||
**Purpose:** Grants Public-role customer users access to selected private
|
||||
production presentations.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `projectId` | UUID | No | - | FK to projects |
|
||||
| `userId` | UUID | No | - | FK to users |
|
||||
| `createdById` | UUID | Yes | - | FK to users |
|
||||
| `updatedById` | UUID | Yes | - | FK to users |
|
||||
| `importHash` | STRING(255) | Yes | - | unique |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ------------- | ----------- | -------- | ------- | -------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `projectId` | UUID | No | - | FK to projects |
|
||||
| `userId` | UUID | No | - | FK to users |
|
||||
| `createdById` | UUID | Yes | - | FK to users |
|
||||
| `updatedById` | UUID | Yes | - | FK to users |
|
||||
| `importHash` | STRING(255) | Yes | - | unique |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `projectId`
|
||||
- `userId`
|
||||
- `projectId, userId` unique for active rows
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
production_presentation_access.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
||||
production_presentation_access.belongsTo(users, { as: 'user', onDelete: 'CASCADE' });
|
||||
production_presentation_access.belongsTo(users, { as: 'createdBy', onDelete: 'SET NULL' });
|
||||
production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SET NULL' });
|
||||
production_presentation_access.belongsTo(projects, {
|
||||
as: 'project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
production_presentation_access.belongsTo(users, {
|
||||
as: 'user',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
production_presentation_access.belongsTo(users, {
|
||||
as: 'createdBy',
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
production_presentation_access.belongsTo(users, {
|
||||
as: 'updatedBy',
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
@ -310,23 +360,24 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE
|
||||
|
||||
**Purpose:** Individual pages within a tour with UI elements schema.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | No | 'dev' | dev, stage, production |
|
||||
| `source_key` | TEXT | Yes | - | Original page ID for cloning |
|
||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||
| `slug` | TEXT | No | - | notEmpty, alphanumeric + dashes |
|
||||
| `sort_order` | INTEGER | No | 0 | - |
|
||||
| `background_image_url` | TEXT | Yes | - | - |
|
||||
| `background_video_url` | TEXT | Yes | - | - |
|
||||
| `background_audio_url` | TEXT | Yes | - | - |
|
||||
| `background_loop` | BOOLEAN | No | false | - |
|
||||
| `requires_auth` | BOOLEAN | No | false | - |
|
||||
| `ui_schema_json` | JSON | Yes | - | Page elements, links, transitions |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ---------------------- | ------- | -------- | ------- | --------------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | No | 'dev' | dev, stage, production |
|
||||
| `source_key` | TEXT | Yes | - | Original page ID for cloning |
|
||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||
| `slug` | TEXT | No | - | notEmpty, alphanumeric + dashes |
|
||||
| `sort_order` | INTEGER | No | 0 | - |
|
||||
| `background_image_url` | TEXT | Yes | - | - |
|
||||
| `background_video_url` | TEXT | Yes | - | - |
|
||||
| `background_audio_url` | TEXT | Yes | - | - |
|
||||
| `background_loop` | BOOLEAN | No | false | - |
|
||||
| `requires_auth` | BOOLEAN | No | false | - |
|
||||
| `ui_schema_json` | JSON | Yes | - | Page elements, links, transitions |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `projectId`
|
||||
- `[projectId, environment, slug]` (unique) - Composite unique per project+environment
|
||||
- `[projectId, environment, sort_order]` - For ordering queries
|
||||
@ -340,15 +391,19 @@ production_presentation_access.belongsTo(users, { as: 'updatedBy', onDelete: 'SE
|
||||
|
||||
**Purpose:** RBAC role definitions.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `name` | TEXT | No | - | notEmpty, len[1,100] |
|
||||
| `role_customization` | TEXT | Yes | - | Custom role metadata |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| -------------------- | ---- | -------- | ------- | -------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `name` | TEXT | No | - | notEmpty, len[1,100] |
|
||||
| `role_customization` | TEXT | Yes | - | Custom role metadata |
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
roles.belongsToMany(permissions, { as: 'permissions', through: 'rolesPermissionsPermissions' });
|
||||
roles.belongsToMany(permissions, {
|
||||
as: 'permissions',
|
||||
through: 'rolesPermissionsPermissions',
|
||||
});
|
||||
roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
||||
```
|
||||
|
||||
@ -358,10 +413,10 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
||||
|
||||
**Purpose:** Individual permission definitions.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `name` | TEXT | No | - | notEmpty, unique, len[1,100] |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ------ | ---- | -------- | ------- | ---------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `name` | TEXT | No | - | notEmpty, unique, len[1,100] |
|
||||
|
||||
**Permission Naming Convention:** `{ACTION}_{ENTITY}` (e.g., `READ_USERS`, `CREATE_ASSETS`)
|
||||
|
||||
@ -373,24 +428,25 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
||||
|
||||
**Purpose:** Uploaded media files (images, videos, audio, documents).
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `name` | TEXT | Yes | - | len[0,255] |
|
||||
| `asset_type` | ENUM | No | - | image, video, audio, file |
|
||||
| `type` | ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general |
|
||||
| `cdn_url` | TEXT | Yes | - | - |
|
||||
| `storage_key` | TEXT | Yes | - | S3/storage path |
|
||||
| `mime_type` | TEXT | Yes | - | MIME type format |
|
||||
| `size_mb` | DECIMAL | Yes | - | - |
|
||||
| `width_px` | INTEGER | Yes | - | Image/video width |
|
||||
| `height_px` | INTEGER | Yes | - | Image/video height |
|
||||
| `duration_sec` | DECIMAL | Yes | - | Audio/video duration |
|
||||
| `checksum` | TEXT | Yes | - | File hash |
|
||||
| `is_public` | BOOLEAN | No | false | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| -------------- | ------- | -------- | --------- | ---------------------------------------------------------------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `name` | TEXT | Yes | - | len[0,255] |
|
||||
| `asset_type` | ENUM | No | - | image, video, audio, file |
|
||||
| `type` | ENUM | No | 'general' | icon, background_image, audio, video, transition, logo, favicon, document, general |
|
||||
| `cdn_url` | TEXT | Yes | - | - |
|
||||
| `storage_key` | TEXT | Yes | - | S3/storage path |
|
||||
| `mime_type` | TEXT | Yes | - | MIME type format |
|
||||
| `size_mb` | DECIMAL | Yes | - | - |
|
||||
| `width_px` | INTEGER | Yes | - | Image/video width |
|
||||
| `height_px` | INTEGER | Yes | - | Image/video height |
|
||||
| `duration_sec` | DECIMAL | Yes | - | Audio/video duration |
|
||||
| `checksum` | TEXT | Yes | - | File hash |
|
||||
| `is_public` | BOOLEAN | No | false | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `projectId`
|
||||
- `asset_type`
|
||||
- `type`
|
||||
@ -398,8 +454,12 @@ roles.hasMany(users, { as: 'users_app_role', onDelete: 'SET NULL' });
|
||||
- `deletedAt`
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
assets.hasMany(asset_variants, { as: 'asset_variants_asset', onDelete: 'CASCADE' });
|
||||
assets.hasMany(asset_variants, {
|
||||
as: 'asset_variants_asset',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
||||
```
|
||||
|
||||
@ -409,17 +469,18 @@ assets.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
||||
|
||||
**Purpose:** Optimized versions of assets (thumbnails, different formats).
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `variant_type` | ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original |
|
||||
| `cdn_url` | TEXT | Yes | - | len[0,2048], URL format |
|
||||
| `width_px` | INTEGER | Yes | - | min: 0 |
|
||||
| `height_px` | INTEGER | Yes | - | min: 0 |
|
||||
| `size_mb` | DECIMAL | Yes | - | min: 0 |
|
||||
| `assetId` | UUID | Yes | - | FK to assets |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| -------------- | ------- | -------- | ------- | ----------------------------------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `variant_type` | ENUM | Yes | - | thumbnail, preview, webp, mp4_low, mp4_high, original |
|
||||
| `cdn_url` | TEXT | Yes | - | len[0,2048], URL format |
|
||||
| `width_px` | INTEGER | Yes | - | min: 0 |
|
||||
| `height_px` | INTEGER | Yes | - | min: 0 |
|
||||
| `size_mb` | DECIMAL | Yes | - | min: 0 |
|
||||
| `assetId` | UUID | Yes | - | FK to assets |
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' });
|
||||
```
|
||||
@ -432,26 +493,28 @@ asset_variants.belongsTo(assets, { as: 'asset', onDelete: 'CASCADE' });
|
||||
|
||||
**Purpose:** Global platform-wide default settings for UI element types.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `element_type` | TEXT | No | - | notEmpty, unique, len[1,100] |
|
||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||
| `sort_order` | INTEGER | No | 0 | - |
|
||||
| `is_active` | VIRTUAL | - | true | Always returns true |
|
||||
| `default_settings_json` | TEXT | Yes | - | Mapped from `settings_json` column |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ----------------------- | ------- | -------- | ------- | ---------------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `element_type` | TEXT | No | - | notEmpty, unique, len[1,100] |
|
||||
| `name` | TEXT | No | - | notEmpty, len[1,255] |
|
||||
| `sort_order` | INTEGER | No | 0 | - |
|
||||
| `is_active` | VIRTUAL | - | true | Always returns true |
|
||||
| `default_settings_json` | TEXT | Yes | - | Mapped from `settings_json` column |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `element_type`
|
||||
- `sort_order`
|
||||
- `deletedAt`
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
element_type_defaults.hasMany(project_element_defaults, {
|
||||
as: 'project_defaults',
|
||||
foreignKey: 'source_element_id',
|
||||
onDelete: 'SET NULL'
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
```
|
||||
|
||||
@ -463,18 +526,19 @@ element_type_defaults.hasMany(project_element_defaults, {
|
||||
|
||||
**Purpose:** Project-specific overrides for element defaults.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `element_type` | TEXT | No | - | notEmpty, len[1,100] |
|
||||
| `name` | TEXT | Yes | - | len[0,255] |
|
||||
| `sort_order` | INTEGER | No | 0 | - |
|
||||
| `settings_json` | TEXT | Yes | - | Element configuration |
|
||||
| `source_element_id` | UUID | Yes | - | FK to element_type_defaults |
|
||||
| `snapshot_version` | INTEGER | No | 1 | Version tracking |
|
||||
| `projectId` | UUID | No | - | FK to projects |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ------------------- | ------- | -------- | ------- | --------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `element_type` | TEXT | No | - | notEmpty, len[1,100] |
|
||||
| `name` | TEXT | Yes | - | len[0,255] |
|
||||
| `sort_order` | INTEGER | No | 0 | - |
|
||||
| `settings_json` | TEXT | Yes | - | Element configuration |
|
||||
| `source_element_id` | UUID | Yes | - | FK to element_type_defaults |
|
||||
| `snapshot_version` | INTEGER | No | 1 | Version tracking |
|
||||
| `projectId` | UUID | No | - | FK to projects |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `projectId`
|
||||
- `[projectId, element_type]` (unique)
|
||||
- `element_type`
|
||||
@ -482,11 +546,15 @@ element_type_defaults.hasMany(project_element_defaults, {
|
||||
- `deletedAt`
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
project_element_defaults.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
||||
project_element_defaults.belongsTo(projects, {
|
||||
as: 'project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
project_element_defaults.belongsTo(element_type_defaults, {
|
||||
as: 'source_element',
|
||||
onDelete: 'SET NULL'
|
||||
onDelete: 'SET NULL',
|
||||
});
|
||||
```
|
||||
|
||||
@ -498,24 +566,25 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
||||
|
||||
**Purpose:** Track publishing actions between environments.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `title` | STRING | Yes | - | len[0,255] |
|
||||
| `description` | TEXT | Yes | - | len[0,5000] |
|
||||
| `from_environment` | ENUM | No | - | dev, stage, production |
|
||||
| `to_environment` | ENUM | No | - | dev, stage, production |
|
||||
| `started_at` | DATE | Yes | - | - |
|
||||
| `finished_at` | DATE | Yes | - | - |
|
||||
| `status` | ENUM | No | 'queued' | queued, running, success, failed |
|
||||
| `error_message` | TEXT | Yes | - | - |
|
||||
| `pages_copied` | INTEGER | Yes | - | min: 0 |
|
||||
| `transitions_copied` | INTEGER | Yes | - | min: 0 |
|
||||
| `audios_copied` | INTEGER | Yes | - | min: 0 |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| `userId` | UUID | Yes | - | FK to users |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| -------------------- | ------- | -------- | -------- | -------------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `title` | STRING | Yes | - | len[0,255] |
|
||||
| `description` | TEXT | Yes | - | len[0,5000] |
|
||||
| `from_environment` | ENUM | No | - | dev, stage, production |
|
||||
| `to_environment` | ENUM | No | - | dev, stage, production |
|
||||
| `started_at` | DATE | Yes | - | - |
|
||||
| `finished_at` | DATE | Yes | - | - |
|
||||
| `status` | ENUM | No | 'queued' | queued, running, success, failed |
|
||||
| `error_message` | TEXT | Yes | - | - |
|
||||
| `pages_copied` | INTEGER | Yes | - | min: 0 |
|
||||
| `transitions_copied` | INTEGER | Yes | - | min: 0 |
|
||||
| `audios_copied` | INTEGER | Yes | - | min: 0 |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| `userId` | UUID | Yes | - | FK to users |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `projectId`
|
||||
- `userId`
|
||||
- `status`
|
||||
@ -527,18 +596,19 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
||||
|
||||
**Purpose:** Audit trail for user activity.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | No | - | admin, stage, production |
|
||||
| `path` | TEXT | Yes | - | len[0,2048] |
|
||||
| `ip_address` | TEXT | Yes | - | len[0,45] (IPv6 max) |
|
||||
| `user_agent` | TEXT | Yes | - | len[0,1024] |
|
||||
| `accessed_at` | DATE | No | NOW | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| `userId` | UUID | Yes | - | FK to users |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ------------- | ---- | -------- | ------- | ------------------------ |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | No | - | admin, stage, production |
|
||||
| `path` | TEXT | Yes | - | len[0,2048] |
|
||||
| `ip_address` | TEXT | Yes | - | len[0,45] (IPv6 max) |
|
||||
| `user_agent` | TEXT | Yes | - | len[0,1024] |
|
||||
| `accessed_at` | DATE | No | NOW | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| `userId` | UUID | Yes | - | FK to users |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `projectId`
|
||||
- `environment`
|
||||
- `userId`
|
||||
@ -552,17 +622,18 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
||||
|
||||
**Purpose:** User access to projects with role-based permissions.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `access_level` | ENUM | No | 'viewer' | owner, editor, reviewer, viewer |
|
||||
| `is_active` | BOOLEAN | No | false | - |
|
||||
| `invited_at` | DATE | Yes | - | - |
|
||||
| `accepted_at` | DATE | Yes | - | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| `userId` | UUID | Yes | - | FK to users |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| -------------- | ------- | -------- | -------- | ------------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `access_level` | ENUM | No | 'viewer' | owner, editor, reviewer, viewer |
|
||||
| `is_active` | BOOLEAN | No | false | - |
|
||||
| `invited_at` | DATE | Yes | - | - |
|
||||
| `accepted_at` | DATE | Yes | - | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| `userId` | UUID | Yes | - | FK to users |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `projectId`
|
||||
- `userId`
|
||||
- `[projectId, userId]` (unique) - One membership per user per project
|
||||
@ -575,19 +646,19 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
||||
|
||||
**Purpose:** Background audio tracks for projects.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | Yes | - | dev, stage, production |
|
||||
| `source_key` | TEXT | Yes | - | Original track ID for cloning |
|
||||
| `name` | TEXT | Yes | - | len[0,255] |
|
||||
| `slug` | TEXT | Yes | - | - |
|
||||
| `url` | TEXT | Yes | - | - |
|
||||
| `loop` | BOOLEAN | No | false | - |
|
||||
| `volume` | DECIMAL | Yes | - | min: 0, max: 1 |
|
||||
| `sort_order` | INTEGER | Yes | - | - |
|
||||
| `is_enabled` | BOOLEAN | No | false | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ------------- | ------- | -------- | ------- | ----------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | Yes | - | dev, stage, production |
|
||||
| `source_key` | TEXT | Yes | - | Original track ID for cloning |
|
||||
| `name` | TEXT | Yes | - | len[0,255] |
|
||||
| `slug` | TEXT | Yes | - | - |
|
||||
| `url` | TEXT | Yes | - | - |
|
||||
| `loop` | BOOLEAN | No | false | - |
|
||||
| `volume` | DECIMAL | Yes | - | min: 0, max: 1 |
|
||||
| `sort_order` | INTEGER | Yes | - | - |
|
||||
| `is_enabled` | BOOLEAN | No | false | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
|
||||
---
|
||||
|
||||
@ -595,27 +666,32 @@ project_element_defaults.belongsTo(element_type_defaults, {
|
||||
|
||||
**Purpose:** Environment-aware CSS transition settings for page navigation.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | No | - | dev, stage, production |
|
||||
| `source_key` | TEXT | Yes | - | Original settings ID for cloning |
|
||||
| `transition_type` | TEXT | No | 'fade' | CSS transition type |
|
||||
| `duration_ms` | INTEGER | No | 700 | Transition duration in ms |
|
||||
| `easing` | TEXT | No | 'ease-in-out' | CSS easing function |
|
||||
| `overlay_color` | TEXT | No | '#000000' | Transition overlay color |
|
||||
| `projectId` | UUID | No | - | FK to projects |
|
||||
| `createdById` | UUID | Yes | - | FK to users |
|
||||
| `updatedById` | UUID | Yes | - | FK to users |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ----------------- | ------- | -------- | ------------- | -------------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | No | - | dev, stage, production |
|
||||
| `source_key` | TEXT | Yes | - | Original settings ID for cloning |
|
||||
| `transition_type` | TEXT | No | 'fade' | CSS transition type |
|
||||
| `duration_ms` | INTEGER | No | 700 | Transition duration in ms |
|
||||
| `easing` | TEXT | No | 'ease-in-out' | CSS easing function |
|
||||
| `overlay_color` | TEXT | No | '#000000' | Transition overlay color |
|
||||
| `projectId` | UUID | No | - | FK to projects |
|
||||
| `createdById` | UUID | Yes | - | FK to users |
|
||||
| `updatedById` | UUID | Yes | - | FK to users |
|
||||
|
||||
**Indexes:**
|
||||
|
||||
- `[projectId, environment]` (unique where deletedAt IS NULL)
|
||||
- `projectId`
|
||||
- `deletedAt`
|
||||
|
||||
**Associations:**
|
||||
|
||||
```javascript
|
||||
project_transition_settings.belongsTo(projects, { as: 'project', onDelete: 'CASCADE' });
|
||||
project_transition_settings.belongsTo(projects, {
|
||||
as: 'project',
|
||||
onDelete: 'CASCADE',
|
||||
});
|
||||
project_transition_settings.belongsTo(users, { as: 'createdBy' });
|
||||
project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
||||
```
|
||||
@ -628,16 +704,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
||||
|
||||
**Purpose:** PWA offline cache manifest tracking.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | Yes | - | dev, stage, production |
|
||||
| `cache_version` | TEXT | Yes | - | len[0,255] |
|
||||
| `manifest_json` | JSON | Yes | - | PWA manifest |
|
||||
| `asset_list_json` | JSON | Yes | - | Cached asset URLs |
|
||||
| `generated_at` | DATE | Yes | - | - |
|
||||
| `is_active` | BOOLEAN | No | false | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ----------------- | ------- | -------- | ------- | ---------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `environment` | ENUM | Yes | - | dev, stage, production |
|
||||
| `cache_version` | TEXT | Yes | - | len[0,255] |
|
||||
| `manifest_json` | JSON | Yes | - | PWA manifest |
|
||||
| `asset_list_json` | JSON | Yes | - | Cached asset URLs |
|
||||
| `generated_at` | DATE | Yes | - | - |
|
||||
| `is_active` | BOOLEAN | No | false | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
|
||||
---
|
||||
|
||||
@ -645,18 +721,18 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
||||
|
||||
**Purpose:** Audit log for S3 presigned URL requests.
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `purpose` | ENUM | Yes | - | upload, download |
|
||||
| `asset_type` | ENUM | Yes | - | image, video, audio, file |
|
||||
| `requested_key` | TEXT | Yes | - | len[0,1024] |
|
||||
| `mime_type` | TEXT | Yes | - | MIME format, len[0,255] |
|
||||
| `requested_size_mb` | DECIMAL | Yes | - | min: 0 |
|
||||
| `expires_at` | DATE | Yes | - | - |
|
||||
| `status` | TEXT | Yes | - | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| `userId` | UUID | Yes | - | FK to users |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ------------------- | ------- | -------- | ------- | ------------------------- |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `purpose` | ENUM | Yes | - | upload, download |
|
||||
| `asset_type` | ENUM | Yes | - | image, video, audio, file |
|
||||
| `requested_key` | TEXT | Yes | - | len[0,1024] |
|
||||
| `mime_type` | TEXT | Yes | - | MIME format, len[0,255] |
|
||||
| `requested_size_mb` | DECIMAL | Yes | - | min: 0 |
|
||||
| `expires_at` | DATE | Yes | - | - |
|
||||
| `status` | TEXT | Yes | - | - |
|
||||
| `projectId` | UUID | Yes | - | FK to projects |
|
||||
| `userId` | UUID | Yes | - | FK to users |
|
||||
|
||||
---
|
||||
|
||||
@ -664,16 +740,16 @@ project_transition_settings.belongsTo(users, { as: 'updatedBy' });
|
||||
|
||||
**Purpose:** Generic file attachments (user avatars, etc.).
|
||||
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
|-------|------|----------|---------|------------|
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `belongsTo` | STRING(255) | Yes | - | Parent table name |
|
||||
| `belongsToId` | UUID | Yes | - | Parent record ID |
|
||||
| `belongsToColumn` | STRING(255) | Yes | - | Parent column name |
|
||||
| `name` | STRING(2083) | No | - | notEmpty |
|
||||
| `sizeInBytes` | INTEGER | Yes | - | - |
|
||||
| `privateUrl` | STRING(2083) | Yes | - | - |
|
||||
| `publicUrl` | STRING(2083) | No | - | notEmpty |
|
||||
| Field | Type | Nullable | Default | Validation |
|
||||
| ----------------- | ------------ | -------- | ------- | ------------------ |
|
||||
| `id` | UUID | No | UUIDv4 | - |
|
||||
| `belongsTo` | STRING(255) | Yes | - | Parent table name |
|
||||
| `belongsToId` | UUID | Yes | - | Parent record ID |
|
||||
| `belongsToColumn` | STRING(255) | Yes | - | Parent column name |
|
||||
| `name` | STRING(2083) | No | - | notEmpty |
|
||||
| `sizeInBytes` | INTEGER | Yes | - | - |
|
||||
| `privateUrl` | STRING(2083) | Yes | - | - |
|
||||
| `publicUrl` | STRING(2083) | No | - | notEmpty |
|
||||
|
||||
**Usage Pattern:** Polymorphic association via `belongsTo`, `belongsToId`, `belongsToColumn` fields and scoped `hasMany` on parent models:
|
||||
|
||||
@ -811,19 +887,17 @@ importHash: {
|
||||
|
||||
### 4. Cascade Delete Patterns
|
||||
|
||||
| Relationship | onDelete | Use Case |
|
||||
|--------------|----------|----------|
|
||||
| `CASCADE` | Delete children when parent deleted | Projects → tour_pages |
|
||||
| `SET NULL` | Keep children, null the FK | Roles → users |
|
||||
| Relationship | onDelete | Use Case |
|
||||
| ------------ | ----------------------------------- | --------------------- |
|
||||
| `CASCADE` | Delete children when parent deleted | Projects → tour_pages |
|
||||
| `SET NULL` | Keep children, null the FK | Roles → users |
|
||||
|
||||
### 5. Composite Unique Constraints
|
||||
|
||||
For scoped uniqueness:
|
||||
|
||||
```javascript
|
||||
indexes: [
|
||||
{ fields: ['projectId', 'environment', 'slug'], unique: true },
|
||||
]
|
||||
indexes: [{ fields: ['projectId', 'environment', 'slug'], unique: true }];
|
||||
```
|
||||
|
||||
### 6. JSON Fields
|
||||
@ -831,8 +905,12 @@ indexes: [
|
||||
Complex configurations stored as JSON:
|
||||
|
||||
```javascript
|
||||
ui_schema_json: { type: DataTypes.JSON } // Parsed JSON column
|
||||
settings_json: { type: DataTypes.TEXT } // Stringified JSON text
|
||||
ui_schema_json: {
|
||||
type: DataTypes.JSON;
|
||||
} // Parsed JSON column
|
||||
settings_json: {
|
||||
type: DataTypes.TEXT;
|
||||
} // Stringified JSON text
|
||||
```
|
||||
|
||||
### 7. Virtual Fields
|
||||
@ -933,7 +1011,7 @@ const transaction = await db.sequelize.transaction();
|
||||
// Access Sequelize operators
|
||||
const { Op } = db.Sequelize;
|
||||
const users = await db.users.findAll({
|
||||
where: { email: { [Op.like]: '%@example.com' } }
|
||||
where: { email: { [Op.like]: '%@example.com' } },
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@ -30,6 +30,7 @@ backend/src/db/seeders/
|
||||
## Configuration
|
||||
|
||||
### NPM Scripts
|
||||
|
||||
```bash
|
||||
# Run pending seeders
|
||||
npm run db:seed
|
||||
@ -42,7 +43,9 @@ npm run db:reset
|
||||
```
|
||||
|
||||
### Server Startup
|
||||
|
||||
Seeders run automatically via `npm start`:
|
||||
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
@ -65,13 +68,14 @@ development and the compiled JavaScript file from `dist/` in production builds.
|
||||
|
||||
**Users Created:**
|
||||
|
||||
| User | Email | Role Assignment |
|
||||
|------|-------|-----------------|
|
||||
| Admin | `config.admin_email` | Administrator |
|
||||
| John | john@doe.com | Account Manager |
|
||||
| Client | client@hello.com | Platform Owner |
|
||||
| User | Email | Role Assignment |
|
||||
| ------ | -------------------- | --------------- |
|
||||
| Admin | `config.admin_email` | Administrator |
|
||||
| John | john@doe.com | Account Manager |
|
||||
| Client | client@hello.com | Platform Owner |
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Uses bcrypt for password hashing with configured salt rounds
|
||||
- Hardcoded UUIDs for consistent user IDs
|
||||
- Reads credentials from `config.ts` (environment variables)
|
||||
@ -112,14 +116,15 @@ data.
|
||||
|
||||
**Data Created:**
|
||||
|
||||
| Data Type | Count | Description |
|
||||
|-----------|-------|-------------|
|
||||
| Roles | 7 | User role definitions |
|
||||
| Permissions | 54 | CRUD permissions for 13 entities + special |
|
||||
| Role-Permission Links | 200+ | M:N relationships |
|
||||
| Join Table | 1 | `rolesPermissionsPermissions` table |
|
||||
| Data Type | Count | Description |
|
||||
| --------------------- | ----- | ------------------------------------------ |
|
||||
| Roles | 7 | User role definitions |
|
||||
| Permissions | 54 | CRUD permissions for 13 entities + special |
|
||||
| Role-Permission Links | 200+ | M:N relationships |
|
||||
| Join Table | 1 | `rolesPermissionsPermissions` table |
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Uses stable named role and permission definitions, then reuses existing DB IDs
|
||||
when the same role/permission name is already present.
|
||||
- Inserts only missing roles, permissions, and role-permission links. This keeps
|
||||
@ -128,19 +133,21 @@ data.
|
||||
by email after RBAC data exists.
|
||||
|
||||
#### Roles
|
||||
|
||||
```javascript
|
||||
const roles = [
|
||||
'Administrator', // Full system access
|
||||
'PlatformOwner', // Full project/content access
|
||||
'AccountManager', // User and project management
|
||||
'TourDesigner', // Content creation and editing
|
||||
'ContentReviewer', // Read + limited update access
|
||||
'AnalyticsViewer', // Read-only access
|
||||
'Public', // Public/unauthenticated access
|
||||
'Administrator', // Full system access
|
||||
'PlatformOwner', // Full project/content access
|
||||
'AccountManager', // User and project management
|
||||
'TourDesigner', // Content creation and editing
|
||||
'ContentReviewer', // Read + limited update access
|
||||
'AnalyticsViewer', // Read-only access
|
||||
'Public', // Public/unauthenticated access
|
||||
];
|
||||
```
|
||||
|
||||
#### Permission Generation Pattern
|
||||
|
||||
```javascript
|
||||
// Generates CREATE, READ, UPDATE, DELETE permissions per entity
|
||||
function createPermissions(name) {
|
||||
@ -153,13 +160,26 @@ function createPermissions(name) {
|
||||
}
|
||||
|
||||
const entities = [
|
||||
'users', 'roles', 'permissions', 'projects', 'project_memberships',
|
||||
'assets', 'asset_variants', 'presigned_url_requests', 'tour_pages',
|
||||
'project_audio_tracks', 'publish_events', 'pwa_caches', 'access_logs'
|
||||
'users',
|
||||
'roles',
|
||||
'permissions',
|
||||
'projects',
|
||||
'project_memberships',
|
||||
'assets',
|
||||
'asset_variants',
|
||||
'presigned_url_requests',
|
||||
'tour_pages',
|
||||
'project_audio_tracks',
|
||||
'publish_events',
|
||||
'pwa_caches',
|
||||
'access_logs',
|
||||
];
|
||||
|
||||
// Creates 52 permissions (13 entities × 4 CRUD operations)
|
||||
await queryInterface.bulkInsert('permissions', entities.flatMap(createPermissions));
|
||||
await queryInterface.bulkInsert(
|
||||
'permissions',
|
||||
entities.flatMap(createPermissions),
|
||||
);
|
||||
|
||||
// Plus special permissions
|
||||
await queryInterface.bulkInsert('permissions', [
|
||||
@ -169,6 +189,7 @@ await queryInterface.bulkInsert('permissions', [
|
||||
```
|
||||
|
||||
#### ID Map Pattern
|
||||
|
||||
```javascript
|
||||
// Consistent UUID generation using key-based map
|
||||
const idMap = new Map();
|
||||
@ -183,25 +204,26 @@ function getId(key) {
|
||||
}
|
||||
|
||||
// Usage - same key always returns same UUID within seeder run
|
||||
getId('Administrator') // Returns consistent UUID
|
||||
getId('CREATE_USERS') // Returns consistent UUID
|
||||
getId('Administrator'); // Returns consistent UUID
|
||||
getId('CREATE_USERS'); // Returns consistent UUID
|
||||
```
|
||||
|
||||
#### Permission Matrix
|
||||
|
||||
| Role | Users | Projects | Assets | Tour Pages | Access Logs |
|
||||
|------|-------|----------|--------|------------|-------------|
|
||||
| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD |
|
||||
| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD |
|
||||
| **AccountManager** | RU | CRU | CRU | CRU | R |
|
||||
| **TourDesigner** | R | RU | CRU | CRU | R |
|
||||
| **ContentReviewer** | R | RU | RU | RU | R |
|
||||
| **AnalyticsViewer** | R | R | R | R | R |
|
||||
| **Public** | - | - | - | - | - |
|
||||
| Role | Users | Projects | Assets | Tour Pages | Access Logs |
|
||||
| ------------------- | ----- | -------- | ------ | ---------- | ----------- |
|
||||
| **Administrator** | CRUD | CRUD | CRUD | CRUD | CRUD |
|
||||
| **PlatformOwner** | CRUD | CRUD | CRUD | CRUD | CRUD |
|
||||
| **AccountManager** | RU | CRU | CRU | CRU | R |
|
||||
| **TourDesigner** | R | RU | CRU | CRU | R |
|
||||
| **ContentReviewer** | R | RU | RU | RU | R |
|
||||
| **AnalyticsViewer** | R | R | R | R | R |
|
||||
| **Public** | - | - | - | - | - |
|
||||
|
||||
**Legend:** C=Create, R=Read, U=Update, D=Delete
|
||||
|
||||
#### Join Table Creation
|
||||
|
||||
```javascript
|
||||
// Creates M:N relationship table directly in seeder
|
||||
await queryInterface.sequelize.query(`
|
||||
@ -235,6 +257,7 @@ separate. Umzug records the seeder under its legacy `.js` name for storage
|
||||
compatibility only.
|
||||
|
||||
**Opt-In Activation:**
|
||||
|
||||
```bash
|
||||
# Enable sample data seeding
|
||||
export ENABLE_SAMPLE_DATA=true
|
||||
@ -242,6 +265,7 @@ npm run db:seed
|
||||
```
|
||||
|
||||
**Check in Code:**
|
||||
|
||||
```typescript
|
||||
const sampleDataSeeder: SequelizeSeeder = {
|
||||
async up() {
|
||||
@ -253,20 +277,21 @@ const sampleDataSeeder: SequelizeSeeder = {
|
||||
|
||||
**Data Created:**
|
||||
|
||||
| Entity | Records | Description |
|
||||
|--------|---------|-------------|
|
||||
| Projects | 3 | Sample tour projects |
|
||||
| Project Memberships | 3 | User-project associations |
|
||||
| Assets | 3 | Images, videos, audio |
|
||||
| Asset Variants | 3 | Thumbnail/preview variants |
|
||||
| Presigned URL Requests | 3 | Upload/download requests |
|
||||
| Tour Pages | 3 | Sample tour pages |
|
||||
| Project Audio Tracks | 3 | Background audio |
|
||||
| Publish Events | 3 | Deployment history |
|
||||
| PWA Caches | 3 | Offline cache configs |
|
||||
| Access Logs | 3 | Visitor tracking |
|
||||
| Entity | Records | Description |
|
||||
| ---------------------- | ------- | -------------------------- |
|
||||
| Projects | 3 | Sample tour projects |
|
||||
| Project Memberships | 3 | User-project associations |
|
||||
| Assets | 3 | Images, videos, audio |
|
||||
| Asset Variants | 3 | Thumbnail/preview variants |
|
||||
| Presigned URL Requests | 3 | Upload/download requests |
|
||||
| Tour Pages | 3 | Sample tour pages |
|
||||
| Project Audio Tracks | 3 | Background audio |
|
||||
| Publish Events | 3 | Deployment history |
|
||||
| PWA Caches | 3 | Offline cache configs |
|
||||
| Access Logs | 3 | Visitor tracking |
|
||||
|
||||
#### Sample Projects
|
||||
|
||||
```javascript
|
||||
const ProjectsData = [
|
||||
{
|
||||
@ -293,6 +318,7 @@ const ProjectsData = [
|
||||
```
|
||||
|
||||
#### Association Helper Pattern
|
||||
|
||||
```javascript
|
||||
// Associates records after bulk creation using Sequelize model methods
|
||||
async function associateAssetWithProject() {
|
||||
@ -314,6 +340,7 @@ async function associateAssetWithProject() {
|
||||
## Seeder Patterns
|
||||
|
||||
### 1. bulkInsert Pattern
|
||||
|
||||
**Purpose:** Insert multiple records efficiently.
|
||||
|
||||
```javascript
|
||||
@ -329,6 +356,7 @@ await queryInterface.bulkInsert('tableName', [
|
||||
```
|
||||
|
||||
### 2. bulkDelete Pattern
|
||||
|
||||
**Purpose:** Remove seeded data during rollback.
|
||||
|
||||
```javascript
|
||||
@ -340,6 +368,7 @@ async down(queryInterface, Sequelize) {
|
||||
```
|
||||
|
||||
### 3. Conditional Execution Pattern
|
||||
|
||||
**Purpose:** Enable/disable seeders based on environment.
|
||||
|
||||
Seeder-only environment gates are intentionally allowed to read `process.env`
|
||||
@ -354,13 +383,14 @@ records, or sample-data entities.
|
||||
```javascript
|
||||
up: async () => {
|
||||
if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
|
||||
return; // Skip seeding
|
||||
return; // Skip seeding
|
||||
}
|
||||
// ... proceed with seeding
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
### 4. ID Consistency Pattern
|
||||
|
||||
**Purpose:** Use deterministic IDs for reliable down() migrations.
|
||||
|
||||
```javascript
|
||||
@ -381,6 +411,7 @@ function getId(key) {
|
||||
```
|
||||
|
||||
### 5. Model-Based Association Pattern
|
||||
|
||||
**Purpose:** Create relationships using Sequelize models after bulk insert.
|
||||
|
||||
```javascript
|
||||
@ -391,6 +422,7 @@ await asset.setProject(project);
|
||||
```
|
||||
|
||||
### 6. Raw SQL Pattern
|
||||
|
||||
**Purpose:** Create structures not managed by Sequelize models.
|
||||
|
||||
```javascript
|
||||
@ -403,7 +435,7 @@ await queryInterface.sequelize.query(`
|
||||
|
||||
// Create indexes
|
||||
await queryInterface.sequelize.query(
|
||||
'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");'
|
||||
'CREATE INDEX IF NOT EXISTS "index_name" ON "tableName" ("columnName");',
|
||||
);
|
||||
```
|
||||
|
||||
@ -448,16 +480,21 @@ npm run db:seed
|
||||
## Best Practices
|
||||
|
||||
### 1. Use Consistent IDs
|
||||
|
||||
```javascript
|
||||
// Good - allows rollback
|
||||
const ids = ['uuid-1', 'uuid-2'];
|
||||
await queryInterface.bulkInsert('table', records.map((r, i) => ({ id: ids[i], ...r })));
|
||||
await queryInterface.bulkInsert(
|
||||
'table',
|
||||
records.map((r, i) => ({ id: ids[i], ...r })),
|
||||
);
|
||||
|
||||
// Down migration can target specific IDs
|
||||
await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
|
||||
```
|
||||
|
||||
### 2. Always Include Timestamps
|
||||
|
||||
```javascript
|
||||
{
|
||||
field: 'value',
|
||||
@ -467,6 +504,7 @@ await queryInterface.bulkDelete('table', { id: { [Op.in]: ids } });
|
||||
```
|
||||
|
||||
### 3. Handle Errors
|
||||
|
||||
```javascript
|
||||
try {
|
||||
await queryInterface.bulkInsert('users', [...]);
|
||||
@ -477,6 +515,7 @@ try {
|
||||
```
|
||||
|
||||
### 4. Environment-Aware Seeding
|
||||
|
||||
```javascript
|
||||
// Production - only essential data
|
||||
// Development - include sample data
|
||||
@ -486,6 +525,7 @@ if (process.env.ENABLE_SAMPLE_DATA !== 'true') {
|
||||
```
|
||||
|
||||
### 5. Idempotent Where Possible
|
||||
|
||||
```javascript
|
||||
// Use IF NOT EXISTS for table/index creation
|
||||
await queryInterface.sequelize.query(`
|
||||
@ -523,23 +563,27 @@ sample-data.ts
|
||||
## Running Seeders
|
||||
|
||||
### Development Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
### With Sample Data
|
||||
|
||||
```bash
|
||||
export ENABLE_SAMPLE_DATA=true
|
||||
npm run db:seed
|
||||
```
|
||||
|
||||
### Fresh Database
|
||||
|
||||
```bash
|
||||
npm run db:reset # drop, create, migrate, seed
|
||||
```
|
||||
|
||||
### Undo Seeders
|
||||
|
||||
```bash
|
||||
npm run db:seed:undo # Runs all down() methods in reverse order
|
||||
```
|
||||
@ -548,22 +592,22 @@ npm run db:seed:undo # Runs all down() methods in reverse order
|
||||
|
||||
## Seeder Inventory
|
||||
|
||||
| # | Timestamp | Name | Records | Required |
|
||||
|---|-----------|------|---------|----------|
|
||||
| 1 | 20200430130759 | admin-user | 3 users | Yes |
|
||||
| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes |
|
||||
| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) |
|
||||
| # | Timestamp | Name | Records | Required |
|
||||
| --- | -------------- | ----------- | ----------------------------------- | ----------- |
|
||||
| 1 | 20200430130759 | admin-user | 3 users | Yes |
|
||||
| 2 | 20200430130760 | user-roles | 7 roles, 54 permissions, 200+ links | Yes |
|
||||
| 3 | 20231127130745 | sample-data | 30+ sample records | No (opt-in) |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| Variable | Purpose | Default |
|
||||
|----------|---------|---------|
|
||||
| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` |
|
||||
| `ADMIN_EMAIL` | Admin user email | (from config) |
|
||||
| `ADMIN_PASS` | Admin user password | (from config) |
|
||||
| `USER_PASS` | Default user password | (from config) |
|
||||
| Variable | Purpose | Default |
|
||||
| -------------------- | ------------------------- | ------------- |
|
||||
| `ENABLE_SAMPLE_DATA` | Enable sample data seeder | `false` |
|
||||
| `ADMIN_EMAIL` | Admin user email | (from config) |
|
||||
| `ADMIN_PASS` | Admin user password | (from config) |
|
||||
| `USER_PASS` | Default user password | (from config) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -129,13 +129,13 @@ export default class EmailSender {
|
||||
|
||||
**Key Methods:**
|
||||
|
||||
| Method | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `constructor(email)` | Instance | Accepts email template object |
|
||||
| `send()` | Async | Sends email via Nodemailer |
|
||||
| `isConfigured` | Static getter | Checks if SMTP credentials exist |
|
||||
| `transportConfig` | Getter | Returns SMTP config |
|
||||
| `from` | Getter | Returns sender address |
|
||||
| Method | Type | Description |
|
||||
| -------------------- | ------------- | -------------------------------- |
|
||||
| `constructor(email)` | Instance | Accepts email template object |
|
||||
| `send()` | Async | Sends email via Nodemailer |
|
||||
| `isConfigured` | Static getter | Checks if SMTP credentials exist |
|
||||
| `transportConfig` | Getter | Returns SMTP config |
|
||||
| `from` | Getter | Returns sender address |
|
||||
|
||||
---
|
||||
|
||||
@ -167,7 +167,7 @@ export default class PasswordResetEmail implements EmailTemplate {
|
||||
get subject() {
|
||||
return getNotification(
|
||||
'emails.passwordReset.subject',
|
||||
getNotification('app.title')
|
||||
getNotification('app.title'),
|
||||
);
|
||||
// → "Reset your password for Tour Builder Platform"
|
||||
}
|
||||
@ -179,10 +179,11 @@ export default class PasswordResetEmail implements EmailTemplate {
|
||||
.replace(/{resetUrl}/g, this.link)
|
||||
.replace(/{accountName}/g, this.to);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Template Variables:**
|
||||
|
||||
- `{appTitle}` - Application name
|
||||
- `{resetUrl}` - Password reset link
|
||||
- `{accountName}` - User email address
|
||||
@ -201,7 +202,7 @@ export default class EmailAddressVerificationEmail implements EmailTemplate {
|
||||
get subject() {
|
||||
return getNotification(
|
||||
'emails.emailAddressVerification.subject',
|
||||
getNotification('app.title')
|
||||
getNotification('app.title'),
|
||||
);
|
||||
// → "Verify your email for Tour Builder Platform"
|
||||
}
|
||||
@ -213,10 +214,11 @@ export default class EmailAddressVerificationEmail implements EmailTemplate {
|
||||
.replace(/{signupUrl}/g, this.link)
|
||||
.replace(/{to}/g, this.to);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Template Variables:**
|
||||
|
||||
- `{appTitle}` - Application name
|
||||
- `{signupUrl}` - Email verification link
|
||||
- `{to}` - User email address
|
||||
@ -235,7 +237,7 @@ export default class InvitationEmail implements EmailTemplate {
|
||||
get subject() {
|
||||
return getNotification(
|
||||
'emails.invitation.subject',
|
||||
getNotification('app.title')
|
||||
getNotification('app.title'),
|
||||
);
|
||||
// → "You've been invited to Tour Builder Platform"
|
||||
}
|
||||
@ -248,10 +250,11 @@ export default class InvitationEmail implements EmailTemplate {
|
||||
.replace(/{signupUrl}/g, signupUrl)
|
||||
.replace(/{to}/g, this.to);
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Template Variables:**
|
||||
|
||||
- `{appTitle}` - Application name
|
||||
- `{signupUrl}` - Account setup link with `&invitation=true`
|
||||
- `{to}` - User email address
|
||||
@ -267,66 +270,66 @@ All HTML templates follow consistent styling:
|
||||
```html
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<head>
|
||||
<style>
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #3498db; /* Primary blue */
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.link-primary {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: #fff !important;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #3498db; /* Primary blue */
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.link-primary {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: #fff !important;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">...</div>
|
||||
<div class="email-body">...</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br/>
|
||||
The {appTitle} Team
|
||||
</div>
|
||||
<div class="email-header">...</div>
|
||||
<div class="email-body">...</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br />
|
||||
The {appTitle} Team
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### Template Comparison
|
||||
|
||||
| Template | Header Text | Call-to-Action | Button Style |
|
||||
|----------|-------------|----------------|--------------|
|
||||
| Password Reset | "Reset your password for {appTitle}" | Link | Text link |
|
||||
| Email Verification | "Verify your email for {appTitle}!" | Link | Text link |
|
||||
| Invitation | "Welcome to {appTitle}!" | Button | Primary button |
|
||||
| Template | Header Text | Call-to-Action | Button Style |
|
||||
| ------------------ | ------------------------------------ | -------------- | -------------- |
|
||||
| Password Reset | "Reset your password for {appTitle}" | Link | Text link |
|
||||
| Email Verification | "Verify your email for {appTitle}!" | Link | Text link |
|
||||
| Invitation | "Welcome to {appTitle}!" | Button | Primary button |
|
||||
|
||||
---
|
||||
|
||||
@ -351,11 +354,11 @@ email: {
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Required | Description |
|
||||
|----------|----------|-------------|
|
||||
| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) |
|
||||
| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) |
|
||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification |
|
||||
| Variable | Required | Description |
|
||||
| ------------------------------- | -------- | ----------------------------------------- |
|
||||
| `EMAIL_USER` | Yes | SMTP username (AWS SES IAM user) |
|
||||
| `EMAIL_PASS` | Yes | SMTP password (AWS SES IAM credentials) |
|
||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | No | Set to `'false'` to skip TLS verification |
|
||||
|
||||
### AWS SES Configuration
|
||||
|
||||
@ -397,9 +400,10 @@ class Auth {
|
||||
const token = await UsersDBApi.generatePasswordResetToken(email);
|
||||
const link = `${host}/password-reset?token=${token}`;
|
||||
|
||||
const emailObj = type === 'invitation'
|
||||
? new InvitationEmail({ to: email, host: link })
|
||||
: new PasswordResetEmail({ to: email, link });
|
||||
const emailObj =
|
||||
type === 'invitation'
|
||||
? new InvitationEmail({ to: email, host: link })
|
||||
: new PasswordResetEmail({ to: email, link });
|
||||
|
||||
return new EmailSender(emailObj).send();
|
||||
}
|
||||
@ -445,10 +449,14 @@ router.get('/email-configured', (req, res) => {
|
||||
});
|
||||
|
||||
// Resend verification email (authenticated)
|
||||
router.put('/send-email-address-verification-email', jwtAuth, async (req, res) => {
|
||||
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
|
||||
res.status(200).send(true);
|
||||
});
|
||||
router.put(
|
||||
'/send-email-address-verification-email',
|
||||
jwtAuth,
|
||||
async (req, res) => {
|
||||
await AuthService.sendEmailAddressVerificationEmail(req.currentUser.email);
|
||||
res.status(200).send(true);
|
||||
},
|
||||
);
|
||||
|
||||
// Request password reset (public)
|
||||
router.put('/send-password-reset-email', async (req, res) => {
|
||||
@ -542,10 +550,10 @@ static async markEmailVerified(id, options) {
|
||||
|
||||
### Token Properties
|
||||
|
||||
| Token Type | Field | Expiry Field | TTL |
|
||||
|------------|-------|--------------|-----|
|
||||
| Token Type | Field | Expiry Field | TTL |
|
||||
| ------------------ | ------------------------ | --------------------------------- | -------- |
|
||||
| Email Verification | `emailVerificationToken` | `emailVerificationTokenExpiresAt` | 24 hours |
|
||||
| Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours |
|
||||
| Password Reset | `passwordResetToken` | `passwordResetTokenExpiresAt` | 24 hours |
|
||||
|
||||
---
|
||||
|
||||
@ -670,6 +678,7 @@ static async markEmailVerified(id, options) {
|
||||
### Email Configured Mode
|
||||
|
||||
When `EMAIL_USER` and `EMAIL_PASS` are set:
|
||||
|
||||
- Email verification required before login
|
||||
- Password reset emails sent on request
|
||||
- User invitations include email
|
||||
@ -677,6 +686,7 @@ When `EMAIL_USER` and `EMAIL_PASS` are set:
|
||||
### Email Not Configured Mode
|
||||
|
||||
When credentials are missing:
|
||||
|
||||
- `EmailSender.isConfigured` returns `false`
|
||||
- Users auto-verified on signin: `user.emailVerified = true`
|
||||
- Password reset/invitation silently skipped
|
||||
@ -746,13 +756,13 @@ emails: {
|
||||
|
||||
### Email-Related Errors
|
||||
|
||||
| Error Code | Message | When Thrown |
|
||||
|------------|---------|-------------|
|
||||
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | Token generation fails |
|
||||
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | Invalid/expired verification token |
|
||||
| `auth.passwordReset.error` | "Email not recognized" | Password reset token generation fails |
|
||||
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | Invalid/expired reset token |
|
||||
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification |
|
||||
| Error Code | Message | When Thrown |
|
||||
| ------------------------------------------------- | --------------------------------------------------- | ------------------------------------- |
|
||||
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | Token generation fails |
|
||||
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | Invalid/expired verification token |
|
||||
| `auth.passwordReset.error` | "Email not recognized" | Password reset token generation fails |
|
||||
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | Invalid/expired reset token |
|
||||
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | Login without email verification |
|
||||
|
||||
### Error Flow
|
||||
|
||||
@ -827,7 +837,7 @@ describe('EmailSender', () => {
|
||||
expect.objectContaining({
|
||||
to: 'test@example.com',
|
||||
subject: expect.stringContaining('Reset your password'),
|
||||
})
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -843,6 +853,7 @@ EMAIL_PASS=
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
- `EmailSender.isConfigured` returns `false`
|
||||
- Users auto-verified on login
|
||||
- No emails sent
|
||||
@ -859,19 +870,21 @@ Result:
|
||||
<!-- services/email/htmlTemplates/welcome/welcomeEmail.html -->
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>/* Same styles as other templates */</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">Welcome to {appTitle}!</div>
|
||||
<div class="email-body">
|
||||
<head>
|
||||
<style>
|
||||
/* Same styles as other templates */
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">Welcome to {appTitle}!</div>
|
||||
<div class="email-body">
|
||||
<p>Hello {userName},</p>
|
||||
<p>Your account has been activated.</p>
|
||||
</div>
|
||||
<div class="email-footer">Thanks,<br />The {appTitle} Team</div>
|
||||
</div>
|
||||
<div class="email-footer">Thanks,<br/>The {appTitle} Team</div>
|
||||
</div>
|
||||
</body>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
@ -944,12 +957,12 @@ await new EmailSender(email).send();
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `nodemailer` | ^6.x | SMTP transport |
|
||||
| `assert` | built-in | Input validation |
|
||||
| `fs.promises` | built-in | Template file reading |
|
||||
| `path` | built-in | Template path resolution |
|
||||
| Package | Version | Purpose |
|
||||
| ------------- | -------- | ------------------------ |
|
||||
| `nodemailer` | ^6.x | SMTP transport |
|
||||
| `assert` | built-in | Input validation |
|
||||
| `fs.promises` | built-in | Template file reading |
|
||||
| `path` | built-in | Template path resolution |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -7,9 +7,10 @@ The Factories module provides code generation patterns that eliminate boilerplat
|
||||
**Location:** `backend/src/factories/`
|
||||
|
||||
**Files:**
|
||||
| File | Purpose | LOC |
|
||||
|------|---------|-----|
|
||||
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 |
|
||||
|
||||
| File | Purpose | LOC |
|
||||
| -------------------- | --------------------------------------------------- | --- |
|
||||
| `router.factory.ts` | Generates Express routers with CRUD endpoints | 429 |
|
||||
| `service.factory.ts` | Generates service classes with transaction handling | 350 |
|
||||
|
||||
---
|
||||
@ -63,34 +64,34 @@ function createEntityRouter(entityName, Service, DBApi, options = {})
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `entityName` | `string` | Entity name for routes and permissions |
|
||||
| `Service` | `class` | Service class with CRUD methods |
|
||||
| `DBApi` | `class` | Database API class extending GenericDBApi |
|
||||
| `options` | `object` | Configuration options |
|
||||
| Parameter | Type | Description |
|
||||
| ------------ | -------- | ----------------------------------------- |
|
||||
| `entityName` | `string` | Entity name for routes and permissions |
|
||||
| `Service` | `class` | Service class with CRUD methods |
|
||||
| `DBApi` | `class` | Database API class extending GenericDBApi |
|
||||
| `options` | `object` | Configuration options |
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| `permissionEntity` | `string` | `entityName` | Override permission entity name |
|
||||
| `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export |
|
||||
| `customRoutes` | `function` | `null` | Callback to add custom routes |
|
||||
| Option | Type | Default | Description |
|
||||
| ------------------ | ---------- | ------------------ | ------------------------------- |
|
||||
| `permissionEntity` | `string` | `entityName` | Override permission entity name |
|
||||
| `csvFields` | `string[]` | `DBApi.CSV_FIELDS` | Fields to include in CSV export |
|
||||
| `customRoutes` | `function` | `null` | Callback to add custom routes |
|
||||
|
||||
#### Generated Endpoints
|
||||
|
||||
| Method | Endpoint | Description |
|
||||
|--------|----------|-------------|
|
||||
| `POST` | `/` | Create new record |
|
||||
| `POST` | `/bulk-import` | Bulk import from CSV |
|
||||
| `PUT` | `/:id` | Update record by ID |
|
||||
| `DELETE` | `/:id` | Delete record by ID |
|
||||
| `POST` | `/deleteByIds` | Delete multiple records |
|
||||
| `GET` | `/` | List all records (with filters, pagination) |
|
||||
| `GET` | `/count` | Get record count |
|
||||
| `GET` | `/autocomplete` | Get autocomplete suggestions |
|
||||
| `GET` | `/:id` | Get single record by ID |
|
||||
| Method | Endpoint | Description |
|
||||
| -------- | --------------- | ------------------------------------------- |
|
||||
| `POST` | `/` | Create new record |
|
||||
| `POST` | `/bulk-import` | Bulk import from CSV |
|
||||
| `PUT` | `/:id` | Update record by ID |
|
||||
| `DELETE` | `/:id` | Delete record by ID |
|
||||
| `POST` | `/deleteByIds` | Delete multiple records |
|
||||
| `GET` | `/` | List all records (with filters, pagination) |
|
||||
| `GET` | `/count` | Get record count |
|
||||
| `GET` | `/autocomplete` | Get autocomplete suggestions |
|
||||
| `GET` | `/:id` | Get single record by ID |
|
||||
|
||||
#### Implementation
|
||||
|
||||
@ -108,111 +109,153 @@ function createEntityRouter(entityName, Service, DBApi, options = {}) {
|
||||
router.use(checkCrudPermissions(permissionEntity));
|
||||
|
||||
// POST / - Create
|
||||
router.post('/', wrapAsync(async (req, res) => {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
const payload = await Service.create({
|
||||
data: req.body.data,
|
||||
currentUser: req.currentUser,
|
||||
runtimeContext: req.runtimeContext,
|
||||
sendInvitationEmails: true,
|
||||
host: link.host,
|
||||
});
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
router.post(
|
||||
'/',
|
||||
wrapAsync(async (req, res) => {
|
||||
const referer =
|
||||
req.headers.referer ||
|
||||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
const payload = await Service.create({
|
||||
data: req.body.data,
|
||||
currentUser: req.currentUser,
|
||||
runtimeContext: req.runtimeContext,
|
||||
sendInvitationEmails: true,
|
||||
host: link.host,
|
||||
});
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /bulk-import - Bulk CSV import
|
||||
router.post('/bulk-import', wrapAsync(async (req, res) => {
|
||||
const referer = req.headers.referer || `${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
await Service.bulkImport(req, res, true, link.host);
|
||||
res.status(200).send(true);
|
||||
}));
|
||||
router.post(
|
||||
'/bulk-import',
|
||||
wrapAsync(async (req, res) => {
|
||||
const referer =
|
||||
req.headers.referer ||
|
||||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
await Service.bulkImport(req, res, true, link.host);
|
||||
res.status(200).send(true);
|
||||
}),
|
||||
);
|
||||
|
||||
// PUT /:id - Update
|
||||
router.put('/:id', wrapAsync(async (req, res) => {
|
||||
assertRouteIdMatchesBody(req);
|
||||
await Service.update({
|
||||
id: req.params.id,
|
||||
data: req.body.data,
|
||||
currentUser: req.currentUser,
|
||||
runtimeContext: req.runtimeContext,
|
||||
});
|
||||
res.status(200).send(true);
|
||||
}));
|
||||
router.put(
|
||||
'/:id',
|
||||
wrapAsync(async (req, res) => {
|
||||
assertRouteIdMatchesBody(req);
|
||||
await Service.update({
|
||||
id: req.params.id,
|
||||
data: req.body.data,
|
||||
currentUser: req.currentUser,
|
||||
runtimeContext: req.runtimeContext,
|
||||
});
|
||||
res.status(200).send(true);
|
||||
}),
|
||||
);
|
||||
|
||||
// DELETE /:id - Delete single
|
||||
router.delete('/:id', wrapAsync(async (req, res) => {
|
||||
await Service.remove({
|
||||
id: req.params.id,
|
||||
currentUser: req.currentUser,
|
||||
runtimeContext: req.runtimeContext,
|
||||
});
|
||||
res.status(200).send(true);
|
||||
}));
|
||||
router.delete(
|
||||
'/:id',
|
||||
wrapAsync(async (req, res) => {
|
||||
await Service.remove({
|
||||
id: req.params.id,
|
||||
currentUser: req.currentUser,
|
||||
runtimeContext: req.runtimeContext,
|
||||
});
|
||||
res.status(200).send(true);
|
||||
}),
|
||||
);
|
||||
|
||||
// POST /deleteByIds - Delete multiple
|
||||
router.post('/deleteByIds', wrapAsync(async (req, res) => {
|
||||
await Service.deleteByIds({
|
||||
ids: req.body.data,
|
||||
currentUser: req.currentUser,
|
||||
runtimeContext: req.runtimeContext,
|
||||
});
|
||||
res.status(200).send(true);
|
||||
}));
|
||||
router.post(
|
||||
'/deleteByIds',
|
||||
wrapAsync(async (req, res) => {
|
||||
await Service.deleteByIds({
|
||||
ids: req.body.data,
|
||||
currentUser: req.currentUser,
|
||||
runtimeContext: req.runtimeContext,
|
||||
});
|
||||
res.status(200).send(true);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET / - List all with optional CSV export
|
||||
router.get('/', wrapAsync(async (req, res) => {
|
||||
const filetype = req.query.filetype;
|
||||
const currentUser = req.currentUser;
|
||||
const runtimeContext = req.runtimeContext;
|
||||
router.get(
|
||||
'/',
|
||||
wrapAsync(async (req, res) => {
|
||||
const filetype = req.query.filetype;
|
||||
const currentUser = req.currentUser;
|
||||
const runtimeContext = req.runtimeContext;
|
||||
|
||||
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi, {
|
||||
csv: filetype === 'csv',
|
||||
}), { currentUser, runtimeContext });
|
||||
const payload = await DBApi.findAll(
|
||||
normalizeQuery(req.query, DBApi, {
|
||||
csv: filetype === 'csv',
|
||||
}),
|
||||
{ currentUser, runtimeContext },
|
||||
);
|
||||
|
||||
if (filetype === 'csv') {
|
||||
const fields = options.csvFields || DBApi.CSV_FIELDS || ['id', 'createdAt'];
|
||||
const opts = { fields };
|
||||
try {
|
||||
const csv = parse(payload.rows, opts);
|
||||
res.status(200).attachment('export.csv').send(csv);
|
||||
} catch (err) {
|
||||
logger.error({ err, entityName }, 'CSV export error');
|
||||
res.status(500).send('CSV export error');
|
||||
if (filetype === 'csv') {
|
||||
const fields = options.csvFields ||
|
||||
DBApi.CSV_FIELDS || ['id', 'createdAt'];
|
||||
const opts = { fields };
|
||||
try {
|
||||
const csv = parse(payload.rows, opts);
|
||||
res.status(200).attachment('export.csv').send(csv);
|
||||
} catch (err) {
|
||||
logger.error({ err, entityName }, 'CSV export error');
|
||||
res.status(500).send('CSV export error');
|
||||
}
|
||||
} else {
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
} else {
|
||||
res.status(200).send(payload);
|
||||
}
|
||||
}));
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /count - Count only
|
||||
router.get('/count', wrapAsync(async (req, res) => {
|
||||
const currentUser = req.currentUser;
|
||||
const runtimeContext = req.runtimeContext;
|
||||
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), { countOnly: true, currentUser, runtimeContext });
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
router.get(
|
||||
'/count',
|
||||
wrapAsync(async (req, res) => {
|
||||
const currentUser = req.currentUser;
|
||||
const runtimeContext = req.runtimeContext;
|
||||
const payload = await DBApi.findAll(normalizeQuery(req.query, DBApi), {
|
||||
countOnly: true,
|
||||
currentUser,
|
||||
runtimeContext,
|
||||
});
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /autocomplete - Autocomplete search
|
||||
router.get('/autocomplete', wrapAsync(async (req, res) => {
|
||||
const payload = await DBApi.findAllAutocomplete({
|
||||
query: req.query.query,
|
||||
limit,
|
||||
offset: req.query.offset,
|
||||
});
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
router.get(
|
||||
'/autocomplete',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await DBApi.findAllAutocomplete({
|
||||
query: req.query.query,
|
||||
limit,
|
||||
offset: req.query.offset,
|
||||
});
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// GET /:id - Find by ID
|
||||
router.get('/:id', wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send(`Invalid ${entityName} id`);
|
||||
}
|
||||
const runtimeContext = req.runtimeContext;
|
||||
const payload = await DBApi.findBy({ id: req.params.id }, { runtimeContext });
|
||||
res.status(200).send(payload);
|
||||
}));
|
||||
router.get(
|
||||
'/:id',
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send(`Invalid ${entityName} id`);
|
||||
}
|
||||
const runtimeContext = req.runtimeContext;
|
||||
const payload = await DBApi.findBy(
|
||||
{ id: req.params.id },
|
||||
{ runtimeContext },
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// Custom routes hook
|
||||
if (options.customRoutes) {
|
||||
@ -241,10 +284,10 @@ Generic CRUD query safety:
|
||||
|
||||
#### Exports
|
||||
|
||||
| Export | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `createEntityRouter` | `function` | Factory function |
|
||||
| `isUuidV4` | `function` | UUID validation helper |
|
||||
| Export | Type | Description |
|
||||
| -------------------- | ---------- | ---------------------- |
|
||||
| `createEntityRouter` | `function` | Factory function |
|
||||
| `isUuidV4` | `function` | UUID validation helper |
|
||||
|
||||
---
|
||||
|
||||
@ -262,26 +305,26 @@ function createEntityService(DBApi, options = {})
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `DBApi` | `class` | Database API class extending GenericDBApi |
|
||||
| `options` | `object` | Configuration options |
|
||||
| Parameter | Type | Description |
|
||||
| --------- | -------- | ----------------------------------------- |
|
||||
| `DBApi` | `class` | Database API class extending GenericDBApi |
|
||||
| `options` | `object` | Configuration options |
|
||||
|
||||
#### Options
|
||||
|
||||
| Option | Type | Default | Description |
|
||||
|--------|------|---------|-------------|
|
||||
| Option | Type | Default | Description |
|
||||
| ------------ | -------- | ---------- | --------------------------- |
|
||||
| `entityName` | `string` | `'Entity'` | Name used in error messages |
|
||||
|
||||
#### Generated Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction |
|
||||
| `bulkImport(req, res)` | Bulk import from CSV with transaction |
|
||||
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction |
|
||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction |
|
||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction |
|
||||
| Method | Description |
|
||||
| ---------------------------------------------------------------- | ------------------------------------- |
|
||||
| `create({ data, currentUser, transaction, runtimeContext })` | Create record with transaction |
|
||||
| `bulkImport(req, res)` | Bulk import from CSV with transaction |
|
||||
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record with transaction |
|
||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Delete multiple with transaction |
|
||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Delete single with transaction |
|
||||
|
||||
#### Implementation
|
||||
|
||||
@ -296,11 +339,22 @@ function createEntityService(DBApi, options = {}) {
|
||||
const entityName = options.entityName || 'Entity';
|
||||
|
||||
return class GenericService {
|
||||
static async create({ data, currentUser, transaction: externalTransaction, runtimeContext }) {
|
||||
const transaction = externalTransaction || await db.sequelize.transaction();
|
||||
static async create({
|
||||
data,
|
||||
currentUser,
|
||||
transaction: externalTransaction,
|
||||
runtimeContext,
|
||||
}) {
|
||||
const transaction =
|
||||
externalTransaction || (await db.sequelize.transaction());
|
||||
const ownsTransaction = !externalTransaction;
|
||||
try {
|
||||
const record = await DBApi.create({ data, currentUser, transaction, runtimeContext });
|
||||
const record = await DBApi.create({
|
||||
data,
|
||||
currentUser,
|
||||
transaction,
|
||||
runtimeContext,
|
||||
});
|
||||
if (ownsTransaction) await transaction.commit();
|
||||
return record;
|
||||
} catch (error) {
|
||||
@ -340,17 +394,33 @@ function createEntityService(DBApi, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
static async update({ id, data, currentUser, transaction: externalTransaction, runtimeContext }) {
|
||||
const transaction = externalTransaction || await db.sequelize.transaction();
|
||||
static async update({
|
||||
id,
|
||||
data,
|
||||
currentUser,
|
||||
transaction: externalTransaction,
|
||||
runtimeContext,
|
||||
}) {
|
||||
const transaction =
|
||||
externalTransaction || (await db.sequelize.transaction());
|
||||
const ownsTransaction = !externalTransaction;
|
||||
try {
|
||||
const record = await DBApi.findBy({ id }, { transaction, runtimeContext });
|
||||
const record = await DBApi.findBy(
|
||||
{ id },
|
||||
{ transaction, runtimeContext },
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
throw new ValidationError(`${entityName}NotFound`);
|
||||
}
|
||||
|
||||
const updated = await DBApi.update({ id, data, currentUser, transaction, runtimeContext });
|
||||
const updated = await DBApi.update({
|
||||
id,
|
||||
data,
|
||||
currentUser,
|
||||
transaction,
|
||||
runtimeContext,
|
||||
});
|
||||
if (ownsTransaction) await transaction.commit();
|
||||
return updated;
|
||||
} catch (error) {
|
||||
@ -359,11 +429,22 @@ function createEntityService(DBApi, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
static async deleteByIds({ ids, currentUser, transaction: externalTransaction, runtimeContext }) {
|
||||
const transaction = externalTransaction || await db.sequelize.transaction();
|
||||
static async deleteByIds({
|
||||
ids,
|
||||
currentUser,
|
||||
transaction: externalTransaction,
|
||||
runtimeContext,
|
||||
}) {
|
||||
const transaction =
|
||||
externalTransaction || (await db.sequelize.transaction());
|
||||
const ownsTransaction = !externalTransaction;
|
||||
try {
|
||||
await DBApi.deleteByIds({ ids, currentUser, transaction, runtimeContext });
|
||||
await DBApi.deleteByIds({
|
||||
ids,
|
||||
currentUser,
|
||||
transaction,
|
||||
runtimeContext,
|
||||
});
|
||||
if (ownsTransaction) await transaction.commit();
|
||||
} catch (error) {
|
||||
if (ownsTransaction) await transaction.rollback();
|
||||
@ -371,8 +452,14 @@ function createEntityService(DBApi, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
static async remove({ id, currentUser, transaction: externalTransaction, runtimeContext }) {
|
||||
const transaction = externalTransaction || await db.sequelize.transaction();
|
||||
static async remove({
|
||||
id,
|
||||
currentUser,
|
||||
transaction: externalTransaction,
|
||||
runtimeContext,
|
||||
}) {
|
||||
const transaction =
|
||||
externalTransaction || (await db.sequelize.transaction());
|
||||
const ownsTransaction = !externalTransaction;
|
||||
try {
|
||||
await DBApi.remove({ id, currentUser, transaction, runtimeContext });
|
||||
@ -444,7 +531,9 @@ module.exports = class Helpers {
|
||||
|
||||
// UUID v4 validation
|
||||
static isUuidV4(value) {
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value);
|
||||
return /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
value,
|
||||
);
|
||||
}
|
||||
};
|
||||
```
|
||||
@ -505,37 +594,37 @@ The DB API base class that all entity APIs extend. Provides declarative configur
|
||||
|
||||
### Static Getters (Override in Subclasses)
|
||||
|
||||
| Getter | Type | Description |
|
||||
|--------|------|-------------|
|
||||
| `MODEL` | `Model` | Sequelize model reference (required) |
|
||||
| `TABLE_NAME` | `string` | Database table name |
|
||||
| `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) |
|
||||
| `RANGE_FIELDS` | `string[]` | Fields for range filtering |
|
||||
| `ENUM_FIELDS` | `string[]` | Fields for exact match filtering |
|
||||
| `RELATION_FILTERS` | `object[]` | Related entity filters |
|
||||
| `CSV_FIELDS` | `string[]` | Fields for CSV export |
|
||||
| `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete |
|
||||
| `ASSOCIATIONS` | `object[]` | Related entity setters |
|
||||
| `FIND_BY_INCLUDES` | `object[]` | Includes for findBy |
|
||||
| `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll |
|
||||
| `JSON_FIELDS` | `string[]` | Fields to auto-stringify |
|
||||
| `FIELD_TRANSFORMERS` | `object` | Custom field transformers |
|
||||
| `FIELD_DEFAULTS` | `object` | Default values for fields |
|
||||
| Getter | Type | Description |
|
||||
| -------------------- | ---------- | ------------------------------------ |
|
||||
| `MODEL` | `Model` | Sequelize model reference (required) |
|
||||
| `TABLE_NAME` | `string` | Database table name |
|
||||
| `SEARCHABLE_FIELDS` | `string[]` | Fields for text search (ILIKE) |
|
||||
| `RANGE_FIELDS` | `string[]` | Fields for range filtering |
|
||||
| `ENUM_FIELDS` | `string[]` | Fields for exact match filtering |
|
||||
| `RELATION_FILTERS` | `object[]` | Related entity filters |
|
||||
| `CSV_FIELDS` | `string[]` | Fields for CSV export |
|
||||
| `AUTOCOMPLETE_FIELD` | `string` | Field for autocomplete |
|
||||
| `ASSOCIATIONS` | `object[]` | Related entity setters |
|
||||
| `FIND_BY_INCLUDES` | `object[]` | Includes for findBy |
|
||||
| `FIND_ALL_INCLUDES` | `object[]` | Includes for findAll |
|
||||
| `JSON_FIELDS` | `string[]` | Fields to auto-stringify |
|
||||
| `FIELD_TRANSFORMERS` | `object` | Custom field transformers |
|
||||
| `FIELD_DEFAULTS` | `object` | Default values for fields |
|
||||
|
||||
### Methods
|
||||
|
||||
| Method | Description |
|
||||
|--------|-------------|
|
||||
| `getFieldMapping(data)` | Transform input data for database |
|
||||
| `create(data, options)` | Create record |
|
||||
| `bulkImport(data, options)` | Bulk create records |
|
||||
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record |
|
||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple |
|
||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single |
|
||||
| `findBy(where, options)` | Find single by criteria |
|
||||
| `findAll(filter, options)` | Find all with pagination/filters |
|
||||
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
|
||||
| `toCSV(rows)` | Convert to CSV string |
|
||||
| Method | Description |
|
||||
| ---------------------------------------------------------------- | --------------------------------- |
|
||||
| `getFieldMapping(data)` | Transform input data for database |
|
||||
| `create(data, options)` | Create record |
|
||||
| `bulkImport(data, options)` | Bulk create records |
|
||||
| `update({ id, data, currentUser, transaction, runtimeContext })` | Update record |
|
||||
| `deleteByIds({ ids, currentUser, transaction, runtimeContext })` | Soft delete multiple |
|
||||
| `remove({ id, currentUser, transaction, runtimeContext })` | Soft delete single |
|
||||
| `findBy(where, options)` | Find single by criteria |
|
||||
| `findAll(filter, options)` | Find all with pagination/filters |
|
||||
| `findAllAutocomplete({ query, limit, offset }, options)` | Autocomplete search |
|
||||
| `toCSV(rows)` | Convert to CSV string |
|
||||
|
||||
---
|
||||
|
||||
@ -544,6 +633,7 @@ The DB API base class that all entity APIs extend. Provides declarative configur
|
||||
### Basic Entity (Minimal Configuration)
|
||||
|
||||
**Route (assets.ts):**
|
||||
|
||||
```typescript
|
||||
import AssetsDBApi from '../db/api/assets.ts';
|
||||
import { createEntityRouter } from '../factories/router.factory.ts';
|
||||
@ -554,6 +644,7 @@ export default createEntityRouter('assets', AssetsService, AssetsDBApi);
|
||||
```
|
||||
|
||||
**Service (assets.ts):**
|
||||
|
||||
```typescript
|
||||
import AssetsDBApi from '../db/api/assets.ts';
|
||||
import { createEntityService } from '../factories/service.factory.ts';
|
||||
@ -565,6 +656,7 @@ export default createEntityService(AssetsDBApi, {
|
||||
```
|
||||
|
||||
**DB API (assets.js):**
|
||||
|
||||
```javascript
|
||||
const GenericDBApi = require('./base.api');
|
||||
const db = require('../models');
|
||||
@ -607,6 +699,7 @@ module.exports = AssetsDBApi;
|
||||
### Entity with Custom Routes
|
||||
|
||||
**Route (project_element_defaults.ts):**
|
||||
|
||||
```typescript
|
||||
import Service from '../services/project_element_defaults.ts';
|
||||
import DBApi from '../db/api/project_element_defaults.ts';
|
||||
@ -618,22 +711,28 @@ const baseRouter = createEntityRouter(
|
||||
'project_element_defaults',
|
||||
Service,
|
||||
DBApi,
|
||||
{ permissionEntity: 'page_elements' } // Override permission entity
|
||||
{ permissionEntity: 'page_elements' }, // Override permission entity
|
||||
);
|
||||
|
||||
// Add custom endpoint
|
||||
baseRouter.post('/:id/reset', wrapAsync(async (req, res) => {
|
||||
const payload = await Service.resetToGlobal(req.params.id, {
|
||||
currentUser: req.currentUser,
|
||||
});
|
||||
res.status(200).json(payload);
|
||||
}));
|
||||
baseRouter.post(
|
||||
'/:id/reset',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await Service.resetToGlobal(req.params.id, {
|
||||
currentUser: req.currentUser,
|
||||
});
|
||||
res.status(200).json(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
// Add another custom endpoint
|
||||
baseRouter.get('/:id/diff', wrapAsync(async (req, res) => {
|
||||
const payload = await Service.getDiffFromGlobal(req.params.id);
|
||||
res.status(200).json(payload);
|
||||
}));
|
||||
baseRouter.get(
|
||||
'/:id/diff',
|
||||
wrapAsync(async (req, res) => {
|
||||
const payload = await Service.getDiffFromGlobal(req.params.id);
|
||||
res.status(200).json(payload);
|
||||
}),
|
||||
);
|
||||
|
||||
export default baseRouter;
|
||||
```
|
||||
@ -641,6 +740,7 @@ export default baseRouter;
|
||||
### Service with Extended Methods
|
||||
|
||||
**Service (project_element_defaults.ts):**
|
||||
|
||||
```typescript
|
||||
import DBApi from '../db/api/project_element_defaults.ts';
|
||||
import { createEntityService } from '../factories/service.factory.ts';
|
||||
@ -673,16 +773,22 @@ Alternative approach using the options callback:
|
||||
```javascript
|
||||
module.exports = createEntityRouter('entities', Service, DBApi, {
|
||||
customRoutes: (router, Service, DBApi) => {
|
||||
router.post('/:id/custom-action', wrapAsync(async (req, res) => {
|
||||
const result = await Service.customAction(req.params.id);
|
||||
res.status(200).json(result);
|
||||
}));
|
||||
router.post(
|
||||
'/:id/custom-action',
|
||||
wrapAsync(async (req, res) => {
|
||||
const result = await Service.customAction(req.params.id);
|
||||
res.status(200).json(result);
|
||||
}),
|
||||
);
|
||||
|
||||
router.get('/stats', wrapAsync(async (req, res) => {
|
||||
const stats = await DBApi.getStatistics();
|
||||
res.status(200).json(stats);
|
||||
}));
|
||||
}
|
||||
router.get(
|
||||
'/stats',
|
||||
wrapAsync(async (req, res) => {
|
||||
const stats = await DBApi.getStatistics();
|
||||
res.status(200).json(stats);
|
||||
}),
|
||||
);
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
@ -692,50 +798,50 @@ module.exports = createEntityRouter('entities', Service, DBApi, {
|
||||
|
||||
### Entities Using Router Factory (13)
|
||||
|
||||
| Entity | Permission Override | Custom Routes |
|
||||
|--------|--------------------|--------------|
|
||||
| `access_logs` | - | No |
|
||||
| `asset_variants` | - | No |
|
||||
| `assets` | - | No |
|
||||
| `element_type_defaults` | - | No |
|
||||
| `permissions` | - | No |
|
||||
| `presigned_url_requests` | - | No |
|
||||
| `project_audio_tracks` | - | No |
|
||||
| `project_element_defaults` | `page_elements` | Yes (reset, diff) |
|
||||
| `project_memberships` | - | No |
|
||||
| `publish_events` | - | No |
|
||||
| `pwa_caches` | - | No |
|
||||
| `roles` | - | No |
|
||||
| `tour_pages` | - | No |
|
||||
| Entity | Permission Override | Custom Routes |
|
||||
| -------------------------- | ------------------- | ----------------- |
|
||||
| `access_logs` | - | No |
|
||||
| `asset_variants` | - | No |
|
||||
| `assets` | - | No |
|
||||
| `element_type_defaults` | - | No |
|
||||
| `permissions` | - | No |
|
||||
| `presigned_url_requests` | - | No |
|
||||
| `project_audio_tracks` | - | No |
|
||||
| `project_element_defaults` | `page_elements` | Yes (reset, diff) |
|
||||
| `project_memberships` | - | No |
|
||||
| `publish_events` | - | No |
|
||||
| `pwa_caches` | - | No |
|
||||
| `roles` | - | No |
|
||||
| `tour_pages` | - | No |
|
||||
|
||||
### Entities Using Service Factory (11)
|
||||
|
||||
| Entity | Custom Methods |
|
||||
|--------|---------------|
|
||||
| `access_logs` | No |
|
||||
| `asset_variants` | No |
|
||||
| `assets` | No |
|
||||
| `element_type_defaults` | No |
|
||||
| `permissions` | No |
|
||||
| `presigned_url_requests` | No |
|
||||
| `pwa_caches` | No |
|
||||
| `publish_events` | No |
|
||||
| `tour_pages` | No |
|
||||
| Entity | Custom Methods |
|
||||
| -------------------------- | -------------------------------------------------------------- |
|
||||
| `access_logs` | No |
|
||||
| `asset_variants` | No |
|
||||
| `assets` | No |
|
||||
| `element_type_defaults` | No |
|
||||
| `permissions` | No |
|
||||
| `presigned_url_requests` | No |
|
||||
| `pwa_caches` | No |
|
||||
| `publish_events` | No |
|
||||
| `tour_pages` | No |
|
||||
| `project_element_defaults` | Yes (resetToGlobal, getDiffFromGlobal, snapshotGlobalDefaults) |
|
||||
| `project_memberships` | No |
|
||||
| `project_memberships` | No |
|
||||
|
||||
### Entities NOT Using Factories
|
||||
|
||||
Some entities have custom implementations due to specialized requirements:
|
||||
|
||||
| Entity | Reason |
|
||||
|--------|--------|
|
||||
| `users` | Complex auth, password hashing, token management |
|
||||
| `projects` | Publishing workflow, complex business logic |
|
||||
| `auth` | Authentication flows (login, OAuth, password reset) |
|
||||
| `file` | File upload/download, S3/GCloud/Local storage |
|
||||
| `search` | Full-text search across multiple entities |
|
||||
| `publish` | Multi-step publishing workflow |
|
||||
| Entity | Reason |
|
||||
| ---------- | --------------------------------------------------- |
|
||||
| `users` | Complex auth, password hashing, token management |
|
||||
| `projects` | Publishing workflow, complex business logic |
|
||||
| `auth` | Authentication flows (login, OAuth, password reset) |
|
||||
| `file` | File upload/download, S3/GCloud/Local storage |
|
||||
| `search` | Full-text search across multiple entities |
|
||||
| `publish` | Multi-step publishing workflow |
|
||||
|
||||
---
|
||||
|
||||
@ -797,15 +903,19 @@ HTTP Response
|
||||
## Design Patterns
|
||||
|
||||
### Factory Pattern
|
||||
|
||||
Both `createEntityRouter` and `createEntityService` implement the Factory pattern, creating objects (router, service class) without specifying their exact classes.
|
||||
|
||||
### Template Method Pattern
|
||||
|
||||
`GenericDBApi.getFieldMapping()` uses the Template Method pattern - the base class defines the algorithm skeleton, while subclasses can override specific steps via static getters (`JSON_FIELDS`, `FIELD_TRANSFORMERS`, `FIELD_DEFAULTS`).
|
||||
|
||||
### Strategy Pattern
|
||||
|
||||
The permission checking system uses Strategy pattern - different entities can have different permission strategies by overriding `permissionEntity` option.
|
||||
|
||||
### Decorator Pattern
|
||||
|
||||
The router factory decorates Express routers with permission middleware and error handling.
|
||||
|
||||
---
|
||||
@ -815,6 +925,7 @@ The router factory decorates Express routers with permission middleware and erro
|
||||
### When to Use Factories
|
||||
|
||||
Use factories when:
|
||||
|
||||
- Entity requires standard CRUD operations
|
||||
- No complex business logic beyond data transformation
|
||||
- Permissions follow standard READ/CREATE/UPDATE/DELETE pattern
|
||||
@ -823,6 +934,7 @@ Use factories when:
|
||||
### When NOT to Use Factories
|
||||
|
||||
Don't use factories when:
|
||||
|
||||
- Complex multi-step workflows (use custom service)
|
||||
- Special authentication (OAuth flows, password reset)
|
||||
- External API integration (file storage, AI)
|
||||
|
||||
@ -5,13 +5,14 @@
|
||||
The Middleware module provides cross-cutting concerns for the Express application including rate limiting, permission checking, runtime context management, file uploads, and public access control.
|
||||
|
||||
**Files:**
|
||||
| File | Lines | Purpose |
|
||||
|------|-------|---------|
|
||||
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
|
||||
|
||||
| File | Lines | Purpose |
|
||||
| -------------------------------------- | --------------------------------------------- | ------------------------------------------------------- |
|
||||
| `src/middlewares/rateLimiter.js` | 268 | Configurable rate limiting with in-memory store |
|
||||
| `src/middlewares/check-permissions.ts` | RBAC permission checking through AccessPolicy |
|
||||
| `src/middlewares/runtime-context.ts` | 34 | Runtime environment context from headers |
|
||||
| `src/middlewares/runtime-public.ts` | 200 | Public runtime access control and response sanitization |
|
||||
| `src/middlewares/upload.ts` | 34 | Multer-based file upload handling |
|
||||
| `src/middlewares/runtime-context.ts` | 34 | Runtime environment context from headers |
|
||||
| `src/middlewares/runtime-public.ts` | 200 | Public runtime access control and response sanitization |
|
||||
| `src/middlewares/upload.ts` | 34 | Multer-based file upload handling |
|
||||
|
||||
---
|
||||
|
||||
@ -126,19 +127,21 @@ setInterval(() => {
|
||||
Creates a configurable rate limiter middleware.
|
||||
|
||||
**Parameters:**
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
|
||||
| `windowMs` | number | `900000` (15min) | Time window in milliseconds |
|
||||
| `max` | number | `100` | Maximum requests per window |
|
||||
| `message` | string | `'Too many requests...'` | Error message on limit |
|
||||
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
|
||||
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
|
||||
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
| -------------------- | -------- | ------------------------ | -------------------------------------- |
|
||||
| `keyPrefix` | string | `'rate-limit'` | Prefix for rate limit keys |
|
||||
| `windowMs` | number | `900000` (15min) | Time window in milliseconds |
|
||||
| `max` | number | `100` | Maximum requests per window |
|
||||
| `message` | string | `'Too many requests...'` | Error message on limit |
|
||||
| `skipFailedRequests` | boolean | `false` | Don't count 4xx/5xx responses |
|
||||
| `keyGenerator` | function | `null` | Custom key generator `(req) => string` |
|
||||
| `skip` | function | `null` | Skip rate limiting `(req) => boolean` |
|
||||
|
||||
**Returns:** Express middleware function
|
||||
|
||||
**Response Headers:**
|
||||
|
||||
```
|
||||
X-RateLimit-Limit: 100
|
||||
X-RateLimit-Remaining: 99
|
||||
@ -147,6 +150,7 @@ Retry-After: 300 (only when limit exceeded)
|
||||
```
|
||||
|
||||
**Rate Limit Exceeded Response (429):**
|
||||
|
||||
```json
|
||||
{
|
||||
"error": "Too Many Requests",
|
||||
@ -174,14 +178,14 @@ const createAuthenticatedRateLimiter = (options = {}) => {
|
||||
|
||||
#### Pre-configured Limiters
|
||||
|
||||
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
|
||||
|---------|------------|--------|-----|-------------|----------|
|
||||
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
|
||||
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
|
||||
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
|
||||
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
|
||||
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
|
||||
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
|
||||
| Limiter | Key Prefix | Window | Max | Skip Failed | Use Case |
|
||||
| ---------------------- | ---------------- | ------ | --- | ----------- | -------------- |
|
||||
| `authLimiter` | `auth` | 15 min | 10 | No | Login attempts |
|
||||
| `passwordResetLimiter` | `password-reset` | 1 hour | 5 | No | Password reset |
|
||||
| `apiLimiter` | `api` | 1 min | 100 | Yes | General API |
|
||||
| `uploadLimiter` | `upload` | 1 min | 10 | No | File uploads |
|
||||
| `downloadLimiter` | `download` | 1 min | 200 | Yes | File downloads |
|
||||
| `searchLimiter` | `search` | 1 min | 30 | No | Search queries |
|
||||
|
||||
#### Route Mapping
|
||||
|
||||
@ -238,6 +242,7 @@ fetchAndCachePublicRole();
|
||||
Creates middleware that checks if user has specific permission.
|
||||
|
||||
**Permission Check Flow:**
|
||||
|
||||
```
|
||||
1. AccessPolicy.hasPermission(user, permission)
|
||||
├── Public users are always denied admin API permissions
|
||||
@ -254,6 +259,7 @@ to `GET`, `PUT`, and `PATCH` on the authenticated user's own `/api/users/:id`
|
||||
route in `checkCrudPermissions`.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const { checkPermissions } = require('./middlewares/check-permissions');
|
||||
|
||||
@ -265,6 +271,7 @@ router.get('/users', checkPermissions('READ_USERS'), handler);
|
||||
```
|
||||
|
||||
**Error Response (403):**
|
||||
|
||||
```json
|
||||
{
|
||||
"message": "Forbidden"
|
||||
@ -276,17 +283,19 @@ router.get('/users', checkPermissions('READ_USERS'), handler);
|
||||
Creates middleware that maps HTTP method to CRUD permission.
|
||||
|
||||
**Method Mapping:**
|
||||
|
||||
| HTTP Method | Permission Prefix |
|
||||
|-------------|-------------------|
|
||||
| `POST` | `CREATE_` |
|
||||
| `GET` | `READ_` |
|
||||
| `PUT` | `UPDATE_` |
|
||||
| `PATCH` | `UPDATE_` |
|
||||
| `DELETE` | `DELETE_` |
|
||||
| ----------- | ----------------- |
|
||||
| `POST` | `CREATE_` |
|
||||
| `GET` | `READ_` |
|
||||
| `PUT` | `UPDATE_` |
|
||||
| `PATCH` | `UPDATE_` |
|
||||
| `DELETE` | `DELETE_` |
|
||||
|
||||
**Permission Name Format:** `{METHOD}_{ENTITY}`
|
||||
|
||||
Examples:
|
||||
|
||||
- `GET /api/users` → `READ_USERS`
|
||||
- `POST /api/projects` → `CREATE_PROJECTS`
|
||||
- `DELETE /api/assets/123` → `DELETE_ASSETS`
|
||||
@ -300,6 +309,7 @@ is "use inherited defaults", so those routes require `UPDATE_PAGE_ELEMENTS`
|
||||
rather than `DELETE_PAGE_ELEMENTS`.
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const { checkCrudPermissions } = require('./middlewares/check-permissions');
|
||||
|
||||
@ -352,7 +362,7 @@ For public read bypass to work, the middleware that sets `req.isRuntimePublicReq
|
||||
```javascript
|
||||
// ❌ WRONG - allowPublicRead runs AFTER checkCrudPermissions
|
||||
router.use(checkCrudPermissions('entity'));
|
||||
router.get('/', allowPublicRead, handler); // Too late!
|
||||
router.get('/', allowPublicRead, handler); // Too late!
|
||||
|
||||
// ✅ CORRECT - allowPublicRead runs BEFORE checkCrudPermissions
|
||||
router.use(allowPublicRead);
|
||||
@ -373,22 +383,25 @@ Middleware that extracts runtime environment context from request headers.
|
||||
Reads environment and project slug from headers for route-based access.
|
||||
|
||||
**Headers:**
|
||||
| Header | Values | Description |
|
||||
|--------|--------|-------------|
|
||||
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
|
||||
| `X-Runtime-Project-Slug` | string | Project identifier |
|
||||
|
||||
| Header | Values | Description |
|
||||
| ------------------------ | ---------------------------- | ------------------- |
|
||||
| `X-Runtime-Environment` | `production`, `stage`, `dev` | Content environment |
|
||||
| `X-Runtime-Project-Slug` | string | Project identifier |
|
||||
|
||||
**Context Object:**
|
||||
|
||||
```javascript
|
||||
req.runtimeContext = {
|
||||
mode: 'admin', // Default mode
|
||||
projectSlug: null, // Extracted from path or header
|
||||
headerEnvironment: 'production', // From X-Runtime-Environment
|
||||
headerProjectSlug: 'my-tour' // From X-Runtime-Project-Slug
|
||||
mode: 'admin', // Default mode
|
||||
projectSlug: null, // Extracted from path or header
|
||||
headerEnvironment: 'production', // From X-Runtime-Environment
|
||||
headerProjectSlug: 'my-tour', // From X-Runtime-Project-Slug
|
||||
};
|
||||
```
|
||||
|
||||
**Usage in Routes:**
|
||||
|
||||
```javascript
|
||||
// index.js
|
||||
app.use(runtimeContextMiddleware);
|
||||
@ -401,11 +414,12 @@ if (env === 'production') {
|
||||
```
|
||||
|
||||
**Route-Based Environment Access:**
|
||||
| Route | Environment | Access |
|
||||
|-------|-------------|--------|
|
||||
| `/p/[slug]` | `production` | Public (no auth) |
|
||||
| `/p/[slug]/stage` | `stage` | Authenticated only |
|
||||
| `/constructor?projectId=` | `dev` | Authenticated only |
|
||||
|
||||
| Route | Environment | Access |
|
||||
| ------------------------- | ------------ | ------------------ |
|
||||
| `/p/[slug]` | `production` | Public (no auth) |
|
||||
| `/p/[slug]/stage` | `stage` | Authenticated only |
|
||||
| `/constructor?projectId=` | `dev` | Authenticated only |
|
||||
|
||||
---
|
||||
|
||||
@ -420,16 +434,41 @@ Only these fields are returned for public runtime requests:
|
||||
```javascript
|
||||
const PUBLIC_RUNTIME_ENTITY_FIELDS = {
|
||||
projects: [
|
||||
'id', 'name', 'slug', 'description', 'logo_url', 'favicon_url', 'og_image_url',
|
||||
'id',
|
||||
'name',
|
||||
'slug',
|
||||
'description',
|
||||
'logo_url',
|
||||
'favicon_url',
|
||||
'og_image_url',
|
||||
],
|
||||
tour_pages: [
|
||||
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
|
||||
'sort_order', 'background_image_url', 'background_video_url',
|
||||
'background_audio_url', 'background_loop', 'requires_auth', 'ui_schema_json',
|
||||
'id',
|
||||
'projectId',
|
||||
'environment',
|
||||
'source_key',
|
||||
'name',
|
||||
'slug',
|
||||
'sort_order',
|
||||
'background_image_url',
|
||||
'background_video_url',
|
||||
'background_audio_url',
|
||||
'background_loop',
|
||||
'requires_auth',
|
||||
'ui_schema_json',
|
||||
],
|
||||
project_audio_tracks: [
|
||||
'id', 'projectId', 'environment', 'source_key', 'name', 'slug',
|
||||
'url', 'loop', 'volume', 'sort_order', 'is_enabled',
|
||||
'id',
|
||||
'projectId',
|
||||
'environment',
|
||||
'source_key',
|
||||
'name',
|
||||
'slug',
|
||||
'url',
|
||||
'loop',
|
||||
'volume',
|
||||
'sort_order',
|
||||
'is_enabled',
|
||||
],
|
||||
};
|
||||
```
|
||||
@ -459,10 +498,12 @@ const blockNonPublicRuntimeListEndpoints = (req, res, next) => {
|
||||
```
|
||||
|
||||
**Blocked:**
|
||||
|
||||
- Individual record access: `GET /api/projects/123` → 404
|
||||
- CSV exports: `GET /api/projects?filetype=csv` → 404
|
||||
|
||||
**Allowed:**
|
||||
|
||||
- List endpoints: `GET /api/projects/` → Continue
|
||||
|
||||
#### Function: sanitizePublicRuntimeListResponse(entityName)
|
||||
@ -491,27 +532,33 @@ const sanitizePublicRuntimeListResponse = (entityName) => {
|
||||
```
|
||||
|
||||
**Before Sanitization:**
|
||||
|
||||
```json
|
||||
{
|
||||
"rows": [{
|
||||
"id": "123",
|
||||
"name": "My Tour",
|
||||
"slug": "my-tour",
|
||||
"createdAt": "2024-01-01",
|
||||
"createdById": "user-456",
|
||||
"internalNotes": "sensitive data"
|
||||
}]
|
||||
"rows": [
|
||||
{
|
||||
"id": "123",
|
||||
"name": "My Tour",
|
||||
"slug": "my-tour",
|
||||
"createdAt": "2024-01-01",
|
||||
"createdById": "user-456",
|
||||
"internalNotes": "sensitive data"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
**After Sanitization:**
|
||||
|
||||
```json
|
||||
{
|
||||
"rows": [{
|
||||
"id": "123",
|
||||
"name": "My Tour",
|
||||
"slug": "my-tour"
|
||||
}]
|
||||
"rows": [
|
||||
{
|
||||
"id": "123",
|
||||
"name": "My Tour",
|
||||
"slug": "my-tour"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
@ -552,13 +599,15 @@ module.exports = processFileMiddleware;
|
||||
```
|
||||
|
||||
**Configuration:**
|
||||
| Setting | Value |
|
||||
|---------|-------|
|
||||
| Storage | Memory (Buffer) |
|
||||
| Field Name | `file` |
|
||||
| Max Files | 1 (single) |
|
||||
|
||||
| Setting | Value |
|
||||
| ---------- | --------------- |
|
||||
| Storage | Memory (Buffer) |
|
||||
| Field Name | `file` |
|
||||
| Max Files | 1 (single) |
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const upload = require('./middlewares/upload');
|
||||
|
||||
@ -729,8 +778,8 @@ Content-Type: multipart/form-data
|
||||
|
||||
### Environment Variables
|
||||
|
||||
| Variable | Affects | Description |
|
||||
|----------|---------|-------------|
|
||||
| Variable | Affects | Description |
|
||||
| ---------- | ------------- | ----------------------------- |
|
||||
| `NODE_ENV` | Rate limiting | Skip localhost in development |
|
||||
|
||||
### Constants
|
||||
@ -749,8 +798,12 @@ const METHOD_MAP = {
|
||||
};
|
||||
|
||||
const RUNTIME_PUBLIC_READ_ENTITIES = new Set([
|
||||
'PROJECTS', 'TOUR_PAGES', 'PAGE_ELEMENTS',
|
||||
'PAGE_LINKS', 'TRANSITIONS', 'PROJECT_AUDIO_TRACKS',
|
||||
'PROJECTS',
|
||||
'TOUR_PAGES',
|
||||
'PAGE_ELEMENTS',
|
||||
'PAGE_LINKS',
|
||||
'TRANSITIONS',
|
||||
'PROJECT_AUDIO_TRACKS',
|
||||
]);
|
||||
|
||||
// runtime-public.ts
|
||||
@ -761,12 +814,13 @@ const PUBLIC_RUNTIME_ALLOWED_PATH = '/';
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `multer` | ^1.4.5 | Multipart form data parsing |
|
||||
| `util` | built-in | Promisify multer |
|
||||
| Package | Version | Purpose |
|
||||
| -------- | -------- | --------------------------- |
|
||||
| `multer` | ^1.4.5 | Multipart form data parsing |
|
||||
| `util` | built-in | Promisify multer |
|
||||
|
||||
**Internal Dependencies:**
|
||||
|
||||
- `../utils/logger` - Pino logger for rate limit logging
|
||||
- `../services/notifications/errors/validation` - ValidationError class
|
||||
- `../db/api/roles` - RolesDBApi for Public role
|
||||
@ -835,6 +889,7 @@ The Middleware module provides:
|
||||
5. **upload.ts** - Simple Multer-based file upload
|
||||
|
||||
**Key Features:**
|
||||
|
||||
- Configurable rate limiting per endpoint type
|
||||
- Role-based permission checking with method-to-CRUD mapping
|
||||
- Public runtime access for production presentations
|
||||
|
||||
@ -107,7 +107,8 @@ const errors = {
|
||||
error: 'Email not recognized',
|
||||
},
|
||||
passwordUpdate: {
|
||||
samePassword: "You can't use the same password. Please create new password",
|
||||
samePassword:
|
||||
"You can't use the same password. Please create new password",
|
||||
},
|
||||
userNotVerified: 'Sorry, your email has not been verified yet',
|
||||
emailAddressVerificationEmail: {
|
||||
@ -133,7 +134,8 @@ const errors = {
|
||||
errors: {
|
||||
invalidFileEmpty: 'The file is empty',
|
||||
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
|
||||
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.',
|
||||
invalidFileUpload:
|
||||
'Invalid file. Make sure you are using the last version of the template.',
|
||||
importHashRequired: 'Import hash is required',
|
||||
importHashExistent: 'Data has already been imported',
|
||||
userEmailMissing: 'Some items in the CSV do not have an email',
|
||||
@ -173,14 +175,14 @@ module.exports = errors;
|
||||
|
||||
### Message Categories
|
||||
|
||||
| Category | Purpose | Example Key |
|
||||
|----------|---------|-------------|
|
||||
| `app` | Application metadata | `app.title` |
|
||||
| `auth` | Authentication errors | `auth.userNotFound` |
|
||||
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
|
||||
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
|
||||
| `errors` | Generic error messages | `errors.validation.message` |
|
||||
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
|
||||
| Category | Purpose | Example Key |
|
||||
| ---------- | -------------------------- | ---------------------------------- |
|
||||
| `app` | Application metadata | `app.title` |
|
||||
| `auth` | Authentication errors | `auth.userNotFound` |
|
||||
| `iam` | Identity/access management | `iam.errors.userAlreadyExists` |
|
||||
| `importer` | CSV/file import errors | `importer.errors.invalidFileEmpty` |
|
||||
| `errors` | Generic error messages | `errors.validation.message` |
|
||||
| `emails` | Email subjects/bodies | `emails.invitation.subject` |
|
||||
|
||||
---
|
||||
|
||||
@ -228,7 +230,7 @@ const getNotification = (key, ...args) => {
|
||||
const message = _get(errors, key);
|
||||
|
||||
if (!message) {
|
||||
return key; // Return raw key as fallback
|
||||
return key; // Return raw key as fallback
|
||||
}
|
||||
|
||||
return format(message, args);
|
||||
@ -252,8 +254,8 @@ getNotification('emails.invitation.subject', 'Tour Builder Platform');
|
||||
// → "You've been invited to Tour Builder Platform"
|
||||
|
||||
// Check if key exists
|
||||
isNotification('auth.userNotFound'); // → true
|
||||
isNotification('custom.message'); // → false
|
||||
isNotification('auth.userNotFound'); // → true
|
||||
isNotification('custom.message'); // → false
|
||||
|
||||
// Unknown key returns the key itself
|
||||
getNotification('unknown.key');
|
||||
@ -290,10 +292,12 @@ module.exports = class ValidationError extends Error {
|
||||
```
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `message` - Human-readable error message
|
||||
- `code` - HTTP status code (400)
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const ValidationError = require('./notifications/errors/validation');
|
||||
|
||||
@ -336,10 +340,12 @@ module.exports = class ForbiddenError extends Error {
|
||||
```
|
||||
|
||||
**Properties:**
|
||||
|
||||
- `message` - Human-readable error message
|
||||
- `code` - HTTP status code (403)
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const ForbiddenError = require('./notifications/errors/forbidden');
|
||||
|
||||
@ -358,62 +364,62 @@ throw new ForbiddenError();
|
||||
|
||||
### Authentication Messages (`auth.*`)
|
||||
|
||||
| Key | Message | Used In |
|
||||
|-----|---------|---------|
|
||||
| `auth.userDisabled` | "Your account is disabled" | signin |
|
||||
| `auth.forbidden` | "Forbidden" | authorization failures |
|
||||
| `auth.unauthorized` | "Unauthorized" | missing authentication |
|
||||
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
|
||||
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
|
||||
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
|
||||
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
|
||||
| `auth.invalidEmail` | "Please provide a valid email" | signup |
|
||||
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin |
|
||||
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset |
|
||||
| `auth.passwordReset.error` | "Email not recognized" | password reset request |
|
||||
| `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update |
|
||||
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification |
|
||||
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification |
|
||||
| Key | Message | Used In |
|
||||
| ------------------------------------------------- | --------------------------------------------------- | ----------------------- |
|
||||
| `auth.userDisabled` | "Your account is disabled" | signin |
|
||||
| `auth.forbidden` | "Forbidden" | authorization failures |
|
||||
| `auth.unauthorized` | "Unauthorized" | missing authentication |
|
||||
| `auth.userNotFound` | "Sorry, we don't recognize your credentials" | signin |
|
||||
| `auth.wrongPassword` | "Sorry, we don't recognize your credentials" | signin, password update |
|
||||
| `auth.weakPassword` | "This password is too weak" | signup, password reset |
|
||||
| `auth.emailAlreadyInUse` | "Email is already in use" | signup |
|
||||
| `auth.invalidEmail` | "Please provide a valid email" | signup |
|
||||
| `auth.userNotVerified` | "Sorry, your email has not been verified yet" | signin |
|
||||
| `auth.passwordReset.invalidToken` | "Password reset link is invalid or has expired" | password reset |
|
||||
| `auth.passwordReset.error` | "Email not recognized" | password reset request |
|
||||
| `auth.passwordUpdate.samePassword` | "You can't use the same password..." | password update |
|
||||
| `auth.emailAddressVerificationEmail.invalidToken` | "Email verification link is invalid or has expired" | email verification |
|
||||
| `auth.emailAddressVerificationEmail.error` | "Email not recognized" | email verification |
|
||||
|
||||
### IAM Messages (`iam.errors.*`)
|
||||
|
||||
| Key | Message | Used In |
|
||||
|-----|---------|---------|
|
||||
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
|
||||
| `iam.errors.userNotFound` | "User not found" | user update/delete |
|
||||
| `iam.errors.disablingHimself` | "You can't disable yourself" | user disable |
|
||||
| `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke |
|
||||
| `iam.errors.deletingHimself` | "You can't delete yourself" | user delete |
|
||||
| `iam.errors.emailRequired` | "Email is required" | user creation |
|
||||
| `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update |
|
||||
| `iam.errors.searchQueryRequired` | "Search query is required" | search |
|
||||
| Key | Message | Used In |
|
||||
| ---------------------------------- | ------------------------------------------------ | --------------------- |
|
||||
| `iam.errors.userAlreadyExists` | "User with this email already exists" | user creation |
|
||||
| `iam.errors.userNotFound` | "User not found" | user update/delete |
|
||||
| `iam.errors.disablingHimself` | "You can't disable yourself" | user disable |
|
||||
| `iam.errors.revokingOwnPermission` | "You can't revoke your own owner permission" | permission revoke |
|
||||
| `iam.errors.deletingHimself` | "You can't delete yourself" | user delete |
|
||||
| `iam.errors.emailRequired` | "Email is required" | user creation |
|
||||
| `iam.errors.slugAlreadyExists` | "This slug is already in use by another project" | project create/update |
|
||||
| `iam.errors.searchQueryRequired` | "Search query is required" | search |
|
||||
|
||||
### Importer Messages (`importer.errors.*`)
|
||||
|
||||
| Key | Message | Used In |
|
||||
|-----|---------|---------|
|
||||
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
|
||||
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
|
||||
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
|
||||
| `importer.errors.importHashRequired` | "Import hash is required" | bulk import |
|
||||
| `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import |
|
||||
| `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import |
|
||||
| Key | Message | Used In |
|
||||
| ------------------------------------ | -------------------------------------------- | ---------------- |
|
||||
| `importer.errors.invalidFileEmpty` | "The file is empty" | CSV import |
|
||||
| `importer.errors.invalidFileExcel` | "Only excel (.xlsx) files are allowed" | file import |
|
||||
| `importer.errors.invalidFileUpload` | "Invalid file..." | file import |
|
||||
| `importer.errors.importHashRequired` | "Import hash is required" | bulk import |
|
||||
| `importer.errors.importHashExistent` | "Data has already been imported" | duplicate import |
|
||||
| `importer.errors.userEmailMissing` | "Some items in the CSV do not have an email" | user CSV import |
|
||||
|
||||
### Generic Messages (`errors.*`)
|
||||
|
||||
| Key | Message | Used In |
|
||||
|-----|---------|---------|
|
||||
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
|
||||
| `errors.validation.message` | "An error occurred" | ValidationError default |
|
||||
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
|
||||
| Key | Message | Used In |
|
||||
| ------------------------------------ | -------------------------- | ----------------------- |
|
||||
| `errors.forbidden.message` | "Forbidden" | ForbiddenError default |
|
||||
| `errors.validation.message` | "An error occurred" | ValidationError default |
|
||||
| `errors.searchQueryRequired.message` | "Search query is required" | search validation |
|
||||
|
||||
### Email Messages (`emails.*`)
|
||||
|
||||
| Key | Message | Used In |
|
||||
|-----|---------|---------|
|
||||
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
|
||||
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
|
||||
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
|
||||
| Key | Message | Used In |
|
||||
| ----------------------------------------- | ----------------------------- | ------------------ |
|
||||
| `emails.invitation.subject` | "You've been invited to {0}" | user invitation |
|
||||
| `emails.emailAddressVerification.subject` | "Verify your email for {0}" | email verification |
|
||||
| `emails.passwordReset.subject` | "Reset your password for {0}" | password reset |
|
||||
|
||||
---
|
||||
|
||||
@ -421,27 +427,27 @@ throw new ForbiddenError();
|
||||
|
||||
### Services Using Notifications
|
||||
|
||||
| Service | Errors Used | Common Keys |
|
||||
|---------|-------------|-------------|
|
||||
| `auth.js` | ValidationError, ForbiddenError | auth.*, iam.* |
|
||||
| `users.ts` | ValidationError | iam.errors.* |
|
||||
| `projects.ts` | ValidationError | projectsNotFound |
|
||||
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
|
||||
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
|
||||
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
|
||||
| Service | Errors Used | Common Keys |
|
||||
| ------------------------- | ------------------------------- | ------------------------------------------------ |
|
||||
| `auth.js` | ValidationError, ForbiddenError | auth._, iam._ |
|
||||
| `users.ts` | ValidationError | iam.errors.* |
|
||||
| `projects.ts` | ValidationError | projectsNotFound |
|
||||
| `roles.ts` | ValidationError | rolesNotFound, Public role permission validation |
|
||||
| `search.js` | ValidationError | auth.unauthorized, auth.forbidden |
|
||||
| `project_audio_tracks.ts` | ValidationError | project_audio_tracksNotFound |
|
||||
|
||||
### Email Templates Using Notifications
|
||||
|
||||
| Template | Helper Usage |
|
||||
|----------|--------------|
|
||||
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
|
||||
| Template | Helper Usage |
|
||||
| ------------------------ | ------------------------------------------------------------ |
|
||||
| `passwordReset.js` | `getNotification('emails.passwordReset.subject')` |
|
||||
| `addressVerification.js` | `getNotification('emails.emailAddressVerification.subject')` |
|
||||
| `invitation.js` | `getNotification('emails.invitation.subject')` |
|
||||
| `invitation.js` | `getNotification('emails.invitation.subject')` |
|
||||
|
||||
### Middleware Using Notifications
|
||||
|
||||
| Middleware | Error Used | Purpose |
|
||||
|------------|------------|---------|
|
||||
| Middleware | Error Used | Purpose |
|
||||
| ---------------------- | --------------- | --------------------------- |
|
||||
| `check-permissions.ts` | ValidationError | Permission denied responses |
|
||||
|
||||
### Service Factory Using Notifications
|
||||
@ -596,13 +602,13 @@ class ValidationError extends AppError {
|
||||
|
||||
### When to Use Which
|
||||
|
||||
| Scenario | Use |
|
||||
|----------|-----|
|
||||
| Scenario | Use |
|
||||
| ----------------------------- | -------------------------------------- |
|
||||
| Service business logic errors | `notifications/errors/ValidationError` |
|
||||
| Authorization failures | `notifications/errors/ForbiddenError` |
|
||||
| Email subject/body text | `getNotification()` |
|
||||
| Low-level utility errors | `utils/errors.js` |
|
||||
| Errors needing details object | `utils/errors.js` |
|
||||
| Authorization failures | `notifications/errors/ForbiddenError` |
|
||||
| Email subject/body text | `getNotification()` |
|
||||
| Low-level utility errors | `utils/errors.js` |
|
||||
| Errors needing details object | `utils/errors.js` |
|
||||
|
||||
---
|
||||
|
||||
@ -619,7 +625,8 @@ const notifications = {
|
||||
errors: {
|
||||
notFound: 'Project not found',
|
||||
slugTaken: 'A project with this slug already exists',
|
||||
invalidSlug: 'Project slug must contain only letters, numbers, and hyphens',
|
||||
invalidSlug:
|
||||
'Project slug must contain only letters, numbers, and hyphens',
|
||||
},
|
||||
},
|
||||
};
|
||||
@ -704,13 +711,13 @@ const { getNotification, isNotification } = require('./helpers');
|
||||
describe('getNotification', () => {
|
||||
it('should return message for valid key', () => {
|
||||
expect(getNotification('auth.userNotFound')).toBe(
|
||||
"Sorry, we don't recognize your credentials"
|
||||
"Sorry, we don't recognize your credentials",
|
||||
);
|
||||
});
|
||||
|
||||
it('should substitute parameters', () => {
|
||||
expect(getNotification('emails.invitation.subject', 'My App')).toBe(
|
||||
"You've been invited to My App"
|
||||
"You've been invited to My App",
|
||||
);
|
||||
});
|
||||
|
||||
@ -734,9 +741,9 @@ describe('isNotification', () => {
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Version | Purpose |
|
||||
|---------|---------|---------|
|
||||
| `lodash/get` | ^4.x | Deep object property access |
|
||||
| Package | Version | Purpose |
|
||||
| ------------ | ------- | --------------------------- |
|
||||
| `lodash/get` | ^4.x | Deep object property access |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -7,32 +7,33 @@ The Routes module defines all HTTP endpoints for the application. It uses a fact
|
||||
**Directory:** `src/routes/`
|
||||
|
||||
**Files (25 total):**
|
||||
| File | Lines | Pattern | Description |
|
||||
|------|-------|---------|-------------|
|
||||
| `auth.ts` | 327 | Custom | Authentication endpoints |
|
||||
| `file.ts` | 150 | Custom | File upload/download endpoints |
|
||||
| `publish.ts` | 107 | Custom | Publishing workflow endpoints |
|
||||
| `search.ts` | 64 | Custom | Global search endpoint |
|
||||
| `runtime-context.ts` | 16 | Custom | Runtime context inspection |
|
||||
| `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint |
|
||||
| `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID |
|
||||
| `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status |
|
||||
| `roles.ts` | 141 | Factory | Roles CRUD via factory |
|
||||
| `permissions.ts` | 188 | Factory | Permissions CRUD via factory |
|
||||
| `assets.ts` | 155 | Factory | Assets CRUD via factory |
|
||||
| `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory |
|
||||
| `access_logs.ts` | 145 | Factory | Access logs CRUD via factory |
|
||||
| `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory |
|
||||
| `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory |
|
||||
| `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults |
|
||||
| `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults |
|
||||
| `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides |
|
||||
| `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides |
|
||||
| `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory |
|
||||
| `publish_events.ts` | 157 | Factory | Publish events CRUD via factory |
|
||||
| `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory |
|
||||
| `element_type_defaults.ts` | 12 | Factory | Element type defaults via factory |
|
||||
| `project_element_defaults.ts` | 92 | Hybrid | Project element defaults CRUD + custom (reset, diff) |
|
||||
|
||||
| File | Lines | Pattern | Description |
|
||||
| -------------------------------- | ----- | ----------- | ------------------------------------------------------------- |
|
||||
| `auth.ts` | 327 | Custom | Authentication endpoints |
|
||||
| `file.ts` | 150 | Custom | File upload/download endpoints |
|
||||
| `publish.ts` | 107 | Custom | Publishing workflow endpoints |
|
||||
| `search.ts` | 64 | Custom | Global search endpoint |
|
||||
| `runtime-context.ts` | 16 | Custom | Runtime context inspection |
|
||||
| `projects.ts` | 46 | Hybrid | Projects CRUD + custom clone endpoint |
|
||||
| `users.ts` | 64 | Hybrid | Users CRUD via factory + sanitized GET by ID |
|
||||
| `tour_pages.ts` | 380 | Manual CRUD | Tour pages CRUD plus reorder, duplicate, reverse-video status |
|
||||
| `roles.ts` | 141 | Factory | Roles CRUD via factory |
|
||||
| `permissions.ts` | 188 | Factory | Permissions CRUD via factory |
|
||||
| `assets.ts` | 155 | Factory | Assets CRUD via factory |
|
||||
| `asset_variants.ts` | 147 | Factory | Asset variants CRUD via factory |
|
||||
| `access_logs.ts` | 145 | Factory | Access logs CRUD via factory |
|
||||
| `project_memberships.ts` | 145 | Factory | Project memberships CRUD via factory |
|
||||
| `project_audio_tracks.ts` | 151 | Factory | Project audio tracks CRUD via factory |
|
||||
| `global_transition_defaults.ts` | 145 | Custom | Runtime-readable global transition defaults |
|
||||
| `global_ui_control_defaults.ts` | 79 | Custom | Runtime-readable global UI-control defaults |
|
||||
| `project_transition_settings.ts` | 212 | Custom | Project/environment transition overrides |
|
||||
| `project_ui_control_settings.ts` | 125 | Custom | Project/environment global UI-control overrides |
|
||||
| `presigned_url_requests.ts` | 150 | Factory | Presigned URL requests CRUD via factory |
|
||||
| `publish_events.ts` | 157 | Factory | Publish events CRUD via factory |
|
||||
| `pwa_caches.ts` | 148 | Factory | PWA caches CRUD via factory |
|
||||
| `element_type_defaults.ts` | 12 | Factory | Element type defaults via factory |
|
||||
| `project_element_defaults.ts` | 92 | Hybrid | Project element defaults CRUD + custom (reset, diff) |
|
||||
|
||||
---
|
||||
|
||||
@ -98,40 +99,43 @@ Generates standardized CRUD routes for entities.
|
||||
|
||||
```javascript
|
||||
const router = createEntityRouter(
|
||||
'tour_pages', // Entity name
|
||||
Tour_pagesService, // Service class
|
||||
Tour_pagesDBApi, // Database API class
|
||||
'tour_pages', // Entity name
|
||||
Tour_pagesService, // Service class
|
||||
Tour_pagesDBApi, // Database API class
|
||||
{
|
||||
permissionEntity: 'tour_pages', // Permission entity name (optional)
|
||||
csvFields: ['id', 'name'], // CSV export fields (optional)
|
||||
validation: { // Request validation overrides (optional)
|
||||
permissionEntity: 'tour_pages', // Permission entity name (optional)
|
||||
csvFields: ['id', 'name'], // CSV export fields (optional)
|
||||
validation: {
|
||||
// Request validation overrides (optional)
|
||||
create: customCreateSchema,
|
||||
update: customUpdateSchema,
|
||||
},
|
||||
customRoutes: (router, Service, DBApi) => { // Custom routes (optional)
|
||||
customRoutes: (router, Service, DBApi) => {
|
||||
// Custom routes (optional)
|
||||
router.post('/custom', handler);
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
#### Generated Endpoints
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| `POST` | `/` | Create new item |
|
||||
| `POST` | `/bulk-import` | Bulk import items |
|
||||
| `PUT` | `/:id` | Update item by ID |
|
||||
| `DELETE` | `/:id` | Delete item by ID |
|
||||
| `POST` | `/deleteByIds` | Delete multiple items |
|
||||
| `GET` | `/` | List items (with pagination, filters) |
|
||||
| `GET` | `/count` | Count items matching filters |
|
||||
| `GET` | `/autocomplete` | Autocomplete search |
|
||||
| `GET` | `/:id` | Get single item by ID |
|
||||
| Method | Path | Description |
|
||||
| -------- | --------------- | ------------------------------------- |
|
||||
| `POST` | `/` | Create new item |
|
||||
| `POST` | `/bulk-import` | Bulk import items |
|
||||
| `PUT` | `/:id` | Update item by ID |
|
||||
| `DELETE` | `/:id` | Delete item by ID |
|
||||
| `POST` | `/deleteByIds` | Delete multiple items |
|
||||
| `GET` | `/` | List items (with pagination, filters) |
|
||||
| `GET` | `/count` | Count items matching filters |
|
||||
| `GET` | `/autocomplete` | Autocomplete search |
|
||||
| `GET` | `/:id` | Get single item by ID |
|
||||
|
||||
#### Factory Features
|
||||
|
||||
**Permission Checking:**
|
||||
|
||||
```javascript
|
||||
router.use(checkCrudPermissions(permissionEntity));
|
||||
// Maps HTTP methods to permissions:
|
||||
@ -142,6 +146,7 @@ router.use(checkCrudPermissions(permissionEntity));
|
||||
```
|
||||
|
||||
**Request Validation:**
|
||||
|
||||
```typescript
|
||||
import { validateRequest } from '../middlewares/validate-request.ts';
|
||||
import { crud as crudSchemas } from '../validators/request-schemas.ts';
|
||||
@ -159,6 +164,7 @@ router.get(
|
||||
Factory CRUD routes validate request bodies, params, and common query controls before service/DB calls. List and count routes validate `limit`, `page`, `field`, `sort`, and `filetype` while keeping existing entity filter query parameters. Entity routers can pass `validation` overrides for stricter contracts, as `users` and `projects` do.
|
||||
|
||||
**CSV Export:**
|
||||
|
||||
```javascript
|
||||
// GET /?filetype=csv
|
||||
if (filetype === 'csv') {
|
||||
@ -168,6 +174,7 @@ if (filetype === 'csv') {
|
||||
```
|
||||
|
||||
**Runtime Context:**
|
||||
|
||||
```javascript
|
||||
const runtimeContext = req.runtimeContext;
|
||||
const payload = await DBApi.findAll(req.query, {
|
||||
@ -226,22 +233,22 @@ mountRuntimeEntityRoute('/api/project_audio_tracks', 'project_audio_tracks', ...
|
||||
|
||||
Authentication and account management.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/signin/local` | No | Email/password login |
|
||||
| POST | `/signup` | No | Register new user |
|
||||
| GET | `/me` | JWT | Get current user |
|
||||
| PUT | `/password-reset` | No | Reset password with token |
|
||||
| PUT | `/password-update` | JWT | Change password |
|
||||
| PUT | `/profile` | JWT | Update user profile |
|
||||
| PUT | `/verify-email` | No | Verify email with token |
|
||||
| POST | `/send-email-address-verification-email` | JWT | Resend verification |
|
||||
| POST | `/send-password-reset-email` | No | Send reset email |
|
||||
| GET | `/email-configured` | No | Check email config |
|
||||
| GET | `/signin/google` | No | Google OAuth start |
|
||||
| GET | `/signin/google/callback` | No | Google OAuth callback |
|
||||
| GET | `/signin/microsoft` | No | Microsoft OAuth start |
|
||||
| GET | `/signin/microsoft/callback` | No | Microsoft OAuth callback |
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | ---------------------------------------- | ---- | ------------------------- |
|
||||
| POST | `/signin/local` | No | Email/password login |
|
||||
| POST | `/signup` | No | Register new user |
|
||||
| GET | `/me` | JWT | Get current user |
|
||||
| PUT | `/password-reset` | No | Reset password with token |
|
||||
| PUT | `/password-update` | JWT | Change password |
|
||||
| PUT | `/profile` | JWT | Update user profile |
|
||||
| PUT | `/verify-email` | No | Verify email with token |
|
||||
| POST | `/send-email-address-verification-email` | JWT | Resend verification |
|
||||
| POST | `/send-password-reset-email` | No | Send reset email |
|
||||
| GET | `/email-configured` | No | Check email config |
|
||||
| GET | `/signin/google` | No | Google OAuth start |
|
||||
| GET | `/signin/google/callback` | No | Google OAuth callback |
|
||||
| GET | `/signin/microsoft` | No | Microsoft OAuth start |
|
||||
| GET | `/signin/microsoft/callback` | No | Microsoft OAuth callback |
|
||||
|
||||
`auth.ts` is a typed ESM route boundary. It uses reusable request body/query contracts from `backend/src/types/auth-routes.ts`, the typed `backend/src/services/auth.ts` service, and the shared Passport helpers in `backend/src/auth/passport-middleware.ts`.
|
||||
|
||||
@ -253,17 +260,18 @@ File upload and download operations.
|
||||
|
||||
`file.ts` is a typed ESM route boundary. It calls the typed unified storage facade in `backend/src/services/file.ts`, while provider contracts and request/response shapes live in reusable `backend/src/types/file.ts` contracts. S3 and GCloud use official SDK-provided types instead of local SDK declarations.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/download` | No | Download file by privateUrl |
|
||||
| POST | `/presign` | No | Generate presigned URLs (max 50) |
|
||||
| POST | `/upload/:table/:field` | JWT | Legacy single file upload |
|
||||
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
|
||||
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
|
||||
| PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk |
|
||||
| POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload |
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | ------------------------------------------------ | ---- | -------------------------------- |
|
||||
| GET | `/download` | No | Download file by privateUrl |
|
||||
| POST | `/presign` | No | Generate presigned URLs (max 50) |
|
||||
| POST | `/upload/:table/:field` | JWT | Legacy single file upload |
|
||||
| POST | `/upload-sessions/init` | JWT | Initialize chunked upload |
|
||||
| GET | `/upload-sessions/:sessionId` | JWT | Get upload session status |
|
||||
| PUT | `/upload-sessions/:sessionId/chunks/:chunkIndex` | JWT | Upload chunk |
|
||||
| POST | `/upload-sessions/:sessionId/finalize` | JWT | Finalize chunked upload |
|
||||
|
||||
**Presigned URLs Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"urls": ["assets/image.jpg", "assets/video.mp4"]
|
||||
@ -271,6 +279,7 @@ File upload and download operations.
|
||||
```
|
||||
|
||||
**Presigned URLs Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"presignedUrls": {
|
||||
@ -286,13 +295,14 @@ File upload and download operations.
|
||||
|
||||
Publishing workflow for Dev → Stage → Production.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/` | JWT | Publish stage to production |
|
||||
| POST | `/publish` | JWT | Alias for publish |
|
||||
| POST | `/save-to-stage` | JWT | Save dev to stage |
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | ---------------- | ---- | --------------------------- |
|
||||
| POST | `/` | JWT | Publish stage to production |
|
||||
| POST | `/publish` | JWT | Alias for publish |
|
||||
| POST | `/save-to-stage` | JWT | Save dev to stage |
|
||||
|
||||
**Publish Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"projectId": "uuid",
|
||||
@ -302,6 +312,7 @@ Publishing workflow for Dev → Stage → Production.
|
||||
```
|
||||
|
||||
**Save to Stage Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"projectId": "uuid"
|
||||
@ -314,11 +325,12 @@ Publishing workflow for Dev → Stage → Production.
|
||||
|
||||
Global full-text search across entities.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| POST | `/` | JWT | Search across all entities |
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | ---- | ---- | -------------------------- |
|
||||
| POST | `/` | JWT | Search across all entities |
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"searchQuery": "my search term"
|
||||
@ -326,6 +338,7 @@ Global full-text search across entities.
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"users": [...],
|
||||
@ -338,11 +351,12 @@ Global full-text search across entities.
|
||||
|
||||
Runtime context inspection for debugging.
|
||||
|
||||
| Method | Path | Auth | Description |
|
||||
|--------|------|------|-------------|
|
||||
| GET | `/` | No | Get current runtime context |
|
||||
| Method | Path | Auth | Description |
|
||||
| ------ | ---- | ---- | --------------------------- |
|
||||
| GET | `/` | No | Get current runtime context |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"mode": "admin",
|
||||
@ -361,22 +375,25 @@ Runtime context inspection for debugging.
|
||||
Projects with factory CRUD plus a clone endpoint.
|
||||
|
||||
**Standard CRUD Endpoints:**
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/` | Create project |
|
||||
| POST | `/bulk-import` | Bulk import projects |
|
||||
| PUT | `/:id` | Update project |
|
||||
| DELETE | `/:id` | Delete project |
|
||||
| POST | `/deleteByIds` | Delete multiple projects |
|
||||
| GET | `/` | List projects |
|
||||
| GET | `/count` | Count projects |
|
||||
| GET | `/autocomplete` | Autocomplete search |
|
||||
| GET | `/:id` | Get project by ID |
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | --------------- | ------------------------ |
|
||||
| POST | `/` | Create project |
|
||||
| POST | `/bulk-import` | Bulk import projects |
|
||||
| PUT | `/:id` | Update project |
|
||||
| DELETE | `/:id` | Delete project |
|
||||
| POST | `/deleteByIds` | Delete multiple projects |
|
||||
| GET | `/` | List projects |
|
||||
| GET | `/count` | Count projects |
|
||||
| GET | `/autocomplete` | Autocomplete search |
|
||||
| GET | `/:id` | Get project by ID |
|
||||
|
||||
**Custom Endpoints:**
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/:id/clone` | Clone project with all pages |
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------ | ---------------------------- |
|
||||
| POST | `/:id/clone` | Clone project with all pages |
|
||||
|
||||
---
|
||||
|
||||
### tour_pages.ts (380 lines)
|
||||
@ -384,30 +401,33 @@ Projects with factory CRUD plus a clone endpoint.
|
||||
Typed manual CRUD route for tour pages. In addition to standard create/update/delete/list/count/autocomplete/find-by-id operations, it owns page reorder, dev-page duplication, CSV export, and reverse-video status endpoints. The route uses reusable contracts from `backend/src/types/tour-pages.ts`.
|
||||
|
||||
**Schema Fields:**
|
||||
|
||||
- `source_key`, `name`, `slug`
|
||||
- `background_image_url`, `background_video_url`, `background_audio_url`
|
||||
- `ui_schema_json`, `sort_order`
|
||||
|
||||
**Endpoints:**
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/` | Create page |
|
||||
| POST | `/bulk-import` | Bulk import pages |
|
||||
| POST | `/reorder` | Reorder dev pages in a project/environment |
|
||||
| POST | `/:id/duplicate` | Duplicate a dev page |
|
||||
| PUT | `/:id` | Update page |
|
||||
| DELETE | `/:id` | Remove page |
|
||||
| POST | `/deleteByIds` | Remove multiple pages |
|
||||
| GET | `/` | List pages with reverse video URL population and optional CSV export |
|
||||
| POST | `/reverse-video-status` | Check generated reverse-video variants |
|
||||
| GET | `/count` | Count pages |
|
||||
| GET | `/autocomplete` | Page autocomplete |
|
||||
| GET | `/:id` | Get one page with reverse video URL population |
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ----------------------- | -------------------------------------------------------------------- |
|
||||
| POST | `/` | Create page |
|
||||
| POST | `/bulk-import` | Bulk import pages |
|
||||
| POST | `/reorder` | Reorder dev pages in a project/environment |
|
||||
| POST | `/:id/duplicate` | Duplicate a dev page |
|
||||
| PUT | `/:id` | Update page |
|
||||
| DELETE | `/:id` | Remove page |
|
||||
| POST | `/deleteByIds` | Remove multiple pages |
|
||||
| GET | `/` | List pages with reverse video URL population and optional CSV export |
|
||||
| POST | `/reverse-video-status` | Check generated reverse-video variants |
|
||||
| GET | `/count` | Count pages |
|
||||
| GET | `/autocomplete` | Page autocomplete |
|
||||
| GET | `/:id` | Get one page with reverse video URL population |
|
||||
|
||||
- `POST /api/tour_pages/reorder` accepts `{ data: { projectId, environment,
|
||||
orderedPageIds } }`, validates a complete page list for the project/dev
|
||||
orderedPageIds } }`, validates a complete page list for the project/dev
|
||||
environment, and updates only `sort_order`.
|
||||
- `POST /api/tour_pages/:id/duplicate` accepts `{ data: { projectId,
|
||||
environment, name, slug } }`, duplicates a dev page into a new independent dev
|
||||
environment, name, slug } }`, duplicates a dev page into a new independent dev
|
||||
page, appends it to the project order, deep-copies `ui_schema_json`, and
|
||||
regenerates inline element IDs.
|
||||
- Constructor page deletion uses the standard `DELETE /api/tour_pages/:id`
|
||||
@ -431,12 +451,13 @@ module.exports = createEntityRouter(
|
||||
Element_type_defaultsService,
|
||||
Element_type_defaultsDBApi,
|
||||
{
|
||||
permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions
|
||||
permissionEntity: 'page_elements', // Uses PAGE_ELEMENTS permissions
|
||||
},
|
||||
);
|
||||
```
|
||||
|
||||
**URL Aliases:**
|
||||
|
||||
- `/api/element-type-defaults` (primary)
|
||||
- `/api/ui-elements` (backwards compatibility)
|
||||
|
||||
@ -462,21 +483,24 @@ baseRouter.get('/:id/diff', ...);
|
||||
```
|
||||
|
||||
**Standard CRUD Endpoints:** (via factory)
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/` | Create project element default |
|
||||
| PUT | `/:id` | Update project element default |
|
||||
| DELETE | `/:id` | Delete project element default |
|
||||
| GET | `/` | List project element defaults |
|
||||
| GET | `/:id` | Get project element default by ID |
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------ | --------------------------------- |
|
||||
| POST | `/` | Create project element default |
|
||||
| PUT | `/:id` | Update project element default |
|
||||
| DELETE | `/:id` | Delete project element default |
|
||||
| GET | `/` | List project element defaults |
|
||||
| GET | `/:id` | Get project element default by ID |
|
||||
|
||||
**Custom Endpoints:**
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| POST | `/:id/reset` | Reset project element default to global |
|
||||
| GET | `/:id/diff` | Get diff from global element type default |
|
||||
|
||||
| Method | Path | Description |
|
||||
| ------ | ------------ | ----------------------------------------- |
|
||||
| POST | `/:id/reset` | Reset project element default to global |
|
||||
| GET | `/:id/diff` | Get diff from global element type default |
|
||||
|
||||
**URL Alias:**
|
||||
|
||||
- `/api/project-element-defaults` (primary)
|
||||
|
||||
---
|
||||
@ -486,17 +510,19 @@ baseRouter.get('/:id/diff', ...);
|
||||
### List Endpoint (GET /)
|
||||
|
||||
**Query Parameters:**
|
||||
| Param | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `page` | number | Page number (0-indexed) |
|
||||
| `limit` | number | Items per page |
|
||||
| `field` | string | Sort field |
|
||||
| `sort` | string | Sort direction (`asc`/`desc`) |
|
||||
| `filetype` | string | Export format (`csv`) |
|
||||
| `[fieldName]` | string | Filter by field value |
|
||||
| `[fieldName]Range` | array | Filter by range `[start, end]` |
|
||||
|
||||
| Param | Type | Description |
|
||||
| ------------------ | ------ | ------------------------------ |
|
||||
| `page` | number | Page number (0-indexed) |
|
||||
| `limit` | number | Items per page |
|
||||
| `field` | string | Sort field |
|
||||
| `sort` | string | Sort direction (`asc`/`desc`) |
|
||||
| `filetype` | string | Export format (`csv`) |
|
||||
| `[fieldName]` | string | Filter by field value |
|
||||
| `[fieldName]Range` | array | Filter by range `[start, end]` |
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"rows": [...],
|
||||
@ -507,6 +533,7 @@ baseRouter.get('/:id/diff', ...);
|
||||
### Create Endpoint (POST /)
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": {
|
||||
@ -517,6 +544,7 @@ baseRouter.get('/:id/diff', ...);
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
@ -529,6 +557,7 @@ baseRouter.get('/:id/diff', ...);
|
||||
### Update Endpoint (PUT /:id)
|
||||
|
||||
**Request:**
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "uuid",
|
||||
@ -539,6 +568,7 @@ baseRouter.get('/:id/diff', ...);
|
||||
```
|
||||
|
||||
**Response:**
|
||||
|
||||
```json
|
||||
true
|
||||
```
|
||||
@ -546,11 +576,13 @@ true
|
||||
### Delete Endpoints
|
||||
|
||||
**Single Delete (DELETE /:id):**
|
||||
|
||||
```json
|
||||
true
|
||||
```
|
||||
|
||||
**Bulk Delete (POST /deleteByIds):**
|
||||
|
||||
```json
|
||||
{
|
||||
"data": ["uuid1", "uuid2", "uuid3"]
|
||||
@ -625,15 +657,16 @@ router.use('/', require('../helpers').commonErrorHandler);
|
||||
```
|
||||
|
||||
**Error Response Mapping:**
|
||||
| Status Code | Description |
|
||||
|-------------|-------------|
|
||||
| 400 | Bad Request |
|
||||
| 401 | Unauthorized |
|
||||
| 403 | Forbidden |
|
||||
| 404 | Not Found |
|
||||
| 409 | Conflict |
|
||||
| 422 | Unprocessable Entity |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
| Status Code | Description |
|
||||
| ----------- | --------------------- |
|
||||
| 400 | Bad Request |
|
||||
| 401 | Unauthorized |
|
||||
| 403 | Forbidden |
|
||||
| 404 | Not Found |
|
||||
| 409 | Conflict |
|
||||
| 422 | Unprocessable Entity |
|
||||
| 500 | Internal Server Error |
|
||||
|
||||
---
|
||||
|
||||
@ -641,49 +674,50 @@ router.use('/', require('../helpers').commonErrorHandler);
|
||||
|
||||
### Public Routes (No Auth)
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `GET /api/health` | Health check |
|
||||
| `POST /api/auth/signin/local` | Login |
|
||||
| `GET /api/auth/signin/google` | Google OAuth |
|
||||
| `GET /api/auth/signin/microsoft` | Microsoft OAuth |
|
||||
| `GET /api/file/download` | File download |
|
||||
| `POST /api/file/presign` | Generate presigned URLs |
|
||||
| `GET /api/runtime-context` | Runtime context |
|
||||
| Route | Description |
|
||||
| -------------------------------- | ----------------------- |
|
||||
| `GET /api/health` | Health check |
|
||||
| `POST /api/auth/signin/local` | Login |
|
||||
| `GET /api/auth/signin/google` | Google OAuth |
|
||||
| `GET /api/auth/signin/microsoft` | Microsoft OAuth |
|
||||
| `GET /api/file/download` | File download |
|
||||
| `POST /api/file/presign` | Generate presigned URLs |
|
||||
| `GET /api/runtime-context` | Runtime context |
|
||||
|
||||
### Runtime Public Routes (Production Environment)
|
||||
|
||||
| Route | Description |
|
||||
|-------|-------------|
|
||||
| `GET /api/projects` | List projects (sanitized) |
|
||||
| `GET /api/tour_pages` | List tour pages (sanitized) |
|
||||
| Route | Description |
|
||||
| ------------------------------- | ----------------------------- |
|
||||
| `GET /api/projects` | List projects (sanitized) |
|
||||
| `GET /api/tour_pages` | List tour pages (sanitized) |
|
||||
| `GET /api/project_audio_tracks` | List audio tracks (sanitized) |
|
||||
|
||||
### Authenticated Routes (JWT Required)
|
||||
|
||||
All other routes require JWT authentication via:
|
||||
|
||||
```javascript
|
||||
app.use('/api/users', jwtAuth, usersRoutes);
|
||||
```
|
||||
|
||||
### Rate Limited Routes
|
||||
|
||||
| Route | Limiter | Config |
|
||||
|-------|---------|--------|
|
||||
| `/api/auth/signin/local` | authLimiter | 10/15min |
|
||||
| `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour |
|
||||
| `/api/file/upload*` | uploadLimiter | 10/min |
|
||||
| `/api/file/download`, `/presign` | downloadLimiter | 200/min |
|
||||
| `/api/search` | searchLimiter | 30/min |
|
||||
| Route | Limiter | Config |
|
||||
| ------------------------------------- | -------------------- | -------- |
|
||||
| `/api/auth/signin/local` | authLimiter | 10/15min |
|
||||
| `/api/auth/send-password-reset-email` | passwordResetLimiter | 5/hour |
|
||||
| `/api/file/upload*` | uploadLimiter | 10/min |
|
||||
| `/api/file/download`, `/presign` | downloadLimiter | 200/min |
|
||||
| `/api/search` | searchLimiter | 30/min |
|
||||
|
||||
## Dependencies
|
||||
|
||||
| Package | Purpose |
|
||||
|---------|---------|
|
||||
| `express` | Router and middleware |
|
||||
| `json2csv` | CSV export functionality |
|
||||
| `passport` | JWT authentication |
|
||||
| `body-parser` | JSON body parsing |
|
||||
| Package | Purpose |
|
||||
| ------------- | ------------------------ |
|
||||
| `express` | Router and middleware |
|
||||
| `json2csv` | CSV export functionality |
|
||||
| `passport` | JWT authentication |
|
||||
| `body-parser` | JSON body parsing |
|
||||
|
||||
---
|
||||
|
||||
@ -750,6 +784,7 @@ The Routes module provides:
|
||||
10. **Error Handling** - Centralized via wrapAsync and commonErrorHandler
|
||||
|
||||
**Route Statistics:**
|
||||
|
||||
- 26 route files
|
||||
- ~50+ unique endpoints
|
||||
- 11 factory-generated entity routers
|
||||
|
||||
@ -46,18 +46,18 @@ The Services module implements the **business logic layer** of the backend appli
|
||||
|
||||
Generated using `createEntityService()` from `factories/service.factory.ts`. These provide standardized CRUD operations with transaction handling.
|
||||
|
||||
| Service | File | Entity | LOC |
|
||||
| -------------------------- | ------------------------------- | ---------------------------------------------- | ------ |
|
||||
| tour_pages | `tour_pages.ts` | Tour Pages (includes reverse video generation) | ~1,300 |
|
||||
| permissions | `permissions.ts` | Permissions | 6 |
|
||||
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
|
||||
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
|
||||
| publish_events | `publish_events.ts` | Publish Events | 6 |
|
||||
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
|
||||
| access_logs | `access_logs.ts` | Access Logs | 6 |
|
||||
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
|
||||
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
|
||||
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
|
||||
| Service | File | Entity | LOC |
|
||||
| -------------------------- | ------------------------------- | --------------------------------------------------------------------------- | ------ |
|
||||
| tour_pages | `tour_pages.ts` | Tour Pages (reverse video generation plus targeted back-transition refresh) | ~1,500 |
|
||||
| permissions | `permissions.ts` | Permissions | 6 |
|
||||
| asset_variants | `asset_variants.ts` | Asset Variants | 6 |
|
||||
| presigned_url_requests | `presigned_url_requests.ts` | Presigned URL Requests | 6 |
|
||||
| publish_events | `publish_events.ts` | Publish Events | 6 |
|
||||
| pwa_caches | `pwa_caches.ts` | PWA Caches | 6 |
|
||||
| access_logs | `access_logs.ts` | Access Logs | 6 |
|
||||
| element_type_defaults | `element_type_defaults.ts` | Element Type Defaults | 6 |
|
||||
| project_memberships | `project_memberships.ts` | Project Memberships | 6 |
|
||||
| global_transition_defaults | `global_transition_defaults.ts` | Global transition defaults | 6 |
|
||||
|
||||
**Example - Factory Service:**
|
||||
|
||||
@ -565,6 +565,16 @@ Phase G: Clone tour_pages, audio_tracks, element_defaults
|
||||
- Primary assets: `assets/{projectId}/{uuid}.ext`
|
||||
- Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID)
|
||||
|
||||
**Tour page transition safeguards:**
|
||||
|
||||
- Before save-time auto-reverse validation, targeted back buttons refresh their
|
||||
transition fields from the current incoming forward element.
|
||||
- If the source page's forward transition was removed, stale back-button
|
||||
`transitionVideoUrl`, `transitionReverseMode`, and `reverseVideoUrl` values
|
||||
are cleared before validation so old heavy videos do not block unrelated saves.
|
||||
- Project-wide reverse regeneration performs the same refresh before queuing
|
||||
missing reversed videos.
|
||||
|
||||
**Error Handling:**
|
||||
|
||||
- Failed file copies fall back to original storage path (cloned project still functional, shares assets with source)
|
||||
|
||||
@ -5,6 +5,7 @@
|
||||
The Utilities module provides centralized helper functions, error handling, logging, environment validation, and i18n message management. Utilities are organized across several locations based on their domain.
|
||||
|
||||
**Locations:**
|
||||
|
||||
- `backend/src/utils/` - Core utilities (errors, logging, env validation, request context)
|
||||
- `backend/src/helpers.ts` - Request helpers (async wrapper, error handler, JWT)
|
||||
- `backend/src/db/utils.ts` - Database utilities
|
||||
@ -47,7 +48,7 @@ class AppError extends Error {
|
||||
super(message);
|
||||
this.statusCode = statusCode;
|
||||
this.details = details;
|
||||
this.isOperational = true; // Distinguishes from programming errors
|
||||
this.isOperational = true; // Distinguishes from programming errors
|
||||
Error.captureStackTrace(this, this.constructor);
|
||||
}
|
||||
}
|
||||
@ -55,16 +56,17 @@ class AppError extends Error {
|
||||
|
||||
**Error Types:**
|
||||
|
||||
| Class | Status Code | Default Message | Usage |
|
||||
|-------|-------------|-----------------|-------|
|
||||
| `AppError` | 500 | (custom) | Base class |
|
||||
| `NotFoundError` | 404 | `{resource} not found` | Missing resources |
|
||||
| `ValidationError` | 400 | (custom) | Invalid input |
|
||||
| `ForbiddenError` | 403 | `Access denied` | Permission denied |
|
||||
| `UnauthorizedError` | 401 | `Unauthorized` | Auth required |
|
||||
| `ConflictError` | 409 | `Resource conflict` | Duplicate resources |
|
||||
| Class | Status Code | Default Message | Usage |
|
||||
| ------------------- | ----------- | ---------------------- | ------------------- |
|
||||
| `AppError` | 500 | (custom) | Base class |
|
||||
| `NotFoundError` | 404 | `{resource} not found` | Missing resources |
|
||||
| `ValidationError` | 400 | (custom) | Invalid input |
|
||||
| `ForbiddenError` | 403 | `Access denied` | Permission denied |
|
||||
| `UnauthorizedError` | 401 | `Unauthorized` | Auth required |
|
||||
| `ConflictError` | 409 | `Resource conflict` | Duplicate resources |
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const { NotFoundError, ValidationError, ForbiddenError } = require('./utils');
|
||||
|
||||
@ -110,6 +112,7 @@ const logger = pino({
|
||||
```
|
||||
|
||||
**Log Levels:**
|
||||
|
||||
- `fatal` - Unrecoverable errors
|
||||
- `error` - Errors requiring attention
|
||||
- `warn` - Warning conditions (400-499 responses)
|
||||
@ -139,6 +142,7 @@ process.on('unhandledRejection', (reason) => {
|
||||
```
|
||||
|
||||
**Request Logger Middleware:**
|
||||
|
||||
```javascript
|
||||
function requestLogger(req, res, next) {
|
||||
// Generate or use existing request ID
|
||||
@ -162,9 +166,15 @@ function requestLogger(req, res, next) {
|
||||
|
||||
// Log level based on status code
|
||||
if (res.statusCode >= 500) {
|
||||
getRequestLogger(req)?.error(logData, 'Request completed with server error');
|
||||
getRequestLogger(req)?.error(
|
||||
logData,
|
||||
'Request completed with server error',
|
||||
);
|
||||
} else if (res.statusCode >= 400) {
|
||||
getRequestLogger(req)?.warn(logData, 'Request completed with client error');
|
||||
getRequestLogger(req)?.warn(
|
||||
logData,
|
||||
'Request completed with client error',
|
||||
);
|
||||
} else {
|
||||
getRequestLogger(req)?.info(logData, 'Request completed');
|
||||
}
|
||||
@ -177,6 +187,7 @@ function requestLogger(req, res, next) {
|
||||
**Log Output Examples:**
|
||||
|
||||
Development (pino-pretty):
|
||||
|
||||
```
|
||||
[12:34:56.789] INFO (tour-builder-api): Request completed
|
||||
requestId: "abc-123"
|
||||
@ -187,11 +198,24 @@ Development (pino-pretty):
|
||||
```
|
||||
|
||||
Production (JSON):
|
||||
|
||||
```json
|
||||
{"level":30,"time":1711723456789,"service":"tour-builder-api","env":"production","requestId":"abc-123","method":"GET","url":"/api/users","status":200,"duration":45,"msg":"Request completed"}
|
||||
{
|
||||
"level": 30,
|
||||
"time": 1711723456789,
|
||||
"service": "tour-builder-api",
|
||||
"env": "production",
|
||||
"requestId": "abc-123",
|
||||
"method": "GET",
|
||||
"url": "/api/users",
|
||||
"status": 200,
|
||||
"duration": 45,
|
||||
"msg": "Request completed"
|
||||
}
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const { logger, requestLogger } = require('./utils/logger');
|
||||
|
||||
@ -223,6 +247,7 @@ routes/services should read through `getCurrentUser`, `getRuntimeContext`,
|
||||
Joi-based validation ensuring all required environment variables are present with correct types.
|
||||
|
||||
**Schema Definition:**
|
||||
|
||||
```javascript
|
||||
const Joi = require('joi');
|
||||
|
||||
@ -275,15 +300,16 @@ const envSchema = Joi.object({
|
||||
LOG_LEVEL: Joi.string()
|
||||
.valid('fatal', 'error', 'warn', 'info', 'debug', 'trace')
|
||||
.default('info'),
|
||||
}).unknown(true); // Allow additional env vars
|
||||
}).unknown(true); // Allow additional env vars
|
||||
```
|
||||
|
||||
**Validation Function:**
|
||||
|
||||
```javascript
|
||||
function validateEnv() {
|
||||
const { error, value } = envSchema.validate(process.env, {
|
||||
abortEarly: false, // Report all errors
|
||||
stripUnknown: false, // Keep unknown vars
|
||||
abortEarly: false, // Report all errors
|
||||
stripUnknown: false, // Keep unknown vars
|
||||
});
|
||||
|
||||
if (error) {
|
||||
@ -298,22 +324,22 @@ function validateEnv() {
|
||||
}
|
||||
}
|
||||
|
||||
return value; // Returns validated/defaulted values
|
||||
return value; // Returns validated/defaulted values
|
||||
}
|
||||
```
|
||||
|
||||
**Environment Variable Categories:**
|
||||
|
||||
| Category | Variables | Required |
|
||||
|----------|-----------|----------|
|
||||
| **Server** | `NODE_ENV`, `PORT` | Defaults |
|
||||
| **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | Defaults |
|
||||
| **Auth** | `SECRET_KEY`, `ADMIN_PASS`, `USER_PASS`, `ADMIN_EMAIL` | Defaults |
|
||||
| **OAuth** | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | Optional |
|
||||
| **AWS S3** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`, `AWS_S3_REGION`, `AWS_S3_PREFIX` | Optional |
|
||||
| **Email** | `EMAIL_USER`, `EMAIL_PASS`, `EMAIL_TLS_REJECT_UNAUTHORIZED` | Optional |
|
||||
| **External APIs** | `PEXELS_KEY` | Optional |
|
||||
| **Logging** | `LOG_LEVEL` | Defaults |
|
||||
| Category | Variables | Required |
|
||||
| ----------------- | ----------------------------------------------------------------------------------------------- | -------- |
|
||||
| **Server** | `NODE_ENV`, `PORT` | Defaults |
|
||||
| **Database** | `DB_HOST`, `DB_PORT`, `DB_NAME`, `DB_USER`, `DB_PASS` | Defaults |
|
||||
| **Auth** | `SECRET_KEY`, `ADMIN_PASS`, `USER_PASS`, `ADMIN_EMAIL` | Defaults |
|
||||
| **OAuth** | `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, `MS_CLIENT_ID`, `MS_CLIENT_SECRET` | Optional |
|
||||
| **AWS S3** | `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_S3_BUCKET`, `AWS_S3_REGION`, `AWS_S3_PREFIX` | Optional |
|
||||
| **Email** | `EMAIL_USER`, `EMAIL_PASS`, `EMAIL_TLS_REJECT_UNAUTHORIZED` | Optional |
|
||||
| **External APIs** | `PEXELS_KEY` | Optional |
|
||||
| **Logging** | `LOG_LEVEL` | Defaults |
|
||||
|
||||
---
|
||||
|
||||
@ -330,6 +356,7 @@ module.exports = {
|
||||
```
|
||||
|
||||
**Exported:**
|
||||
|
||||
- `AppError`, `NotFoundError`, `ValidationError`, `ForbiddenError`, `UnauthorizedError`, `ConflictError`
|
||||
- `logger`, `requestLogger`, `registerProcessErrorHandlers`,
|
||||
`exitAfterLogging`,
|
||||
@ -382,12 +409,12 @@ module.exports = class Helpers {
|
||||
|
||||
**Functions:**
|
||||
|
||||
| Function | Purpose | Usage |
|
||||
|----------|---------|-------|
|
||||
| `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers |
|
||||
| `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware |
|
||||
| `jwtSign(data)` | Creates JWT with 6h expiry | Auth service |
|
||||
| `isUuidV4(value)` | Validates UUID v4 format | Route parameter validation |
|
||||
| Function | Purpose | Usage |
|
||||
| ----------------------------------------- | ---------------------------------------- | -------------------------- |
|
||||
| `wrapAsync(fn)` | Wraps async handlers to propagate errors | All async route handlers |
|
||||
| `commonErrorHandler(err, req, res, next)` | Standardizes error responses | Route error middleware |
|
||||
| `jwtSign(data)` | Creates JWT with 6h expiry | Auth service |
|
||||
| `isUuidV4(value)` | Validates UUID v4 format | Route parameter validation |
|
||||
|
||||
## Request Validation
|
||||
|
||||
@ -433,18 +460,22 @@ Request validation errors return JSON:
|
||||
Service/domain `ValidationError` responses keep the legacy plain-text format unless they are raised by request validation middleware.
|
||||
|
||||
**Usage Pattern:**
|
||||
|
||||
```javascript
|
||||
const { wrapAsync, commonErrorHandler, isUuidV4 } = require('../helpers');
|
||||
|
||||
// Async route handler
|
||||
router.get('/users/:id', wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send('Invalid ID format');
|
||||
}
|
||||
router.get(
|
||||
'/users/:id',
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isUuidV4(req.params.id)) {
|
||||
return res.status(400).send('Invalid ID format');
|
||||
}
|
||||
|
||||
const user = await UserService.findOne(req.params.id);
|
||||
res.json(user);
|
||||
}));
|
||||
const user = await UserService.findOne(req.params.id);
|
||||
res.json(user);
|
||||
}),
|
||||
);
|
||||
|
||||
// Register error handler at end of router
|
||||
router.use('/', commonErrorHandler);
|
||||
@ -489,19 +520,21 @@ module.exports = class Utils {
|
||||
|
||||
**Functions:**
|
||||
|
||||
| Function | Purpose | Returns |
|
||||
|----------|---------|---------|
|
||||
| `isValidUuid(value)` | Check if value is a valid UUID | `boolean` |
|
||||
| `generateUuid()` | Generate a new UUID v4 | `string` |
|
||||
| `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` |
|
||||
| `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause |
|
||||
| Function | Purpose | Returns |
|
||||
| ----------------------------- | -------------------------------- | ---------------------- |
|
||||
| `isValidUuid(value)` | Check if value is a valid UUID | `boolean` |
|
||||
| `generateUuid()` | Generate a new UUID v4 | `string` |
|
||||
| `filterValidUuids(values)` | Filter array to only valid UUIDs | `string[]` |
|
||||
| `ilike(model, column, value)` | Case-insensitive LIKE search | Sequelize where clause |
|
||||
|
||||
**UUID Validation Behavior:**
|
||||
|
||||
- Invalid single ID filter (`?id=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
||||
- Invalid UUIDs in relation filters (`?project=uuid|name`) → filtered out for ID search, kept for text search
|
||||
- Invalid UUID field filter (`?projectId=xxx`) → returns `{ rows: [], count: 0 }` immediately
|
||||
|
||||
**Usage in DB API:**
|
||||
|
||||
```javascript
|
||||
const Utils = require('../utils');
|
||||
|
||||
@ -596,7 +629,8 @@ const errors = {
|
||||
errors: {
|
||||
invalidFileEmpty: 'The file is empty',
|
||||
invalidFileExcel: 'Only excel (.xlsx) files are allowed',
|
||||
invalidFileUpload: 'Invalid file. Make sure you are using the last version of the template.',
|
||||
invalidFileUpload:
|
||||
'Invalid file. Make sure you are using the last version of the template.',
|
||||
importHashRequired: 'Import hash is required',
|
||||
importHashExistent: 'Data has already been imported',
|
||||
userEmailMissing: 'Some items in the CSV do not have an email',
|
||||
@ -628,14 +662,14 @@ const errors = {
|
||||
|
||||
**Message Categories:**
|
||||
|
||||
| Category | Purpose | Examples |
|
||||
|----------|---------|----------|
|
||||
| `app` | Application metadata | `app.title` |
|
||||
| `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` |
|
||||
| `iam` | User management errors | `iam.errors.userAlreadyExists` |
|
||||
| `importer` | Import/export errors | `importer.errors.invalidFileEmpty` |
|
||||
| `errors` | Generic errors | `errors.forbidden.message` |
|
||||
| `emails` | Email templates | `emails.invitation.subject` |
|
||||
| Category | Purpose | Examples |
|
||||
| ---------- | ---------------------- | ----------------------------------------- |
|
||||
| `app` | Application metadata | `app.title` |
|
||||
| `auth` | Authentication errors | `auth.userDisabled`, `auth.wrongPassword` |
|
||||
| `iam` | User management errors | `iam.errors.userAlreadyExists` |
|
||||
| `importer` | Import/export errors | `importer.errors.invalidFileEmpty` |
|
||||
| `errors` | Generic errors | `errors.forbidden.message` |
|
||||
| `emails` | Email templates | `emails.invitation.subject` |
|
||||
|
||||
---
|
||||
|
||||
@ -670,6 +704,7 @@ const getNotification = (key, ...args) => {
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const { getNotification, isNotification } = require('./helpers');
|
||||
|
||||
@ -682,8 +717,8 @@ getNotification('emails.invitation.subject', 'Tour Builder');
|
||||
// → "You've been invited to Tour Builder"
|
||||
|
||||
// Check existence
|
||||
isNotification('auth.userDisabled'); // → true
|
||||
isNotification('unknown.key'); // → false
|
||||
isNotification('auth.userDisabled'); // → true
|
||||
isNotification('unknown.key'); // → false
|
||||
```
|
||||
|
||||
---
|
||||
@ -693,6 +728,7 @@ isNotification('unknown.key'); // → false
|
||||
i18n-aware error classes (legacy pattern, prefer `utils/errors.js`):
|
||||
|
||||
**ForbiddenError:**
|
||||
|
||||
```javascript
|
||||
const { getNotification, isNotification } = require('../helpers');
|
||||
|
||||
@ -713,6 +749,7 @@ module.exports = class ForbiddenError extends Error {
|
||||
```
|
||||
|
||||
**ValidationError:**
|
||||
|
||||
```javascript
|
||||
module.exports = class ValidationError extends Error {
|
||||
constructor(messageCode) {
|
||||
@ -731,6 +768,7 @@ module.exports = class ValidationError extends Error {
|
||||
```
|
||||
|
||||
**Usage:**
|
||||
|
||||
```javascript
|
||||
const ForbiddenError = require('./services/notifications/errors/forbidden');
|
||||
const ValidationError = require('./services/notifications/errors/validation');
|
||||
@ -740,7 +778,7 @@ throw new ForbiddenError('auth.forbidden');
|
||||
throw new ValidationError('iam.errors.emailRequired');
|
||||
|
||||
// With default message
|
||||
throw new ForbiddenError(); // → 'Forbidden'
|
||||
throw new ForbiddenError(); // → 'Forbidden'
|
||||
throw new ValidationError(); // → 'An error occurred'
|
||||
```
|
||||
|
||||
@ -804,46 +842,46 @@ const { getNotification } = require('./services/notifications/helpers');
|
||||
|
||||
### Error Class Selection
|
||||
|
||||
| Scenario | Recommended Class |
|
||||
|----------|-------------------|
|
||||
| Resource not found | `NotFoundError` from `utils/errors.js` |
|
||||
| Invalid input | `ValidationError` from `utils/errors.js` |
|
||||
| Permission denied | `ForbiddenError` from `utils/errors.js` |
|
||||
| Auth required | `UnauthorizedError` from `utils/errors.js` |
|
||||
| Duplicate resource | `ConflictError` from `utils/errors.js` |
|
||||
| Scenario | Recommended Class |
|
||||
| ------------------ | ------------------------------------------- |
|
||||
| Resource not found | `NotFoundError` from `utils/errors.js` |
|
||||
| Invalid input | `ValidationError` from `utils/errors.js` |
|
||||
| Permission denied | `ForbiddenError` from `utils/errors.js` |
|
||||
| Auth required | `UnauthorizedError` from `utils/errors.js` |
|
||||
| Duplicate resource | `ConflictError` from `utils/errors.js` |
|
||||
| i18n error message | Legacy classes from `notifications/errors/` |
|
||||
|
||||
---
|
||||
|
||||
## Environment Variables Reference
|
||||
|
||||
| Variable | Type | Default | Description |
|
||||
|----------|------|---------|-------------|
|
||||
| `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` |
|
||||
| `PORT` | number | `8080` | Server port |
|
||||
| `DB_HOST` | string | `localhost` | PostgreSQL host |
|
||||
| `DB_PORT` | number | `5432` | PostgreSQL port |
|
||||
| `DB_NAME` | string | `db_tour_builder_platform` | Database name |
|
||||
| `DB_USER` | string | `postgres` | Database user |
|
||||
| `DB_PASS` | string | `` | Database password |
|
||||
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
|
||||
| `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email |
|
||||
| `ADMIN_PASS` | string | `88dbeaf8` | Admin account password |
|
||||
| `USER_PASS` | string | `c3baadeda5c6` | Default user password |
|
||||
| `GOOGLE_CLIENT_ID` | string | `` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | string | `` | Google OAuth client secret |
|
||||
| `MS_CLIENT_ID` | string | `` | Microsoft OAuth client ID |
|
||||
| `MS_CLIENT_SECRET` | string | `` | Microsoft OAuth client secret |
|
||||
| `AWS_ACCESS_KEY_ID` | string | `` | AWS access key |
|
||||
| `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key |
|
||||
| `AWS_S3_BUCKET` | string | `` | S3 bucket name |
|
||||
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
|
||||
| `AWS_S3_PREFIX` | string | UUID | S3 key prefix |
|
||||
| `EMAIL_USER` | string | `` | SMTP username |
|
||||
| `EMAIL_PASS` | string | `` | SMTP password |
|
||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation |
|
||||
| `PEXELS_KEY` | string | `` | Pexels API key |
|
||||
| `LOG_LEVEL` | string | `info` | Pino log level |
|
||||
| Variable | Type | Default | Description |
|
||||
| ------------------------------- | ------ | -------------------------- | ------------------------------------------------ |
|
||||
| `NODE_ENV` | string | `development` | `development`, `test`, `production`, `dev_stage` |
|
||||
| `PORT` | number | `8080` | Server port |
|
||||
| `DB_HOST` | string | `localhost` | PostgreSQL host |
|
||||
| `DB_PORT` | number | `5432` | PostgreSQL port |
|
||||
| `DB_NAME` | string | `db_tour_builder_platform` | Database name |
|
||||
| `DB_USER` | string | `postgres` | Database user |
|
||||
| `DB_PASS` | string | `` | Database password |
|
||||
| `SECRET_KEY` | string | UUID | JWT signing key (min 16 chars) |
|
||||
| `ADMIN_EMAIL` | email | `admin@flatlogic.com` | Admin account email |
|
||||
| `ADMIN_PASS` | string | `88dbeaf8` | Admin account password |
|
||||
| `USER_PASS` | string | `c3baadeda5c6` | Default user password |
|
||||
| `GOOGLE_CLIENT_ID` | string | `` | Google OAuth client ID |
|
||||
| `GOOGLE_CLIENT_SECRET` | string | `` | Google OAuth client secret |
|
||||
| `MS_CLIENT_ID` | string | `` | Microsoft OAuth client ID |
|
||||
| `MS_CLIENT_SECRET` | string | `` | Microsoft OAuth client secret |
|
||||
| `AWS_ACCESS_KEY_ID` | string | `` | AWS access key |
|
||||
| `AWS_SECRET_ACCESS_KEY` | string | `` | AWS secret key |
|
||||
| `AWS_S3_BUCKET` | string | `` | S3 bucket name |
|
||||
| `AWS_S3_REGION` | string | `us-east-1` | S3 region |
|
||||
| `AWS_S3_PREFIX` | string | UUID | S3 key prefix |
|
||||
| `EMAIL_USER` | string | `` | SMTP username |
|
||||
| `EMAIL_PASS` | string | `` | SMTP password |
|
||||
| `EMAIL_TLS_REJECT_UNAUTHORIZED` | string | `true` | TLS cert validation |
|
||||
| `PEXELS_KEY` | string | `` | Pexels API key |
|
||||
| `LOG_LEVEL` | string | `info` | Pino log level |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@ -5,13 +5,13 @@
|
||||
Backend tests use Node.js 24's built-in test runner and TypeScript test files.
|
||||
The suite is split into three layers:
|
||||
|
||||
| Layer | Command | Location | Purpose |
|
||||
|-------|---------|----------|---------|
|
||||
| Unit | `npm run test` | `backend/tests/*.test.ts` | Pure helpers, validators, policy decisions, service contracts, and file/session utilities |
|
||||
| Integration | `npm run test:integration` | `backend/tests/integration/*.test.ts` | Cross-module behavior such as DB-backed access policy and Express router factory contracts |
|
||||
| E2E | `npm run test:e2e` | `backend/tests/e2e/*.test.ts` | Real HTTP request/response checks against local Express test apps |
|
||||
| Full suite | `npm run test:all` | all test folders | Runs unit, integration, and e2e in sequence |
|
||||
| Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite |
|
||||
| Layer | Command | Location | Purpose |
|
||||
| ------------ | -------------------------- | ------------------------------------- | ------------------------------------------------------------------------------------------ |
|
||||
| Unit | `npm run test` | `backend/tests/*.test.ts` | Pure helpers, validators, policy decisions, service contracts, and file/session utilities |
|
||||
| Integration | `npm run test:integration` | `backend/tests/integration/*.test.ts` | Cross-module behavior such as DB-backed access policy and Express router factory contracts |
|
||||
| E2E | `npm run test:e2e` | `backend/tests/e2e/*.test.ts` | Real HTTP request/response checks against local Express test apps |
|
||||
| Full suite | `npm run test:all` | all test folders | Runs unit, integration, and e2e in sequence |
|
||||
| Verification | `npm run verify` | static checks plus all test folders | Runs typecheck, lint, ESM boundary checks, and the full test suite |
|
||||
|
||||
## Current Coverage
|
||||
|
||||
|
||||
@ -32,7 +32,9 @@ function toProjectPath(filePath: string): string {
|
||||
}
|
||||
|
||||
function isHistoricalMigration(projectPath: string): boolean {
|
||||
return projectPath.startsWith('src/db/migrations/') && projectPath.endsWith('.js');
|
||||
return (
|
||||
projectPath.startsWith('src/db/migrations/') && projectPath.endsWith('.js')
|
||||
);
|
||||
}
|
||||
|
||||
function checkJsBoundary(projectPath: string): BoundaryViolation | null {
|
||||
@ -44,7 +46,10 @@ function checkJsBoundary(projectPath: string): BoundaryViolation | null {
|
||||
};
|
||||
}
|
||||
|
||||
function checkTsBoundary(projectPath: string, source: string): BoundaryViolation | null {
|
||||
function checkTsBoundary(
|
||||
projectPath: string,
|
||||
source: string,
|
||||
): BoundaryViolation | null {
|
||||
if (!commonJsPattern.test(source)) return null;
|
||||
|
||||
return {
|
||||
@ -70,7 +75,9 @@ async function checkFile(filePath: string): Promise<BoundaryViolation | null> {
|
||||
|
||||
async function main(): Promise<void> {
|
||||
const files = (
|
||||
await Promise.all(sourceRoots.map((root) => collectFiles(path.join(process.cwd(), root))))
|
||||
await Promise.all(
|
||||
sourceRoots.map((root) => collectFiles(path.join(process.cwd(), root))),
|
||||
)
|
||||
).flat();
|
||||
const checks = await Promise.all(files.map((file) => checkFile(file)));
|
||||
const violations = checks.filter((violation) => violation !== null);
|
||||
|
||||
@ -10,7 +10,10 @@ import config from '../config.ts';
|
||||
import db from '../db/models/index.ts';
|
||||
import UsersDBApi from '../db/api/users.ts';
|
||||
import { jwtSign } from '../helpers.ts';
|
||||
import { setCurrentUser, setSocialAuthToken } from '../utils/request-context.ts';
|
||||
import {
|
||||
setCurrentUser,
|
||||
setSocialAuthToken,
|
||||
} from '../utils/request-context.ts';
|
||||
import type {
|
||||
AuthTokenPayload,
|
||||
CurrentUser,
|
||||
@ -111,9 +114,11 @@ async function socialStrategy(
|
||||
}
|
||||
|
||||
try {
|
||||
const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate({
|
||||
where: { email, provider },
|
||||
});
|
||||
const [user]: [SocialAuthUserRecord, boolean] = await db.users.findOrCreate(
|
||||
{
|
||||
where: { email, provider },
|
||||
},
|
||||
);
|
||||
const body: AuthTokenPayload['user'] = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
@ -165,11 +170,7 @@ passport.use(
|
||||
secretOrKey: config.secret_key,
|
||||
jwtFromRequest: ExtractJwt.fromAuthHeaderAsBearerToken(),
|
||||
},
|
||||
(
|
||||
req: Request,
|
||||
token: unknown,
|
||||
done: VerifiedCallback,
|
||||
) => {
|
||||
(req: Request, token: unknown, done: VerifiedCallback) => {
|
||||
void verifyJwt(req, token, done);
|
||||
},
|
||||
),
|
||||
|
||||
@ -47,7 +47,9 @@ function authenticatePassport(
|
||||
const middleware: unknown = passport.authenticate(strategy, options);
|
||||
|
||||
if (!isRequestHandler(middleware)) {
|
||||
throw new Error(`Passport ${strategy} authentication middleware is unavailable.`);
|
||||
throw new Error(
|
||||
`Passport ${strategy} authentication middleware is unavailable.`,
|
||||
);
|
||||
}
|
||||
|
||||
return middleware;
|
||||
|
||||
@ -73,7 +73,9 @@ class Asset_variantsDBApi extends GenericDBApi {
|
||||
];
|
||||
}
|
||||
|
||||
static override getFieldMapping(data: AssetVariantData): AssetVariantFieldMapping {
|
||||
static override getFieldMapping(
|
||||
data: AssetVariantData,
|
||||
): AssetVariantFieldMapping {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
assetId: data.assetId || null,
|
||||
|
||||
@ -88,15 +88,12 @@ function isGenericDbModel(value: unknown): value is GenericDbModel {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
'getTableName' in value &&
|
||||
typeof value.getTableName === 'function'
|
||||
);
|
||||
return 'getTableName' in value && typeof value.getTableName === 'function';
|
||||
}
|
||||
|
||||
function buildTransactionOptions(
|
||||
transaction: Transaction | undefined,
|
||||
): { transaction?: Transaction | undefined } {
|
||||
function buildTransactionOptions(transaction: Transaction | undefined): {
|
||||
transaction?: Transaction | undefined;
|
||||
} {
|
||||
const options: { transaction?: Transaction | undefined } = {};
|
||||
if (transaction !== undefined) {
|
||||
options.transaction = transaction;
|
||||
@ -139,12 +136,17 @@ function addRangeFilter(where: DbData, field: string, range: unknown): void {
|
||||
addRangeBoundary(where, field, Op.lte, end);
|
||||
}
|
||||
|
||||
function getFilterString(filter: GenericDbListFilter, field: string): string | null {
|
||||
function getFilterString(
|
||||
filter: GenericDbListFilter,
|
||||
field: string,
|
||||
): string | null {
|
||||
const value = filter[field];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
|
||||
function isDbFindAllOptions(value: unknown): value is DbFindAllOptions<unknown> {
|
||||
function isDbFindAllOptions(
|
||||
value: unknown,
|
||||
): value is DbFindAllOptions<unknown> {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
('filter' in value ||
|
||||
@ -495,9 +497,7 @@ class GenericDBApi {
|
||||
return record;
|
||||
}
|
||||
|
||||
static async findBy(
|
||||
options: DbFindByOptions,
|
||||
): Promise<EntityRecord | null>;
|
||||
static async findBy(options: DbFindByOptions): Promise<EntityRecord | null>;
|
||||
static async findBy(
|
||||
where: unknown,
|
||||
options?: ServiceOptions & { include?: unknown[] },
|
||||
@ -514,7 +514,9 @@ class GenericDBApi {
|
||||
const rawTransaction = hasWhereOption
|
||||
? maybeOptions.transaction
|
||||
: options.transaction;
|
||||
const transaction = isTransaction(rawTransaction) ? rawTransaction : undefined;
|
||||
const transaction = isTransaction(rawTransaction)
|
||||
? rawTransaction
|
||||
: undefined;
|
||||
const include =
|
||||
hasWhereOption && Array.isArray(maybeOptions.include)
|
||||
? maybeOptions.include
|
||||
@ -681,7 +683,8 @@ class GenericDBApi {
|
||||
queryOptions.transaction = options.transaction;
|
||||
}
|
||||
|
||||
const { rows, count } = await this.getModel().findAndCountAll(queryOptions);
|
||||
const { rows, count } =
|
||||
await this.getModel().findAndCountAll(queryOptions);
|
||||
return {
|
||||
rows,
|
||||
count,
|
||||
|
||||
@ -33,7 +33,9 @@ function isMissingTableError(error: unknown): boolean {
|
||||
return original.code === '42P01';
|
||||
}
|
||||
|
||||
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null {
|
||||
function stringifySettings(
|
||||
value: ElementSettingsJson | string | null | undefined,
|
||||
): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
@ -437,7 +439,9 @@ class Element_type_defaultsDBApi extends GenericDBApi {
|
||||
return super.deleteByIds(options);
|
||||
}
|
||||
|
||||
static override async remove(options: EntityIdOptions): Promise<EntityRecord> {
|
||||
static override async remove(
|
||||
options: EntityIdOptions,
|
||||
): Promise<EntityRecord> {
|
||||
await this.ensureInitialized();
|
||||
return super.remove(options);
|
||||
}
|
||||
|
||||
@ -11,7 +11,9 @@ import type {
|
||||
RelationFileRecord,
|
||||
} from '../../types/index.ts';
|
||||
|
||||
function normalizeRelationFiles(rawFiles: RelationFileInput): RelationFileRecord[] {
|
||||
function normalizeRelationFiles(
|
||||
rawFiles: RelationFileInput,
|
||||
): RelationFileRecord[] {
|
||||
if (Array.isArray(rawFiles)) return rawFiles;
|
||||
return rawFiles ? [rawFiles] : [];
|
||||
}
|
||||
|
||||
@ -34,7 +34,9 @@ function isGlobalTransitionListFilter(
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isDbFindAllOptions(value: unknown): value is DbFindAllOptions<unknown> {
|
||||
function isDbFindAllOptions(
|
||||
value: unknown,
|
||||
): value is DbFindAllOptions<unknown> {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
|
||||
@ -28,13 +28,17 @@ import type {
|
||||
|
||||
const { Op } = db.Sequelize;
|
||||
|
||||
function stringifySettings(value: ElementSettingsJson | string | null | undefined): string | null {
|
||||
function stringifySettings(
|
||||
value: ElementSettingsJson | string | null | undefined,
|
||||
): string | null {
|
||||
if (value === undefined || value === null) return null;
|
||||
if (typeof value === 'string') return value;
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
|
||||
function parseSettings(value: ElementSettingsJson | string | null | undefined): ElementSettingsJson {
|
||||
function parseSettings(
|
||||
value: ElementSettingsJson | string | null | undefined,
|
||||
): ElementSettingsJson {
|
||||
if (!value) return {};
|
||||
if (typeof value !== 'string') return value;
|
||||
|
||||
@ -84,7 +88,9 @@ function isQueryWhere(value: unknown): value is QueryWhere {
|
||||
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function isRangeFilter(value: unknown): value is ProjectElementDefaultsRangeFilter {
|
||||
function isRangeFilter(
|
||||
value: unknown,
|
||||
): value is ProjectElementDefaultsRangeFilter {
|
||||
return Array.isArray(value) && value.length === 2;
|
||||
}
|
||||
|
||||
@ -229,7 +235,8 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
||||
whereOrOptions: { id: string } | DbFindByOptions,
|
||||
options: ProjectElementDefaultsOptions = {},
|
||||
): Promise<ProjectElementDefaultRecord | null> {
|
||||
const where = 'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
|
||||
const where =
|
||||
'where' in whereOrOptions ? whereOrOptions.where : whereOrOptions;
|
||||
const findOptions: {
|
||||
where: QueryWhere;
|
||||
include?: unknown[];
|
||||
@ -271,7 +278,8 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
||||
const offset = Math.max(currentPage - 1, 0) * limit;
|
||||
const where: QueryWhere = {};
|
||||
|
||||
const projectFilter = normalizedFilter.project || normalizedFilter.projectId;
|
||||
const projectFilter =
|
||||
normalizedFilter.project || normalizedFilter.projectId;
|
||||
const terms = projectFilter ? projectFilter.split('|') : [];
|
||||
const validUuids = Utils.filterValidUuids(terms);
|
||||
const include: RuntimeProjectInclude[] = [
|
||||
@ -398,17 +406,19 @@ class Project_element_defaultsDBApi extends GenericDBApi {
|
||||
}
|
||||
|
||||
const seenTypes = new Set<string>();
|
||||
const dedupedDefaults = globalDefaults.rows.filter(isGlobalElementDefaultRecord).filter((row) => {
|
||||
if (seenTypes.has(row.element_type)) {
|
||||
logger.warn(
|
||||
{ elementType: row.element_type },
|
||||
'Duplicate element_type in global defaults skipped',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
seenTypes.add(row.element_type);
|
||||
return true;
|
||||
});
|
||||
const dedupedDefaults = globalDefaults.rows
|
||||
.filter(isGlobalElementDefaultRecord)
|
||||
.filter((row) => {
|
||||
if (seenTypes.has(row.element_type)) {
|
||||
logger.warn(
|
||||
{ elementType: row.element_type },
|
||||
'Duplicate element_type in global defaults skipped',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
seenTypes.add(row.element_type);
|
||||
return true;
|
||||
});
|
||||
|
||||
const now = new Date();
|
||||
const currentUserId = options.currentUser?.id || null;
|
||||
|
||||
@ -49,7 +49,10 @@ function normalizeFilter(
|
||||
return isProjectListFilter(filter.filter) ? filter.filter : {};
|
||||
}
|
||||
|
||||
function getFilterString(filter: ProjectListFilter, field: string): string | null {
|
||||
function getFilterString(
|
||||
filter: ProjectListFilter,
|
||||
field: string,
|
||||
): string | null {
|
||||
const value = filter[field];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
@ -69,7 +72,11 @@ function addRangeBoundary(
|
||||
};
|
||||
}
|
||||
|
||||
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
|
||||
function addRangeFilter(
|
||||
where: QueryWhere,
|
||||
field: string,
|
||||
range: unknown,
|
||||
): void {
|
||||
if (!isRangeFilter(range)) return;
|
||||
|
||||
const [start, end] = range;
|
||||
@ -85,7 +92,8 @@ function getDefinedProjectFields(data: ProjectData): ProjectFieldMapping {
|
||||
description: 'description' in data ? data.description || null : undefined,
|
||||
logo_url: 'logo_url' in data ? data.logo_url || null : undefined,
|
||||
favicon_url: 'favicon_url' in data ? data.favicon_url || null : undefined,
|
||||
og_image_url: 'og_image_url' in data ? data.og_image_url || null : undefined,
|
||||
og_image_url:
|
||||
'og_image_url' in data ? data.og_image_url || null : undefined,
|
||||
design_width: 'design_width' in data ? data.design_width : undefined,
|
||||
design_height: 'design_height' in data ? data.design_height : undefined,
|
||||
production_presentation_visibility:
|
||||
|
||||
@ -90,7 +90,9 @@ class Publish_eventsDBApi extends GenericDBApi {
|
||||
];
|
||||
}
|
||||
|
||||
static override getFieldMapping(data: PublishEventData): PublishEventFieldMapping {
|
||||
static override getFieldMapping(
|
||||
data: PublishEventData,
|
||||
): PublishEventFieldMapping {
|
||||
return {
|
||||
id: data.id || undefined,
|
||||
title: data.title || null,
|
||||
|
||||
@ -28,7 +28,9 @@ function getRuntimeEnvironment(
|
||||
return null;
|
||||
}
|
||||
|
||||
function getRuntimeProjectSlug(options: RuntimeFilterOptions = {}): string | null {
|
||||
function getRuntimeProjectSlug(
|
||||
options: RuntimeFilterOptions = {},
|
||||
): string | null {
|
||||
const runtimeContext = getRuntimeContext(options);
|
||||
return runtimeContext?.headerProjectSlug ?? null;
|
||||
}
|
||||
|
||||
@ -52,7 +52,10 @@ function normalizeFilter(
|
||||
return isTourPageListQuery(filter.filter) ? filter.filter : {};
|
||||
}
|
||||
|
||||
function getFilterString(filter: TourPageListQuery, field: string): string | null {
|
||||
function getFilterString(
|
||||
filter: TourPageListQuery,
|
||||
field: string,
|
||||
): string | null {
|
||||
const value = filter[field];
|
||||
return typeof value === 'string' ? value : null;
|
||||
}
|
||||
@ -72,7 +75,11 @@ function addRangeBoundary(
|
||||
};
|
||||
}
|
||||
|
||||
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
|
||||
function addRangeFilter(
|
||||
where: QueryWhere,
|
||||
field: string,
|
||||
range: unknown,
|
||||
): void {
|
||||
if (!isRangeFilter(range)) return;
|
||||
|
||||
const [start, end] = range;
|
||||
@ -83,7 +90,8 @@ function addRangeFilter(where: QueryWhere, field: string, range: unknown): void
|
||||
function getProjectId(data: TourPageData): string | null {
|
||||
if (typeof data.projectId === 'string') return data.projectId;
|
||||
if (typeof data.project === 'string') return data.project;
|
||||
if (data.project && typeof data.project.id === 'string') return data.project.id;
|
||||
if (data.project && typeof data.project.id === 'string')
|
||||
return data.project.id;
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
@ -116,7 +116,11 @@ function addRangeBoundary(
|
||||
};
|
||||
}
|
||||
|
||||
function addRangeFilter(where: QueryWhere, field: string, range: unknown): void {
|
||||
function addRangeFilter(
|
||||
where: QueryWhere,
|
||||
field: string,
|
||||
range: unknown,
|
||||
): void {
|
||||
if (!isRangeFilter(range)) return;
|
||||
|
||||
const [start, end] = range;
|
||||
@ -128,9 +132,9 @@ function getCurrentUserId(options?: ServiceOptions): string | null {
|
||||
return options?.currentUser?.id ?? null;
|
||||
}
|
||||
|
||||
function buildTransactionOptions(
|
||||
transaction: Transaction | undefined,
|
||||
): { transaction?: Transaction | undefined } {
|
||||
function buildTransactionOptions(transaction: Transaction | undefined): {
|
||||
transaction?: Transaction | undefined;
|
||||
} {
|
||||
const options: { transaction?: Transaction | undefined } = {};
|
||||
if (transaction !== undefined) {
|
||||
options.transaction = transaction;
|
||||
@ -173,7 +177,8 @@ function buildUserUpdatePayload(
|
||||
|
||||
if (data.firstName !== undefined) updatePayload.firstName = data.firstName;
|
||||
if (data.lastName !== undefined) updatePayload.lastName = data.lastName;
|
||||
if (data.phoneNumber !== undefined) updatePayload.phoneNumber = data.phoneNumber;
|
||||
if (data.phoneNumber !== undefined)
|
||||
updatePayload.phoneNumber = data.phoneNumber;
|
||||
if (data.email !== undefined) updatePayload.email = data.email;
|
||||
if (data.disabled !== undefined) updatePayload.disabled = data.disabled;
|
||||
if (data.password !== undefined && data.password !== null) {
|
||||
@ -195,7 +200,8 @@ function buildUserUpdatePayload(
|
||||
updatePayload.passwordResetToken = data.passwordResetToken;
|
||||
}
|
||||
if (data.passwordResetTokenExpiresAt !== undefined) {
|
||||
updatePayload.passwordResetTokenExpiresAt = data.passwordResetTokenExpiresAt;
|
||||
updatePayload.passwordResetTokenExpiresAt =
|
||||
data.passwordResetTokenExpiresAt;
|
||||
}
|
||||
if (data.provider !== undefined) updatePayload.provider = data.provider;
|
||||
|
||||
@ -255,7 +261,9 @@ class UsersDBApi {
|
||||
];
|
||||
}
|
||||
|
||||
static async create(options: Parameters<UsersDbApi['create']>[0]): Promise<UserRecord> {
|
||||
static async create(
|
||||
options: Parameters<UsersDbApi['create']>[0],
|
||||
): Promise<UserRecord> {
|
||||
assertCreateOptions(options, 'DBApi');
|
||||
|
||||
const { data: userData, transaction } = options;
|
||||
@ -342,12 +350,7 @@ class UsersDBApi {
|
||||
updateOptions: Parameters<UsersDbApi['update']>[0],
|
||||
): Promise<UserRecord> {
|
||||
assertUpdateOptions(updateOptions, 'DBApi');
|
||||
const {
|
||||
id,
|
||||
data,
|
||||
transaction,
|
||||
runtimeContext,
|
||||
} = updateOptions;
|
||||
const { id, data, transaction, runtimeContext } = updateOptions;
|
||||
const dbOptions: ServiceOptions = {};
|
||||
if (transaction !== undefined) {
|
||||
dbOptions.transaction = transaction;
|
||||
@ -359,7 +362,10 @@ class UsersDBApi {
|
||||
dbOptions.runtimeContext = runtimeContext;
|
||||
}
|
||||
|
||||
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
|
||||
const users = await db.users.findByPk(
|
||||
id,
|
||||
buildTransactionOptions(transaction),
|
||||
);
|
||||
if (!users) {
|
||||
throw new Error('UsersNotFound');
|
||||
}
|
||||
@ -379,9 +385,10 @@ class UsersDBApi {
|
||||
}
|
||||
}
|
||||
if (!data?.custom_permissions) {
|
||||
const existingPermissionIds = users.custom_permissions?.flatMap((item) =>
|
||||
item.id ? [item.id] : [],
|
||||
) || [];
|
||||
const existingPermissionIds =
|
||||
users.custom_permissions?.flatMap((item) =>
|
||||
item.id ? [item.id] : [],
|
||||
) || [];
|
||||
if (existingPermissionIds.length) {
|
||||
data.custom_permissions = existingPermissionIds;
|
||||
}
|
||||
@ -410,9 +417,12 @@ class UsersDBApi {
|
||||
}
|
||||
|
||||
if (data.custom_permissions !== undefined) {
|
||||
await users.setCustom_permissions(normalizePermissionIds(data.custom_permissions) || [], {
|
||||
transaction,
|
||||
});
|
||||
await users.setCustom_permissions(
|
||||
normalizePermissionIds(data.custom_permissions) || [],
|
||||
{
|
||||
transaction,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
await FileDBApi.replaceRelationFiles(
|
||||
@ -461,7 +471,10 @@ class UsersDBApi {
|
||||
const { id, transaction } = options;
|
||||
const currentUserId = getCurrentUserId(options);
|
||||
|
||||
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
|
||||
const users = await db.users.findByPk(
|
||||
id,
|
||||
buildTransactionOptions(transaction),
|
||||
);
|
||||
if (!users) {
|
||||
throw new Error('UsersNotFound');
|
||||
}
|
||||
@ -539,19 +552,21 @@ class UsersDBApi {
|
||||
...buildTransactionOptions(transaction),
|
||||
});
|
||||
|
||||
output.allowed_private_production_project_ids = productionPresentationAccess
|
||||
.flatMap((row: UserProductionPresentationAccessRecord) => {
|
||||
const plain = row.get({ plain: true });
|
||||
const project = plain.project;
|
||||
if (!project?.id || !project.name || !project.slug) return [];
|
||||
output.allowed_private_production_project_ids =
|
||||
productionPresentationAccess.flatMap(
|
||||
(row: UserProductionPresentationAccessRecord) => {
|
||||
const plain = row.get({ plain: true });
|
||||
const project = plain.project;
|
||||
if (!project?.id || !project.name || !project.slug) return [];
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
label: `${project.name} (${project.slug})`,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
};
|
||||
});
|
||||
return {
|
||||
id: project.id,
|
||||
label: `${project.name} (${project.slug})`,
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
};
|
||||
},
|
||||
);
|
||||
|
||||
return output;
|
||||
}
|
||||
@ -669,7 +684,11 @@ class UsersDBApi {
|
||||
'emailVerificationToken',
|
||||
normalizedFilter.emailVerificationToken,
|
||||
);
|
||||
addTextFilter(where, 'passwordResetToken', normalizedFilter.passwordResetToken);
|
||||
addTextFilter(
|
||||
where,
|
||||
'passwordResetToken',
|
||||
normalizedFilter.passwordResetToken,
|
||||
);
|
||||
addTextFilter(where, 'provider', normalizedFilter.provider);
|
||||
addRangeFilter(
|
||||
where,
|
||||
@ -684,7 +703,8 @@ class UsersDBApi {
|
||||
|
||||
if (normalizedFilter.active !== undefined) {
|
||||
where.active =
|
||||
normalizedFilter.active === true || normalizedFilter.active === 'true';
|
||||
normalizedFilter.active === true ||
|
||||
normalizedFilter.active === 'true';
|
||||
}
|
||||
|
||||
if (normalizedFilter.disabled) {
|
||||
@ -733,7 +753,7 @@ class UsersDBApi {
|
||||
typeof normalizedFilter.field === 'string' &&
|
||||
this.SORTABLE_FIELDS.includes(normalizedFilter.field)
|
||||
? normalizedFilter.field
|
||||
: 'createdAt';
|
||||
: 'createdAt';
|
||||
const sortDirection =
|
||||
String(normalizedFilter.sort || 'desc').toUpperCase() === 'ASC'
|
||||
? 'ASC'
|
||||
@ -787,7 +807,9 @@ class UsersDBApi {
|
||||
const where: QueryWhere = {};
|
||||
|
||||
if (query) {
|
||||
const orConditions: unknown[] = [Utils.ilike('users', 'firstName', query)];
|
||||
const orConditions: unknown[] = [
|
||||
Utils.ilike('users', 'firstName', query),
|
||||
];
|
||||
|
||||
if (Utils.isValidUuid(query)) {
|
||||
orConditions.unshift({ id: query });
|
||||
@ -849,7 +871,10 @@ class UsersDBApi {
|
||||
const currentUserId = getCurrentUserId(options);
|
||||
const transaction = options.transaction;
|
||||
|
||||
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
|
||||
const users = await db.users.findByPk(
|
||||
id,
|
||||
buildTransactionOptions(transaction),
|
||||
);
|
||||
if (!users) {
|
||||
throw new Error('UsersNotFound');
|
||||
}
|
||||
@ -928,7 +953,10 @@ class UsersDBApi {
|
||||
const currentUserId = getCurrentUserId(options);
|
||||
const transaction = options.transaction;
|
||||
|
||||
const users = await db.users.findByPk(id, buildTransactionOptions(transaction));
|
||||
const users = await db.users.findByPk(
|
||||
id,
|
||||
buildTransactionOptions(transaction),
|
||||
);
|
||||
if (!users) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -66,7 +66,9 @@ type FunctionPropertyName =
|
||||
function isDatabaseConfigEnvironment(
|
||||
value: string,
|
||||
): value is keyof typeof dbConfig {
|
||||
return value === 'development' || value === 'production' || value === 'dev_stage';
|
||||
return (
|
||||
value === 'development' || value === 'production' || value === 'dev_stage'
|
||||
);
|
||||
}
|
||||
|
||||
function getDatabaseConfig(): DatabaseEnvironmentConfig {
|
||||
@ -115,7 +117,9 @@ function isSampleDataModel(value: object): value is SampleDataModel {
|
||||
return hasFunctionProperties(value, ['bulkCreate', 'count', 'findOne']);
|
||||
}
|
||||
|
||||
function isProjectModel(value: object): value is ProjectModel & SampleDataModel {
|
||||
function isProjectModel(
|
||||
value: object,
|
||||
): value is ProjectModel & SampleDataModel {
|
||||
return hasFunctionProperties(value, [
|
||||
'bulkCreate',
|
||||
'count',
|
||||
@ -127,9 +131,7 @@ function isProjectModel(value: object): value is ProjectModel & SampleDataModel
|
||||
]);
|
||||
}
|
||||
|
||||
function isProjectCloneAssetModel(
|
||||
value: object,
|
||||
): value is DbModels['assets'] {
|
||||
function isProjectCloneAssetModel(value: object): value is DbModels['assets'] {
|
||||
return hasFunctionProperties(value, [
|
||||
'bulkCreate',
|
||||
'count',
|
||||
@ -149,7 +151,9 @@ function isProjectCloneVariantModel(
|
||||
]);
|
||||
}
|
||||
|
||||
function isPublishEventModel(value: object): value is DbModels['publish_events'] {
|
||||
function isPublishEventModel(
|
||||
value: object,
|
||||
): value is DbModels['publish_events'] {
|
||||
return hasFunctionProperties(value, [
|
||||
'bulkCreate',
|
||||
'count',
|
||||
@ -158,7 +162,9 @@ function isPublishEventModel(value: object): value is DbModels['publish_events']
|
||||
]);
|
||||
}
|
||||
|
||||
function isTourPageModel(value: object): value is TourPageModel & SampleDataModel {
|
||||
function isTourPageModel(
|
||||
value: object,
|
||||
): value is TourPageModel & SampleDataModel {
|
||||
return hasFunctionProperties(value, [
|
||||
'bulkCreate',
|
||||
'count',
|
||||
@ -244,7 +250,12 @@ function isProductionPresentationAccessModel(
|
||||
}
|
||||
|
||||
function isRoleModel(value: object): value is RoleModel {
|
||||
return hasFunctionProperties(value, ['create', 'findAll', 'findByPk', 'findOne']);
|
||||
return hasFunctionProperties(value, [
|
||||
'create',
|
||||
'findAll',
|
||||
'findByPk',
|
||||
'findOne',
|
||||
]);
|
||||
}
|
||||
|
||||
function isPermissionModel(value: object): value is PermissionModel {
|
||||
|
||||
@ -3,7 +3,10 @@ import type {
|
||||
SequelizeModelFactory,
|
||||
} from '../../types/index.ts';
|
||||
|
||||
const definePermissionsModel: SequelizeModelFactory = (sequelize, DataTypes) => {
|
||||
const definePermissionsModel: SequelizeModelFactory = (
|
||||
sequelize,
|
||||
DataTypes,
|
||||
) => {
|
||||
const permissions: SequelizeModel = sequelize.define(
|
||||
'permissions',
|
||||
{
|
||||
|
||||
@ -25,7 +25,9 @@ interface UsersSequelizeModel extends ModelStatic<UserModelInstance> {
|
||||
}
|
||||
|
||||
function isKnownExternalProvider(provider: string): boolean {
|
||||
return provider !== providers.LOCAL && Object.values(providers).includes(provider);
|
||||
return (
|
||||
provider !== providers.LOCAL && Object.values(providers).includes(provider)
|
||||
);
|
||||
}
|
||||
|
||||
function trimStringFields(user: UserModelInstance): UserModelInstance {
|
||||
|
||||
@ -6,11 +6,7 @@ import { Sequelize } from 'sequelize';
|
||||
import * as SequelizeModule from 'sequelize';
|
||||
import type { QueryInterface } from 'sequelize';
|
||||
import { SequelizeStorage, Umzug } from 'umzug';
|
||||
import type {
|
||||
MigrationMeta,
|
||||
MigrationParams,
|
||||
RunnableMigration,
|
||||
} from 'umzug';
|
||||
import type { MigrationMeta, MigrationParams, RunnableMigration } from 'umzug';
|
||||
|
||||
import '../load-env.ts';
|
||||
import dbConfig from './db-config.ts';
|
||||
@ -120,7 +116,9 @@ function getModuleDefault(value: unknown): unknown {
|
||||
return value;
|
||||
}
|
||||
|
||||
function isLegacyMigrationModule(value: unknown): value is LegacyMigrationModule {
|
||||
function isLegacyMigrationModule(
|
||||
value: unknown,
|
||||
): value is LegacyMigrationModule {
|
||||
return (
|
||||
isRecord(value) &&
|
||||
typeof value.up === 'function' &&
|
||||
@ -266,7 +264,10 @@ function createMigrators(sequelize: Sequelize): DbMigrators {
|
||||
};
|
||||
}
|
||||
|
||||
function printMigrationList(title: string, migrations: readonly MigrationMeta[]): void {
|
||||
function printMigrationList(
|
||||
title: string,
|
||||
migrations: readonly MigrationMeta[],
|
||||
): void {
|
||||
console.log(`${title}: ${migrations.length}`);
|
||||
for (const migration of migrations) {
|
||||
console.log(`- ${migration.name}`);
|
||||
@ -370,9 +371,7 @@ function selectMigrator(
|
||||
command: DbUmzugCommand,
|
||||
migrators: DbMigrators,
|
||||
): Umzug<DbUmzugContext> {
|
||||
return command.startsWith('seed:')
|
||||
? migrators.seeders
|
||||
: migrators.migrations;
|
||||
return command.startsWith('seed:') ? migrators.seeders : migrators.migrations;
|
||||
}
|
||||
|
||||
async function runCommand(command: DbUmzugCommand): Promise<void> {
|
||||
@ -410,7 +409,9 @@ async function main(): Promise<void> {
|
||||
}
|
||||
|
||||
const mainScriptUrl =
|
||||
process.argv[1] === undefined ? undefined : pathToFileURL(process.argv[1]).href;
|
||||
process.argv[1] === undefined
|
||||
? undefined
|
||||
: pathToFileURL(process.argv[1]).href;
|
||||
|
||||
if (import.meta.url === mainScriptUrl) {
|
||||
void main();
|
||||
|
||||
@ -19,9 +19,8 @@ export default class Utils {
|
||||
}
|
||||
|
||||
static ilike(model: string, column: string, value: string): Where {
|
||||
return where(
|
||||
fn('lower', col(`${model}.${column}`)),
|
||||
{ [Op.like]: `%${value}%`.toLowerCase() },
|
||||
);
|
||||
return where(fn('lower', col(`${model}.${column}`)), {
|
||||
[Op.like]: `%${value}%`.toLowerCase(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -12,10 +12,7 @@ import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
|
||||
import { validateRequest } from '../middlewares/validate-request.ts';
|
||||
import { crud as crudSchemas } from '../validators/request-schemas.ts';
|
||||
import { logger } from '../utils/logger.ts';
|
||||
import {
|
||||
getCurrentUser,
|
||||
getRuntimeContext,
|
||||
} from '../utils/request-context.ts';
|
||||
import { getCurrentUser, getRuntimeContext } from '../utils/request-context.ts';
|
||||
import type {
|
||||
EntityRouterDbApi,
|
||||
EntityRouterOptions,
|
||||
@ -69,7 +66,9 @@ function hasRawAttributes(value: unknown): value is {
|
||||
}
|
||||
|
||||
function isStringArray(value: unknown): value is string[] {
|
||||
return Array.isArray(value) && value.every((item) => typeof item === 'string');
|
||||
return (
|
||||
Array.isArray(value) && value.every((item) => typeof item === 'string')
|
||||
);
|
||||
}
|
||||
|
||||
function clampLimit(value: unknown, options: ClampLimitOptions): number {
|
||||
@ -83,7 +82,8 @@ function clampLimit(value: unknown, options: ClampLimitOptions): number {
|
||||
|
||||
function getSortableFields(DBApi: EntityRouterDbApi): readonly string[] {
|
||||
if (isStringArray(DBApi.SORTABLE_FIELDS)) return DBApi.SORTABLE_FIELDS;
|
||||
if (hasRawAttributes(DBApi.MODEL)) return Object.keys(DBApi.MODEL.rawAttributes);
|
||||
if (hasRawAttributes(DBApi.MODEL))
|
||||
return Object.keys(DBApi.MODEL.rawAttributes);
|
||||
return [];
|
||||
}
|
||||
|
||||
@ -115,10 +115,7 @@ function normalizeQuery(
|
||||
}
|
||||
|
||||
const sortableFields = getSortableFields(DBApi);
|
||||
if (
|
||||
typeof query.field === 'string' &&
|
||||
sortableFields.includes(query.field)
|
||||
) {
|
||||
if (typeof query.field === 'string' && sortableFields.includes(query.field)) {
|
||||
normalized.field = query.field;
|
||||
}
|
||||
|
||||
@ -278,20 +275,22 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
|
||||
router.post(
|
||||
'/',
|
||||
validateRequest(schemaFor(options.validation, 'create')),
|
||||
wrapAsync<never, unknown, EntityDataRequestBody<TCreate>>(async (req, res) => {
|
||||
const referer =
|
||||
req.headers.referer ||
|
||||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
const payload = await Service.create(
|
||||
buildCreateOptions(req.body.data, {
|
||||
currentUser: getCurrentUser(req),
|
||||
runtimeContext: getRuntimeContext(req),
|
||||
host: link.origin,
|
||||
}),
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
wrapAsync<never, unknown, EntityDataRequestBody<TCreate>>(
|
||||
async (req, res) => {
|
||||
const referer =
|
||||
req.headers.referer ||
|
||||
`${req.protocol}://${req.hostname}${req.originalUrl}`;
|
||||
const link = new URL(referer);
|
||||
const payload = await Service.create(
|
||||
buildCreateOptions(req.body.data, {
|
||||
currentUser: getCurrentUser(req),
|
||||
runtimeContext: getRuntimeContext(req),
|
||||
host: link.origin,
|
||||
}),
|
||||
);
|
||||
res.status(200).send(payload);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.post(
|
||||
@ -307,14 +306,14 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
|
||||
validateRequest(schemaFor(options.validation, 'update')),
|
||||
wrapAsync<{ id: string }, unknown, RouteEntityDataRequestBody<TUpdate>>(
|
||||
async (req, res) => {
|
||||
assertRouteIdMatchesBody(req);
|
||||
await Service.update(
|
||||
buildUpdateOptions(req.params.id, req.body.data, {
|
||||
currentUser: getCurrentUser(req),
|
||||
runtimeContext: getRuntimeContext(req),
|
||||
}),
|
||||
);
|
||||
res.status(200).send(true);
|
||||
assertRouteIdMatchesBody(req);
|
||||
await Service.update(
|
||||
buildUpdateOptions(req.params.id, req.body.data, {
|
||||
currentUser: getCurrentUser(req),
|
||||
runtimeContext: getRuntimeContext(req),
|
||||
}),
|
||||
);
|
||||
res.status(200).send(true);
|
||||
},
|
||||
),
|
||||
);
|
||||
@ -336,15 +335,17 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
|
||||
router.post(
|
||||
'/deleteByIds',
|
||||
validateRequest(schemaFor(options.validation, 'deleteByIds')),
|
||||
wrapAsync<never, unknown, EntityDeleteByIdsRequestBody>(async (req, res) => {
|
||||
await Service.deleteByIds(
|
||||
buildDeleteByIdsOptions(req.body.data, {
|
||||
currentUser: getCurrentUser(req),
|
||||
runtimeContext: getRuntimeContext(req),
|
||||
}),
|
||||
);
|
||||
res.status(200).send(true);
|
||||
}),
|
||||
wrapAsync<never, unknown, EntityDeleteByIdsRequestBody>(
|
||||
async (req, res) => {
|
||||
await Service.deleteByIds(
|
||||
buildDeleteByIdsOptions(req.body.data, {
|
||||
currentUser: getCurrentUser(req),
|
||||
runtimeContext: getRuntimeContext(req),
|
||||
}),
|
||||
);
|
||||
res.status(200).send(true);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
router.get(
|
||||
@ -367,10 +368,8 @@ function createEntityRouter<TCreate = unknown, TUpdate = TCreate>(
|
||||
return;
|
||||
}
|
||||
|
||||
const fields = options.csvFields || DBApi.CSV_FIELDS || [
|
||||
'id',
|
||||
'createdAt',
|
||||
];
|
||||
const fields = options.csvFields ||
|
||||
DBApi.CSV_FIELDS || ['id', 'createdAt'];
|
||||
try {
|
||||
const csv = parse(payload.rows, { fields: [...fields] });
|
||||
res.status(200).attachment('export.csv').send(csv);
|
||||
|
||||
@ -5,10 +5,7 @@ import type {
|
||||
RequestHandler,
|
||||
Response,
|
||||
} from 'express';
|
||||
import type {
|
||||
ParamsDictionary,
|
||||
Query,
|
||||
} from 'express-serve-static-core';
|
||||
import type { ParamsDictionary, Query } from 'express-serve-static-core';
|
||||
import jwt from 'jsonwebtoken';
|
||||
|
||||
import config from './config.ts';
|
||||
|
||||
@ -7,7 +7,10 @@ import helmet from 'helmet';
|
||||
import * as swaggerUI from 'swagger-ui-express';
|
||||
|
||||
import './auth/auth.ts';
|
||||
import { authenticateJwt, authenticateJwtWithCallback } from './auth/passport-middleware.ts';
|
||||
import {
|
||||
authenticateJwt,
|
||||
authenticateJwtWithCallback,
|
||||
} from './auth/passport-middleware.ts';
|
||||
import config from './config.ts';
|
||||
import db from './db/models/index.ts';
|
||||
import { wrapAsync } from './helpers.ts';
|
||||
@ -86,7 +89,10 @@ function getExpressRouter(name: string, router: unknown): ExpressRouter {
|
||||
return router;
|
||||
}
|
||||
|
||||
const accessLogsRoutes = getExpressRouter('access_logs', accessLogsRoutesModule);
|
||||
const accessLogsRoutes = getExpressRouter(
|
||||
'access_logs',
|
||||
accessLogsRoutesModule,
|
||||
);
|
||||
const assetVariantsRoutes = getExpressRouter(
|
||||
'asset_variants',
|
||||
assetVariantsRoutesModule,
|
||||
@ -106,7 +112,10 @@ const globalUiControlDefaultsRoutes = getExpressRouter(
|
||||
'global_ui_control_defaults',
|
||||
globalUiControlDefaultsRoutesModule,
|
||||
);
|
||||
const permissionsRoutes = getExpressRouter('permissions', permissionsRoutesModule);
|
||||
const permissionsRoutes = getExpressRouter(
|
||||
'permissions',
|
||||
permissionsRoutesModule,
|
||||
);
|
||||
const presignedUrlRequestsRoutes = getExpressRouter(
|
||||
'presigned_url_requests',
|
||||
presignedUrlRequestsRoutesModule,
|
||||
@ -133,10 +142,16 @@ const projectUiControlSettingsRoutes = getExpressRouter(
|
||||
);
|
||||
const projectsRoutes = getExpressRouter('projects', projectsRoutesModule);
|
||||
const publishRoutes = getExpressRouter('publish', publishRoutesModule);
|
||||
const publishEventsRoutes = getExpressRouter('publish_events', publishEventsRoutesModule);
|
||||
const publishEventsRoutes = getExpressRouter(
|
||||
'publish_events',
|
||||
publishEventsRoutesModule,
|
||||
);
|
||||
const pwaCachesRoutes = getExpressRouter('pwa_caches', pwaCachesRoutesModule);
|
||||
const rolesRoutes = getExpressRouter('roles', rolesRoutesModule);
|
||||
const runtimeAccessRoutes = getExpressRouter('runtime-access', runtimeAccessRoutesModule);
|
||||
const runtimeAccessRoutes = getExpressRouter(
|
||||
'runtime-access',
|
||||
runtimeAccessRoutesModule,
|
||||
);
|
||||
const runtimeContextRoutes = getExpressRouter(
|
||||
'runtime-context',
|
||||
runtimeContextRoutesModule,
|
||||
@ -149,11 +164,7 @@ const specs = createOpenApiDocument({
|
||||
serverUrl: config.server.swaggerServerUrl,
|
||||
});
|
||||
|
||||
app.use(
|
||||
'/api-docs',
|
||||
swaggerUI.serve,
|
||||
swaggerUI.setup(specs),
|
||||
);
|
||||
app.use('/api-docs', swaggerUI.serve, swaggerUI.setup(specs));
|
||||
|
||||
app.enable('trust proxy');
|
||||
app.use(
|
||||
@ -184,104 +195,105 @@ app.use(bodyParser.json({ limit: '50mb' }));
|
||||
app.use(bodyParser.urlencoded({ extended: true, limit: '50mb' }));
|
||||
app.use(runtimeContextMiddleware);
|
||||
|
||||
const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(async (
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
) => {
|
||||
try {
|
||||
const runtimeContext = getRuntimeContext(req);
|
||||
const headerEnvironment = runtimeContext?.headerEnvironment;
|
||||
const headerProjectSlug = runtimeContext?.headerProjectSlug;
|
||||
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
||||
const hasAuthHeader = Boolean(req.headers.authorization);
|
||||
const requireRuntimeReadOrAuth: RuntimeReadOrAuthMiddleware = wrapAsync(
|
||||
async (req, res, next) => {
|
||||
try {
|
||||
const runtimeContext = getRuntimeContext(req);
|
||||
const headerEnvironment = runtimeContext?.headerEnvironment;
|
||||
const headerProjectSlug = runtimeContext?.headerProjectSlug;
|
||||
const isReadOnlyRequest = ['GET', 'OPTIONS'].includes(req.method);
|
||||
const hasAuthHeader = Boolean(req.headers.authorization);
|
||||
|
||||
// Only production is public. Stage requires authentication (workspace for review).
|
||||
const isPublicEnvironment = headerEnvironment === 'production';
|
||||
// Only production is public. Stage requires authentication (workspace for review).
|
||||
const isPublicEnvironment = headerEnvironment === 'production';
|
||||
|
||||
if (!isPublicEnvironment || !isReadOnlyRequest) {
|
||||
setRuntimePublicRequest(req, false);
|
||||
jwtAuth(req, res, next);
|
||||
return;
|
||||
}
|
||||
if (!isPublicEnvironment || !isReadOnlyRequest) {
|
||||
setRuntimePublicRequest(req, false);
|
||||
jwtAuth(req, res, next);
|
||||
return;
|
||||
}
|
||||
|
||||
const isPrivateProductionPresentation =
|
||||
await RuntimePresentationAccessService.isPrivateProductionPresentation(
|
||||
headerProjectSlug,
|
||||
);
|
||||
const isPrivateProductionPresentation =
|
||||
await RuntimePresentationAccessService.isPrivateProductionPresentation(
|
||||
headerProjectSlug,
|
||||
);
|
||||
|
||||
if (!isPrivateProductionPresentation) {
|
||||
setRuntimePublicRequest(req, true);
|
||||
return next();
|
||||
}
|
||||
if (!isPrivateProductionPresentation) {
|
||||
setRuntimePublicRequest(req, true);
|
||||
return next();
|
||||
}
|
||||
|
||||
if (!hasAuthHeader) {
|
||||
setRuntimePublicRequest(req, false);
|
||||
res.status(401).send({ message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
if (!hasAuthHeader) {
|
||||
setRuntimePublicRequest(req, false);
|
||||
res.status(401).send({ message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
const privatePresentationAuth = authenticateJwtWithCallback(
|
||||
async (error, user) => {
|
||||
if (error) return next(error);
|
||||
const privatePresentationAuth = authenticateJwtWithCallback(
|
||||
async (error, user) => {
|
||||
if (error) return next(error);
|
||||
|
||||
if (!user) {
|
||||
setRuntimePublicRequest(req, false);
|
||||
res.status(401).send({ message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setCurrentUser(req, user);
|
||||
|
||||
try {
|
||||
const canAccess =
|
||||
await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation(
|
||||
user,
|
||||
headerProjectSlug,
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
if (!user) {
|
||||
setRuntimePublicRequest(req, false);
|
||||
res.status(403).send({ message: 'Presentation access denied' });
|
||||
res.status(401).send({ message: 'Authentication required' });
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimePublicRequest(req, true);
|
||||
return next();
|
||||
} catch (accessError) {
|
||||
return next(accessError);
|
||||
}
|
||||
},
|
||||
);
|
||||
setCurrentUser(req, user);
|
||||
|
||||
privatePresentationAuth(req, res, next);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
});
|
||||
try {
|
||||
const canAccess =
|
||||
await RuntimePresentationAccessService.canUserAccessPrivateProductionPresentation(
|
||||
user,
|
||||
headerProjectSlug,
|
||||
);
|
||||
|
||||
if (!canAccess) {
|
||||
setRuntimePublicRequest(req, false);
|
||||
res.status(403).send({ message: 'Presentation access denied' });
|
||||
return;
|
||||
}
|
||||
|
||||
setRuntimePublicRequest(req, true);
|
||||
return next();
|
||||
} catch (accessError) {
|
||||
return next(accessError);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
privatePresentationAuth(req, res, next);
|
||||
} catch (error) {
|
||||
return next(error);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// Health check endpoint (no auth required)
|
||||
app.get('/api/health', wrapAsync(async (_req, res) => {
|
||||
const health: HealthResponse = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: config.server.env,
|
||||
};
|
||||
app.get(
|
||||
'/api/health',
|
||||
wrapAsync(async (_req, res) => {
|
||||
const health: HealthResponse = {
|
||||
status: 'ok',
|
||||
timestamp: new Date().toISOString(),
|
||||
uptime: process.uptime(),
|
||||
environment: config.server.env,
|
||||
};
|
||||
|
||||
try {
|
||||
await db.sequelize.authenticate();
|
||||
health.database = 'connected';
|
||||
} catch (error) {
|
||||
health.status = 'degraded';
|
||||
health.database = 'disconnected';
|
||||
health.databaseError =
|
||||
error instanceof Error ? error.message : 'Unknown database error';
|
||||
}
|
||||
try {
|
||||
await db.sequelize.authenticate();
|
||||
health.database = 'connected';
|
||||
} catch (error) {
|
||||
health.status = 'degraded';
|
||||
health.database = 'disconnected';
|
||||
health.databaseError =
|
||||
error instanceof Error ? error.message : 'Unknown database error';
|
||||
}
|
||||
|
||||
const statusCode = health.status === 'ok' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
}));
|
||||
const statusCode = health.status === 'ok' ? 200 : 503;
|
||||
res.status(statusCode).json(health);
|
||||
}),
|
||||
);
|
||||
|
||||
app.use('/api/auth', authRoutes);
|
||||
app.use('/api/runtime-context', runtimeContextRoutes);
|
||||
@ -333,11 +345,7 @@ app.use('/api/access_logs', jwtAuth, accessLogsRoutes);
|
||||
app.use('/api/element-type-defaults', jwtAuth, elementTypeDefaultsRoutes);
|
||||
// Backwards compatibility alias for old API endpoint
|
||||
app.use('/api/ui-elements', jwtAuth, elementTypeDefaultsRoutes);
|
||||
app.use(
|
||||
'/api/project-element-defaults',
|
||||
jwtAuth,
|
||||
projectElementDefaultsRoutes,
|
||||
);
|
||||
app.use('/api/project-element-defaults', jwtAuth, projectElementDefaultsRoutes);
|
||||
// Global transition defaults - routes handle their own auth (GET public, PUT protected)
|
||||
app.use('/api/global-transition-defaults', globalTransitionDefaultsRoutes);
|
||||
|
||||
@ -402,10 +410,7 @@ app.use(appErrorHandler);
|
||||
const PORT = config.server.port;
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
logger.info(
|
||||
{ port: PORT, env: config.server.env },
|
||||
'Server started',
|
||||
);
|
||||
logger.info({ port: PORT, env: config.server.env }, 'Server started');
|
||||
});
|
||||
|
||||
server.on('error', (err: NodeJS.ErrnoException) => {
|
||||
|
||||
@ -82,9 +82,7 @@ async function checkPermissionRequest(
|
||||
|
||||
if (!effectiveRole) {
|
||||
return next(
|
||||
new Error(
|
||||
'Internal Server Error: Could not determine effective role.',
|
||||
),
|
||||
new Error('Internal Server Error: Could not determine effective role.'),
|
||||
);
|
||||
}
|
||||
|
||||
@ -152,8 +150,7 @@ function getCrudPermissionName(
|
||||
permissionNameOverride?: string,
|
||||
): string {
|
||||
return (
|
||||
permissionNameOverride ||
|
||||
`${METHOD_MAP[method]}_${name.toUpperCase()}`
|
||||
permissionNameOverride || `${METHOD_MAP[method]}_${name.toUpperCase()}`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -143,9 +143,13 @@ async function handleProjectSettingsReadOrAuth(
|
||||
return;
|
||||
}
|
||||
|
||||
handlePrivateProductionReadAuth(req, res, next, user, runtimeProjectSlug).catch(
|
||||
handlePrivateProductionReadAuth(
|
||||
req,
|
||||
res,
|
||||
next,
|
||||
);
|
||||
user,
|
||||
runtimeProjectSlug,
|
||||
).catch(next);
|
||||
});
|
||||
|
||||
privateReadAuth(req, res, next);
|
||||
|
||||
@ -54,7 +54,9 @@ setInterval(() => {
|
||||
* @param {Function} [options.skip] - Skip rate limiting for certain requests (req) => boolean
|
||||
* @returns {Function} Express middleware
|
||||
*/
|
||||
const createRateLimiter = (options: RateLimiterOptions = {}): RequestHandler => {
|
||||
const createRateLimiter = (
|
||||
options: RateLimiterOptions = {},
|
||||
): RequestHandler => {
|
||||
const {
|
||||
keyPrefix = 'rate-limit',
|
||||
windowMs = 15 * 60 * 1000, // 15 minutes
|
||||
|
||||
@ -88,7 +88,9 @@ function hasPlainGetter(
|
||||
return typeof value.get === 'function';
|
||||
}
|
||||
|
||||
function toPlainRecord(value: RuntimePublicPlainRecord): RuntimePublicPlainRecord {
|
||||
function toPlainRecord(
|
||||
value: RuntimePublicPlainRecord,
|
||||
): RuntimePublicPlainRecord {
|
||||
return hasPlainGetter(value) ? value.get({ plain: true }) : value;
|
||||
}
|
||||
|
||||
@ -107,7 +109,9 @@ function matchesPublicRuntimePath(
|
||||
: requestPath === pattern;
|
||||
}
|
||||
|
||||
function getAllowedPaths(entityName: string): readonly RuntimePublicPathPattern[] {
|
||||
function getAllowedPaths(
|
||||
entityName: string,
|
||||
): readonly RuntimePublicPathPattern[] {
|
||||
return PUBLIC_RUNTIME_ALLOWED_PATHS[entityName] ?? ['/'];
|
||||
}
|
||||
|
||||
@ -198,4 +202,7 @@ const sanitizePublicRuntimeListResponse =
|
||||
next();
|
||||
};
|
||||
|
||||
export { blockNonPublicRuntimeListEndpoints, sanitizePublicRuntimeListResponse };
|
||||
export {
|
||||
blockNonPublicRuntimeListEndpoints,
|
||||
sanitizePublicRuntimeListResponse,
|
||||
};
|
||||
|
||||
@ -8,7 +8,11 @@ import type {
|
||||
RequestSchemaMap,
|
||||
} from '../types/index.ts';
|
||||
|
||||
const VALID_REQUEST_PARTS: RequestValidationPart[] = ['params', 'query', 'body'];
|
||||
const VALID_REQUEST_PARTS: RequestValidationPart[] = [
|
||||
'params',
|
||||
'query',
|
||||
'body',
|
||||
];
|
||||
|
||||
interface RequestPartsTarget {
|
||||
params: unknown;
|
||||
|
||||
@ -233,7 +233,10 @@ const booleanResponse = jsonResponse('Boolean success response', {
|
||||
type: 'boolean',
|
||||
});
|
||||
|
||||
const successObjectResponse = jsonResponse('Success response', ref('SuccessResponse'));
|
||||
const successObjectResponse = jsonResponse(
|
||||
'Success response',
|
||||
ref('SuccessResponse'),
|
||||
);
|
||||
|
||||
const errorResponses = {
|
||||
400: { $ref: '#/components/responses/BadRequestError' },
|
||||
@ -293,8 +296,7 @@ const crudPaths = (resource: CrudResource): OpenApiPaths => {
|
||||
get: {
|
||||
tags: [resource.tag],
|
||||
summary: `List ${resource.tag} items`,
|
||||
description:
|
||||
`${readDescription}. Supports pagination, sorting, entity filters, and CSV export through filetype=csv.`,
|
||||
description: `${readDescription}. Supports pagination, sorting, entity filters, and CSV export through filetype=csv.`,
|
||||
security: readSecurity,
|
||||
parameters: [...commonReadParameters, ...listParameters],
|
||||
responses: {
|
||||
@ -634,7 +636,10 @@ const schemas: Record<string, OpenApiSchema> = {
|
||||
properties: {
|
||||
id: uuidSchema,
|
||||
name: nullable({ type: 'string' }),
|
||||
asset_type: { type: 'string', enum: ['image', 'video', 'audio', 'embed'] },
|
||||
asset_type: {
|
||||
type: 'string',
|
||||
enum: ['image', 'video', 'audio', 'embed'],
|
||||
},
|
||||
type: { type: 'string', enum: ['general', 'background', 'transition'] },
|
||||
cdn_url: nullable({ type: 'string' }),
|
||||
storage_key: nullable({ type: 'string' }),
|
||||
@ -774,7 +779,10 @@ const schemas: Record<string, OpenApiSchema> = {
|
||||
target_environment: { type: 'string', enum: runtimeEnvironmentValues },
|
||||
started_at: nullable(dateTimeSchema),
|
||||
finished_at: nullable(dateTimeSchema),
|
||||
status: { type: 'string', enum: ['queued', 'running', 'success', 'failed'] },
|
||||
status: {
|
||||
type: 'string',
|
||||
enum: ['queued', 'running', 'success', 'failed'],
|
||||
},
|
||||
error_message: nullable({ type: 'string' }),
|
||||
pages_count: nullable({ type: 'integer' }),
|
||||
assets_count: nullable({ type: 'integer' }),
|
||||
@ -809,7 +817,10 @@ const schemas: Record<string, OpenApiSchema> = {
|
||||
id: uuidSchema,
|
||||
project: uuidSchema,
|
||||
environment: { type: 'string', enum: runtimeEnvironmentValues },
|
||||
asset_type: { type: 'string', enum: ['image', 'video', 'audio', 'embed'] },
|
||||
asset_type: {
|
||||
type: 'string',
|
||||
enum: ['image', 'video', 'audio', 'embed'],
|
||||
},
|
||||
url: nullable({ type: 'string' }),
|
||||
mime_type: nullable({ type: 'string' }),
|
||||
size_mb: nullable({ type: 'number' }),
|
||||
@ -1530,7 +1541,10 @@ const customPaths: OpenApiPaths = {
|
||||
security: bearerSecurity,
|
||||
requestBody: jsonRequest(ref('ReverseVideoStatusRequest')),
|
||||
responses: {
|
||||
200: jsonResponse('Reverse video status map', ref('ReverseVideoStatusResponse')),
|
||||
200: jsonResponse(
|
||||
'Reverse video status map',
|
||||
ref('ReverseVideoStatusResponse'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1542,7 +1556,10 @@ const customPaths: OpenApiPaths = {
|
||||
security: bearerSecurity,
|
||||
parameters: [idParameter],
|
||||
responses: {
|
||||
200: jsonResponse('Reset project element default', ref('ProjectElementDefault')),
|
||||
200: jsonResponse(
|
||||
'Reset project element default',
|
||||
ref('ProjectElementDefault'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1554,7 +1571,10 @@ const customPaths: OpenApiPaths = {
|
||||
security: bearerSecurity,
|
||||
parameters: [idParameter],
|
||||
responses: {
|
||||
200: jsonResponse('Project element default diff', ref('ProjectElementDefaultDiff')),
|
||||
200: jsonResponse(
|
||||
'Project element default diff',
|
||||
ref('ProjectElementDefaultDiff'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1565,7 +1585,10 @@ const customPaths: OpenApiPaths = {
|
||||
summary: 'Get singleton global transition defaults',
|
||||
security: [],
|
||||
responses: {
|
||||
200: jsonResponse('Global transition defaults', ref('GlobalTransitionDefault')),
|
||||
200: jsonResponse(
|
||||
'Global transition defaults',
|
||||
ref('GlobalTransitionDefault'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1577,7 +1600,10 @@ const customPaths: OpenApiPaths = {
|
||||
security: [],
|
||||
parameters: [idParameter],
|
||||
responses: {
|
||||
200: jsonResponse('Global transition defaults', ref('GlobalTransitionDefault')),
|
||||
200: jsonResponse(
|
||||
'Global transition defaults',
|
||||
ref('GlobalTransitionDefault'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1600,7 +1626,10 @@ const customPaths: OpenApiPaths = {
|
||||
security: bearerSecurity,
|
||||
parameters: listParameters,
|
||||
responses: {
|
||||
200: jsonResponse('Paginated list', paginatedSchema('ProjectTransitionSetting')),
|
||||
200: jsonResponse(
|
||||
'Paginated list',
|
||||
paginatedSchema('ProjectTransitionSetting'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1608,7 +1637,9 @@ const customPaths: OpenApiPaths = {
|
||||
tags: ['ProjectTransitionSettings'],
|
||||
summary: 'Create project transition settings',
|
||||
security: bearerSecurity,
|
||||
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')),
|
||||
requestBody: jsonRequest(
|
||||
entityEnvelopeSchema('ProjectTransitionSetting'),
|
||||
),
|
||||
responses: {
|
||||
200: jsonResponse('Created settings', ref('ProjectTransitionSetting')),
|
||||
...errorResponses,
|
||||
@ -1631,7 +1662,9 @@ const customPaths: OpenApiPaths = {
|
||||
summary: 'Update project transition settings by ID',
|
||||
security: bearerSecurity,
|
||||
parameters: [idParameter],
|
||||
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')),
|
||||
requestBody: jsonRequest(
|
||||
entityEnvelopeSchema('ProjectTransitionSetting'),
|
||||
),
|
||||
responses: {
|
||||
200: booleanResponse,
|
||||
...errorResponses,
|
||||
@ -1664,7 +1697,9 @@ const customPaths: OpenApiPaths = {
|
||||
summary: 'Upsert project transition settings for project environment',
|
||||
security: bearerSecurity,
|
||||
parameters: [projectIdParameter, environmentParameter],
|
||||
requestBody: jsonRequest(entityEnvelopeSchema('ProjectTransitionSetting')),
|
||||
requestBody: jsonRequest(
|
||||
entityEnvelopeSchema('ProjectTransitionSetting'),
|
||||
),
|
||||
responses: {
|
||||
200: jsonResponse('Settings', ref('ProjectTransitionSetting')),
|
||||
...errorResponses,
|
||||
@ -1687,7 +1722,10 @@ const customPaths: OpenApiPaths = {
|
||||
summary: 'Get singleton global UI-control defaults',
|
||||
security: [],
|
||||
responses: {
|
||||
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')),
|
||||
200: jsonResponse(
|
||||
'Global UI-control defaults',
|
||||
ref('GlobalUiControlDefaults'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1699,7 +1737,10 @@ const customPaths: OpenApiPaths = {
|
||||
security: [],
|
||||
parameters: [idParameter],
|
||||
responses: {
|
||||
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')),
|
||||
200: jsonResponse(
|
||||
'Global UI-control defaults',
|
||||
ref('GlobalUiControlDefaults'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1710,7 +1751,10 @@ const customPaths: OpenApiPaths = {
|
||||
parameters: [idParameter],
|
||||
requestBody: jsonRequest(entityEnvelopeSchema('GlobalUiControlDefaults')),
|
||||
responses: {
|
||||
200: jsonResponse('Global UI-control defaults', ref('GlobalUiControlDefaults')),
|
||||
200: jsonResponse(
|
||||
'Global UI-control defaults',
|
||||
ref('GlobalUiControlDefaults'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1722,7 +1766,10 @@ const customPaths: OpenApiPaths = {
|
||||
security: bearerSecurity,
|
||||
parameters: listParameters,
|
||||
responses: {
|
||||
200: jsonResponse('Paginated list', paginatedSchema('ProjectUiControlSettings')),
|
||||
200: jsonResponse(
|
||||
'Paginated list',
|
||||
paginatedSchema('ProjectUiControlSettings'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1743,7 +1790,9 @@ const customPaths: OpenApiPaths = {
|
||||
summary: 'Upsert project UI-control settings for project environment',
|
||||
security: bearerSecurity,
|
||||
parameters: [projectIdParameter, environmentParameter],
|
||||
requestBody: jsonRequest(entityEnvelopeSchema('ProjectUiControlSettings')),
|
||||
requestBody: jsonRequest(
|
||||
entityEnvelopeSchema('ProjectUiControlSettings'),
|
||||
),
|
||||
responses: {
|
||||
200: jsonResponse('Settings', ref('ProjectUiControlSettings')),
|
||||
...errorResponses,
|
||||
@ -1822,7 +1871,10 @@ const customPaths: OpenApiPaths = {
|
||||
},
|
||||
],
|
||||
responses: {
|
||||
200: jsonResponse('Presentation access metadata', ref('RuntimePresentationAccess')),
|
||||
200: jsonResponse(
|
||||
'Presentation access metadata',
|
||||
ref('RuntimePresentationAccess'),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1830,10 +1882,14 @@ const customPaths: OpenApiPaths = {
|
||||
'/api/runtime-access/private-production-presentations': {
|
||||
get: {
|
||||
tags: ['RuntimeAccess'],
|
||||
summary: 'List private production presentations for user-management grants',
|
||||
summary:
|
||||
'List private production presentations for user-management grants',
|
||||
security: bearerSecurity,
|
||||
responses: {
|
||||
200: jsonResponse('Private production presentations', arrayOf(ref('Project'))),
|
||||
200: jsonResponse(
|
||||
'Private production presentations',
|
||||
arrayOf(ref('Project')),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1841,10 +1897,14 @@ const customPaths: OpenApiPaths = {
|
||||
'/api/runtime-access/private-production-presentations/autocomplete': {
|
||||
get: {
|
||||
tags: ['RuntimeAccess'],
|
||||
summary: 'Autocomplete private production presentations for user-management grants',
|
||||
summary:
|
||||
'Autocomplete private production presentations for user-management grants',
|
||||
security: bearerSecurity,
|
||||
responses: {
|
||||
200: jsonResponse('Private production presentations', arrayOf(ref('Project'))),
|
||||
200: jsonResponse(
|
||||
'Private production presentations',
|
||||
arrayOf(ref('Project')),
|
||||
),
|
||||
...errorResponses,
|
||||
},
|
||||
},
|
||||
@ -1904,7 +1964,9 @@ function buildCrudPaths(): OpenApiPaths {
|
||||
);
|
||||
}
|
||||
|
||||
function createOpenApiDocument(options: OpenApiDocumentOptions): OpenApiDocument {
|
||||
function createOpenApiDocument(
|
||||
options: OpenApiDocumentOptions,
|
||||
): OpenApiDocument {
|
||||
return {
|
||||
openapi: '3.0.0',
|
||||
info: {
|
||||
@ -1942,7 +2004,10 @@ function createOpenApiDocument(options: OpenApiDocumentOptions): OpenApiDocument
|
||||
),
|
||||
ForbiddenError: jsonResponse('Permission denied', ref('ErrorResponse')),
|
||||
NotFoundError: jsonResponse('Resource not found', ref('ErrorResponse')),
|
||||
RateLimitError: jsonResponse('Rate limit exceeded', ref('ErrorResponse')),
|
||||
RateLimitError: jsonResponse(
|
||||
'Rate limit exceeded',
|
||||
ref('ErrorResponse'),
|
||||
),
|
||||
ServerError: jsonResponse('Server error', ref('ErrorResponse')),
|
||||
},
|
||||
},
|
||||
|
||||
@ -56,7 +56,7 @@ function getRequestHost(req: Request): string {
|
||||
const uiUrl = safeParseUrl(config.uiUrl);
|
||||
const fallbackHost = uiUrl
|
||||
? uiUrl.origin
|
||||
: config.backUrl ?? 'http://localhost:3000';
|
||||
: (config.backUrl ?? 'http://localhost:3000');
|
||||
const origin = safeParseUrl(req.headers.origin);
|
||||
const referer = safeParseUrl(req.headers.referer);
|
||||
|
||||
@ -159,10 +159,7 @@ router.post(
|
||||
signinLimiter,
|
||||
validateRequest(authSchemas.signinLocal),
|
||||
wrapAsync(async (req: Request<never, string, SigninLocalBody>, res) => {
|
||||
const payload = await AuthService.signin(
|
||||
req.body.email,
|
||||
req.body.password,
|
||||
);
|
||||
const payload = await AuthService.signin(req.body.email, req.body.password);
|
||||
res.status(200).send(payload);
|
||||
}),
|
||||
);
|
||||
@ -253,10 +250,14 @@ router.post(
|
||||
validateRequest(authSchemas.sendPasswordResetEmail),
|
||||
wrapAsync(
|
||||
async (req: Request<never, boolean, SendPasswordResetEmailBody>, res) => {
|
||||
const host = getRequestHost(req);
|
||||
await AuthService.sendPasswordResetEmail(req.body.email, 'register', host);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
const host = getRequestHost(req);
|
||||
await AuthService.sendPasswordResetEmail(
|
||||
req.body.email,
|
||||
'register',
|
||||
host,
|
||||
);
|
||||
const payload = true;
|
||||
res.status(200).send(payload);
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
@ -12,7 +12,10 @@ import {
|
||||
useUpdatePermissionForProjectEnvironmentReset,
|
||||
} from '../middlewares/project-settings-runtime-auth.ts';
|
||||
import ProjectTransitionSettingsService from '../services/project_transition_settings.ts';
|
||||
import type { RouteMessageResponse, RouteSuccessResponse } from '../types/index.ts';
|
||||
import type {
|
||||
RouteMessageResponse,
|
||||
RouteSuccessResponse,
|
||||
} from '../types/index.ts';
|
||||
import {
|
||||
assertBodyIdMatchesRouteId,
|
||||
isEntityDataRequestBody,
|
||||
@ -41,7 +44,9 @@ router.get(
|
||||
requireProductionProjectSettingsReadOrAuth,
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
||||
const response: RouteMessageResponse = {
|
||||
message: 'Invalid route params',
|
||||
};
|
||||
return res.status(400).send(response);
|
||||
}
|
||||
|
||||
@ -61,7 +66,9 @@ router.put(
|
||||
'/project/:projectId/env/:environment',
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
||||
const response: RouteMessageResponse = {
|
||||
message: 'Invalid route params',
|
||||
};
|
||||
return res.status(400).send(response);
|
||||
}
|
||||
|
||||
@ -91,7 +98,9 @@ router.delete(
|
||||
'/project/:projectId/env/:environment',
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
||||
const response: RouteMessageResponse = {
|
||||
message: 'Invalid route params',
|
||||
};
|
||||
return res.status(400).send(response);
|
||||
}
|
||||
|
||||
|
||||
@ -35,7 +35,9 @@ router.get(
|
||||
requireProductionProjectSettingsReadOrAuth,
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
||||
const response: RouteMessageResponse = {
|
||||
message: 'Invalid route params',
|
||||
};
|
||||
return res.status(400).send(response);
|
||||
}
|
||||
|
||||
@ -55,14 +57,14 @@ router.put(
|
||||
'/project/:projectId/env/:environment',
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
||||
const response: RouteMessageResponse = {
|
||||
message: 'Invalid route params',
|
||||
};
|
||||
return res.status(400).send(response);
|
||||
}
|
||||
|
||||
const body: unknown = req.body;
|
||||
const data: unknown = isEntityDataRequestBody(body)
|
||||
? body.data
|
||||
: {};
|
||||
const data: unknown = isEntityDataRequestBody(body) ? body.data : {};
|
||||
|
||||
if (!isProjectUiControlSettingsData(data)) {
|
||||
const response: RouteMessageResponse = {
|
||||
@ -87,7 +89,9 @@ router.delete(
|
||||
'/project/:projectId/env/:environment',
|
||||
wrapAsync(async (req, res) => {
|
||||
if (!isProjectEnvironmentRouteParams(req.params)) {
|
||||
const response: RouteMessageResponse = { message: 'Invalid route params' };
|
||||
const response: RouteMessageResponse = {
|
||||
message: 'Invalid route params',
|
||||
};
|
||||
return res.status(400).send(response);
|
||||
}
|
||||
|
||||
|
||||
@ -2,10 +2,7 @@ import express from 'express';
|
||||
import { parse } from 'json2csv';
|
||||
|
||||
import Tour_pagesDBApi from '../db/api/tour_pages.ts';
|
||||
import {
|
||||
wrapAsync,
|
||||
commonErrorHandler,
|
||||
} from '../helpers.ts';
|
||||
import { wrapAsync, commonErrorHandler } from '../helpers.ts';
|
||||
import { checkCrudPermissions } from '../middlewares/check-permissions.ts';
|
||||
import { validateRequest } from '../middlewares/validate-request.ts';
|
||||
import Tour_pagesService from '../services/tour_pages.ts';
|
||||
|
||||
@ -51,12 +51,12 @@ const originalGetByIdHandler = originalGetById?.route.stack[0];
|
||||
if (originalGetByIdHandler) {
|
||||
originalGetByIdHandler.handle = wrapAsync(
|
||||
async (req: Request<{ id: string }>, res) => {
|
||||
// Call original handler with a custom response
|
||||
const payload = await UsersDBApi.findBy({ id: req.params.id });
|
||||
if (payload) {
|
||||
delete payload.password;
|
||||
}
|
||||
res.status(200).send(payload);
|
||||
// Call original handler with a custom response
|
||||
const payload = await UsersDBApi.findBy({ id: req.params.id });
|
||||
if (payload) {
|
||||
delete payload.password;
|
||||
}
|
||||
res.status(200).send(payload);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@ -33,7 +33,9 @@ export default class AccessPolicy {
|
||||
return user?.app_role?.name || user?.role?.name || null;
|
||||
}
|
||||
|
||||
static getStandaloneRoleName(role: RoleRecord | null | undefined): string | null {
|
||||
static getStandaloneRoleName(
|
||||
role: RoleRecord | null | undefined,
|
||||
): string | null {
|
||||
return role?.name || null;
|
||||
}
|
||||
|
||||
@ -105,7 +107,9 @@ export default class AccessPolicy {
|
||||
): Promise<boolean> {
|
||||
if (!user || !permission) return Promise.resolve(false);
|
||||
if (this.isPublicUser(user)) return Promise.resolve(false);
|
||||
return Promise.resolve(this.getEffectivePermissionNames(user).has(permission));
|
||||
return Promise.resolve(
|
||||
this.getEffectivePermissionNames(user).has(permission),
|
||||
);
|
||||
}
|
||||
|
||||
static isPublicUser(user: AccessPolicyUser): boolean {
|
||||
|
||||
@ -61,7 +61,9 @@ const ALLOWED_EMBED_DOMAINS = [
|
||||
'360stories.com',
|
||||
];
|
||||
|
||||
function extractEmbedUrl(embedCode: string | null | undefined): AssetEmbedUrlResult {
|
||||
function extractEmbedUrl(
|
||||
embedCode: string | null | undefined,
|
||||
): AssetEmbedUrlResult {
|
||||
if (!embedCode?.trim()) {
|
||||
throw new ValidationError('Embed code is required');
|
||||
}
|
||||
@ -175,7 +177,9 @@ function buildUpdateServiceOptions(
|
||||
return serviceOptions;
|
||||
}
|
||||
|
||||
function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOptions {
|
||||
function buildAssetFindByOptions(
|
||||
options: AssetUpdateOptions,
|
||||
): AssetFindByOptions {
|
||||
const findByOptions: AssetFindByOptions = {};
|
||||
|
||||
if (options.transaction !== undefined) {
|
||||
@ -188,9 +192,7 @@ function buildAssetFindByOptions(options: AssetUpdateOptions): AssetFindByOption
|
||||
return findByOptions;
|
||||
}
|
||||
|
||||
function getCurrentUserId(
|
||||
options: ServiceOptions,
|
||||
): string | null {
|
||||
function getCurrentUserId(options: ServiceOptions): string | null {
|
||||
return options.currentUser?.id ?? null;
|
||||
}
|
||||
|
||||
|
||||
@ -1,52 +1,52 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<head>
|
||||
<style>
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.link-primary {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.link-primary {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
Verify your email for {appTitle}!
|
||||
</div>
|
||||
<div class="email-body">
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">Verify your email for {appTitle}!</div>
|
||||
<div class="email-body">
|
||||
<p>Hello,</p>
|
||||
<p>Follow this link to verify your email address.</p>
|
||||
<p>If you didn't ask to verify this address, you can ignore this email.</p>
|
||||
<p>
|
||||
If you didn't ask to verify this address, you can ignore this email.
|
||||
</p>
|
||||
<p><a href="{signupUrl}" class="link-primary">{signupUrl}</a></p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br/>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br />
|
||||
The {appTitle} Team
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,55 +1,56 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<head>
|
||||
<style>
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: #fff!important;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.btn-primary {
|
||||
background-color: #3498db;
|
||||
color: #fff !important;
|
||||
padding: 8px 16px;
|
||||
border-radius: 4px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
Welcome to {appTitle}!
|
||||
</div>
|
||||
<div class="email-body">
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">Welcome to {appTitle}!</div>
|
||||
<div class="email-body">
|
||||
<p>Hello,</p>
|
||||
<p>You've been invited to join {appTitle}. Please click the button below to set up your account.</p>
|
||||
<p>
|
||||
You've been invited to join {appTitle}. Please click the button below
|
||||
to set up your account.
|
||||
</p>
|
||||
<a href="{signupUrl}" class="btn-primary">Set up account</a>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br/>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br />
|
||||
The {appTitle} Team
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -1,52 +1,55 @@
|
||||
<!DOCTYPE html>
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<head>
|
||||
<style>
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.link-primary {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
.email-container {
|
||||
max-width: 600px;
|
||||
margin: auto;
|
||||
background-color: #ffffff;
|
||||
border: 1px solid #e2e8f0;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.email-header {
|
||||
background-color: #3498db;
|
||||
color: #fff;
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
.email-body {
|
||||
padding: 16px;
|
||||
}
|
||||
.email-footer {
|
||||
padding: 16px;
|
||||
background-color: #f7fafc;
|
||||
text-align: center;
|
||||
color: #4a5568;
|
||||
font-size: 14px;
|
||||
}
|
||||
.link-primary {
|
||||
color: #3498db;
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">
|
||||
Reset your password for {appTitle}
|
||||
</div>
|
||||
<div class="email-body">
|
||||
</head>
|
||||
<body>
|
||||
<div class="email-container">
|
||||
<div class="email-header">Reset your password for {appTitle}</div>
|
||||
<div class="email-body">
|
||||
<p>Hello,</p>
|
||||
<p>Follow this link to reset your {appTitle} password for your {accountName} account.</p>
|
||||
<p>
|
||||
Follow this link to reset your {appTitle} password for your
|
||||
{accountName} account.
|
||||
</p>
|
||||
<p><a href="{resetUrl}" class="link-primary">{resetUrl}</a></p>
|
||||
<p>If you didn't ask to reset your password, you can ignore this email.</p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br/>
|
||||
<p>
|
||||
If you didn't ask to reset your password, you can ignore this email.
|
||||
</p>
|
||||
</div>
|
||||
<div class="email-footer">
|
||||
Thanks,<br />
|
||||
The {appTitle} Team
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -178,10 +178,7 @@ export default class LocalStorageProvider extends BaseStorageProvider {
|
||||
return { key: destinationKey };
|
||||
}
|
||||
|
||||
override getSignedUrl(
|
||||
key: string,
|
||||
_expiresIn: number,
|
||||
): Promise<string> {
|
||||
override getSignedUrl(key: string, _expiresIn: number): Promise<string> {
|
||||
return Promise.resolve(`/uploads/${key}`);
|
||||
}
|
||||
|
||||
|
||||
@ -177,10 +177,7 @@ export default class S3StorageProvider extends BaseStorageProvider {
|
||||
return 503;
|
||||
}
|
||||
|
||||
if (
|
||||
error instanceof S3ServiceException &&
|
||||
error.$metadata.httpStatusCode
|
||||
) {
|
||||
if (error instanceof S3ServiceException && error.$metadata.httpStatusCode) {
|
||||
return error.$metadata.httpStatusCode;
|
||||
}
|
||||
|
||||
@ -193,11 +190,11 @@ export default class S3StorageProvider extends BaseStorageProvider {
|
||||
|
||||
return Boolean(
|
||||
(errorName && RETRYABLE_ERRORS.has(errorName)) ||
|
||||
(errorCode && RETRYABLE_ERRORS.has(errorCode)) ||
|
||||
(error instanceof S3ServiceException &&
|
||||
error.$metadata.httpStatusCode !== undefined &&
|
||||
error.$metadata.httpStatusCode >= 500 &&
|
||||
error.$metadata.httpStatusCode < 600),
|
||||
(errorCode && RETRYABLE_ERRORS.has(errorCode)) ||
|
||||
(error instanceof S3ServiceException &&
|
||||
error.$metadata.httpStatusCode !== undefined &&
|
||||
error.$metadata.httpStatusCode >= 500 &&
|
||||
error.$metadata.httpStatusCode < 600),
|
||||
);
|
||||
}
|
||||
|
||||
@ -411,10 +408,7 @@ export default class S3StorageProvider extends BaseStorageProvider {
|
||||
return keys;
|
||||
}
|
||||
|
||||
override async getSignedUrl(
|
||||
key: string,
|
||||
expiresIn = 3600,
|
||||
): Promise<string> {
|
||||
override async getSignedUrl(key: string, expiresIn = 3600): Promise<string> {
|
||||
const fullKey = this.buildKey(key);
|
||||
|
||||
const command = new GetObjectCommand({
|
||||
|
||||
@ -61,7 +61,9 @@ const isUploadSessionChunkMeta = (
|
||||
const isUploadSessionUploadedChunks = (
|
||||
value: unknown,
|
||||
): value is UploadSessionUploadedChunks => {
|
||||
return isRecord(value) && Object.values(value).every(isUploadSessionChunkMeta);
|
||||
return (
|
||||
isRecord(value) && Object.values(value).every(isUploadSessionChunkMeta)
|
||||
);
|
||||
};
|
||||
|
||||
const isNullableString = (value: unknown): value is string | null => {
|
||||
@ -160,7 +162,11 @@ export default class UploadSessionManager {
|
||||
return sessionId;
|
||||
}
|
||||
|
||||
saveChunk(sessionId: string, chunkIndex: number, data: Buffer): Promise<void> {
|
||||
saveChunk(
|
||||
sessionId: string,
|
||||
chunkIndex: number,
|
||||
data: Buffer,
|
||||
): Promise<void> {
|
||||
const chunkPath = this.getChunkPath(sessionId, chunkIndex);
|
||||
ensureDirectoryExistence(chunkPath);
|
||||
fs.writeFileSync(chunkPath, data);
|
||||
|
||||
@ -147,10 +147,7 @@ export default class ProjectAudioTracksService {
|
||||
|
||||
const results = await parseCsvRows(req.file.buffer);
|
||||
|
||||
logger.debug(
|
||||
{ rows: results.length },
|
||||
'Project audio tracks CSV parsed',
|
||||
);
|
||||
logger.debug({ rows: results.length }, 'Project audio tracks CSV parsed');
|
||||
|
||||
const bulkImportOptions = buildContextOptions({
|
||||
currentUser: getCurrentUser(req),
|
||||
|
||||
@ -12,9 +12,9 @@ import type {
|
||||
} from '../types/index.ts';
|
||||
import type { Transaction } from 'sequelize';
|
||||
|
||||
function buildTransactionOptions(
|
||||
transaction: Transaction | undefined,
|
||||
): { transaction?: Transaction } {
|
||||
function buildTransactionOptions(transaction: Transaction | undefined): {
|
||||
transaction?: Transaction;
|
||||
} {
|
||||
return transaction ? { transaction } : {};
|
||||
}
|
||||
|
||||
|
||||
@ -536,7 +536,8 @@ export default class ProjectsService extends BaseProjectsService {
|
||||
if (sourceAsset.mime_type !== undefined) {
|
||||
payload.mime_type = sourceAsset.mime_type;
|
||||
}
|
||||
if (sourceAsset.size_mb !== undefined) payload.size_mb = sourceAsset.size_mb;
|
||||
if (sourceAsset.size_mb !== undefined)
|
||||
payload.size_mb = sourceAsset.size_mb;
|
||||
if (sourceAsset.width_px !== undefined) {
|
||||
payload.width_px = sourceAsset.width_px;
|
||||
}
|
||||
|
||||
@ -162,7 +162,11 @@ export default class PublishService {
|
||||
{ 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;
|
||||
|
||||
function hasPermissionMethod(value: unknown): value is RoleWithPermissionMethod {
|
||||
function hasPermissionMethod(
|
||||
value: unknown,
|
||||
): value is RoleWithPermissionMethod {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
|
||||
@ -165,69 +165,79 @@ const regenerateElementInstanceIds = (
|
||||
if (!uiSchema || typeof uiSchema !== 'object') return uiSchema;
|
||||
|
||||
const parsedClone = parseUnknownJson(JSON.stringify(uiSchema));
|
||||
const clonedSchema = isStructuredUiSchema(parsedClone) ? parsedClone : uiSchema;
|
||||
const clonedSchema = isStructuredUiSchema(parsedClone)
|
||||
? parsedClone
|
||||
: uiSchema;
|
||||
if (!Array.isArray(clonedSchema.elements)) return clonedSchema;
|
||||
|
||||
clonedSchema.elements = clonedSchema.elements.map((element): TourPageElement => {
|
||||
const clonedElement: TourPageElement = {
|
||||
...element,
|
||||
id: createLocalElementId(),
|
||||
};
|
||||
clonedSchema.elements = clonedSchema.elements.map(
|
||||
(element): TourPageElement => {
|
||||
const clonedElement: TourPageElement = {
|
||||
...element,
|
||||
id: createLocalElementId(),
|
||||
};
|
||||
|
||||
const galleryCards = regenerateNestedItemIds(clonedElement.galleryCards);
|
||||
if (galleryCards !== undefined) {
|
||||
clonedElement.galleryCards = galleryCards;
|
||||
}
|
||||
const galleryCards = regenerateNestedItemIds(clonedElement.galleryCards);
|
||||
if (galleryCards !== undefined) {
|
||||
clonedElement.galleryCards = galleryCards;
|
||||
}
|
||||
|
||||
const galleryInfoSpans = regenerateNestedItemIds(
|
||||
clonedElement.galleryInfoSpans,
|
||||
);
|
||||
if (galleryInfoSpans !== undefined) {
|
||||
clonedElement.galleryInfoSpans = galleryInfoSpans;
|
||||
}
|
||||
|
||||
const carouselSlides = regenerateNestedItemIds(clonedElement.carouselSlides);
|
||||
if (carouselSlides !== undefined) {
|
||||
clonedElement.carouselSlides = carouselSlides;
|
||||
}
|
||||
|
||||
if (Array.isArray(clonedElement.infoPanelSections)) {
|
||||
clonedElement.infoPanelSections = clonedElement.infoPanelSections.map(
|
||||
(section) => {
|
||||
const clonedSection = {
|
||||
...section,
|
||||
id: `section-${createLocalElementId()}`,
|
||||
};
|
||||
const spans = regenerateNestedItemIds(section.spans);
|
||||
if (spans !== undefined) {
|
||||
clonedSection.spans = spans;
|
||||
}
|
||||
const images = regenerateNestedItemIds(section.images);
|
||||
if (images !== undefined) {
|
||||
clonedSection.images = images;
|
||||
}
|
||||
return clonedSection;
|
||||
},
|
||||
const galleryInfoSpans = regenerateNestedItemIds(
|
||||
clonedElement.galleryInfoSpans,
|
||||
);
|
||||
}
|
||||
if (galleryInfoSpans !== undefined) {
|
||||
clonedElement.galleryInfoSpans = galleryInfoSpans;
|
||||
}
|
||||
|
||||
return clonedElement;
|
||||
});
|
||||
const carouselSlides = regenerateNestedItemIds(
|
||||
clonedElement.carouselSlides,
|
||||
);
|
||||
if (carouselSlides !== undefined) {
|
||||
clonedElement.carouselSlides = carouselSlides;
|
||||
}
|
||||
|
||||
if (Array.isArray(clonedElement.infoPanelSections)) {
|
||||
clonedElement.infoPanelSections = clonedElement.infoPanelSections.map(
|
||||
(section) => {
|
||||
const clonedSection = {
|
||||
...section,
|
||||
id: `section-${createLocalElementId()}`,
|
||||
};
|
||||
const spans = regenerateNestedItemIds(section.spans);
|
||||
if (spans !== undefined) {
|
||||
clonedSection.spans = spans;
|
||||
}
|
||||
const images = regenerateNestedItemIds(section.images);
|
||||
if (images !== undefined) {
|
||||
clonedSection.images = images;
|
||||
}
|
||||
return clonedSection;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return clonedElement;
|
||||
},
|
||||
);
|
||||
|
||||
return clonedSchema;
|
||||
};
|
||||
|
||||
// Create base service from factory
|
||||
const BaseService = createEntityService<TourPageRecord, TourPageData, TourPageData>(
|
||||
Tour_pagesDBApi,
|
||||
{
|
||||
const BaseService = createEntityService<
|
||||
TourPageRecord,
|
||||
TourPageData,
|
||||
TourPageData
|
||||
>(Tour_pagesDBApi, {
|
||||
entityName: 'tour_pages',
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
const buildCreateServiceOptions = (
|
||||
options: TourPageCreateOptions,
|
||||
): Pick<TourPageCreateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> => {
|
||||
): Pick<
|
||||
TourPageCreateOptions,
|
||||
'currentUser' | 'transaction' | 'runtimeContext'
|
||||
> => {
|
||||
const serviceOptions: Pick<
|
||||
TourPageCreateOptions,
|
||||
'currentUser' | 'transaction' | 'runtimeContext'
|
||||
@ -248,7 +258,10 @@ const buildCreateServiceOptions = (
|
||||
|
||||
const buildUpdateServiceOptions = (
|
||||
options: TourPageUpdateOptions,
|
||||
): Pick<TourPageUpdateOptions, 'currentUser' | 'transaction' | 'runtimeContext'> => {
|
||||
): Pick<
|
||||
TourPageUpdateOptions,
|
||||
'currentUser' | 'transaction' | 'runtimeContext'
|
||||
> => {
|
||||
const serviceOptions: Pick<
|
||||
TourPageUpdateOptions,
|
||||
'currentUser' | 'transaction' | 'runtimeContext'
|
||||
@ -270,8 +283,10 @@ const buildUpdateServiceOptions = (
|
||||
const buildFindByOptions = (
|
||||
options: TourPageUpdateOptions,
|
||||
): { transaction?: Transaction; runtimeContext?: RuntimeContext } => {
|
||||
const findByOptions: { transaction?: Transaction; runtimeContext?: RuntimeContext } =
|
||||
{};
|
||||
const findByOptions: {
|
||||
transaction?: Transaction;
|
||||
runtimeContext?: RuntimeContext;
|
||||
} = {};
|
||||
|
||||
if (options.transaction !== undefined) {
|
||||
findByOptions.transaction = options.transaction;
|
||||
@ -317,6 +332,10 @@ const toPlainTourPage = (
|
||||
return page;
|
||||
};
|
||||
|
||||
const getStringValue = (value: unknown): string => {
|
||||
return typeof value === 'string' ? value.trim() : '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Tour Pages Service with reversed video generation
|
||||
*/
|
||||
@ -325,7 +344,9 @@ class TourPagesService extends BaseService {
|
||||
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
|
||||
}
|
||||
|
||||
static getAssetSizeBytes(asset: AssetRecord | null | undefined): number | null {
|
||||
static getAssetSizeBytes(
|
||||
asset: AssetRecord | null | undefined,
|
||||
): number | null {
|
||||
if (!asset || asset.size_mb == null) return null;
|
||||
|
||||
const sizeMb = Number(asset.size_mb);
|
||||
@ -740,7 +761,16 @@ class TourPagesService extends BaseService {
|
||||
static isBackElement(element: TourPageElement): boolean {
|
||||
return Boolean(
|
||||
element.type === 'navigation_prev' ||
|
||||
(element.type?.startsWith?.('navigation') && element.navType === 'back'),
|
||||
(element.type?.startsWith?.('navigation') && element.navType === 'back'),
|
||||
);
|
||||
}
|
||||
|
||||
static isForwardNavigationElement(element: TourPageElement): boolean {
|
||||
return Boolean(
|
||||
element.type === 'navigation_next' ||
|
||||
(element.type?.startsWith?.('navigation') &&
|
||||
element.navType !== 'back' &&
|
||||
element.type !== 'navigation_prev'),
|
||||
);
|
||||
}
|
||||
|
||||
@ -753,14 +783,183 @@ class TourPagesService extends BaseService {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isForward =
|
||||
element.type === 'navigation_next' ||
|
||||
(element.type?.startsWith?.('navigation') &&
|
||||
element.navType !== 'back' &&
|
||||
element.type !== 'navigation_prev');
|
||||
// Check for target (slug or legacy ID) and transition video
|
||||
const hasTarget = element.targetPageSlug || element.targetPageId;
|
||||
return Boolean(isForward && hasTarget && element.transitionVideoUrl);
|
||||
return Boolean(
|
||||
TourPagesService.isForwardNavigationElement(element) &&
|
||||
hasTarget &&
|
||||
element.transitionVideoUrl,
|
||||
);
|
||||
}
|
||||
|
||||
static isForwardElementTargetingPage(
|
||||
element: TourPageElement,
|
||||
page: Pick<TourPageData, 'id' | 'slug'>,
|
||||
): boolean {
|
||||
if (element.navigationTargetMode === 'external_url') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!TourPagesService.isForwardNavigationElement(element)) return false;
|
||||
|
||||
const targetSlug = getStringValue(element.targetPageSlug);
|
||||
const targetId = getStringValue(element.targetPageId);
|
||||
const pageSlug = getStringValue(page.slug);
|
||||
const pageId = getStringValue(page.id);
|
||||
|
||||
return Boolean(
|
||||
(pageSlug && targetSlug === pageSlug) || (pageId && targetId === pageId),
|
||||
);
|
||||
}
|
||||
|
||||
static findBestIncomingForwardElement(
|
||||
sourcePage: TourPageRecord,
|
||||
currentPage: Pick<TourPageData, 'id' | 'slug'>,
|
||||
): TourPageElement | null {
|
||||
const uiSchema = parseStructuredUiSchema(sourcePage.ui_schema_json);
|
||||
if (!uiSchema?.elements || !Array.isArray(uiSchema.elements)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const candidates = uiSchema.elements.filter((element) =>
|
||||
TourPagesService.isForwardElementTargetingPage(element, currentPage),
|
||||
);
|
||||
|
||||
const scoreCandidate = (element: TourPageElement): number => {
|
||||
const hasTransition = Boolean(getStringValue(element.transitionVideoUrl));
|
||||
const hasReverse = Boolean(getStringValue(element.reverseVideoUrl));
|
||||
|
||||
if (hasTransition && hasReverse) return 3;
|
||||
if (hasTransition) return 2;
|
||||
if (hasReverse) return 1;
|
||||
return 0;
|
||||
};
|
||||
|
||||
return candidates.reduce<TourPageElement | null>((best, candidate) => {
|
||||
if (!best) return candidate;
|
||||
return scoreCandidate(candidate) > scoreCandidate(best)
|
||||
? candidate
|
||||
: best;
|
||||
}, null);
|
||||
}
|
||||
|
||||
static applyIncomingTransitionToBackElement(
|
||||
backElement: TourPageElement,
|
||||
incomingElement: TourPageElement | null,
|
||||
): boolean {
|
||||
const nextTransitionVideoUrl = getStringValue(
|
||||
incomingElement?.transitionVideoUrl,
|
||||
);
|
||||
const nextReverseMode = nextTransitionVideoUrl
|
||||
? incomingElement?.transitionReverseMode === 'separate_video'
|
||||
? 'separate_video'
|
||||
: 'auto_reverse'
|
||||
: '';
|
||||
const nextReverseVideoUrl = nextTransitionVideoUrl
|
||||
? getStringValue(incomingElement?.reverseVideoUrl)
|
||||
: '';
|
||||
|
||||
const currentTransitionVideoUrl = getStringValue(
|
||||
backElement.transitionVideoUrl,
|
||||
);
|
||||
const currentReverseMode = getStringValue(
|
||||
backElement.transitionReverseMode,
|
||||
);
|
||||
const currentReverseVideoUrl = getStringValue(backElement.reverseVideoUrl);
|
||||
|
||||
if (
|
||||
currentTransitionVideoUrl === nextTransitionVideoUrl &&
|
||||
currentReverseMode === nextReverseMode &&
|
||||
currentReverseVideoUrl === nextReverseVideoUrl
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
backElement.transitionVideoUrl = nextTransitionVideoUrl;
|
||||
backElement.transitionReverseMode = nextReverseMode;
|
||||
backElement.reverseVideoUrl = nextReverseVideoUrl;
|
||||
return true;
|
||||
}
|
||||
|
||||
static refreshBackNavigationTransitionSources(
|
||||
uiSchema: TourPageStructuredUiSchema,
|
||||
pages: TourPageRecord[],
|
||||
currentPage: Pick<TourPageData, 'id' | 'slug'>,
|
||||
): boolean {
|
||||
if (!uiSchema.elements || !Array.isArray(uiSchema.elements)) return false;
|
||||
|
||||
let wasModified = false;
|
||||
|
||||
for (const element of uiSchema.elements) {
|
||||
if (!TourPagesService.isBackElement(element)) continue;
|
||||
|
||||
const sourcePageSlug = getStringValue(element.targetPageSlug);
|
||||
const sourcePageId = getStringValue(element.targetPageId);
|
||||
|
||||
if (!sourcePageSlug && !sourcePageId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourcePage = pages.find(
|
||||
(page) =>
|
||||
(sourcePageSlug && page.slug === sourcePageSlug) ||
|
||||
(sourcePageId && page.id === sourcePageId),
|
||||
);
|
||||
|
||||
const incomingElement = sourcePage
|
||||
? TourPagesService.findBestIncomingForwardElement(
|
||||
sourcePage,
|
||||
currentPage,
|
||||
)
|
||||
: null;
|
||||
|
||||
if (
|
||||
TourPagesService.applyIncomingTransitionToBackElement(
|
||||
element,
|
||||
incomingElement,
|
||||
)
|
||||
) {
|
||||
wasModified = true;
|
||||
}
|
||||
}
|
||||
|
||||
return wasModified;
|
||||
}
|
||||
|
||||
static async refreshBackNavigationTransitionSourcesForPage(
|
||||
data: TourPageData,
|
||||
uiSchema: TourPageStructuredUiSchema,
|
||||
projectId: string | null | undefined,
|
||||
): Promise<boolean> {
|
||||
const currentPageSlug = getStringValue(data.slug);
|
||||
const currentPageId = getStringValue(data.id);
|
||||
if (!projectId || (!currentPageSlug && !currentPageId)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasTargetedBackNavigation = uiSchema.elements?.some(
|
||||
(element) =>
|
||||
TourPagesService.isBackElement(element) &&
|
||||
(getStringValue(element.targetPageSlug) ||
|
||||
getStringValue(element.targetPageId)),
|
||||
);
|
||||
if (!hasTargetedBackNavigation) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const { rows: pages } = await Tour_pagesDBApi.findAll(
|
||||
{
|
||||
projectId,
|
||||
environment: data.environment || 'dev',
|
||||
},
|
||||
{},
|
||||
);
|
||||
|
||||
return TourPagesService.refreshBackNavigationTransitionSources(
|
||||
uiSchema,
|
||||
pages,
|
||||
{ id: currentPageId, slug: currentPageSlug },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -802,8 +1001,14 @@ class TourPagesService extends BaseService {
|
||||
'Processing reversed videos for navigation elements',
|
||||
);
|
||||
|
||||
let wasModified =
|
||||
await TourPagesService.refreshBackNavigationTransitionSourcesForPage(
|
||||
data,
|
||||
uiSchema,
|
||||
projectId,
|
||||
);
|
||||
|
||||
const storageKeysToValidate = new Set<string>();
|
||||
let wasModified = false;
|
||||
|
||||
for (const element of uiSchema.elements) {
|
||||
const isBack = TourPagesService.isBackElement(element);
|
||||
@ -972,38 +1177,39 @@ class TourPagesService extends BaseService {
|
||||
|
||||
setImmediate(() => {
|
||||
void (async () => {
|
||||
const log = logger.child({
|
||||
projectId,
|
||||
storageKey,
|
||||
pageId,
|
||||
operation: 'singleReverseGeneration',
|
||||
});
|
||||
|
||||
try {
|
||||
log.info('Starting background reversed variant generation');
|
||||
const reversedUrl = await TourPagesService.getOrGenerateReversedVariant(
|
||||
storageKey,
|
||||
currentUser,
|
||||
);
|
||||
|
||||
if (!reversedUrl) {
|
||||
log.warn(
|
||||
'Background reversed variant generation finished without result',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await TourPagesService.applyReversedUrlToProjectElements(
|
||||
const log = logger.child({
|
||||
projectId,
|
||||
storageKey,
|
||||
reversedUrl,
|
||||
currentUser,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error({ err }, 'Background reversed generation failed');
|
||||
} finally {
|
||||
singleReverseGenerationInProgress.delete(taskKey);
|
||||
}
|
||||
pageId,
|
||||
operation: 'singleReverseGeneration',
|
||||
});
|
||||
|
||||
try {
|
||||
log.info('Starting background reversed variant generation');
|
||||
const reversedUrl =
|
||||
await TourPagesService.getOrGenerateReversedVariant(
|
||||
storageKey,
|
||||
currentUser,
|
||||
);
|
||||
|
||||
if (!reversedUrl) {
|
||||
log.warn(
|
||||
'Background reversed variant generation finished without result',
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
await TourPagesService.applyReversedUrlToProjectElements(
|
||||
projectId,
|
||||
storageKey,
|
||||
reversedUrl,
|
||||
currentUser,
|
||||
);
|
||||
} catch (err) {
|
||||
log.error({ err }, 'Background reversed generation failed');
|
||||
} finally {
|
||||
singleReverseGenerationInProgress.delete(taskKey);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
@ -1027,20 +1233,20 @@ class TourPagesService extends BaseService {
|
||||
|
||||
setImmediate(() => {
|
||||
void (async () => {
|
||||
try {
|
||||
await TourPagesService.regenerateProjectReversedVideos(
|
||||
projectId,
|
||||
currentUser,
|
||||
excludePageId,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, projectId },
|
||||
'Background project regeneration failed',
|
||||
);
|
||||
} finally {
|
||||
projectRegenInProgress.delete(projectId);
|
||||
}
|
||||
try {
|
||||
await TourPagesService.regenerateProjectReversedVideos(
|
||||
projectId,
|
||||
currentUser,
|
||||
excludePageId,
|
||||
);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
{ err, projectId },
|
||||
'Background project regeneration failed',
|
||||
);
|
||||
} finally {
|
||||
projectRegenInProgress.delete(projectId);
|
||||
}
|
||||
})();
|
||||
});
|
||||
}
|
||||
@ -1197,9 +1403,13 @@ class TourPagesService extends BaseService {
|
||||
// Upload reversed video to storage
|
||||
const reversedKey = `assets/${asset.id}/reversed.mp4`;
|
||||
|
||||
const result = await FileService.uploadBuffer(reversedKey, reversedBuffer, {
|
||||
contentType: 'video/mp4',
|
||||
});
|
||||
const result = await FileService.uploadBuffer(
|
||||
reversedKey,
|
||||
reversedBuffer,
|
||||
{
|
||||
contentType: 'video/mp4',
|
||||
},
|
||||
);
|
||||
|
||||
// Create variant record
|
||||
await Asset_variantsDBApi.create({
|
||||
@ -1266,7 +1476,12 @@ class TourPagesService extends BaseService {
|
||||
continue;
|
||||
}
|
||||
|
||||
let pageModified = false;
|
||||
let pageModified =
|
||||
TourPagesService.refreshBackNavigationTransitionSources(
|
||||
uiSchema,
|
||||
pages,
|
||||
{ id: page.id, slug: page.slug },
|
||||
);
|
||||
|
||||
for (const element of uiSchema.elements) {
|
||||
// Process both forward elements AND back elements with their own transition
|
||||
@ -1274,7 +1489,7 @@ class TourPagesService extends BaseService {
|
||||
TourPagesService.isForwardElementWithTarget(element);
|
||||
const isBackWithTransition = Boolean(
|
||||
TourPagesService.isBackElement(element) &&
|
||||
element.transitionVideoUrl,
|
||||
element.transitionVideoUrl,
|
||||
);
|
||||
|
||||
log.debug(
|
||||
|
||||
@ -33,12 +33,9 @@ const BaseUsersService = createEntityService<
|
||||
UserData,
|
||||
UserListFilter,
|
||||
UserAutocompleteOption
|
||||
>(
|
||||
UsersDBApi,
|
||||
{
|
||||
entityName: 'Users',
|
||||
},
|
||||
);
|
||||
>(UsersDBApi, {
|
||||
entityName: 'Users',
|
||||
});
|
||||
|
||||
const buildRuntimeOptions = (
|
||||
transaction: Transaction,
|
||||
@ -230,7 +227,9 @@ export default class UsersService extends BaseUsersService {
|
||||
await this.createProductionPresentationAccessForPublicUser(options);
|
||||
}
|
||||
|
||||
static override async create(options: UserCreateOptions): Promise<UserRecord> {
|
||||
static override async create(
|
||||
options: UserCreateOptions,
|
||||
): Promise<UserRecord> {
|
||||
assertCreateOptions(options, 'Service');
|
||||
const {
|
||||
data,
|
||||
@ -319,7 +318,9 @@ export default class UsersService extends BaseUsersService {
|
||||
}
|
||||
}
|
||||
|
||||
static override async update(options: UserUpdateOptions): Promise<UserRecord> {
|
||||
static override async update(
|
||||
options: UserUpdateOptions,
|
||||
): Promise<UserRecord> {
|
||||
assertUpdateOptions(options, 'Service');
|
||||
const {
|
||||
id,
|
||||
@ -405,7 +406,9 @@ export default class UsersService extends BaseUsersService {
|
||||
}
|
||||
}
|
||||
|
||||
static override async remove(options: UserRemoveOptions): Promise<UserRecord> {
|
||||
static override async remove(
|
||||
options: UserRemoveOptions,
|
||||
): Promise<UserRecord> {
|
||||
assertIdOptions(options, 'Service', 'remove');
|
||||
const { id, currentUser, transaction, runtimeContext } = options;
|
||||
|
||||
|
||||
@ -20,7 +20,8 @@ import { logger } from '../utils/logger.ts';
|
||||
|
||||
const loadCommonJsModule = createRequire(import.meta.url);
|
||||
const ffmpegStaticValue: unknown = loadCommonJsModule('ffmpeg-static');
|
||||
const ffmpegPath = typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null;
|
||||
const ffmpegPath =
|
||||
typeof ffmpegStaticValue === 'string' ? ffmpegStaticValue : null;
|
||||
|
||||
let ffmpegQueueTail: Promise<unknown> = Promise.resolve();
|
||||
let queuedFfmpegJobs = 0;
|
||||
@ -47,9 +48,11 @@ interface MediaProbeStream {
|
||||
|
||||
interface MediaProbeOutput {
|
||||
streams?: MediaProbeStream[] | undefined;
|
||||
format?: {
|
||||
duration?: string | number | undefined;
|
||||
} | undefined;
|
||||
format?:
|
||||
| {
|
||||
duration?: string | number | undefined;
|
||||
}
|
||||
| undefined;
|
||||
}
|
||||
|
||||
interface ProcessResult {
|
||||
@ -360,7 +363,9 @@ async function probeMediaMetadata(filePath: string): Promise<MediaMetadata> {
|
||||
}
|
||||
metadata = parsedMetadata;
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse FFprobe metadata: ${toError(error).message}`);
|
||||
throw new Error(
|
||||
`Failed to parse FFprobe metadata: ${toError(error).message}`,
|
||||
);
|
||||
}
|
||||
|
||||
const streams = metadata.streams ?? [];
|
||||
|
||||
@ -21,7 +21,9 @@ export interface ProductionPresentationProject {
|
||||
}
|
||||
|
||||
export interface RoleWithPermissionLoader extends RoleRecord {
|
||||
getPermissions?: () => Promise<ReadonlyArray<PermissionRecord | PermissionName>>;
|
||||
getPermissions?: () => Promise<
|
||||
ReadonlyArray<PermissionRecord | PermissionName>
|
||||
>;
|
||||
}
|
||||
|
||||
export type AccessPolicyUser = CurrentUser | null | undefined;
|
||||
|
||||
@ -145,16 +145,19 @@ export interface InvalidAssetMimeValidationResult {
|
||||
}
|
||||
|
||||
export type AssetMimeValidationResult =
|
||||
| ValidAssetMimeValidationResult
|
||||
| InvalidAssetMimeValidationResult;
|
||||
ValidAssetMimeValidationResult | InvalidAssetMimeValidationResult;
|
||||
|
||||
export type AssetFindByOptions = Pick<
|
||||
UpdateOptions<AssetData>,
|
||||
'transaction' | 'runtimeContext'
|
||||
>;
|
||||
|
||||
export interface AssetsDbApi
|
||||
extends EntityDbApi<AssetRecord, AssetData, AssetData, AssetListFilter> {
|
||||
export interface AssetsDbApi extends EntityDbApi<
|
||||
AssetRecord,
|
||||
AssetData,
|
||||
AssetData,
|
||||
AssetListFilter
|
||||
> {
|
||||
findBy(options: DbFindByOptions): Promise<AssetRecord | null>;
|
||||
findBy(
|
||||
where: { id?: string; storage_key?: string },
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
import type SMTPConnection from 'nodemailer/lib/smtp-connection/index.js';
|
||||
import type SMTPTransport from 'nodemailer/lib/smtp-transport/index.js';
|
||||
|
||||
export interface BackendEmailConfig
|
||||
extends Omit<SMTPTransport.Options, 'auth'> {
|
||||
export interface BackendEmailConfig extends Omit<
|
||||
SMTPTransport.Options,
|
||||
'auth'
|
||||
> {
|
||||
from: string;
|
||||
auth: SMTPConnection.Credentials;
|
||||
}
|
||||
|
||||
@ -103,8 +103,9 @@ export interface DbFindAllOptions<TFilter> extends ServiceOptions {
|
||||
offset?: number;
|
||||
}
|
||||
|
||||
export interface DbFindByOptions<TWhere extends WhereOptions = WhereOptions>
|
||||
extends ServiceOptions {
|
||||
export interface DbFindByOptions<
|
||||
TWhere extends WhereOptions = WhereOptions,
|
||||
> extends ServiceOptions {
|
||||
where: TWhere;
|
||||
include?: Includeable[];
|
||||
}
|
||||
@ -135,13 +136,20 @@ export interface EntityDbApi<
|
||||
remove(options: EntityIdOptions): Promise<TEntity>;
|
||||
deleteByIds(options: DeleteByIdsOptions): Promise<TEntity[]>;
|
||||
findBy(options: DbFindByOptions): Promise<TEntity | null>;
|
||||
findAll(options: DbFindAllOptions<TFilter>): Promise<PaginatedResult<TEntity>>;
|
||||
findAll(
|
||||
options: DbFindAllOptions<TFilter>,
|
||||
): Promise<PaginatedResult<TEntity>>;
|
||||
findAllAutocomplete(options: AutocompleteOptions): Promise<TAutocomplete[]>;
|
||||
}
|
||||
|
||||
export interface SingletonDbApi<TEntity extends EntityRecord, TUpdate>
|
||||
extends EntityDbApi<TEntity, TUpdate, TUpdate, unknown> {
|
||||
export interface SingletonDbApi<
|
||||
TEntity extends EntityRecord,
|
||||
TUpdate,
|
||||
> extends EntityDbApi<TEntity, TUpdate, TUpdate, unknown> {
|
||||
findOne(options?: ServiceOptions): Promise<TEntity | null>;
|
||||
findBy(options: DbFindByOptions): Promise<TEntity | null>;
|
||||
findBy(where: { id: string }, options?: ServiceOptions): Promise<TEntity | null>;
|
||||
findBy(
|
||||
where: { id: string },
|
||||
options?: ServiceOptions,
|
||||
): Promise<TEntity | null>;
|
||||
}
|
||||
|
||||
@ -11,8 +11,7 @@ export interface SequelizeCliStorageOptions {
|
||||
}
|
||||
|
||||
export interface DatabaseEnvironmentConfig
|
||||
extends Options,
|
||||
SequelizeCliStorageOptions {
|
||||
extends Options, SequelizeCliStorageOptions {
|
||||
dialect: 'postgres';
|
||||
use_env_variable?: string;
|
||||
}
|
||||
|
||||
@ -461,9 +461,7 @@ export interface RoleModel {
|
||||
data: { name: string },
|
||||
options: { transaction: Transaction },
|
||||
): Promise<RoleModelRecord>;
|
||||
findOne(options: {
|
||||
where: { name: string };
|
||||
}): Promise<UserPublicRole | null>;
|
||||
findOne(options: { where: { name: string } }): Promise<UserPublicRole | null>;
|
||||
findAll(options: {
|
||||
where: { name: 'Public' };
|
||||
include: readonly [{ association: 'permissions' }];
|
||||
|
||||
@ -76,13 +76,12 @@ export interface ElementTypeDefaultsModel {
|
||||
}): Promise<ElementTypeDefaultsModelRecord | null>;
|
||||
}
|
||||
|
||||
export interface ElementTypeDefaultsDbApi
|
||||
extends EntityDbApi<
|
||||
ElementTypeDefaultsRecord,
|
||||
ElementTypeDefaultsData,
|
||||
ElementTypeDefaultsData,
|
||||
unknown
|
||||
> {
|
||||
export interface ElementTypeDefaultsDbApi extends EntityDbApi<
|
||||
ElementTypeDefaultsRecord,
|
||||
ElementTypeDefaultsData,
|
||||
ElementTypeDefaultsData,
|
||||
unknown
|
||||
> {
|
||||
ensureInitialized(): Promise<void>;
|
||||
bulkImport(
|
||||
data: ElementTypeDefaultsData[],
|
||||
|
||||
@ -47,8 +47,10 @@ export interface EntityRouterQuery {
|
||||
offset?: unknown;
|
||||
}
|
||||
|
||||
export interface NormalizedEntityRouterQuery
|
||||
extends Omit<EntityRouterQuery, 'limit' | 'page' | 'sort' | 'field'> {
|
||||
export interface NormalizedEntityRouterQuery extends Omit<
|
||||
EntityRouterQuery,
|
||||
'limit' | 'page' | 'sort' | 'field'
|
||||
> {
|
||||
limit: number;
|
||||
page: number;
|
||||
sort?: EntityRouterSortDirection;
|
||||
|
||||
@ -34,12 +34,12 @@ export interface EntityServiceConstructor<
|
||||
TFilter = unknown,
|
||||
TAutocomplete extends EntityRecord | { id: string } = TEntity,
|
||||
> extends EntityServiceClass<
|
||||
TEntity,
|
||||
TCreate,
|
||||
TUpdate,
|
||||
TFilter,
|
||||
TAutocomplete
|
||||
> {
|
||||
TEntity,
|
||||
TCreate,
|
||||
TUpdate,
|
||||
TFilter,
|
||||
TAutocomplete
|
||||
> {
|
||||
new (): object;
|
||||
}
|
||||
|
||||
@ -58,7 +58,10 @@ export interface EntityServiceDbApi<
|
||||
readonly __filterType?: TFilter;
|
||||
readonly __autocompleteType?: TAutocomplete;
|
||||
create(options: CreateOptions<unknown>): Promise<TEntity>;
|
||||
bulkImport(rows: readonly unknown[], options: BulkImportOptions): Promise<unknown>;
|
||||
bulkImport(
|
||||
rows: readonly unknown[],
|
||||
options: BulkImportOptions,
|
||||
): Promise<unknown>;
|
||||
update(options: UpdateOptions<unknown>): Promise<TEntity>;
|
||||
findBy(
|
||||
where: { id: string },
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
export type NodeEnvironment =
|
||||
| 'development'
|
||||
| 'test'
|
||||
| 'production'
|
||||
| 'dev_stage';
|
||||
'development' | 'test' | 'production' | 'dev_stage';
|
||||
|
||||
export interface ValidatedEnvironment {
|
||||
NODE_ENV: NodeEnvironment;
|
||||
|
||||
@ -231,7 +231,9 @@ export interface FileServiceFacade {
|
||||
req: FileServiceRequest,
|
||||
res: FileServiceResponse,
|
||||
): Promise<unknown>;
|
||||
generatePresignedUrls(urls: readonly string[]): Promise<Record<string, string>>;
|
||||
generatePresignedUrls(
|
||||
urls: readonly string[],
|
||||
): Promise<Record<string, string>>;
|
||||
isValidPath(urlPath: unknown): urlPath is string;
|
||||
createErrorResponse(
|
||||
message: string,
|
||||
@ -266,10 +268,7 @@ export interface FileDbOptions {
|
||||
}
|
||||
|
||||
export type RelationFileInput =
|
||||
| RelationFileRecord
|
||||
| RelationFileRecord[]
|
||||
| null
|
||||
| undefined;
|
||||
RelationFileRecord | RelationFileRecord[] | null | undefined;
|
||||
|
||||
export interface FileModelCreatePayload {
|
||||
belongsTo: string;
|
||||
@ -364,7 +363,10 @@ export interface UploadSessionChunkMeta {
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
export type UploadSessionUploadedChunks = Record<string, UploadSessionChunkMeta>;
|
||||
export type UploadSessionUploadedChunks = Record<
|
||||
string,
|
||||
UploadSessionChunkMeta
|
||||
>;
|
||||
|
||||
export interface UploadSessionMeta {
|
||||
sessionId: string;
|
||||
|
||||
@ -1,15 +1,8 @@
|
||||
export type TransitionType =
|
||||
| 'fade'
|
||||
| 'slide-left'
|
||||
| 'slide-right'
|
||||
| 'zoom'
|
||||
| 'none';
|
||||
'fade' | 'slide-left' | 'slide-right' | 'zoom' | 'none';
|
||||
|
||||
export type TransitionEasing =
|
||||
| 'ease-in-out'
|
||||
| 'ease-in'
|
||||
| 'ease-out'
|
||||
| 'linear';
|
||||
'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
|
||||
|
||||
export interface GlobalTransitionDefaultsData {
|
||||
id?: string;
|
||||
@ -19,8 +12,7 @@ export interface GlobalTransitionDefaultsData {
|
||||
overlay_color?: string;
|
||||
}
|
||||
|
||||
export interface GlobalTransitionDefaultsRecord
|
||||
extends Required<GlobalTransitionDefaultsData> {
|
||||
export interface GlobalTransitionDefaultsRecord extends Required<GlobalTransitionDefaultsData> {
|
||||
id: string;
|
||||
createdAt?: Date;
|
||||
updatedAt?: Date;
|
||||
@ -86,13 +78,11 @@ export interface GlobalUiControlDefaultsModelRecord {
|
||||
export interface GlobalUiControlDefaultsModel {
|
||||
count(): Promise<number>;
|
||||
sync(): Promise<unknown>;
|
||||
create(
|
||||
data: {
|
||||
settings_json: GlobalUiControlSettingsJson;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
},
|
||||
): Promise<GlobalUiControlDefaultsModelRecord>;
|
||||
create(data: {
|
||||
settings_json: GlobalUiControlSettingsJson;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}): Promise<GlobalUiControlDefaultsModelRecord>;
|
||||
findOne(options: {
|
||||
transaction?: unknown;
|
||||
}): Promise<GlobalUiControlDefaultsModelRecord | null>;
|
||||
|
||||
@ -1,8 +1,5 @@
|
||||
import type { NextFunction, Request, Response } from 'express';
|
||||
import type {
|
||||
ParamsDictionary,
|
||||
Query,
|
||||
} from 'express-serve-static-core';
|
||||
import type { ParamsDictionary, Query } from 'express-serve-static-core';
|
||||
|
||||
import type { RequestValidationDetail } from './validation.ts';
|
||||
|
||||
@ -32,7 +29,11 @@ export interface RouteIdRequestLike {
|
||||
body: unknown;
|
||||
}
|
||||
|
||||
export type RouteIdRequest = Request<{ id: string }, unknown, RouteIdRequestBody>;
|
||||
export type RouteIdRequest = Request<
|
||||
{ id: string },
|
||||
unknown,
|
||||
RouteIdRequestBody
|
||||
>;
|
||||
|
||||
export interface RouteIdRequestBody {
|
||||
id?: string;
|
||||
|
||||
@ -12,8 +12,7 @@ export interface SortOptions<TSortField extends string = string> {
|
||||
}
|
||||
|
||||
export interface ListQueryOptions<TFilter, TSortField extends string = string>
|
||||
extends PaginationOptions,
|
||||
SortOptions<TSortField> {
|
||||
extends PaginationOptions, SortOptions<TSortField> {
|
||||
filter?: TFilter;
|
||||
query?: string;
|
||||
}
|
||||
|
||||
@ -124,9 +124,7 @@ export interface ProjectAudioTracksDbApi {
|
||||
where: { id: string },
|
||||
options?: ProjectAudioTrackRuntimeOptions,
|
||||
): Promise<ProjectAudioTrackRecord | null>;
|
||||
findBy(
|
||||
options: DbFindByOptions,
|
||||
): Promise<ProjectAudioTrackRecord | null>;
|
||||
findBy(options: DbFindByOptions): Promise<ProjectAudioTrackRecord | null>;
|
||||
findAll(
|
||||
filter?: ProjectAudioTrackListFilter,
|
||||
options?: ProjectAudioTrackRuntimeOptions,
|
||||
|
||||
@ -103,8 +103,7 @@ export interface ProjectElementDefaultsOptions extends ServiceOptions {
|
||||
countOnly?: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectElementDefaultsModelRecord
|
||||
extends ProjectElementDefaultRecord {
|
||||
export interface ProjectElementDefaultsModelRecord extends ProjectElementDefaultRecord {
|
||||
update(
|
||||
data: Partial<ProjectElementDefaultRecord> & {
|
||||
updatedById?: string | null;
|
||||
@ -155,13 +154,12 @@ export interface ProjectElementDefaultsModel {
|
||||
}): Promise<PaginatedResult<ProjectElementDefaultRecord>>;
|
||||
}
|
||||
|
||||
export interface ProjectElementDefaultsDbApi
|
||||
extends EntityDbApi<
|
||||
ProjectElementDefaultRecord,
|
||||
ProjectElementDefaultsData,
|
||||
ProjectElementDefaultsData,
|
||||
ProjectElementDefaultsListFilter
|
||||
> {
|
||||
export interface ProjectElementDefaultsDbApi extends EntityDbApi<
|
||||
ProjectElementDefaultRecord,
|
||||
ProjectElementDefaultsData,
|
||||
ProjectElementDefaultsData,
|
||||
ProjectElementDefaultsListFilter
|
||||
> {
|
||||
findByElementType(
|
||||
projectId: string,
|
||||
elementType: string,
|
||||
|
||||
@ -6,10 +6,7 @@ import type {
|
||||
} from './index.ts';
|
||||
|
||||
export type ProjectMembershipAccessLevel =
|
||||
| 'owner'
|
||||
| 'editor'
|
||||
| 'reviewer'
|
||||
| 'viewer';
|
||||
'owner' | 'editor' | 'reviewer' | 'viewer';
|
||||
|
||||
export interface ProjectMembershipData {
|
||||
id?: string;
|
||||
|
||||
@ -18,10 +18,7 @@ import type { Transaction } from 'sequelize';
|
||||
|
||||
export type ProjectTransitionType = 'fade' | 'none' | 'video';
|
||||
export type ProjectTransitionEasing =
|
||||
| 'ease-in-out'
|
||||
| 'ease-in'
|
||||
| 'ease-out'
|
||||
| 'linear';
|
||||
'ease-in-out' | 'ease-in' | 'ease-out' | 'linear';
|
||||
|
||||
export interface ProjectTransitionSettingsData {
|
||||
id?: string;
|
||||
|
||||
@ -39,8 +39,7 @@ export interface ProjectUiControlSettingsRuntimeOptions {
|
||||
transaction?: Transaction;
|
||||
}
|
||||
|
||||
export interface ProjectUiControlSettingsUpsertOptions
|
||||
extends ProjectUiControlSettingsRuntimeOptions {
|
||||
export interface ProjectUiControlSettingsUpsertOptions extends ProjectUiControlSettingsRuntimeOptions {
|
||||
currentUser?: CurrentUser | null;
|
||||
}
|
||||
|
||||
|
||||
@ -1,10 +1,18 @@
|
||||
import type { Transaction } from 'sequelize';
|
||||
|
||||
import type { CurrentUser } from './auth.ts';
|
||||
import type { DbFindAllOptions, DbFindByOptions, EntityDbApi } from './db-api.ts';
|
||||
import type {
|
||||
DbFindAllOptions,
|
||||
DbFindByOptions,
|
||||
EntityDbApi,
|
||||
} from './db-api.ts';
|
||||
import type { PaginatedResult } from './pagination.ts';
|
||||
import type { QueryWhere } from './runtime.ts';
|
||||
import type { CreateOptions, ServiceOptions, UpdateOptions } from './service-options.ts';
|
||||
import type {
|
||||
CreateOptions,
|
||||
ServiceOptions,
|
||||
UpdateOptions,
|
||||
} from './service-options.ts';
|
||||
|
||||
export type ProjectProductionPresentationVisibility = 'public' | 'private';
|
||||
|
||||
@ -38,8 +46,7 @@ export interface ProjectFieldMapping {
|
||||
design_width: number | null | undefined;
|
||||
design_height: number | null | undefined;
|
||||
production_presentation_visibility:
|
||||
| ProjectProductionPresentationVisibility
|
||||
| undefined;
|
||||
ProjectProductionPresentationVisibility | undefined;
|
||||
}
|
||||
|
||||
export interface ProjectCreatePayload extends ProjectFieldMapping {
|
||||
@ -233,14 +240,20 @@ export interface ProjectCloneCreateOptions extends ProjectCloneTransactionOption
|
||||
|
||||
export type ProjectCloneCurrentUser = Pick<CurrentUser, 'id'>;
|
||||
|
||||
export interface ProjectsDbApi
|
||||
extends EntityDbApi<ProjectRecord, ProjectData, ProjectData, ProjectListFilter> {
|
||||
export interface ProjectsDbApi extends EntityDbApi<
|
||||
ProjectRecord,
|
||||
ProjectData,
|
||||
ProjectData,
|
||||
ProjectListFilter
|
||||
> {
|
||||
findBy(options: DbFindByOptions): Promise<ProjectRecord | null>;
|
||||
findBy(
|
||||
where: { id: string },
|
||||
options?: ProjectFindAllOptions,
|
||||
): Promise<ProjectRecord | null>;
|
||||
findAll(options: DbFindAllOptions<unknown>): Promise<PaginatedResult<ProjectRecord>>;
|
||||
findAll(
|
||||
options: DbFindAllOptions<unknown>,
|
||||
): Promise<PaginatedResult<ProjectRecord>>;
|
||||
findAll(
|
||||
filter?: ProjectListFilter,
|
||||
options?: ProjectFindAllOptions,
|
||||
|
||||
@ -33,7 +33,10 @@ export interface SaveToStageResult {
|
||||
|
||||
export type PublishEventStatus = 'queued' | 'running' | 'success' | 'failed';
|
||||
|
||||
export type PublishSourceEnvironment = Extract<RuntimeEnvironment, 'dev' | 'stage'>;
|
||||
export type PublishSourceEnvironment = Extract<
|
||||
RuntimeEnvironment,
|
||||
'dev' | 'stage'
|
||||
>;
|
||||
|
||||
export type PublishTargetEnvironment = Extract<
|
||||
RuntimeEnvironment,
|
||||
|
||||
@ -2,6 +2,8 @@ import type Joi from 'joi';
|
||||
|
||||
import type { RequestValidationPart } from './validation.ts';
|
||||
|
||||
export type RequestSchemaMap = Partial<Record<RequestValidationPart, Joi.Schema>>;
|
||||
export type RequestSchemaMap = Partial<
|
||||
Record<RequestValidationPart, Joi.Schema>
|
||||
>;
|
||||
export type RequestSchemaGroup = Record<string, RequestSchemaMap>;
|
||||
export type RequestSchemaCatalog = Record<string, RequestSchemaGroup>;
|
||||
|
||||
@ -1,4 +1,7 @@
|
||||
import type { AccessPolicyOptions, ProductionPresentationProject } from './access-policy.ts';
|
||||
import type {
|
||||
AccessPolicyOptions,
|
||||
ProductionPresentationProject,
|
||||
} from './access-policy.ts';
|
||||
|
||||
export interface PrivateProductionPresentationOption {
|
||||
id: string;
|
||||
@ -16,16 +19,17 @@ export interface ProductionPresentationAccessGrantPlain {
|
||||
project?: ProductionPresentationAccessProject | null;
|
||||
}
|
||||
|
||||
export interface ProductionPresentationAccessGrantRow
|
||||
extends ProductionPresentationAccessGrantPlain {
|
||||
export interface ProductionPresentationAccessGrantRow extends ProductionPresentationAccessGrantPlain {
|
||||
get?: (options: { plain: true }) => ProductionPresentationAccessGrantPlain;
|
||||
}
|
||||
|
||||
export interface PrivateProductionProjectRow
|
||||
extends Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'> {
|
||||
get?: (
|
||||
options: { plain: true },
|
||||
) => Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'>;
|
||||
export interface PrivateProductionProjectRow extends Pick<
|
||||
ProductionPresentationProject,
|
||||
'id' | 'name' | 'slug'
|
||||
> {
|
||||
get?: (options: {
|
||||
plain: true;
|
||||
}) => Pick<ProductionPresentationProject, 'id' | 'name' | 'slug'>;
|
||||
}
|
||||
|
||||
export type RuntimePresentationAccessOptions = AccessPolicyOptions;
|
||||
|
||||
@ -15,8 +15,7 @@ export interface UnknownRuntimeContext {
|
||||
}
|
||||
|
||||
export type RuntimeContextInspectionResponse =
|
||||
| RuntimeContext
|
||||
| UnknownRuntimeContext;
|
||||
RuntimeContext | UnknownRuntimeContext;
|
||||
|
||||
export interface RuntimeFilterOptions {
|
||||
runtimeContext?: RuntimeContext | null;
|
||||
|
||||
@ -68,8 +68,7 @@ export interface TourPageSequelizeRecord extends TourPageRecord {
|
||||
}
|
||||
|
||||
export type TourPageMaybeSequelizeRecord =
|
||||
| TourPageRecord
|
||||
| TourPageSequelizeRecord;
|
||||
TourPageRecord | TourPageSequelizeRecord;
|
||||
|
||||
export interface TourPageProjectRef {
|
||||
id: string;
|
||||
@ -121,8 +120,7 @@ export interface TourPageRecord extends TourPageData {
|
||||
|
||||
export type TourPageCreateBody = EntityDataRequestBody<TourPageData>;
|
||||
|
||||
export interface TourPageUpdateBody
|
||||
extends EntityDataRequestBody<TourPageData> {
|
||||
export interface TourPageUpdateBody extends EntityDataRequestBody<TourPageData> {
|
||||
id?: string;
|
||||
}
|
||||
|
||||
@ -194,12 +192,11 @@ export interface TourPageReverseGenerationTask {
|
||||
pageId?: string | null | undefined;
|
||||
}
|
||||
|
||||
export interface TourPagesDbApi
|
||||
extends EntityDbApi<
|
||||
TourPageRecord,
|
||||
TourPageData,
|
||||
TourPageData,
|
||||
TourPageListQuery
|
||||
export interface TourPagesDbApi extends EntityDbApi<
|
||||
TourPageRecord,
|
||||
TourPageData,
|
||||
TourPageData,
|
||||
TourPageListQuery
|
||||
> {
|
||||
readonly CSV_FIELDS: readonly string[];
|
||||
findBy(options: DbFindByOptions): Promise<TourPageRecord | null>;
|
||||
@ -207,12 +204,16 @@ export interface TourPagesDbApi
|
||||
where: { id: string },
|
||||
options?: ServiceOptions,
|
||||
): Promise<TourPageRecord | null>;
|
||||
findAll(options: DbFindAllOptions<TourPageListQuery>): Promise<TourPageListResult>;
|
||||
findAll(
|
||||
options: DbFindAllOptions<TourPageListQuery>,
|
||||
): Promise<TourPageListResult>;
|
||||
findAll(
|
||||
filter?: TourPageListQuery,
|
||||
options?: TourPageFindAllOptions,
|
||||
): Promise<TourPageListResult>;
|
||||
findAllAutocomplete(options: TourPageAutocompleteOptions): Promise<TourPageRecord[]>;
|
||||
findAllAutocomplete(
|
||||
options: TourPageAutocompleteOptions,
|
||||
): Promise<TourPageRecord[]>;
|
||||
findAllAutocomplete(options: AutocompleteOptions): Promise<TourPageRecord[]>;
|
||||
}
|
||||
|
||||
|
||||
@ -2,8 +2,15 @@ import type { RequestHandler } from 'express';
|
||||
import type { Transaction } from 'sequelize';
|
||||
|
||||
import type { PermissionRecord, RoleRecord } from './auth.ts';
|
||||
import type { AutocompleteOptions, DeleteByIdsOptions } from './service-options.ts';
|
||||
import type { DbFindAllOptions, DbFindByOptions, EntityDbApi } from './db-api.ts';
|
||||
import type {
|
||||
AutocompleteOptions,
|
||||
DeleteByIdsOptions,
|
||||
} from './service-options.ts';
|
||||
import type {
|
||||
DbFindAllOptions,
|
||||
DbFindByOptions,
|
||||
EntityDbApi,
|
||||
} from './db-api.ts';
|
||||
import type { EntityDataRequestBody } from './http.ts';
|
||||
import type { PaginatedResult } from './pagination.ts';
|
||||
import type { QueryWhere } from './runtime.ts';
|
||||
@ -157,15 +164,10 @@ export interface UserFindAllOptions extends ServiceOptions {
|
||||
}
|
||||
|
||||
export type UserSelectableIdInput =
|
||||
| string
|
||||
| { id?: string | null; value?: string | null }
|
||||
| null
|
||||
| undefined;
|
||||
string | { id?: string | null; value?: string | null } | null | undefined;
|
||||
|
||||
export type UserSelectableIdArrayInput =
|
||||
| Array<UserSelectableIdInput>
|
||||
| null
|
||||
| undefined;
|
||||
Array<UserSelectableIdInput> | null | undefined;
|
||||
|
||||
export interface UserAccessMutationOptions {
|
||||
user: Pick<UserRecord, 'id'> | null | undefined;
|
||||
@ -287,19 +289,15 @@ export interface UserModelApi {
|
||||
): Promise<PaginatedResult<UserModelRecord>>;
|
||||
}
|
||||
|
||||
export interface UsersDbApi
|
||||
extends EntityDbApi<
|
||||
UserRecord,
|
||||
UserData,
|
||||
UserData,
|
||||
UserListFilter,
|
||||
UserAutocompleteOption
|
||||
> {
|
||||
export interface UsersDbApi extends EntityDbApi<
|
||||
UserRecord,
|
||||
UserData,
|
||||
UserData,
|
||||
UserListFilter,
|
||||
UserAutocompleteOption
|
||||
> {
|
||||
create(options: CreateOptions<UserData>): Promise<UserRecord>;
|
||||
bulkImport(
|
||||
data: UserData[],
|
||||
options?: ServiceOptions,
|
||||
): Promise<UserRecord[]>;
|
||||
bulkImport(data: UserData[], options?: ServiceOptions): Promise<UserRecord[]>;
|
||||
update(options: UpdateOptions<UserData>): Promise<UserRecord>;
|
||||
deleteByIds(options: DeleteByIdsOptions): Promise<UserRecord[]>;
|
||||
remove(options: EntityIdOptions): Promise<UserRecord>;
|
||||
@ -312,7 +310,9 @@ export interface UsersDbApi
|
||||
where: UserFindByWhere,
|
||||
options?: ServiceOptions,
|
||||
): Promise<UserRecord | null>;
|
||||
findAll(options: DbFindAllOptions<unknown>): Promise<PaginatedResult<UserRecord>>;
|
||||
findAll(
|
||||
options: DbFindAllOptions<unknown>,
|
||||
): Promise<PaginatedResult<UserRecord>>;
|
||||
findAll(
|
||||
filter?: UserListFilter,
|
||||
options?: UserFindAllOptions,
|
||||
|
||||
@ -64,10 +64,7 @@ const envSchema = Joi.object({
|
||||
.integer()
|
||||
.positive()
|
||||
.default(3),
|
||||
FFMPEG_BREAKER_COOLDOWN_MS: Joi.number()
|
||||
.integer()
|
||||
.positive()
|
||||
.default(120000),
|
||||
FFMPEG_BREAKER_COOLDOWN_MS: Joi.number().integer().positive().default(120000),
|
||||
FFMPEG_BREAKER_SUCCESS_THRESHOLD: Joi.number()
|
||||
.integer()
|
||||
.positive()
|
||||
@ -191,19 +188,11 @@ function toValidatedEnvironment(
|
||||
'AWS_S3_CONNECTION_TIMEOUT',
|
||||
5000,
|
||||
),
|
||||
AWS_S3_REQUEST_TIMEOUT: readNumber(
|
||||
values,
|
||||
'AWS_S3_REQUEST_TIMEOUT',
|
||||
30000,
|
||||
),
|
||||
AWS_S3_REQUEST_TIMEOUT: readNumber(values, 'AWS_S3_REQUEST_TIMEOUT', 30000),
|
||||
AWS_S3_MAX_ATTEMPTS: readNumber(values, 'AWS_S3_MAX_ATTEMPTS', 3),
|
||||
AWS_S3_MAX_SOCKETS: readNumber(values, 'AWS_S3_MAX_SOCKETS', 50),
|
||||
AWS_S3_KEEP_ALIVE: isEnvBooleanString(s3KeepAlive) ? s3KeepAlive : 'true',
|
||||
AWS_S3_PRESIGN_EXPIRY: readNumber(
|
||||
values,
|
||||
'AWS_S3_PRESIGN_EXPIRY',
|
||||
3600,
|
||||
),
|
||||
AWS_S3_PRESIGN_EXPIRY: readNumber(values, 'AWS_S3_PRESIGN_EXPIRY', 3600),
|
||||
FILE_STORAGE_PROVIDER:
|
||||
fileStorageProvider === 's3' ||
|
||||
fileStorageProvider === 'gcloud' ||
|
||||
@ -268,9 +257,9 @@ function toValidatedEnvironment(
|
||||
function validateEnv(): ValidatedEnvironment {
|
||||
const result: Joi.ValidationResult<Record<string, unknown>> =
|
||||
envSchema.validate(process.env, {
|
||||
abortEarly: false,
|
||||
stripUnknown: false,
|
||||
});
|
||||
abortEarly: false,
|
||||
stripUnknown: false,
|
||||
});
|
||||
|
||||
if (result.error) {
|
||||
const messages = result.error.details.map(
|
||||
|
||||
@ -44,7 +44,8 @@ function isGlobalTransitionDefaultsData(
|
||||
transitionTypes.has(value.transition_type))) &&
|
||||
isOptionalNumber(value.duration_ms) &&
|
||||
(value.easing === undefined ||
|
||||
(typeof value.easing === 'string' && transitionEasings.has(value.easing))) &&
|
||||
(typeof value.easing === 'string' &&
|
||||
transitionEasings.has(value.easing))) &&
|
||||
isOptionalString(value.overlay_color)
|
||||
);
|
||||
}
|
||||
|
||||
@ -45,9 +45,7 @@ function normalizeLoggedError(reason: unknown): Error {
|
||||
if (reason instanceof Error) return reason;
|
||||
|
||||
const message =
|
||||
typeof reason === 'string'
|
||||
? reason
|
||||
: 'Non-Error value thrown or rejected';
|
||||
typeof reason === 'string' ? reason : 'Non-Error value thrown or rejected';
|
||||
|
||||
return new Error(message, { cause: reason });
|
||||
}
|
||||
|
||||
@ -25,7 +25,9 @@ function readQueryString(query: unknown, key: string): string | undefined {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function getProjectSettingsListFilter(query: unknown): ProjectSettingsListFilter {
|
||||
function getProjectSettingsListFilter(
|
||||
query: unknown,
|
||||
): ProjectSettingsListFilter {
|
||||
const filter: ProjectSettingsListFilter = {};
|
||||
const id = readQueryString(query, 'id');
|
||||
const project = readQueryString(query, 'project');
|
||||
@ -66,9 +68,7 @@ function readQueryRange(
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const first = value.find((item) => typeof item === 'string');
|
||||
const second = value
|
||||
.slice(1)
|
||||
.find((item) => typeof item === 'string');
|
||||
const second = value.slice(1).find((item) => typeof item === 'string');
|
||||
return [first, second];
|
||||
}
|
||||
|
||||
|
||||
@ -10,9 +10,7 @@ function hasStringId(value: unknown): value is { id: string } {
|
||||
);
|
||||
}
|
||||
|
||||
function isEntityDataRequestBody(
|
||||
body: unknown,
|
||||
): body is EntityDataRequestBody {
|
||||
function isEntityDataRequestBody(body: unknown): body is EntityDataRequestBody {
|
||||
return body !== null && typeof body === 'object' && 'data' in body;
|
||||
}
|
||||
|
||||
|
||||
@ -71,9 +71,7 @@ export function setPermissionNameOverride(
|
||||
permissionNameOverride;
|
||||
}
|
||||
|
||||
export function getPermissionNameOverride(
|
||||
req: Request,
|
||||
): string | undefined {
|
||||
export function getPermissionNameOverride(req: Request): string | undefined {
|
||||
return getRequestContext(req).permissionNameOverride;
|
||||
}
|
||||
|
||||
|
||||
@ -63,11 +63,15 @@ void test('internal users with permissions can use admin api', () => {
|
||||
|
||||
void test('platform-wide roles are explicit', () => {
|
||||
assert.equal(
|
||||
AccessPolicy.isPlatformWideRole(userWithRole('admin-1', { name: 'Administrator' })),
|
||||
AccessPolicy.isPlatformWideRole(
|
||||
userWithRole('admin-1', { name: 'Administrator' }),
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
AccessPolicy.isPlatformWideRole(userWithRole('designer-1', { name: 'Tour Designer' })),
|
||||
AccessPolicy.isPlatformWideRole(
|
||||
userWithRole('designer-1', { name: 'Tour Designer' }),
|
||||
),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user