From c185ea3ff460273345ccf4ebfa05bad7134d82c4 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sun, 9 Nov 2025 21:53:50 +0700 Subject: [PATCH] feat: add Phase 1 foundation for API v2.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement foundational components for Banatie API v2.0 with dual alias system, flows support, and comprehensive type safety. **Type Definitions:** - Add models.ts with database types (Generation, Image, Flow, etc.) - Add requests.ts with all v2 API request types - Add responses.ts with standardized response types and converters - Support for pagination, filters, and complex relations **Constants:** - Define technical aliases (@last, @first, @upload) - Define reserved aliases and validation patterns - Add rate limits for master/project keys (2-bucket system) - Add pagination, image, generation, and flow limits - Comprehensive error messages and codes **Validators:** - aliasValidator: Format validation, reserved alias checking - paginationValidator: Bounds checking with normalization - queryValidator: UUID, aspect ratio, focal point, date range validation **Helpers:** - paginationBuilder: Standardized pagination response construction - hashHelper: SHA-256 utilities for caching and file deduplication - queryHelper: Reusable WHERE clause builders with soft delete support **Core Services:** - AliasService: 3-tier alias resolution (technical → flow → project) - Technical alias computation (@last, @first, @upload) - Flow-scoped alias lookup from JSONB - Project-scoped alias lookup with uniqueness - Conflict detection and validation - Batch resolution support **Dependencies:** - Add drizzle-orm to api-service for direct ORM usage All Phase 1 code is type-safe with zero TypeScript errors. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- apps/api-service/package.json | 1 + .../src/services/core/AliasService.ts | 271 ++++++++++++++++++ apps/api-service/src/services/core/index.ts | 1 + apps/api-service/src/types/models.ts | 89 ++++++ apps/api-service/src/types/requests.ts | 120 ++++++++ apps/api-service/src/types/responses.ts | 268 +++++++++++++++++ .../src/utils/constants/aliases.ts | 31 ++ .../api-service/src/utils/constants/errors.ts | 103 +++++++ apps/api-service/src/utils/constants/index.ts | 3 + .../api-service/src/utils/constants/limits.ts | 55 ++++ .../src/utils/helpers/hashHelper.ts | 21 ++ apps/api-service/src/utils/helpers/index.ts | 3 + .../src/utils/helpers/paginationBuilder.ts | 28 ++ .../src/utils/helpers/queryHelper.ts | 36 +++ .../src/utils/validators/aliasValidator.ts | 99 +++++++ .../api-service/src/utils/validators/index.ts | 3 + .../utils/validators/paginationValidator.ts | 64 +++++ .../src/utils/validators/queryValidator.ts | 100 +++++++ 18 files changed, 1296 insertions(+) create mode 100644 apps/api-service/src/services/core/AliasService.ts create mode 100644 apps/api-service/src/services/core/index.ts create mode 100644 apps/api-service/src/types/models.ts create mode 100644 apps/api-service/src/types/requests.ts create mode 100644 apps/api-service/src/types/responses.ts create mode 100644 apps/api-service/src/utils/constants/aliases.ts create mode 100644 apps/api-service/src/utils/constants/errors.ts create mode 100644 apps/api-service/src/utils/constants/index.ts create mode 100644 apps/api-service/src/utils/constants/limits.ts create mode 100644 apps/api-service/src/utils/helpers/hashHelper.ts create mode 100644 apps/api-service/src/utils/helpers/index.ts create mode 100644 apps/api-service/src/utils/helpers/paginationBuilder.ts create mode 100644 apps/api-service/src/utils/helpers/queryHelper.ts create mode 100644 apps/api-service/src/utils/validators/aliasValidator.ts create mode 100644 apps/api-service/src/utils/validators/index.ts create mode 100644 apps/api-service/src/utils/validators/paginationValidator.ts create mode 100644 apps/api-service/src/utils/validators/queryValidator.ts diff --git a/apps/api-service/package.json b/apps/api-service/package.json index 399226d..cd31a7f 100644 --- a/apps/api-service/package.json +++ b/apps/api-service/package.json @@ -43,6 +43,7 @@ "@google/genai": "^1.22.0", "cors": "^2.8.5", "dotenv": "^17.2.2", + "drizzle-orm": "^0.36.4", "express": "^5.1.0", "express-rate-limit": "^7.4.1", "express-validator": "^7.2.0", diff --git a/apps/api-service/src/services/core/AliasService.ts b/apps/api-service/src/services/core/AliasService.ts new file mode 100644 index 0000000..2aff9f1 --- /dev/null +++ b/apps/api-service/src/services/core/AliasService.ts @@ -0,0 +1,271 @@ +import { eq, and, isNull, desc } from 'drizzle-orm'; +import { db } from '@/db'; +import { images, flows } from '@banatie/database'; +import type { AliasResolution, Image } from '@/types/models'; +import { isTechnicalAlias } from '@/utils/constants/aliases'; +import { + validateAliasFormat, + validateAliasNotReserved, +} from '@/utils/validators'; +import { ERROR_MESSAGES } from '@/utils/constants'; + +export class AliasService { + async resolve( + alias: string, + projectId: string, + flowId?: string + ): Promise { + const formatResult = validateAliasFormat(alias); + if (!formatResult.valid) { + throw new Error(formatResult.error!.message); + } + + if (isTechnicalAlias(alias)) { + if (!flowId) { + throw new Error(ERROR_MESSAGES.TECHNICAL_ALIAS_REQUIRES_FLOW); + } + return await this.resolveTechnicalAlias(alias, flowId, projectId); + } + + if (flowId) { + const flowResolution = await this.resolveFlowAlias(alias, flowId, projectId); + if (flowResolution) { + return flowResolution; + } + } + + return await this.resolveProjectAlias(alias, projectId); + } + + private async resolveTechnicalAlias( + alias: string, + flowId: string, + projectId: string + ): Promise { + let image: Image | undefined; + + switch (alias) { + case '@last': + image = await this.getLastGeneratedInFlow(flowId, projectId); + break; + + case '@first': + image = await this.getFirstGeneratedInFlow(flowId, projectId); + break; + + case '@upload': + image = await this.getLastUploadedInFlow(flowId, projectId); + break; + + default: + return null; + } + + if (!image) { + return null; + } + + return { + imageId: image.id, + scope: 'technical', + flowId, + image, + }; + } + + private async resolveFlowAlias( + alias: string, + flowId: string, + projectId: string + ): Promise { + const flow = await db.query.flows.findFirst({ + where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)), + }); + + if (!flow) { + return null; + } + + const flowAliases = flow.aliases as Record; + const imageId = flowAliases[alias]; + + if (!imageId) { + return null; + } + + const image = await db.query.images.findFirst({ + where: and( + eq(images.id, imageId), + eq(images.projectId, projectId), + isNull(images.deletedAt) + ), + }); + + if (!image) { + return null; + } + + return { + imageId: image.id, + scope: 'flow', + flowId, + image, + }; + } + + private async resolveProjectAlias( + alias: string, + projectId: string + ): Promise { + const image = await db.query.images.findFirst({ + where: and( + eq(images.projectId, projectId), + eq(images.alias, alias), + isNull(images.deletedAt), + isNull(images.flowId) + ), + }); + + if (!image) { + return null; + } + + return { + imageId: image.id, + scope: 'project', + image, + }; + } + + private async getLastGeneratedInFlow( + flowId: string, + projectId: string + ): Promise { + return await db.query.images.findFirst({ + where: and( + eq(images.flowId, flowId), + eq(images.projectId, projectId), + eq(images.source, 'generated'), + isNull(images.deletedAt) + ), + orderBy: [desc(images.createdAt)], + }); + } + + private async getFirstGeneratedInFlow( + flowId: string, + projectId: string + ): Promise { + const allImages = await db.query.images.findMany({ + where: and( + eq(images.flowId, flowId), + eq(images.projectId, projectId), + eq(images.source, 'generated'), + isNull(images.deletedAt) + ), + orderBy: [images.createdAt], + limit: 1, + }); + + return allImages[0]; + } + + private async getLastUploadedInFlow( + flowId: string, + projectId: string + ): Promise { + return await db.query.images.findFirst({ + where: and( + eq(images.flowId, flowId), + eq(images.projectId, projectId), + eq(images.source, 'uploaded'), + isNull(images.deletedAt) + ), + orderBy: [desc(images.createdAt)], + }); + } + + async validateAliasForAssignment(alias: string, projectId: string, flowId?: string): Promise { + const formatResult = validateAliasFormat(alias); + if (!formatResult.valid) { + throw new Error(formatResult.error!.message); + } + + const reservedResult = validateAliasNotReserved(alias); + if (!reservedResult.valid) { + throw new Error(reservedResult.error!.message); + } + + if (flowId) { + await this.checkFlowAliasConflict(alias, flowId, projectId); + } else { + await this.checkProjectAliasConflict(alias, projectId); + } + } + + private async checkProjectAliasConflict(alias: string, projectId: string): Promise { + const existing = await db.query.images.findFirst({ + where: and( + eq(images.projectId, projectId), + eq(images.alias, alias), + isNull(images.deletedAt), + isNull(images.flowId) + ), + }); + + if (existing) { + throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT); + } + } + + private async checkFlowAliasConflict(alias: string, flowId: string, projectId: string): Promise { + const flow = await db.query.flows.findFirst({ + where: and(eq(flows.id, flowId), eq(flows.projectId, projectId)), + }); + + if (!flow) { + throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND); + } + + const flowAliases = flow.aliases as Record; + if (flowAliases[alias]) { + throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT); + } + } + + async resolveMultiple( + aliases: string[], + projectId: string, + flowId?: string + ): Promise> { + const resolutions = new Map(); + + for (const alias of aliases) { + const resolution = await this.resolve(alias, projectId, flowId); + if (resolution) { + resolutions.set(alias, resolution); + } + } + + return resolutions; + } + + async resolveToImageIds( + aliases: string[], + projectId: string, + flowId?: string + ): Promise { + const imageIds: string[] = []; + + for (const alias of aliases) { + const resolution = await this.resolve(alias, projectId, flowId); + if (resolution) { + imageIds.push(resolution.imageId); + } else { + throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`); + } + } + + return imageIds; + } +} diff --git a/apps/api-service/src/services/core/index.ts b/apps/api-service/src/services/core/index.ts new file mode 100644 index 0000000..30899e9 --- /dev/null +++ b/apps/api-service/src/services/core/index.ts @@ -0,0 +1 @@ +export * from './AliasService'; diff --git a/apps/api-service/src/types/models.ts b/apps/api-service/src/types/models.ts new file mode 100644 index 0000000..9eb3799 --- /dev/null +++ b/apps/api-service/src/types/models.ts @@ -0,0 +1,89 @@ +import type { generations, images, flows, promptUrlCache } from '@banatie/database'; + +// Database model types (inferred from Drizzle schema) +export type Generation = typeof generations.$inferSelect; +export type Image = typeof images.$inferSelect; +export type Flow = typeof flows.$inferSelect; +export type PromptUrlCacheEntry = typeof promptUrlCache.$inferSelect; + +// Insert types (for creating new records) +export type NewGeneration = typeof generations.$inferInsert; +export type NewImage = typeof images.$inferInsert; +export type NewFlow = typeof flows.$inferInsert; +export type NewPromptUrlCacheEntry = typeof promptUrlCache.$inferInsert; + +// Generation status enum (matches DB schema) +export type GenerationStatus = 'pending' | 'processing' | 'success' | 'failed'; + +// Image source enum (matches DB schema) +export type ImageSource = 'generated' | 'uploaded'; + +// Alias scope types (for resolution) +export type AliasScope = 'technical' | 'flow' | 'project'; + +// Alias resolution result +export interface AliasResolution { + imageId: string; + scope: AliasScope; + flowId?: string; + image?: Image; +} + +// Enhanced generation with related data +export interface GenerationWithRelations extends Generation { + outputImage?: Image; + referenceImages?: Image[]; + flow?: Flow; +} + +// Enhanced image with related data +export interface ImageWithRelations extends Image { + generation?: Generation; + usedInGenerations?: Generation[]; + flow?: Flow; +} + +// Enhanced flow with computed counts +export interface FlowWithCounts extends Flow { + generationCount: number; + imageCount: number; + generations?: Generation[]; + images?: Image[]; +} + +// Pagination metadata +export interface PaginationMeta { + total: number; + limit: number; + offset: number; + hasMore: boolean; +} + +// Query filters for images +export interface ImageFilters { + projectId: string; + flowId?: string; + source?: ImageSource; + alias?: string; + deleted?: boolean; +} + +// Query filters for generations +export interface GenerationFilters { + projectId: string; + flowId?: string; + status?: GenerationStatus; + deleted?: boolean; +} + +// Query filters for flows +export interface FlowFilters { + projectId: string; +} + +// Cache statistics +export interface CacheStats { + hits: number; + misses: number; + hitRate: number; +} diff --git a/apps/api-service/src/types/requests.ts b/apps/api-service/src/types/requests.ts new file mode 100644 index 0000000..1b757fd --- /dev/null +++ b/apps/api-service/src/types/requests.ts @@ -0,0 +1,120 @@ +import type { ImageSource } from './models'; + +// ======================================== +// GENERATION ENDPOINTS +// ======================================== + +export interface CreateGenerationRequest { + prompt: string; + referenceImages?: string[]; // Array of aliases to resolve + aspectRatio?: string; // e.g., "1:1", "16:9", "3:2", "9:16" + flowId?: string; + outputAlias?: string; // Alias to assign to generated image + flowAliases?: Record; // Flow-scoped aliases to assign + autoEnhance?: boolean; + enhancementOptions?: { + template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general'; + }; + meta?: Record; +} + +export interface ListGenerationsQuery { + flowId?: string; + status?: string; + limit?: number; + offset?: number; + includeDeleted?: boolean; +} + +export interface RetryGenerationRequest { + prompt?: string; // Optional: override original prompt + aspectRatio?: string; // Optional: override original aspect ratio +} + +// ======================================== +// IMAGE ENDPOINTS +// ======================================== + +export interface UploadImageRequest { + alias?: string; // Project-scoped alias + flowId?: string; + flowAliases?: Record; // Flow-scoped aliases + meta?: Record; +} + +export interface ListImagesQuery { + flowId?: string; + source?: ImageSource; + alias?: string; + limit?: number; + offset?: number; + includeDeleted?: boolean; +} + +export interface UpdateImageRequest { + alias?: string; + focalPoint?: { + x: number; // 0.0 to 1.0 + y: number; // 0.0 to 1.0 + }; + meta?: Record; +} + +export interface DeleteImageQuery { + hard?: boolean; // If true, perform hard delete; otherwise soft delete +} + +// ======================================== +// FLOW ENDPOINTS +// ======================================== + +export interface CreateFlowRequest { + meta?: Record; +} + +export interface ListFlowsQuery { + limit?: number; + offset?: number; +} + +export interface UpdateFlowAliasesRequest { + aliases: Record; // { alias: imageId } + merge?: boolean; // If true, merge with existing; otherwise replace +} + +// ======================================== +// LIVE GENERATION ENDPOINT +// ======================================== + +export interface LiveGenerationQuery { + prompt: string; + aspectRatio?: string; + autoEnhance?: boolean; + template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general'; +} + +// ======================================== +// ANALYTICS ENDPOINTS +// ======================================== + +export interface AnalyticsSummaryQuery { + flowId?: string; + startDate?: string; // ISO date string + endDate?: string; // ISO date string +} + +export interface AnalyticsTimelineQuery { + flowId?: string; + startDate?: string; // ISO date string + endDate?: string; // ISO date string + granularity?: 'hour' | 'day' | 'week'; +} + +// ======================================== +// COMMON TYPES +// ======================================== + +export interface PaginationQuery { + limit?: number; + offset?: number; +} diff --git a/apps/api-service/src/types/responses.ts b/apps/api-service/src/types/responses.ts new file mode 100644 index 0000000..457cdfb --- /dev/null +++ b/apps/api-service/src/types/responses.ts @@ -0,0 +1,268 @@ +import type { + Image, + GenerationWithRelations, + FlowWithCounts, + PaginationMeta, + AliasScope, +} from './models'; + +// ======================================== +// COMMON RESPONSE TYPES +// ======================================== + +export interface ApiResponse { + success: boolean; + data?: T; + error?: { + message: string; + code?: string; + details?: unknown; + }; +} + +export interface PaginatedResponse { + success: boolean; + data: T[]; + pagination: PaginationMeta; +} + +// ======================================== +// GENERATION RESPONSES +// ======================================== + +export interface GenerationResponse { + id: string; + projectId: string; + flowId: string | null; + originalPrompt: string; + enhancedPrompt: string | null; + aspectRatio: string | null; + status: string; + errorMessage: string | null; + retryCount: number; + processingTimeMs: number | null; + cost: number | null; + outputImageId: string | null; + outputImage?: ImageResponse | undefined; + referencedImages?: Array<{ imageId: string; alias: string }> | undefined; + referenceImages?: ImageResponse[] | undefined; + apiKeyId: string | null; + meta: Record | null; + createdAt: string; + updatedAt: string; +} + +export type CreateGenerationResponse = ApiResponse; +export type GetGenerationResponse = ApiResponse; +export type ListGenerationsResponse = PaginatedResponse; +export type RetryGenerationResponse = ApiResponse; +export type DeleteGenerationResponse = ApiResponse<{ id: string; deletedAt: string }>; + +// ======================================== +// IMAGE RESPONSES +// ======================================== + +export interface ImageResponse { + id: string; + projectId: string; + flowId: string | null; + storageKey: string; + storageUrl: string; + mimeType: string; + fileSize: number; + width: number | null; + height: number | null; + source: string; + alias: string | null; + focalPoint: { x: number; y: number } | null; + fileHash: string | null; + generationId: string | null; + apiKeyId: string | null; + url?: string | undefined; // Computed field + meta: Record | null; + createdAt: string; + updatedAt: string; + deletedAt: string | null; +} + +export interface AliasResolutionResponse { + alias: string; + imageId: string; + scope: AliasScope; + flowId?: string; + image: ImageResponse; +} + +export type UploadImageResponse = ApiResponse; +export type GetImageResponse = ApiResponse; +export type ListImagesResponse = PaginatedResponse; +export type ResolveAliasResponse = ApiResponse; +export type UpdateImageResponse = ApiResponse; +export type DeleteImageResponse = ApiResponse<{ id: string; deletedAt: string | null }>; + +// ======================================== +// FLOW RESPONSES +// ======================================== + +export interface FlowResponse { + id: string; + projectId: string; + aliases: Record; + generationCount: number; + imageCount: number; + meta: Record; + createdAt: string; + updatedAt: string; +} + +export interface FlowWithDetailsResponse extends FlowResponse { + generations?: GenerationResponse[]; + images?: ImageResponse[]; + resolvedAliases?: Record; +} + +export type CreateFlowResponse = ApiResponse; +export type GetFlowResponse = ApiResponse; +export type ListFlowsResponse = PaginatedResponse; +export type UpdateFlowAliasesResponse = ApiResponse; +export type DeleteFlowAliasResponse = ApiResponse; +export type DeleteFlowResponse = ApiResponse<{ id: string }>; + +// ======================================== +// LIVE GENERATION RESPONSE +// ======================================== +// Note: Live generation streams image bytes directly +// Response headers include: +// - Content-Type: image/jpeg +// - Cache-Control: public, max-age=31536000 +// - X-Cache-Status: HIT | MISS + +// ======================================== +// ANALYTICS RESPONSES +// ======================================== + +export interface AnalyticsSummary { + projectId: string; + flowId?: string; + timeRange: { + startDate: string; + endDate: string; + }; + generations: { + total: number; + success: number; + failed: number; + pending: number; + successRate: number; + }; + images: { + total: number; + generated: number; + uploaded: number; + }; + performance: { + avgProcessingTimeMs: number; + totalCostCents: number; + }; + cache: { + hits: number; + misses: number; + hitRate: number; + }; +} + +export interface AnalyticsTimelineData { + timestamp: string; + generationsTotal: number; + generationsSuccess: number; + generationsFailed: number; + avgProcessingTimeMs: number; + costCents: number; +} + +export interface AnalyticsTimeline { + projectId: string; + flowId?: string; + granularity: 'hour' | 'day' | 'week'; + timeRange: { + startDate: string; + endDate: string; + }; + data: AnalyticsTimelineData[]; +} + +export type GetAnalyticsSummaryResponse = ApiResponse; +export type GetAnalyticsTimelineResponse = ApiResponse; + +// ======================================== +// ERROR RESPONSES +// ======================================== + +export interface ErrorResponse { + success: false; + error: { + message: string; + code?: string; + details?: unknown; + }; +} + +// ======================================== +// HELPER TYPE CONVERTERS +// ======================================== + +export const toGenerationResponse = (gen: GenerationWithRelations): GenerationResponse => ({ + id: gen.id, + projectId: gen.projectId, + flowId: gen.flowId, + originalPrompt: gen.originalPrompt, + enhancedPrompt: gen.enhancedPrompt, + aspectRatio: gen.aspectRatio, + status: gen.status, + errorMessage: gen.errorMessage, + retryCount: gen.retryCount, + processingTimeMs: gen.processingTimeMs, + cost: gen.cost, + outputImageId: gen.outputImageId, + outputImage: gen.outputImage ? toImageResponse(gen.outputImage) : undefined, + referencedImages: gen.referencedImages as Array<{ imageId: string; alias: string }> | undefined, + referenceImages: gen.referenceImages?.map((img) => toImageResponse(img)), + apiKeyId: gen.apiKeyId, + meta: gen.meta as Record, + createdAt: gen.createdAt.toISOString(), + updatedAt: gen.updatedAt.toISOString(), +}); + +export const toImageResponse = (img: Image, includeUrl = true): ImageResponse => ({ + id: img.id, + projectId: img.projectId, + flowId: img.flowId, + storageKey: img.storageKey, + storageUrl: img.storageUrl, + mimeType: img.mimeType, + fileSize: img.fileSize, + width: img.width, + height: img.height, + source: img.source, + alias: img.alias, + focalPoint: img.focalPoint as { x: number; y: number } | null, + fileHash: img.fileHash, + generationId: img.generationId, + apiKeyId: img.apiKeyId, + url: includeUrl ? `/api/v1/images/${img.id}/download` : undefined, + meta: img.meta as Record, + createdAt: img.createdAt.toISOString(), + updatedAt: img.updatedAt.toISOString(), + deletedAt: img.deletedAt?.toISOString() ?? null, +}); + +export const toFlowResponse = (flow: FlowWithCounts): FlowResponse => ({ + id: flow.id, + projectId: flow.projectId, + aliases: flow.aliases as Record, + generationCount: flow.generationCount, + imageCount: flow.imageCount, + meta: flow.meta as Record, + createdAt: flow.createdAt.toISOString(), + updatedAt: flow.updatedAt.toISOString(), +}); diff --git a/apps/api-service/src/utils/constants/aliases.ts b/apps/api-service/src/utils/constants/aliases.ts new file mode 100644 index 0000000..27b803d --- /dev/null +++ b/apps/api-service/src/utils/constants/aliases.ts @@ -0,0 +1,31 @@ +export const TECHNICAL_ALIASES = ['@last', '@first', '@upload'] as const; + +export const RESERVED_ALIASES = [ + ...TECHNICAL_ALIASES, + '@all', + '@latest', + '@oldest', + '@random', + '@next', + '@prev', + '@previous', +] as const; + +export const ALIAS_PATTERN = /^@[a-zA-Z0-9_-]+$/; + +export const ALIAS_MAX_LENGTH = 50; + +export type TechnicalAlias = (typeof TECHNICAL_ALIASES)[number]; +export type ReservedAlias = (typeof RESERVED_ALIASES)[number]; + +export const isTechnicalAlias = (alias: string): alias is TechnicalAlias => { + return TECHNICAL_ALIASES.includes(alias as TechnicalAlias); +}; + +export const isReservedAlias = (alias: string): alias is ReservedAlias => { + return RESERVED_ALIASES.includes(alias as ReservedAlias); +}; + +export const isValidAliasFormat = (alias: string): boolean => { + return ALIAS_PATTERN.test(alias) && alias.length <= ALIAS_MAX_LENGTH; +}; diff --git a/apps/api-service/src/utils/constants/errors.ts b/apps/api-service/src/utils/constants/errors.ts new file mode 100644 index 0000000..17e4444 --- /dev/null +++ b/apps/api-service/src/utils/constants/errors.ts @@ -0,0 +1,103 @@ +export const ERROR_MESSAGES = { + // Authentication & Authorization + INVALID_API_KEY: 'Invalid or expired API key', + MISSING_API_KEY: 'API key is required', + UNAUTHORIZED: 'Unauthorized access', + MASTER_KEY_REQUIRED: 'Master key required for this operation', + PROJECT_KEY_REQUIRED: 'Project key required for this operation', + + // Validation + INVALID_ALIAS_FORMAT: 'Alias must start with @ and contain only alphanumeric characters, hyphens, and underscores', + RESERVED_ALIAS: 'This alias is reserved and cannot be used', + ALIAS_CONFLICT: 'An image with this alias already exists in this scope', + INVALID_PAGINATION: 'Invalid pagination parameters', + INVALID_UUID: 'Invalid UUID format', + INVALID_ASPECT_RATIO: 'Invalid aspect ratio format', + INVALID_FOCAL_POINT: 'Focal point coordinates must be between 0.0 and 1.0', + + // Not Found + GENERATION_NOT_FOUND: 'Generation not found', + IMAGE_NOT_FOUND: 'Image not found', + FLOW_NOT_FOUND: 'Flow not found', + ALIAS_NOT_FOUND: 'Alias not found', + PROJECT_NOT_FOUND: 'Project not found', + + // Resource Limits + MAX_REFERENCE_IMAGES_EXCEEDED: 'Maximum number of reference images exceeded', + MAX_FILE_SIZE_EXCEEDED: 'File size exceeds maximum allowed size', + MAX_RETRY_COUNT_EXCEEDED: 'Maximum retry count exceeded', + RATE_LIMIT_EXCEEDED: 'Rate limit exceeded', + MAX_ALIASES_EXCEEDED: 'Maximum number of aliases per flow exceeded', + + // Generation Errors + GENERATION_FAILED: 'Image generation failed', + GENERATION_ALREADY_SUCCEEDED: 'Cannot retry a generation that already succeeded', + GENERATION_PENDING: 'Generation is still pending', + REFERENCE_IMAGE_RESOLUTION_FAILED: 'Failed to resolve reference image alias', + + // Flow Errors + TECHNICAL_ALIAS_REQUIRES_FLOW: 'Technical aliases (@last, @first, @upload) require a flowId', + FLOW_HAS_NO_GENERATIONS: 'Flow has no generations', + FLOW_HAS_NO_UPLOADS: 'Flow has no uploaded images', + ALIAS_NOT_IN_FLOW: 'Alias not found in flow', + + // General + INTERNAL_SERVER_ERROR: 'Internal server error', + INVALID_REQUEST: 'Invalid request', + OPERATION_FAILED: 'Operation failed', +} as const; + +export const ERROR_CODES = { + // Authentication & Authorization + INVALID_API_KEY: 'INVALID_API_KEY', + MISSING_API_KEY: 'MISSING_API_KEY', + UNAUTHORIZED: 'UNAUTHORIZED', + MASTER_KEY_REQUIRED: 'MASTER_KEY_REQUIRED', + PROJECT_KEY_REQUIRED: 'PROJECT_KEY_REQUIRED', + + // Validation + VALIDATION_ERROR: 'VALIDATION_ERROR', + INVALID_ALIAS_FORMAT: 'INVALID_ALIAS_FORMAT', + RESERVED_ALIAS: 'RESERVED_ALIAS', + ALIAS_CONFLICT: 'ALIAS_CONFLICT', + INVALID_PAGINATION: 'INVALID_PAGINATION', + INVALID_UUID: 'INVALID_UUID', + INVALID_ASPECT_RATIO: 'INVALID_ASPECT_RATIO', + INVALID_FOCAL_POINT: 'INVALID_FOCAL_POINT', + + // Not Found + NOT_FOUND: 'NOT_FOUND', + GENERATION_NOT_FOUND: 'GENERATION_NOT_FOUND', + IMAGE_NOT_FOUND: 'IMAGE_NOT_FOUND', + FLOW_NOT_FOUND: 'FLOW_NOT_FOUND', + ALIAS_NOT_FOUND: 'ALIAS_NOT_FOUND', + PROJECT_NOT_FOUND: 'PROJECT_NOT_FOUND', + + // Resource Limits + RESOURCE_LIMIT_EXCEEDED: 'RESOURCE_LIMIT_EXCEEDED', + MAX_REFERENCE_IMAGES_EXCEEDED: 'MAX_REFERENCE_IMAGES_EXCEEDED', + MAX_FILE_SIZE_EXCEEDED: 'MAX_FILE_SIZE_EXCEEDED', + MAX_RETRY_COUNT_EXCEEDED: 'MAX_RETRY_COUNT_EXCEEDED', + RATE_LIMIT_EXCEEDED: 'RATE_LIMIT_EXCEEDED', + MAX_ALIASES_EXCEEDED: 'MAX_ALIASES_EXCEEDED', + + // Generation Errors + GENERATION_FAILED: 'GENERATION_FAILED', + GENERATION_ALREADY_SUCCEEDED: 'GENERATION_ALREADY_SUCCEEDED', + GENERATION_PENDING: 'GENERATION_PENDING', + REFERENCE_IMAGE_RESOLUTION_FAILED: 'REFERENCE_IMAGE_RESOLUTION_FAILED', + + // Flow Errors + TECHNICAL_ALIAS_REQUIRES_FLOW: 'TECHNICAL_ALIAS_REQUIRES_FLOW', + FLOW_HAS_NO_GENERATIONS: 'FLOW_HAS_NO_GENERATIONS', + FLOW_HAS_NO_UPLOADS: 'FLOW_HAS_NO_UPLOADS', + ALIAS_NOT_IN_FLOW: 'ALIAS_NOT_IN_FLOW', + + // General + INTERNAL_SERVER_ERROR: 'INTERNAL_SERVER_ERROR', + INVALID_REQUEST: 'INVALID_REQUEST', + OPERATION_FAILED: 'OPERATION_FAILED', +} as const; + +export type ErrorCode = (typeof ERROR_CODES)[keyof typeof ERROR_CODES]; +export type ErrorMessage = (typeof ERROR_MESSAGES)[keyof typeof ERROR_MESSAGES]; diff --git a/apps/api-service/src/utils/constants/index.ts b/apps/api-service/src/utils/constants/index.ts new file mode 100644 index 0000000..0d2a6ac --- /dev/null +++ b/apps/api-service/src/utils/constants/index.ts @@ -0,0 +1,3 @@ +export * from './aliases'; +export * from './limits'; +export * from './errors'; diff --git a/apps/api-service/src/utils/constants/limits.ts b/apps/api-service/src/utils/constants/limits.ts new file mode 100644 index 0000000..915298e --- /dev/null +++ b/apps/api-service/src/utils/constants/limits.ts @@ -0,0 +1,55 @@ +export const RATE_LIMITS = { + master: { + requests: { + windowMs: 60 * 60 * 1000, // 1 hour + max: 1000, + }, + generations: { + windowMs: 60 * 60 * 1000, // 1 hour + max: 100, + }, + }, + project: { + requests: { + windowMs: 60 * 60 * 1000, // 1 hour + max: 500, + }, + generations: { + windowMs: 60 * 60 * 1000, // 1 hour + max: 50, + }, + }, +} as const; + +export const PAGINATION_LIMITS = { + DEFAULT_LIMIT: 20, + MAX_LIMIT: 100, + MIN_LIMIT: 1, + DEFAULT_OFFSET: 0, +} as const; + +export const IMAGE_LIMITS = { + MAX_FILE_SIZE: 5 * 1024 * 1024, // 5MB + MAX_REFERENCE_IMAGES: 3, + MAX_WIDTH: 8192, + MAX_HEIGHT: 8192, + ALLOWED_MIME_TYPES: ['image/jpeg', 'image/png', 'image/webp'] as const, +} as const; + +export const GENERATION_LIMITS = { + MAX_PROMPT_LENGTH: 5000, + MAX_RETRY_COUNT: 3, + DEFAULT_ASPECT_RATIO: '1:1', + ALLOWED_ASPECT_RATIOS: ['1:1', '16:9', '9:16', '3:2', '2:3', '4:3', '3:4'] as const, +} as const; + +export const FLOW_LIMITS = { + MAX_NAME_LENGTH: 100, + MAX_DESCRIPTION_LENGTH: 500, + MAX_ALIASES_PER_FLOW: 50, +} as const; + +export const CACHE_LIMITS = { + PRESIGNED_URL_EXPIRY: 24 * 60 * 60, // 24 hours in seconds + CACHE_MAX_AGE: 365 * 24 * 60 * 60, // 1 year in seconds +} as const; diff --git a/apps/api-service/src/utils/helpers/hashHelper.ts b/apps/api-service/src/utils/helpers/hashHelper.ts new file mode 100644 index 0000000..6562a76 --- /dev/null +++ b/apps/api-service/src/utils/helpers/hashHelper.ts @@ -0,0 +1,21 @@ +import crypto from 'crypto'; + +export const computeSHA256 = (data: string | Buffer): string => { + return crypto.createHash('sha256').update(data).digest('hex'); +}; + +export const computeCacheKey = (prompt: string, params: Record): string => { + const sortedKeys = Object.keys(params).sort(); + const sortedParams: Record = {}; + + for (const key of sortedKeys) { + sortedParams[key] = params[key]; + } + + const combined = prompt + JSON.stringify(sortedParams); + return computeSHA256(combined); +}; + +export const computeFileHash = (buffer: Buffer): string => { + return computeSHA256(buffer); +}; diff --git a/apps/api-service/src/utils/helpers/index.ts b/apps/api-service/src/utils/helpers/index.ts new file mode 100644 index 0000000..32cd539 --- /dev/null +++ b/apps/api-service/src/utils/helpers/index.ts @@ -0,0 +1,3 @@ +export * from './paginationBuilder'; +export * from './hashHelper'; +export * from './queryHelper'; diff --git a/apps/api-service/src/utils/helpers/paginationBuilder.ts b/apps/api-service/src/utils/helpers/paginationBuilder.ts new file mode 100644 index 0000000..ac631b7 --- /dev/null +++ b/apps/api-service/src/utils/helpers/paginationBuilder.ts @@ -0,0 +1,28 @@ +import type { PaginationMeta } from '@/types/models'; +import type { PaginatedResponse } from '@/types/responses'; + +export const buildPaginationMeta = ( + total: number, + limit: number, + offset: number +): PaginationMeta => { + return { + total, + limit, + offset, + hasMore: offset + limit < total, + }; +}; + +export const buildPaginatedResponse = ( + data: T[], + total: number, + limit: number, + offset: number +): PaginatedResponse => { + return { + success: true, + data, + pagination: buildPaginationMeta(total, limit, offset), + }; +}; diff --git a/apps/api-service/src/utils/helpers/queryHelper.ts b/apps/api-service/src/utils/helpers/queryHelper.ts new file mode 100644 index 0000000..4e39bef --- /dev/null +++ b/apps/api-service/src/utils/helpers/queryHelper.ts @@ -0,0 +1,36 @@ +import { and, eq, isNull, SQL } from 'drizzle-orm'; + +export const buildWhereClause = (conditions: (SQL | undefined)[]): SQL | undefined => { + const validConditions = conditions.filter((c): c is SQL => c !== undefined); + + if (validConditions.length === 0) { + return undefined; + } + + if (validConditions.length === 1) { + return validConditions[0]; + } + + return and(...validConditions); +}; + +export const withoutDeleted = ( + table: T, + includeDeleted = false +): SQL | undefined => { + if (includeDeleted) { + return undefined; + } + return isNull(table.deletedAt as any); +}; + +export const buildEqCondition = ( + table: T, + column: K, + value: unknown +): SQL | undefined => { + if (value === undefined || value === null) { + return undefined; + } + return eq(table[column] as any, value); +}; diff --git a/apps/api-service/src/utils/validators/aliasValidator.ts b/apps/api-service/src/utils/validators/aliasValidator.ts new file mode 100644 index 0000000..e5793f1 --- /dev/null +++ b/apps/api-service/src/utils/validators/aliasValidator.ts @@ -0,0 +1,99 @@ +import { + ALIAS_PATTERN, + ALIAS_MAX_LENGTH, + isReservedAlias, + isTechnicalAlias +} from '../constants/aliases'; +import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors'; + +export interface AliasValidationResult { + valid: boolean; + error?: { + message: string; + code: string; + }; +} + +export const validateAliasFormat = (alias: string): AliasValidationResult => { + if (!alias) { + return { + valid: false, + error: { + message: 'Alias is required', + code: ERROR_CODES.VALIDATION_ERROR, + }, + }; + } + + if (!alias.startsWith('@')) { + return { + valid: false, + error: { + message: ERROR_MESSAGES.INVALID_ALIAS_FORMAT, + code: ERROR_CODES.INVALID_ALIAS_FORMAT, + }, + }; + } + + if (alias.length > ALIAS_MAX_LENGTH) { + return { + valid: false, + error: { + message: `Alias must not exceed ${ALIAS_MAX_LENGTH} characters`, + code: ERROR_CODES.VALIDATION_ERROR, + }, + }; + } + + if (!ALIAS_PATTERN.test(alias)) { + return { + valid: false, + error: { + message: ERROR_MESSAGES.INVALID_ALIAS_FORMAT, + code: ERROR_CODES.INVALID_ALIAS_FORMAT, + }, + }; + } + + return { valid: true }; +}; + +export const validateAliasNotReserved = (alias: string): AliasValidationResult => { + if (isReservedAlias(alias)) { + return { + valid: false, + error: { + message: ERROR_MESSAGES.RESERVED_ALIAS, + code: ERROR_CODES.RESERVED_ALIAS, + }, + }; + } + + return { valid: true }; +}; + +export const validateAliasForAssignment = (alias: string): AliasValidationResult => { + const formatResult = validateAliasFormat(alias); + if (!formatResult.valid) { + return formatResult; + } + + return validateAliasNotReserved(alias); +}; + +export const validateTechnicalAliasWithFlow = ( + alias: string, + flowId?: string +): AliasValidationResult => { + if (isTechnicalAlias(alias) && !flowId) { + return { + valid: false, + error: { + message: ERROR_MESSAGES.TECHNICAL_ALIAS_REQUIRES_FLOW, + code: ERROR_CODES.TECHNICAL_ALIAS_REQUIRES_FLOW, + }, + }; + } + + return { valid: true }; +}; diff --git a/apps/api-service/src/utils/validators/index.ts b/apps/api-service/src/utils/validators/index.ts new file mode 100644 index 0000000..76d203c --- /dev/null +++ b/apps/api-service/src/utils/validators/index.ts @@ -0,0 +1,3 @@ +export * from './aliasValidator'; +export * from './paginationValidator'; +export * from './queryValidator'; diff --git a/apps/api-service/src/utils/validators/paginationValidator.ts b/apps/api-service/src/utils/validators/paginationValidator.ts new file mode 100644 index 0000000..680c0ac --- /dev/null +++ b/apps/api-service/src/utils/validators/paginationValidator.ts @@ -0,0 +1,64 @@ +import { PAGINATION_LIMITS } from '../constants/limits'; +import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors'; + +export interface PaginationParams { + limit: number; + offset: number; +} + +export interface PaginationValidationResult { + valid: boolean; + params?: PaginationParams; + error?: { + message: string; + code: string; + }; +} + +export const validateAndNormalizePagination = ( + limit?: number | string, + offset?: number | string +): PaginationValidationResult => { + const parsedLimit = + typeof limit === 'string' ? parseInt(limit, 10) : limit ?? PAGINATION_LIMITS.DEFAULT_LIMIT; + const parsedOffset = + typeof offset === 'string' ? parseInt(offset, 10) : offset ?? PAGINATION_LIMITS.DEFAULT_OFFSET; + + if (isNaN(parsedLimit) || isNaN(parsedOffset)) { + return { + valid: false, + error: { + message: ERROR_MESSAGES.INVALID_PAGINATION, + code: ERROR_CODES.INVALID_PAGINATION, + }, + }; + } + + if (parsedLimit < PAGINATION_LIMITS.MIN_LIMIT || parsedLimit > PAGINATION_LIMITS.MAX_LIMIT) { + return { + valid: false, + error: { + message: `Limit must be between ${PAGINATION_LIMITS.MIN_LIMIT} and ${PAGINATION_LIMITS.MAX_LIMIT}`, + code: ERROR_CODES.INVALID_PAGINATION, + }, + }; + } + + if (parsedOffset < 0) { + return { + valid: false, + error: { + message: 'Offset must be non-negative', + code: ERROR_CODES.INVALID_PAGINATION, + }, + }; + } + + return { + valid: true, + params: { + limit: parsedLimit, + offset: parsedOffset, + }, + }; +}; diff --git a/apps/api-service/src/utils/validators/queryValidator.ts b/apps/api-service/src/utils/validators/queryValidator.ts new file mode 100644 index 0000000..f4d7794 --- /dev/null +++ b/apps/api-service/src/utils/validators/queryValidator.ts @@ -0,0 +1,100 @@ +import { ERROR_MESSAGES, ERROR_CODES } from '../constants/errors'; +import { GENERATION_LIMITS } from '../constants/limits'; + +export interface ValidationResult { + valid: boolean; + error?: { + message: string; + code: string; + }; +} + +export const validateUUID = (id: string): ValidationResult => { + const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + + if (!uuidPattern.test(id)) { + return { + valid: false, + error: { + message: ERROR_MESSAGES.INVALID_UUID, + code: ERROR_CODES.INVALID_UUID, + }, + }; + } + + return { valid: true }; +}; + +export const validateAspectRatio = (aspectRatio: string): ValidationResult => { + if (!GENERATION_LIMITS.ALLOWED_ASPECT_RATIOS.includes(aspectRatio as any)) { + return { + valid: false, + error: { + message: `Invalid aspect ratio. Allowed values: ${GENERATION_LIMITS.ALLOWED_ASPECT_RATIOS.join(', ')}`, + code: ERROR_CODES.INVALID_ASPECT_RATIO, + }, + }; + } + + return { valid: true }; +}; + +export const validateFocalPoint = (focalPoint: { + x: number; + y: number; +}): ValidationResult => { + if ( + focalPoint.x < 0 || + focalPoint.x > 1 || + focalPoint.y < 0 || + focalPoint.y > 1 + ) { + return { + valid: false, + error: { + message: ERROR_MESSAGES.INVALID_FOCAL_POINT, + code: ERROR_CODES.INVALID_FOCAL_POINT, + }, + }; + } + + return { valid: true }; +}; + +export const validateDateRange = ( + startDate?: string, + endDate?: string +): ValidationResult => { + if (startDate && isNaN(Date.parse(startDate))) { + return { + valid: false, + error: { + message: 'Invalid start date format', + code: ERROR_CODES.VALIDATION_ERROR, + }, + }; + } + + if (endDate && isNaN(Date.parse(endDate))) { + return { + valid: false, + error: { + message: 'Invalid end date format', + code: ERROR_CODES.VALIDATION_ERROR, + }, + }; + } + + if (startDate && endDate && new Date(startDate) > new Date(endDate)) { + return { + valid: false, + error: { + message: 'Start date must be before end date', + code: ERROR_CODES.VALIDATION_ERROR, + }, + }; + } + + return { valid: true }; +};