banatie-service/apps/api-service/src/routes/v1/scopes.ts

511 lines
15 KiB
TypeScript

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<CreateLiveScopeResponse>) => {
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<ListLiveScopesResponse>) => {
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<GetLiveScopeResponse>) => {
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<UpdateLiveScopeResponse>) => {
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<string, unknown>;
} = {};
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<RegenerateScopeResponse>) => {
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<string, unknown>;
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<DeleteLiveScopeResponse>) => {
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 },
});
}),
);