# Backend Services Module ## Overview The Services module implements the **business logic layer** of the backend application. Services sit between routes (controllers) and the database API layer, encapsulating complex operations, transaction management, and cross-cutting concerns. **Location:** `backend/src/services/` **Total Files:** 37 (24 root services + 13 subdirectory files) --- ## Architecture Diagram ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Routes Layer │ │ (HTTP Request Handling) │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ Services Layer │ │ ┌───────────────────────────────────────────────────────────────┐ │ │ │ Service Categories │ │ │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ │ │ │ │ Factory │ │ Custom │ │ Specialized │ │ │ │ │ │ Services │ │ Services │ │ Modules │ │ │ │ │ │ (9 files) │ │ (12 files) │ │ (3 dirs) │ │ │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ │ │ │ └───────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ │ ▼ ┌─────────────────────────────────────────────────────────────────────┐ │ Database API Layer │ │ (Sequelize ORM Operations) │ └─────────────────────────────────────────────────────────────────────┘ ``` --- ## Service Categories ### 1. Factory-Generated Services (Simple CRUD) 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 | **Example - Factory Service:** ```javascript // permissions.ts import PermissionsDBApi from '../db/api/permissions.ts'; import { createEntityService } from '../factories/service.factory.ts'; export default createEntityService(PermissionsDBApi, { entityName: 'permissions', }); ``` ### 2. Custom Services (Business Logic) Services with domain-specific business logic beyond simple CRUD. | Service | File | Purpose | LOC | |---------|------|---------|-----| | assets | `assets.ts` | Asset management, MIME validation, embed URL validation, stored media metadata probing | ~300 | | auth | `auth.ts` | Authentication, password reset, email verification | ~210 | | users | `users.ts` | User management, invitation emails, Public viewer grants | ~350 | | projects | `projects.ts` | Project cloning, slug generation, slug uniqueness validation | ~680 | | roles | `roles.ts` | Role management, permission assignment, CSV import, Public-role hardening | ~170 | | file | `file.ts` | Multi-provider file storage, downloadToBuffer, uploadBuffer, S3/GCloud circuit breaker for processing paths | ~1,600 | | publish | `publish.ts` | Dev→Stage→Production publishing | ~400 | | search | `search.ts` | Global full-text search | 178 | | pwa_manifest | `pwa_manifest.js` | PWA offline manifest generation | 315 | | project_audio_tracks | `project_audio_tracks.ts` | Audio track management | 117 | | project_transition_settings | `project_transition_settings.ts` | Environment-aware transition settings | 209 | | project_element_defaults | `project_element_defaults.ts` | Element defaults with reset/diff | 34 | | global_ui_control_defaults | `global_ui_control_defaults.ts` | Global defaults CRUD service for system controls | 6 | | project_ui_control_settings | `project_ui_control_settings.ts` | Transactional find/upsert/delete for project UI-control overrides | 51 | | videoProcessing | `videoProcessing.ts` | FFmpeg video reversal for transition videos with single-worker queue, `-threads 1`, hard timeout, metadata logs, and circuit breaker | ~240 | ### 3. Specialized Module Directories ``` services/ ├── file/ # Storage providers (Strategy Pattern) │ ├── BaseStorageProvider.ts # Abstract interface │ ├── S3StorageProvider.ts # AWS S3 implementation │ ├── LocalStorageProvider.ts # Local filesystem │ ├── UploadSessionManager.ts # Chunked uploads │ └── index.js # Provider factory (27 LOC) │ ├── email/ # Email sending │ ├── index.js # EmailSender class (44 LOC) │ └── list/ │ ├── passwordReset.ts # Password reset email │ ├── addressVerification.ts # Email verification │ └── invitation.ts # User invitation email │ └── notifications/ # Error handling & i18n ├── helpers.js # getNotification helper (30 LOC) ├── list.js # Error message catalog (100 LOC) └── errors/ ├── validation.js # ValidationError class (16 LOC) └── forbidden.js # ForbiddenError class (16 LOC) ``` --- ## Service Factory Pattern ### `factories/service.factory.ts` Generates standardized service classes with transaction handling. ```javascript function createEntityService(DBApi, options = {}) { const entityName = options.entityName || 'Entity'; return class GenericService { // Create with transaction static async create({ data, currentUser, transaction, runtimeContext }) { const transaction = await db.sequelize.transaction(); try { const record = await DBApi.create({ data, currentUser, transaction, runtimeContext }); await transaction.commit(); return record; } catch (error) { await transaction.rollback(); throw error; } } // Bulk import from CSV static async bulkImport(req, res) { ... } // Update with existence check static async update({ id, data, currentUser, transaction, runtimeContext }) { ... } // Delete multiple by IDs static async deleteByIds({ ids, currentUser, transaction, runtimeContext }) { ... } // Soft delete single static async remove({ id, currentUser, transaction, runtimeContext }) { ... } }; } ``` **Generated Methods:** | Method | Signature | Description | |--------|-----------|-------------| | `create` | `(data, currentUser) → record` | Create with transaction | | `bulkImport` | `(req, res) → void` | CSV import with validation | | `update` | `(data, id, currentUser) → record` | Update with existence check | | `deleteByIds` | `(ids, currentUser) → void` | Bulk soft delete | | `remove` | `(id, currentUser) → void` | Single soft delete | --- ## Core Services Detail ### Tour Pages Service (`tour_pages.ts`) Extends the factory-generated CRUD service with page-specific operations. **Reorder operation:** - `TourPagesService.reorder(data, currentUser)` accepts `projectId`, `environment`, and `orderedPageIds`. - Reordering is allowed only for `environment='dev'`; this preserves the publishing model where stage and production are derived environments. - The service loads all pages for the project/dev environment inside a transaction, verifies that the ordered list contains every page exactly once, and then updates `sort_order` sequentially. - The operation updates only `sort_order`; it does not change page content, slugs, backgrounds, `ui_schema_json`, navigation, transitions, or media. - Stage receives the new order after Save to Stage; production receives it after Publish. ### Auth Service (`auth.ts`) Handles authentication, password management, and email verification. ```typescript class Auth { // User login with bcrypt verification static async signin(email, password) → JWT // Send email verification link static async sendEmailAddressVerificationEmail(email, host) // Send password reset or invitation email static async sendPasswordResetEmail(email, type, host) // Verify email from token static async verifyEmail(token, options) → boolean // Update password (requires current password) static async passwordUpdate(currentPassword, newPassword, options) // Reset password from token static async passwordReset(token, password, options) // Update user profile static async updateProfile(data, currentUser) } ``` **Security Features:** - bcrypt password hashing (`config.bcrypt.saltRounds`) - JWT token generation via `helpers.jwtSign()` - Email verification required for login - Password reset tokens with expiration ### Assets Service (`assets.ts`) Extended factory service with strict reusable TypeScript contracts, MIME type validation for asset uploads, embed URL validation, and stored audio/video metadata probing. ```typescript class AssetsService extends BaseService { // Create asset with MIME type validation static async create({ data, currentUser, transaction, runtimeContext }) // Update asset with MIME type validation static async update({ id, data, currentUser, transaction, runtimeContext }) } ``` **MIME Type Validation:** | Asset Type | Valid MIME Prefixes | Description | |------------|---------------------|-------------| | `image` | `image/` | JPEG, PNG, GIF, WebP, SVG, etc. | | `video` | `video/` | MP4, WebM, MOV, etc. | | `audio` | `audio/` | MP3, WAV, OGG, etc. | | `embed` | n/a | MIME validation skipped; HTTPS embed URL domain is validated | **Validation Rules:** - `asset_type` and `mime_type` must be consistent - If `asset_type` is `image`, `mime_type` must start with `image/` - If `asset_type` is `video`, `mime_type` must start with `video/` - If `asset_type` is `audio`, `mime_type` must start with `audio/` - Asset types not in the validation list (e.g., `file`) skip validation - Missing `mime_type` is allowed (browser may not always send it) **Error Response:** ```javascript throw new ValidationError( `Invalid file type for ${assetType}. Expected ${patterns.description}, got "${mimeType}"` ); ``` --- ### File Service (`file.js`) Unified file storage using Strategy Pattern for multiple backends. Features comprehensive error handling, AbortController support for client disconnect handling, path validation for security, and structured Pino logging. ```javascript // Provider auto-detection const getFileStorageProvider = () → 's3' | 'gcloud' | 'local' // Core operations (with AbortController support for S3) const uploadFile = async (folder, req, res) → { url } const downloadFile = async (req, res) → stream // Aborts on client disconnect const deleteFile = async (privateUrl, { throwOnError }) → { success, error? } // Server-side file copy (S3 uses CopyObjectCommand, Local uses fs.copyFile) const copyFile = async (sourceKey, destKey, options) → { url } | { key } const copyFilesParallel = async (copies, options) → { succeeded, failed } // Chunked upload session management const initUploadSession = async (req, res) → { sessionId, totalChunks } const getUploadSession = async (req, res) → { status, uploadedChunks } const uploadChunk = async (req, res) → { chunkIndex, uploadedChunks } const finalizeUploadSession = async (req, res) → { url, privateUrl } // Presigned URL generation (S3 only, with path validation) const generatePresignedUrls = async (urls) → { [url]: presignedUrl } // Utilities (exported for route layer) const isValidPath = (urlPath) → boolean // Path traversal protection const createErrorResponse = (message, code, details) → { message, code?, details? } const getS3ErrorStatusCode = (error) → number // HTTP status code mapping ``` **Server-Side File Copy (S3 Native):** The `copyFile()` function uses provider-native copy operations for optimal performance: | Provider | Implementation | Performance | |----------|----------------|-------------| | S3 | `CopyObjectCommand` (server-side) | 15x faster, zero memory | | Local | `fs.promises.copyFile` | Kernel-level copy | | GCloud | Download + Upload (fallback) | Legacy behavior | ```javascript // Single file copy const copyFile = async (sourceKey, destKey, { contentType }) → { url } // Parallel batch copy with concurrency control const copyFilesParallel = async (copies, { concurrency = 10, continueOnError = true }) → { succeeded: [{ sourceKey, destKey }], failed: [{ sourceKey, error }] } ``` **Benefits over download-then-upload:** - **15x faster**: Server-side copy, no data through backend - **Zero memory**: No file buffering in Node.js - **No timeouts**: Works for large files (>100MB) - **Reduced bandwidth**: No double network transfer **Error Response Format:** ```javascript // Standardized across all file endpoints { message: 'Human-readable error message', code: 'ERROR_CODE', // For programmatic handling details: { ... } // Optional additional context } ``` **Request Cancellation:** Downloads automatically abort S3 requests when client disconnects, preventing wasted bandwidth and server resources. **Provider Selection:** | Priority | Provider | Detection | |----------|----------|-----------| | 1 | `config.fileStorage.provider` | Validated `FILE_STORAGE_PROVIDER` override | | 2 | S3 | `S3_BUCKET` + `S3_REGION` + credentials | | 3 | GCloud | Validated `GC_PROJECT_ID` + `GC_CLIENT_EMAIL` + `GC_PRIVATE_KEY` | | 4 | Local | Default fallback | External S3/GCloud operations used by processing paths are protected by the shared file-storage circuit breaker. For S3, only retryable SDK/network errors count toward opening the breaker; expected 4xx errors do not. **Chunked Upload Flow:** ``` 1. POST /api/file/upload-sessions/init ← { sessionId, totalChunks } 2. PUT /api/file/upload-sessions/:sessionId/chunks/:chunkIndex ← { uploadedChunks: N } 3. POST /api/file/upload-sessions/:sessionId/finalize ← { url, privateUrl } ``` **S3 Local Cache (Atomic Writes):** When S3 is the storage provider, downloads are cached locally to reduce S3 requests and improve latency. The cache uses **atomic writes** to prevent race conditions: ``` ┌─────────────────────────────────────────────────────────────────┐ │ Request A Request B (concurrent) │ ├─────────────────────────────────────────────────────────────────┤ │ 1. Check cache → miss 1. Check cache → miss │ │ 2. Create .downloading 2. See .downloading → skip cache │ │ 3. Stream to .tmp file 3. Stream directly from S3 │ │ 4. Verify size matches 4. Complete │ │ 5. Rename .tmp → final ↓ │ │ 6. Delete .downloading Request C (later) │ │ ↓ 1. Check cache → hit │ │ 2. Serve from cache │ └─────────────────────────────────────────────────────────────────┘ ``` **Cache validation:** - File exists AND age < `S3_CACHE_MAX_AGE` (default: 1 hour) - No `.downloading` marker file (indicates download in progress) - Size matches `Content-Length` header (verified before rename) **Why atomic writes:** Without atomic writes, concurrent requests could serve truncated cache files: 1. Request A starts downloading 12MB file, writes to cache 2. After 2MB written, Request B checks cache - file exists, age valid 3. Request B serves 2MB truncated file → **corrupted video/image** The atomic write pattern (write to `.tmp`, verify size, rename) prevents this. ### Publish Service (`publish.ts`) Three-tier content environment publishing workflow. ```javascript module.exports = class PublishService { // Acquire project lock and run callback static async withProjectPublishLock(projectId, callback) // Copy stage content to production (blocking) static async publishToProduction(projectId, currentUser, title, description) // Copy dev content to stage (non-blocking, returns immediately) static async saveToStage(projectId, currentUser) // Generic environment copy static async copyEnvironment(projectId, fromEnv, toEnv, currentUser, transaction) } ``` **Non-Blocking vs Blocking:** - `saveToStage()` - Returns immediately, copy runs in background via `setImmediate()` - `publishToProduction()` - Waits for entire copy operation before returning **Publishing Flow:** ``` ┌─────────┐ ┌─────────┐ ┌────────────┐ │ DEV │──────▶│ STAGE │──────▶│ PRODUCTION │ │(editing)│ │(preview)│ │ (public) │ └─────────┘ └─────────┘ └────────────┘ │ │ │ saveToStage() publishToProduction() │ │ │ │ └──────────────────┴────────────────────┘ Creates publish_event record ``` **Event Status Lifecycle:** 1. `queued` - Event created 2. `running` - Processing started 3. `success` / `failed` - Completed ### Search Service (`search.ts`) Global full-text search with permission filtering. ```typescript export default class SearchService { // Search across all permitted entities static async search(searchQuery: string, currentUser: CurrentUser | undefined): Promise } ``` **Searchable Tables:** | Table | Text Fields | Numeric Fields | |-------|-------------|----------------| | users | firstName, lastName, phoneNumber, email | - | | projects | name, slug, description, logo_url, favicon_url, og_image_url | - | | assets | name, cdn_url, storage_key, mime_type, checksum | size_mb, width_px, height_px, duration_sec | | asset_variants | cdn_url | width_px, height_px, size_mb | | presigned_url_requests | requested_key, mime_type, status | requested_size_mb | | tour_pages | source_key, name, slug, background_image_url, background_video_url, background_audio_url, ui_schema_json | sort_order | | project_audio_tracks | source_key, name, slug, url | volume, sort_order | | publish_events | error_message | pages_copied, transitions_copied, audios_copied | | pwa_caches | cache_version, manifest_json, asset_list_json | - | | access_logs | path, ip_address, user_agent | - | **Permission Check:** ```javascript // Only search tables user has READ permission for if (!hasPermission(permissionSet, `READ_${tableName.toUpperCase()}`)) { return []; } ``` ### Projects Service (`projects.ts`) Project management with cloning capabilities and slug uniqueness validation. ```javascript export default class ProjectsService { // Normalize slug for URL safety static normalizeSlug(value) → slug // Generate unique slug with -copy suffix static async generateUniqueSlug(baseSlug, transaction) → uniqueSlug // Validate slug uniqueness before create/update (throws ValidationError if duplicate) static async validateSlugUniqueness(slug, excludeId, transaction) → normalizedSlug // Create new project (validates slug uniqueness) static async create({ data, currentUser, transaction, runtimeContext }) → project // Clone project with all assets and variants static async cloneFromProject(sourceProjectId, currentUser) → clonedProject // Update project (validates slug uniqueness if changed) static async update({ id, data, currentUser, transaction, runtimeContext }) → project } ``` **Slug Validation:** - `validateSlugUniqueness()` normalizes the slug and checks for duplicates - Uses `excludeId` parameter to skip the current project during updates - Checks soft-deleted projects (`paranoid: false`) to prevent conflicts - Throws `ValidationError('iam.errors.slugAlreadyExists')` if duplicate found **Clone Process (Optimized with S3 Native Copy):** The clone process uses S3's native `CopyObjectCommand` for server-side file copying (15x faster than download-then-upload). Files are copied in parallel with configurable concurrency. ``` Phase A: Create cloned project record ↓ Phase B: Collect all copy operations (assets + non-reversed variants) ↓ Phase C: Execute parallel S3 copy (10 concurrent, continueOnError=true) ↓ Phase D: Build assetPathMap from copy results (failed → use original path) ↓ Phase E: Create asset/variant records, build assetIdMap (old → new asset IDs) ↓ Phase F: Copy reversed videos using asset ID mapping └── Reversed videos use pattern: assets/{assetId}/reversed.mp4 └── Copy from old asset ID path to new asset ID path ↓ Phase G: Clone tour_pages, audio_tracks, element_defaults └── Transform ui_schema_json asset paths using assetPathMap ``` **Key Implementation Details:** | Phase | Operation | Notes | |-------|-----------|-------| | B-C | Parallel file copy | Uses `FileService.copyFilesParallel()` with S3 `CopyObjectCommand` | | E | Asset ID mapping | Tracks `oldAssetId → newAssetId` for reversed video copying | | F | Reversed video copy | Separate phase because reversed videos use asset-ID-based paths, not project-ID-based | | G | Path transformation | `transformUiSchemaAssetPaths()` updates all asset URLs in `ui_schema_json` | **Reversed Video Storage Pattern:** - Primary assets: `assets/{projectId}/{uuid}.ext` - Reversed videos: `assets/{assetId}/reversed.mp4` (uses asset ID, not project ID) **Error Handling:** - Failed file copies fall back to original storage path (cloned project still functional, shares assets with source) - Most assets won't have reversed videos - this is expected (only navigation elements with transitions generate them) - Transaction rollback on DB errors; orphaned S3 files acceptable (can be cleaned later) ### Roles Service (`roles.ts`) Role management service for standard CRUD, CSV bulk import, and permission assignment. The service wraps DB writes in transactions when a caller does not provide one. ```typescript export default class RolesService { static assertPublicRoleHasNoPermissions(data, existingRole) static async create(options) static async bulkImport(req, res) static async update(options) static async deleteByIds(options) static async remove(options) } ``` **Public Role Hardening:** - Creating or updating a role named `Public` rejects non-empty permissions. - Existing role lookup is done before update so renaming a role to `Public` cannot retain assigned permissions through the service boundary. - This keeps customer viewer access separate from admin RBAC permissions. ~~Generates offline manifests for PWA asset downloads.~~ ### Project Element Defaults Service (`project_element_defaults.ts`) Extended factory service with custom methods for managing project-level element defaults. ```javascript class Project_element_defaultsService extends BaseService { // Reset project default to current global default static async resetToGlobal(id, options) → updated record // Get diff between project default and global default static async getDiffFromGlobal(id) → { hasChanges, diff } // Snapshot all global defaults to a project (called on project creation) static async snapshotGlobalDefaults(projectId, options) → created records } ``` **Methods:** | Method | Description | |--------|-------------| | `resetToGlobal(id, options)` | Resets a project element default to match the current global element type default | | `getDiffFromGlobal(id)` | Compares project element default with global default, returns differences | | `snapshotGlobalDefaults(projectId, options)` | Creates project element defaults by copying all global element type defaults | **Use Cases:** - **Project Creation:** `snapshotGlobalDefaults` is called to copy global defaults to new project - **Reset to Global:** User can reset customized project defaults back to global values - **Diff View:** UI can show which settings differ from global defaults --- ## File Storage Module (`services/file/`) ### Strategy Pattern Implementation ``` ┌─────────────────────────────────────────────────────────────────────┐ │ BaseStorageProvider │ │ (Abstract Interface) │ │ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌───────────────┐ │ │ │ upload │ │ download │ │ delete │ │ getSignedUrl │ │ │ │ (abstract) │ │ (abstract) │ │ (abstract) │ │ (optional) │ │ │ └─────────────┘ └─────────────┘ └─────────────┘ └───────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ △ △ △ │ │ │ ┌──────────┴─────────┐ ┌───────┴────────┐ ┌────────┴───────┐ │ S3StorageProvider │ │LocalStorage │ │(GCloud inline) │ │ │ │Provider │ │ │ │ • AWS SDK v3 │ │ • fs module │ │ • @google-cloud│ │ • Presigned URLs │ │ • MIME types │ │ /storage │ │ • Batch delete │ │ • Recursive │ │ • Resumable │ └────────────────────┘ └────────────────┘ └────────────────┘ ``` ### BaseStorageProvider Interface ```typescript export default class BaseStorageProvider { static get providerName(): string upload(key, data, options): Promise download(key): Promise delete(key): Promise deleteMany(keys): Promise exists(key): Promise list(prefix): Promise getSignedUrl(key, expiresIn): Promise } ``` ### S3StorageProvider AWS S3 implementation using SDK v3 with robust timeout, retry, and error handling. `S3StorageProvider.ts` uses official AWS SDK v3 types for client config, command inputs, signed URL generation, streaming download bodies, and `S3ServiceException` metadata. Project-specific storage contracts live in `backend/src/types/file.ts`. ```javascript class S3StorageProvider extends BaseStorageProvider { constructor({ bucket, region, accessKeyId, secretAccessKey, prefix, connectionTimeout, // Default: 5000ms requestTimeout, // Default: 30000ms maxAttempts, // Default: 3 (adaptive retry) maxSockets, // Default: 50 (connection pool) keepAlive // Default: true }) // Static methods for error handling static getErrorStatusCode(error) // Map S3 errors to HTTP status codes static isRetryableError(error) // Check if error should be retried // Instance methods (all support AbortSignal for cancellation) buildKey(key) // Add prefix to key async upload(key, data, { signal }) // PutObjectCommand async download(key, { signal }) // GetObjectCommand async copy(sourceKey, destKey, { signal, contentType }) // CopyObjectCommand (server-side) async delete(key, { signal }) // DeleteObjectCommand async deleteMany(keys, { signal }) // DeleteObjectsCommand (batched 1000) async exists(key, { signal }) // HeadObjectCommand async list(prefix, { signal }) // ListObjectsV2Command (paginated) async getSignedUrl(key, expiry) // @aws-sdk/s3-request-presigner getConfig() // Get provider config for debugging destroy() // Cleanup connection pool } ``` **S3 Error to HTTP Status Code Mapping:** | S3 Error | HTTP Status | |----------|-------------| | NoSuchKey, NotFound, NoSuchBucket | 404 | | AccessDenied, InvalidAccessKeyId | 403 | | ExpiredToken | 401 | | TimeoutError, RequestTimeout | 504 | | NetworkingError, ServiceUnavailable | 503 | | ThrottlingException | 429 | | InternalError | 500 | ### LocalStorageProvider Local filesystem implementation. `LocalStorageProvider.ts` shares the same typed storage contracts as S3 while using Node filesystem and stream APIs. ```javascript class LocalStorageProvider extends BaseStorageProvider { constructor({ basePath = './uploads' }) buildPath(key) // path.join(basePath, key) async upload(key, data) // fs.writeFileSync / stream.pipeline async download(key) // fs.createReadStream async copy(sourceKey, destKey) // fs.promises.copyFile (kernel-level) async delete(key) // fs.unlinkSync async exists(key) // fs.existsSync async list(prefix) // fs.readdirSync (recursive) getContentType(ext) // MIME type mapping } ``` ### UploadSessionManager Chunked upload session management for large files. `UploadSessionManager.ts` uses reusable upload-session metadata contracts from `backend/src/types/file.ts` and validates `meta.json` after parsing instead of relying on type assertions. ```javascript class UploadSessionManager { constructor({ sessionDir, ttlMs = 24h }) createSession(options) // → sessionId (UUID) readMeta(sessionId) // → session metadata writeMeta(sessionId, payload) // Save session state saveChunk(sessionId, index, data) // Save chunk file chunkExists(sessionId, index) // Check chunk exists isComplete(sessionId) // All chunks uploaded? assembleChunks(sessionId, path) // Combine chunks removeSession(sessionId) // Cleanup session cleanupExpiredSessions() // Remove stale sessions } ``` **Session Directory Structure:** ``` upload_sessions/ └── {sessionId}/ ├── meta.json # Session metadata └── chunks/ ├── 0.part ├── 1.part └── N.part ``` --- ## Email Module (`services/email/`) ### EmailSender Class Core email sending using Nodemailer. ```typescript export default class EmailSender { constructor(email: EmailTemplate) async send(): Promise static get isConfigured(): boolean get transportConfig(): SMTPTransport.Options get from(): string } ``` **Configuration (`config.email`):** ```javascript { host: 'email-smtp.us-east-1.amazonaws.com', port: 587, auth: { user: process.env.EMAIL_USER || '', pass: process.env.EMAIL_PASS || '' }, from: 'Tour Builder Platform ' } ``` ### Email Templates | Template | Class | Fields | |----------|-------|--------| | Password Reset | `PasswordResetEmail` | to, link | | Email Verification | `EmailAddressVerificationEmail` | to, link | | User Invitation | `InvitationEmail` | to, host | **Template Pattern:** ```typescript export default class PasswordResetEmail implements EmailTemplate { constructor({ to, link }: LinkEmailTemplateOptions) {} get subject(): string { return getNotification('emails.passwordReset.subject', getNotification('app.title')); } async html(): Promise { const template = await fs.readFile(templatePath, 'utf8'); return template .replace(/{appTitle}/g, appTitle) .replace(/{resetUrl}/g, this.link) .replace(/{accountName}/g, this.to); } }; ``` --- ## Notifications Module (`services/notifications/`) ### Error Classes **ValidationError (400 Bad Request):** ```javascript class ValidationError extends Error { constructor(messageCode) { const message = isNotification(messageCode) ? getNotification(messageCode) : getNotification('errors.validation.message'); super(message); this.code = 400; } } ``` **ForbiddenError (403 Forbidden):** ```javascript class ForbiddenError extends Error { constructor(messageCode) { const message = isNotification(messageCode) ? getNotification(messageCode) : getNotification('errors.forbidden.message'); super(message); this.code = 403; } } ``` ### Notification Catalog (`list.js`) Centralized error messages and i18n strings. ```javascript const errors = { app: { title: 'Tour Builder Platform', }, auth: { userDisabled: 'Your account is disabled', forbidden: 'Forbidden', unauthorized: 'Unauthorized', userNotFound: "Sorry, we don't recognize your credentials", wrongPassword: "Sorry, we don't recognize your credentials", // ... }, iam: { errors: { userAlreadyExists: 'User with this email already exists', userNotFound: 'User not found', // ... } }, emails: { invitation: { subject: "You've been invited to {0}", body: "..." }, // ... } }; ``` ### Helper Functions ```javascript // Get notification with parameter substitution getNotification('emails.invitation.subject', 'Tour Builder') // → "You've been invited to Tour Builder" // Check if key exists in catalog isNotification('auth.userNotFound') // → true isNotification('custom.message') // → false ``` --- ## Transaction Patterns ### Standard Transaction Pattern All services use consistent transaction handling: ```javascript 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 }); if (ownsTransaction) await transaction.commit(); return record; } catch (error) { if (ownsTransaction) await transaction.rollback(); throw error; } } ``` ### Lock Pattern (Publish Service) ```javascript static async withProjectPublishLock(projectId, callback) { return db.sequelize.transaction(async (transaction) => { // Acquire row lock const project = await db.projects.findByPk(projectId, { transaction, lock: transaction.LOCK.UPDATE, }); // Check for concurrent operations const runningEvent = await db.publish_events.findOne({ where: { projectId, status: EVENT_STATUS.RUNNING }, transaction, lock: transaction.LOCK.UPDATE, }); if (runningEvent) { throw new Error('Publish is already running for this project'); } return callback(transaction); }); } ``` --- ## Dependencies ### External Packages | Package | Usage | |---------|-------| | `bcrypt` | Password hashing | | `nodemailer` | Email sending | | `csv-parser` | CSV import parsing | | `axios` | External API calls (widgets) | | `uuid` | Upload session IDs | | `@aws-sdk/client-s3` | S3 operations | | `@aws-sdk/s3-request-presigner` | Presigned URLs | | `@google-cloud/storage` | GCloud storage | | `lodash/get` | Deep object access | ### Internal Dependencies | Module | Services Using | |--------|---------------| | `db/models` | All services (transaction) | | `db/api/*` | All entity services | | `factories/service.factory` | 9 factory services | | `config` | auth, file, users, roles | | `helpers` | auth (jwtSign) | --- ## Service Relationships ``` ┌─────────────────────────────────────────────────────────────────────┐ │ Service Graph │ │ │ │ ┌──────────┐ │ │ │ auth │◄────────────────────┐ │ │ └────┬─────┘ │ │ │ │ uses │ sends invitations │ │ ▼ │ │ │ ┌──────────┐ ┌───────────┐ │ │ │ │ users │────▶│ email │───┘ │ │ │ (DBApi) │ └───────────┘ │ │ └──────────┘ │ │ │ │ uses templates │ │ ▼ │ │ ┌─────────────────┐ │ │ │ notifications │ │ │ │ (error catalog) │ │ │ └─────────────────┘ │ │ ▲ │ │ │ throws errors │ │ │ │ │ ┌──────────┐ ┌─────┴─────┐ ┌──────────┐ │ │ │ projects │ │ publish │ │ search │ │ │ │ (clone) │ │ (env copy)│ │(fulltext)│ │ │ └──────────┘ └───────────┘ └──────────┘ │ │ │ │ ┌──────────────────────────────────────────────────────────────┐ │ │ │ File Service │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────────┐ │ │ │ │ │ S3Provider │ │LocalProvider │ │UploadSessionManager │ │ │ │ │ └──────────────┘ └──────────────┘ └──────────────────────┘ │ │ │ └──────────────────────────────────────────────────────────────┘ │ └─────────────────────────────────────────────────────────────────────┘ ``` --- ## Configuration ### Environment Variables | Variable | Service | Description | |----------|---------|-------------| | `FILE_STORAGE_PROVIDER` | file | Force provider ('s3', 'gcloud', 'local') | | `AWS_S3_BUCKET` | file | S3 bucket name | | `AWS_S3_REGION` | file | AWS region (default: us-east-1) | | `AWS_ACCESS_KEY_ID` | file | AWS access key | | `AWS_SECRET_ACCESS_KEY` | file | AWS secret key | | `AWS_S3_PREFIX` | file | Key prefix | | `AWS_S3_CONNECTION_TIMEOUT` | file | S3 connection timeout in ms (default: 5000) | | `AWS_S3_REQUEST_TIMEOUT` | file | S3 request timeout in ms (default: 30000) | | `AWS_S3_MAX_ATTEMPTS` | file | S3 retry attempts (default: 3) | | `AWS_S3_MAX_SOCKETS` | file | S3 connection pool size (default: 50) | | `AWS_S3_KEEP_ALIVE` | file | Enable HTTP keep-alive (default: true) | | `AWS_S3_PRESIGN_EXPIRY` | file | Presigned URL expiry in seconds (default: 3600) | | `GC_PROJECT_ID` | file | GCloud project | | `GC_CLIENT_EMAIL` | file | GCloud service account | | `GC_PRIVATE_KEY` | file | GCloud private key | | `FFMPEG_REVERSE_TIMEOUT_MS` | videoProcessing | Reverse-video hard timeout in ms (default: 600000) | | `FFPROBE_TIMEOUT_MS` | videoProcessing | Metadata probe timeout in ms (default: 30000) | | `FFMPEG_BREAKER_FAILURE_THRESHOLD` | videoProcessing | Failures before FFmpeg breaker opens (default: 3) | | `FFMPEG_BREAKER_COOLDOWN_MS` | videoProcessing | FFmpeg breaker cooldown in ms (default: 120000) | | `FFMPEG_BREAKER_SUCCESS_THRESHOLD` | videoProcessing | Half-open successes required to close FFmpeg breaker (default: 1) | | `FILE_STORAGE_BREAKER_FAILURE_THRESHOLD` | file | Failures before storage breaker opens (default: 5) | | `FILE_STORAGE_BREAKER_COOLDOWN_MS` | file | Storage breaker cooldown in ms (default: 30000) | | `FILE_STORAGE_BREAKER_SUCCESS_THRESHOLD` | file | Half-open successes required to close storage breaker (default: 2) | | `EMAIL_USER` | email | SMTP username | | `EMAIL_PASS` | email | SMTP password | ### Config References ```javascript // config.ts module.exports = { bcrypt: { saltRounds: 12 }, email: { host, port, auth, from }, s3: { bucket, region, accessKeyId, secretAccessKey, prefix, // Timeout/retry configuration connectionTimeout, // 5000ms default requestTimeout, // 30000ms default maxAttempts, // 3 retries with adaptive backoff // Connection pool maxSockets, // 50 concurrent connections keepAlive, // HTTP keep-alive enabled // Presigned URLs presignExpirySeconds, // 3600 (1 hour) default }, gcloud: { bucket, hash }, uploadDir: './uploads', flHost: 'https://flatlogic.host', // Widget service project_uuid: '...', }; ``` --- ## Testing Guidelines ### Unit Testing Services ```javascript // Mock transaction jest.mock('../db/models', () => ({ sequelize: { transaction: jest.fn(() => ({ commit: jest.fn(), rollback: jest.fn(), })), }, })); // Mock DB API jest.mock('../db/api/assets'); describe('AssetsService', () => { it('should create asset with transaction', async () => { const result = await AssetsService.create({ data: mockData, currentUser: mockUser, }); expect(transaction.commit).toHaveBeenCalled(); }); }); ``` ### Integration Testing ```javascript describe('PublishService', () => { it('should copy dev to stage', async () => { // Create test project with pages const project = await createTestProject(); await createTestPages(project.id, 'dev'); // Publish to stage const result = await PublishService.saveToStage(project.id, mockUser); // Verify stage pages created const stagePages = await findPages(project.id, 'stage'); expect(stagePages.length).toBe(result.summary.pages_copied); }); }); ``` --- ## Best Practices ### 1. Transaction Handling - Always wrap multi-step operations in transactions - Use try/catch/rollback pattern consistently - Pass transaction to all DB API calls ### 2. Error Handling - Use `ValidationError` for client errors (400) - Use `ForbiddenError` for authorization failures (403) - Use notification catalog for consistent messages ### 3. Service Design - Keep services focused on business logic - Delegate DB operations to DB API layer - Use factories for simple CRUD services ### 4. File Operations - Use Strategy Pattern for multi-provider support - Implement chunked uploads for large files - Clean up sessions on completion/failure --- ## Related Documentation - [Backend Architecture](../backend-architecture.md) - Overall backend design - [Database Schema](../database-schema.md) - Data models - [API Endpoints](../api-endpoints.md) - REST API reference - [Auth Module](./auth.md) - Authentication details - [Routes Module](./routes.md) - Route implementations