import { Response, Router } from 'express'; import type { Router as RouterType } from 'express'; import { LiveScopeService, ImageService, GenerationService } from '@/services/core'; import { asyncHandler } from '@/middleware/errorHandler'; import { validateApiKey } from '@/middleware/auth/validateApiKey'; import { requireProjectKey } from '@/middleware/auth/requireProjectKey'; import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter'; import { PAGINATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants'; import { buildPaginationMeta } from '@/utils/helpers'; import { toLiveScopeResponse, toImageResponse } from '@/types/responses'; import type { CreateLiveScopeRequest, ListLiveScopesQuery, UpdateLiveScopeRequest, RegenerateScopeRequest, } from '@/types/requests'; import type { CreateLiveScopeResponse, GetLiveScopeResponse, ListLiveScopesResponse, UpdateLiveScopeResponse, DeleteLiveScopeResponse, RegenerateScopeResponse, } from '@/types/responses'; export const scopesRouter: RouterType = Router(); let scopeService: LiveScopeService; let imageService: ImageService; let generationService: GenerationService; const getScopeService = (): LiveScopeService => { if (!scopeService) { scopeService = new LiveScopeService(); } return scopeService; }; const getImageService = (): ImageService => { if (!imageService) { imageService = new ImageService(); } return imageService; }; const getGenerationService = (): GenerationService => { if (!generationService) { generationService = new GenerationService(); } return generationService; }; /** * Create a new live scope manually with settings * * Creates a live scope for organizing live URL generations: * - Slug must be unique within the project * - Slug format: alphanumeric + hyphens + underscores only * - Configure generation limits and permissions * - Optional custom metadata storage * * Note: Scopes are typically auto-created via live URLs, but this endpoint * allows pre-configuration with specific settings. * * @route POST /api/v1/live/scopes * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {CreateLiveScopeRequest} req.body - Scope configuration * @param {string} req.body.slug - Unique scope identifier (alphanumeric + hyphens + underscores) * @param {boolean} [req.body.allowNewGenerations=true] - Allow new generations in scope * @param {number} [req.body.newGenerationsLimit=30] - Maximum generations allowed * @param {object} [req.body.meta] - Custom metadata * * @returns {CreateLiveScopeResponse} 201 - Created scope with stats * @returns {object} 400 - Invalid slug format * @returns {object} 401 - Missing or invalid API key * @returns {object} 409 - Scope slug already exists * @returns {object} 429 - Rate limit exceeded * * @throws {Error} SCOPE_INVALID_FORMAT - Invalid slug format * @throws {Error} SCOPE_ALREADY_EXISTS - Slug already in use * * @example * POST /api/v1/live/scopes * { * "slug": "hero-section", * "allowNewGenerations": true, * "newGenerationsLimit": 50, * "meta": { "description": "Hero section images" } * } */ scopesRouter.post( '/', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const service = getScopeService(); const { slug, allowNewGenerations, newGenerationsLimit, meta } = req.body as CreateLiveScopeRequest; const projectId = req.apiKey.projectId; // Validate slug format if (!slug || !/^[a-zA-Z0-9_-]+$/.test(slug)) { res.status(400).json({ success: false, error: { message: ERROR_MESSAGES.SCOPE_INVALID_FORMAT, code: 'SCOPE_INVALID_FORMAT', }, }); return; } // Check if scope already exists const existing = await service.getBySlug(projectId, slug); if (existing) { res.status(409).json({ success: false, error: { message: 'Scope with this slug already exists', code: 'SCOPE_ALREADY_EXISTS', }, }); return; } // Create scope const scope = await service.create({ projectId, slug, allowNewGenerations: allowNewGenerations ?? true, newGenerationsLimit: newGenerationsLimit ?? 30, meta: meta || {}, }); // Get with stats const scopeWithStats = await service.getByIdWithStats(scope.id); res.status(201).json({ success: true, data: toLiveScopeResponse(scopeWithStats), }); }), ); /** * List all live scopes for the project with pagination and statistics * * Retrieves all scopes (both auto-created and manually created) with: * - Computed currentGenerations count (active only) * - Last generation timestamp * - Pagination support * - Optional slug filtering * * @route GET /api/v1/live/scopes * @authentication Project Key required * * @param {string} [req.query.slug] - Filter by exact slug match * @param {number} [req.query.limit=20] - Results per page (max 100) * @param {number} [req.query.offset=0] - Number of results to skip * * @returns {ListLiveScopesResponse} 200 - Paginated list of scopes with stats * @returns {object} 400 - Invalid pagination parameters * @returns {object} 401 - Missing or invalid API key * * @example * GET /api/v1/live/scopes?limit=50&offset=0 */ scopesRouter.get( '/', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getScopeService(); const { slug, limit, offset } = req.query as ListLiveScopesQuery; const projectId = req.apiKey.projectId; const parsedLimit = Math.min( (limit ? parseInt(limit.toString(), 10) : PAGINATION_LIMITS.DEFAULT_LIMIT) || PAGINATION_LIMITS.DEFAULT_LIMIT, PAGINATION_LIMITS.MAX_LIMIT, ); const parsedOffset = (offset ? parseInt(offset.toString(), 10) : 0) || 0; const result = await service.list( { projectId, slug }, parsedLimit, parsedOffset, ); const scopeResponses = result.scopes.map(toLiveScopeResponse); res.json({ success: true, data: scopeResponses, pagination: buildPaginationMeta(result.total, parsedLimit, parsedOffset), }); }), ); /** * Get a single live scope by slug with complete statistics * * Retrieves detailed scope information including: * - Current generation count (active generations only) * - Last generation timestamp * - Settings (allowNewGenerations, newGenerationsLimit) * - Custom metadata * - Creation and update timestamps * * @route GET /api/v1/live/scopes/:slug * @authentication Project Key required * * @param {string} req.params.slug - Scope slug identifier * * @returns {GetLiveScopeResponse} 200 - Complete scope details with stats * @returns {object} 404 - Scope not found or access denied * @returns {object} 401 - Missing or invalid API key * * @throws {Error} SCOPE_NOT_FOUND - Scope does not exist * * @example * GET /api/v1/live/scopes/hero-section */ scopesRouter.get( '/:slug', validateApiKey, requireProjectKey, asyncHandler(async (req: any, res: Response) => { const service = getScopeService(); const { slug } = req.params; const projectId = req.apiKey.projectId; const scopeWithStats = await service.getBySlugWithStats(projectId, slug); res.json({ success: true, data: toLiveScopeResponse(scopeWithStats), }); }), ); /** * Update live scope settings and metadata * * Modifies scope configuration: * - Enable/disable new generations * - Adjust generation limits * - Update custom metadata * * Changes take effect immediately for new live URL requests. * * @route PUT /api/v1/live/scopes/:slug * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {string} req.params.slug - Scope slug identifier * @param {UpdateLiveScopeRequest} req.body - Update parameters * @param {boolean} [req.body.allowNewGenerations] - Allow/disallow new generations * @param {number} [req.body.newGenerationsLimit] - Update generation limit * @param {object} [req.body.meta] - Update custom metadata * * @returns {UpdateLiveScopeResponse} 200 - Updated scope with stats * @returns {object} 404 - Scope not found or access denied * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @throws {Error} SCOPE_NOT_FOUND - Scope does not exist * * @example * PUT /api/v1/live/scopes/hero-section * { * "allowNewGenerations": false, * "newGenerationsLimit": 100 * } */ scopesRouter.put( '/:slug', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const service = getScopeService(); const { slug } = req.params; const { allowNewGenerations, newGenerationsLimit, meta } = req.body as UpdateLiveScopeRequest; const projectId = req.apiKey.projectId; // Get scope const scope = await service.getBySlugOrThrow(projectId, slug); // Update scope const updates: { allowNewGenerations?: boolean; newGenerationsLimit?: number; meta?: Record; } = {}; if (allowNewGenerations !== undefined) updates.allowNewGenerations = allowNewGenerations; if (newGenerationsLimit !== undefined) updates.newGenerationsLimit = newGenerationsLimit; if (meta !== undefined) updates.meta = meta; await service.update(scope.id, updates); // Get updated scope with stats const updated = await service.getByIdWithStats(scope.id); res.json({ success: true, data: toLiveScopeResponse(updated), }); }), ); /** * Regenerate images in a live scope * * Regenerates either a specific image or all images in the scope: * - Specific image: Provide imageId in request body * - All images: Omit imageId to regenerate entire scope * - Uses exact same parameters (prompt, aspect ratio, etc.) * - Updates existing images (preserves IDs and URLs) * - Verifies image belongs to scope before regenerating * * Useful for refreshing stale cached images or recovering from failures. * * @route POST /api/v1/live/scopes/:slug/regenerate * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {string} req.params.slug - Scope slug identifier * @param {RegenerateScopeRequest} [req.body] - Regeneration options * @param {string} [req.body.imageId] - Specific image to regenerate (omit for all) * * @returns {RegenerateScopeResponse} 200 - Regeneration results * @returns {object} 400 - Image not in scope * @returns {object} 404 - Scope or image not found * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @throws {Error} SCOPE_NOT_FOUND - Scope does not exist * @throws {Error} IMAGE_NOT_FOUND - Image does not exist * @throws {Error} IMAGE_NOT_IN_SCOPE - Image doesn't belong to scope * * @example * // Regenerate specific image * POST /api/v1/live/scopes/hero-section/regenerate * { * "imageId": "550e8400-e29b-41d4-a716-446655440000" * } * * @example * // Regenerate all images in scope * POST /api/v1/live/scopes/hero-section/regenerate * {} */ scopesRouter.post( '/:slug/regenerate', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const scopeService = getScopeService(); const imgService = getImageService(); const genService = getGenerationService(); const { slug } = req.params; const { imageId } = req.body as RegenerateScopeRequest; const projectId = req.apiKey.projectId; // Get scope const scope = await scopeService.getBySlugWithStats(projectId, slug); if (imageId) { // Regenerate specific image const image = await imgService.getById(imageId); if (!image) { res.status(404).json({ success: false, error: { message: ERROR_MESSAGES.IMAGE_NOT_FOUND, code: 'IMAGE_NOT_FOUND', }, }); return; } // Check if image belongs to this scope const imageMeta = image.meta as Record; if (imageMeta['scope'] !== slug) { res.status(400).json({ success: false, error: { message: 'Image does not belong to this scope', code: 'IMAGE_NOT_IN_SCOPE', }, }); return; } // Regenerate the image's generation if (image.generationId) { await genService.regenerate(image.generationId); } const regeneratedImage = await imgService.getById(imageId); res.json({ success: true, data: { regenerated: 1, images: regeneratedImage ? [toImageResponse(regeneratedImage)] : [], }, }); } else { // Regenerate all images in scope if (!scope.images || scope.images.length === 0) { res.json({ success: true, data: { regenerated: 0, images: [], }, }); return; } const regeneratedImages = []; for (const image of scope.images) { if (image.generationId) { await genService.regenerate(image.generationId); const regenerated = await imgService.getById(image.id); if (regenerated) { regeneratedImages.push(toImageResponse(regenerated)); } } } res.json({ success: true, data: { regenerated: regeneratedImages.length, images: regeneratedImages, }, }); } }), ); /** * Delete a live scope with cascading image deletion * * Permanently removes the scope and all its associated images: * - Hard deletes all images in scope (MinIO + database) * - Follows alias protection rules for each image * - Hard deletes scope record (no soft delete) * - Cannot be undone * * Use with caution: This is a destructive operation that permanently * removes the scope and all cached live URL images. * * @route DELETE /api/v1/live/scopes/:slug * @authentication Project Key required * @rateLimit 100 requests per hour per API key * * @param {string} req.params.slug - Scope slug identifier * * @returns {DeleteLiveScopeResponse} 200 - Deletion confirmation with scope ID * @returns {object} 404 - Scope not found or access denied * @returns {object} 401 - Missing or invalid API key * @returns {object} 429 - Rate limit exceeded * * @throws {Error} SCOPE_NOT_FOUND - Scope does not exist * * @example * DELETE /api/v1/live/scopes/hero-section * * Response: * { * "success": true, * "data": { "id": "550e8400-e29b-41d4-a716-446655440000" } * } */ scopesRouter.delete( '/:slug', validateApiKey, requireProjectKey, rateLimitByApiKey, asyncHandler(async (req: any, res: Response) => { const scopeService = getScopeService(); const imgService = getImageService(); const { slug } = req.params; const projectId = req.apiKey.projectId; // Get scope with images const scope = await scopeService.getBySlugWithStats(projectId, slug); // Delete all images in scope (follows alias protection rules) if (scope.images) { for (const image of scope.images) { await imgService.hardDelete(image.id); } } // Delete scope record await scopeService.delete(scope.id); res.json({ success: true, data: { id: scope.id }, }); }), );