feat: phase 3 part 3 - scope management and IP rate limiting
Implement comprehensive live scope management API and IP-based rate limiting for live URL generation endpoints (Sections 8.5 and 8.6). **Scope Management CRUD Endpoints:** - POST /api/v1/live/scopes - Create scope manually with slug validation - GET /api/v1/live/scopes - List scopes with pagination and stats - GET /api/v1/live/scopes/:slug - Get single scope by slug with stats - PUT /api/v1/live/scopes/:slug - Update scope settings - POST /api/v1/live/scopes/:slug/regenerate - Regenerate scope images - DELETE /api/v1/live/scopes/:slug - Delete scope with cascading image deletion **Scope Management Features:** - Slug format validation (alphanumeric, hyphens, underscores) - Duplicate slug prevention with 409 Conflict response - Scope statistics (currentGenerations, lastGeneratedAt) - Settings management (allowNewGenerations, newGenerationsLimit) - Regeneration support (single image or all images in scope) - Hard delete with image cleanup following alias protection rules - All endpoints require Project Key authentication **IP-Based Rate Limiting:** - In-memory rate limit store with automatic cleanup - Limits: 10 new generations per hour per IP address - Only cache MISS (new generation) counts toward limit - Cache HIT does NOT count toward limit - X-Forwarded-For header support for proxy/load balancer setups - Rate limit headers: X-RateLimit-Limit, X-RateLimit-Remaining, X-RateLimit-Reset - Retry-After header on 429 Too Many Requests response - Automatic cleanup of expired entries every 5 minutes **IP Rate Limiter Middleware:** - ipRateLimiterMiddleware attaches to live URL endpoint - getClientIp() extracts IP from X-Forwarded-For or req.ip - checkIpRateLimit() validates and increments counter - getRemainingRequests() returns available request count - getResetTime() returns seconds until reset - Middleware attaches checkIpRateLimit function to request - Rate limit check executed AFTER cache check (only for cache MISS) **Type System Updates:** - Added LiveScopeResponse interface with all scope fields - Added LiveScopeWithImagesResponse with images array - Added response type aliases for all CRUD operations - Added toLiveScopeResponse() converter function - Added CreateLiveScopeRequest, UpdateLiveScopeRequest interfaces - Added ListLiveScopesQuery with pagination parameters - Added RegenerateScopeRequest with optional imageId **Route Integration:** - Mounted scopes router at /api/v1/live/scopes - Applied ipRateLimiterMiddleware to live URL endpoint - Rate limit increments only on cache MISS (new generation) - Cache HIT bypasses rate limit check entirely **Technical Notes:** - All scope endpoints return toLiveScopeResponse() format - Pagination using buildPaginationMeta helper - Bracket notation for meta field access (TypeScript strict mode) - Proper number parsing with fallback defaults - All Phase 3 Part 3 code is fully type-safe with zero TypeScript errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
1f768d4761
commit
fa65264410
|
|
@ -0,0 +1,176 @@
|
||||||
|
import { Request, Response, NextFunction } from 'express';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP-based rate limiter for live URL generation (Section 8.6)
|
||||||
|
*
|
||||||
|
* Limits: 10 new generations per hour per IP address
|
||||||
|
* - Separate from API key rate limits
|
||||||
|
* - Cache hits do NOT count toward limit
|
||||||
|
* - Only new generations (cache MISS) count
|
||||||
|
*
|
||||||
|
* Implementation uses in-memory store with automatic cleanup
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface RateLimitEntry {
|
||||||
|
count: number;
|
||||||
|
resetAt: number; // Timestamp when count resets
|
||||||
|
}
|
||||||
|
|
||||||
|
// In-memory store for IP rate limits
|
||||||
|
// Key: IP address, Value: { count, resetAt }
|
||||||
|
const ipRateLimits = new Map<string, RateLimitEntry>();
|
||||||
|
|
||||||
|
// Configuration
|
||||||
|
const RATE_LIMIT_WINDOW_MS = 60 * 60 * 1000; // 1 hour
|
||||||
|
const MAX_REQUESTS_PER_WINDOW = 10; // 10 new generations per hour
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get client IP address from request
|
||||||
|
* Supports X-Forwarded-For header for proxy/load balancer setups
|
||||||
|
*/
|
||||||
|
const getClientIp = (req: Request): string => {
|
||||||
|
// Check X-Forwarded-For header (used by proxies/load balancers)
|
||||||
|
const forwardedFor = req.headers['x-forwarded-for'];
|
||||||
|
if (forwardedFor) {
|
||||||
|
// X-Forwarded-For can contain multiple IPs, take the first one
|
||||||
|
const ips = Array.isArray(forwardedFor) ? forwardedFor[0] : forwardedFor;
|
||||||
|
return ips?.split(',')[0]?.trim() || req.ip || 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fall back to req.ip
|
||||||
|
return req.ip || 'unknown';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean up expired entries from the rate limit store
|
||||||
|
* Called periodically to prevent memory leaks
|
||||||
|
*/
|
||||||
|
const cleanupExpiredEntries = (): void => {
|
||||||
|
const now = Date.now();
|
||||||
|
for (const [ip, entry] of ipRateLimits.entries()) {
|
||||||
|
if (now > entry.resetAt) {
|
||||||
|
ipRateLimits.delete(ip);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Run cleanup every 5 minutes
|
||||||
|
setInterval(cleanupExpiredEntries, 5 * 60 * 1000);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if IP has exceeded rate limit
|
||||||
|
* Returns true if limit exceeded, false otherwise
|
||||||
|
*/
|
||||||
|
export const checkIpRateLimit = (ip: string): boolean => {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = ipRateLimits.get(ip);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
// First request from this IP
|
||||||
|
ipRateLimits.set(ip, {
|
||||||
|
count: 1,
|
||||||
|
resetAt: now + RATE_LIMIT_WINDOW_MS,
|
||||||
|
});
|
||||||
|
return false; // Not limited
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if (now > entry.resetAt) {
|
||||||
|
// Reset the counter
|
||||||
|
entry.count = 1;
|
||||||
|
entry.resetAt = now + RATE_LIMIT_WINDOW_MS;
|
||||||
|
return false; // Not limited
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increment counter
|
||||||
|
entry.count += 1;
|
||||||
|
|
||||||
|
// Check if limit exceeded
|
||||||
|
return entry.count > MAX_REQUESTS_PER_WINDOW;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get remaining requests for IP
|
||||||
|
*/
|
||||||
|
export const getRemainingRequests = (ip: string): number => {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = ipRateLimits.get(ip);
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
return MAX_REQUESTS_PER_WINDOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if window has expired
|
||||||
|
if (now > entry.resetAt) {
|
||||||
|
return MAX_REQUESTS_PER_WINDOW;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.max(0, MAX_REQUESTS_PER_WINDOW - entry.count);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get time until rate limit resets (in seconds)
|
||||||
|
*/
|
||||||
|
export const getResetTime = (ip: string): number => {
|
||||||
|
const now = Date.now();
|
||||||
|
const entry = ipRateLimits.get(ip);
|
||||||
|
|
||||||
|
if (!entry || now > entry.resetAt) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.ceil((entry.resetAt - now) / 1000);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Middleware: IP-based rate limiter for live URLs
|
||||||
|
* Only increments counter on cache MISS (new generation)
|
||||||
|
* Use this middleware BEFORE cache check, but only increment after cache MISS
|
||||||
|
*/
|
||||||
|
export const ipRateLimiterMiddleware = (req: Request, res: Response, next: NextFunction): void => {
|
||||||
|
const ip = getClientIp(req);
|
||||||
|
|
||||||
|
// Attach IP to request for later use
|
||||||
|
(req as any).clientIp = ip;
|
||||||
|
|
||||||
|
// Attach rate limit check function to request
|
||||||
|
(req as any).checkIpRateLimit = () => {
|
||||||
|
const limited = checkIpRateLimit(ip);
|
||||||
|
if (limited) {
|
||||||
|
const resetTime = getResetTime(ip);
|
||||||
|
res.status(429).json({
|
||||||
|
success: false,
|
||||||
|
error: {
|
||||||
|
message: `Rate limit exceeded. Try again in ${resetTime} seconds`,
|
||||||
|
code: 'IP_RATE_LIMIT_EXCEEDED',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
res.setHeader('Retry-After', resetTime.toString());
|
||||||
|
res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
|
||||||
|
res.setHeader('X-RateLimit-Remaining', '0');
|
||||||
|
res.setHeader('X-RateLimit-Reset', getResetTime(ip).toString());
|
||||||
|
return true; // Limited
|
||||||
|
}
|
||||||
|
return false; // Not limited
|
||||||
|
};
|
||||||
|
|
||||||
|
// Set rate limit headers
|
||||||
|
const remaining = getRemainingRequests(ip);
|
||||||
|
const resetTime = getResetTime(ip);
|
||||||
|
res.setHeader('X-RateLimit-Limit', MAX_REQUESTS_PER_WINDOW.toString());
|
||||||
|
res.setHeader('X-RateLimit-Remaining', remaining.toString());
|
||||||
|
if (resetTime > 0) {
|
||||||
|
res.setHeader('X-RateLimit-Reset', resetTime.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
next();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Helper function to manually increment IP rate limit counter
|
||||||
|
* Use this after confirming cache MISS (new generation)
|
||||||
|
*/
|
||||||
|
export const incrementIpRateLimit = (_ip: string): void => {
|
||||||
|
// Counter already incremented in checkIpRateLimit
|
||||||
|
// This is a no-op, kept for API consistency
|
||||||
|
};
|
||||||
|
|
@ -6,6 +6,7 @@ import { eq, and, isNull, sql } from 'drizzle-orm';
|
||||||
import { ImageService, GenerationService, LiveScopeService } from '@/services/core';
|
import { ImageService, GenerationService, LiveScopeService } from '@/services/core';
|
||||||
import { StorageFactory } from '@/services/StorageFactory';
|
import { StorageFactory } from '@/services/StorageFactory';
|
||||||
import { asyncHandler } from '@/middleware/errorHandler';
|
import { asyncHandler } from '@/middleware/errorHandler';
|
||||||
|
import { ipRateLimiterMiddleware } from '@/middleware/ipRateLimiter';
|
||||||
import { computeLiveUrlCacheKey } from '@/utils/helpers';
|
import { computeLiveUrlCacheKey } from '@/utils/helpers';
|
||||||
import { GENERATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
|
import { GENERATION_LIMITS, ERROR_MESSAGES } from '@/utils/constants';
|
||||||
import type { LiveGenerationQuery } from '@/types/requests';
|
import type { LiveGenerationQuery } from '@/types/requests';
|
||||||
|
|
@ -159,6 +160,7 @@ cdnRouter.get(
|
||||||
*/
|
*/
|
||||||
cdnRouter.get(
|
cdnRouter.get(
|
||||||
'/:orgSlug/:projectSlug/live/:scope',
|
'/:orgSlug/:projectSlug/live/:scope',
|
||||||
|
ipRateLimiterMiddleware,
|
||||||
asyncHandler(async (req: any, res: Response) => {
|
asyncHandler(async (req: any, res: Response) => {
|
||||||
const { orgSlug, projectSlug, scope } = req.params;
|
const { orgSlug, projectSlug, scope } = req.params;
|
||||||
const { prompt, aspectRatio, autoEnhance, template } = req.query as LiveGenerationQuery;
|
const { prompt, aspectRatio, autoEnhance, template } = req.query as LiveGenerationQuery;
|
||||||
|
|
@ -269,6 +271,13 @@ cdnRouter.get(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cache MISS - check IP rate limit before generating
|
||||||
|
// Only count new generations (cache MISS) toward IP rate limit
|
||||||
|
const isLimited = (req as any).checkIpRateLimit();
|
||||||
|
if (isLimited) {
|
||||||
|
return; // Rate limit response already sent
|
||||||
|
}
|
||||||
|
|
||||||
// Cache MISS - check scope and generate
|
// Cache MISS - check scope and generate
|
||||||
// Get or create scope
|
// Get or create scope
|
||||||
let liveScope;
|
let liveScope;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { generationsRouter } from './generations';
|
||||||
import { flowsRouter } from './flows';
|
import { flowsRouter } from './flows';
|
||||||
import { imagesRouter } from './images';
|
import { imagesRouter } from './images';
|
||||||
import { liveRouter } from './live';
|
import { liveRouter } from './live';
|
||||||
|
import { scopesRouter } from './scopes';
|
||||||
|
|
||||||
export const v1Router: RouterType = Router();
|
export const v1Router: RouterType = Router();
|
||||||
|
|
||||||
|
|
@ -12,3 +13,4 @@ v1Router.use('/generations', generationsRouter);
|
||||||
v1Router.use('/flows', flowsRouter);
|
v1Router.use('/flows', flowsRouter);
|
||||||
v1Router.use('/images', imagesRouter);
|
v1Router.use('/images', imagesRouter);
|
||||||
v1Router.use('/live', liveRouter);
|
v1Router.use('/live', liveRouter);
|
||||||
|
v1Router.use('/live/scopes', scopesRouter);
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,344 @@
|
||||||
|
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;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/live/scopes
|
||||||
|
* Create new live scope manually (Section 8.5)
|
||||||
|
* @authentication Project Key required
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* GET /api/v1/live/scopes
|
||||||
|
* List all live scopes for a project (Section 8.5)
|
||||||
|
* @authentication Project Key required
|
||||||
|
*/
|
||||||
|
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 /api/v1/live/scopes/:slug
|
||||||
|
* Get single live scope by slug (Section 8.5)
|
||||||
|
* @authentication Project Key required
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PUT /api/v1/live/scopes/:slug
|
||||||
|
* Update live scope settings (Section 8.5)
|
||||||
|
* @authentication Project Key required
|
||||||
|
*/
|
||||||
|
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),
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* POST /api/v1/live/scopes/:slug/regenerate
|
||||||
|
* Regenerate images in scope (Section 8.5)
|
||||||
|
* @authentication Project Key required
|
||||||
|
*/
|
||||||
|
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 /api/v1/live/scopes/:slug
|
||||||
|
* Delete live scope (Section 8.5)
|
||||||
|
* Deletes all images in scope following standard deletion rules
|
||||||
|
* @authentication Project Key required
|
||||||
|
*/
|
||||||
|
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 },
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
@ -100,6 +100,33 @@ export interface LiveGenerationQuery {
|
||||||
template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general';
|
template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LIVE SCOPE ENDPOINTS
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface CreateLiveScopeRequest {
|
||||||
|
slug: string;
|
||||||
|
allowNewGenerations?: boolean;
|
||||||
|
newGenerationsLimit?: number;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListLiveScopesQuery {
|
||||||
|
slug?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateLiveScopeRequest {
|
||||||
|
allowNewGenerations?: boolean;
|
||||||
|
newGenerationsLimit?: number;
|
||||||
|
meta?: Record<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RegenerateScopeRequest {
|
||||||
|
imageId?: string; // Optional: regenerate specific image
|
||||||
|
}
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// ANALYTICS ENDPOINTS
|
// ANALYTICS ENDPOINTS
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type {
|
||||||
Image,
|
Image,
|
||||||
GenerationWithRelations,
|
GenerationWithRelations,
|
||||||
FlowWithCounts,
|
FlowWithCounts,
|
||||||
|
LiveScopeWithStats,
|
||||||
PaginationMeta,
|
PaginationMeta,
|
||||||
AliasScope,
|
AliasScope,
|
||||||
} from './models';
|
} from './models';
|
||||||
|
|
@ -130,6 +131,34 @@ export type DeleteFlowResponse = ApiResponse<{ id: string }>;
|
||||||
export type ListFlowGenerationsResponse = PaginatedResponse<GenerationResponse>;
|
export type ListFlowGenerationsResponse = PaginatedResponse<GenerationResponse>;
|
||||||
export type ListFlowImagesResponse = PaginatedResponse<ImageResponse>;
|
export type ListFlowImagesResponse = PaginatedResponse<ImageResponse>;
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// LIVE SCOPE RESPONSES
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
export interface LiveScopeResponse {
|
||||||
|
id: string;
|
||||||
|
projectId: string;
|
||||||
|
slug: string;
|
||||||
|
allowNewGenerations: boolean;
|
||||||
|
newGenerationsLimit: number;
|
||||||
|
currentGenerations: number;
|
||||||
|
lastGeneratedAt: string | null;
|
||||||
|
meta: Record<string, unknown>;
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LiveScopeWithImagesResponse extends LiveScopeResponse {
|
||||||
|
images?: ImageResponse[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||||
|
export type GetLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||||
|
export type ListLiveScopesResponse = PaginatedResponse<LiveScopeResponse>;
|
||||||
|
export type UpdateLiveScopeResponse = ApiResponse<LiveScopeResponse>;
|
||||||
|
export type DeleteLiveScopeResponse = ApiResponse<{ id: string }>;
|
||||||
|
export type RegenerateScopeResponse = ApiResponse<{ regenerated: number; images: ImageResponse[] }>;
|
||||||
|
|
||||||
// ========================================
|
// ========================================
|
||||||
// LIVE GENERATION RESPONSE
|
// LIVE GENERATION RESPONSE
|
||||||
// ========================================
|
// ========================================
|
||||||
|
|
@ -268,3 +297,16 @@ export const toFlowResponse = (flow: FlowWithCounts): FlowResponse => ({
|
||||||
createdAt: flow.createdAt.toISOString(),
|
createdAt: flow.createdAt.toISOString(),
|
||||||
updatedAt: flow.updatedAt.toISOString(),
|
updatedAt: flow.updatedAt.toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
export const toLiveScopeResponse = (scope: LiveScopeWithStats): LiveScopeResponse => ({
|
||||||
|
id: scope.id,
|
||||||
|
projectId: scope.projectId,
|
||||||
|
slug: scope.slug,
|
||||||
|
allowNewGenerations: scope.allowNewGenerations,
|
||||||
|
newGenerationsLimit: scope.newGenerationsLimit,
|
||||||
|
currentGenerations: scope.currentGenerations,
|
||||||
|
lastGeneratedAt: scope.lastGeneratedAt?.toISOString() ?? null,
|
||||||
|
meta: scope.meta as Record<string, unknown>,
|
||||||
|
createdAt: scope.createdAt.toISOString(),
|
||||||
|
updatedAt: scope.updatedAt.toISOString(),
|
||||||
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue