feat: add Phase 1 foundation for API v2.0

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 <noreply@anthropic.com>
This commit is contained in:
Oleg Proskurin 2025-11-09 21:53:50 +07:00
parent df84e400f5
commit c185ea3ff4
18 changed files with 1296 additions and 0 deletions

View File

@ -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",

View File

@ -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<AliasResolution | null> {
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<AliasResolution | null> {
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<AliasResolution | null> {
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<string, string>;
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<AliasResolution | null> {
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<Image | undefined> {
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<Image | undefined> {
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<Image | undefined> {
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<void> {
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<void> {
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<void> {
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<string, string>;
if (flowAliases[alias]) {
throw new Error(ERROR_MESSAGES.ALIAS_CONFLICT);
}
}
async resolveMultiple(
aliases: string[],
projectId: string,
flowId?: string
): Promise<Map<string, AliasResolution>> {
const resolutions = new Map<string, AliasResolution>();
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<string[]> {
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;
}
}

View File

@ -0,0 +1 @@
export * from './AliasService';

View File

@ -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;
}

View File

@ -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<string, string>; // Flow-scoped aliases to assign
autoEnhance?: boolean;
enhancementOptions?: {
template?: 'photorealistic' | 'illustration' | 'minimalist' | 'sticker' | 'product' | 'comic' | 'general';
};
meta?: Record<string, unknown>;
}
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<string, string>; // Flow-scoped aliases
meta?: Record<string, unknown>;
}
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<string, unknown>;
}
export interface DeleteImageQuery {
hard?: boolean; // If true, perform hard delete; otherwise soft delete
}
// ========================================
// FLOW ENDPOINTS
// ========================================
export interface CreateFlowRequest {
meta?: Record<string, unknown>;
}
export interface ListFlowsQuery {
limit?: number;
offset?: number;
}
export interface UpdateFlowAliasesRequest {
aliases: Record<string, string>; // { 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;
}

View File

@ -0,0 +1,268 @@
import type {
Image,
GenerationWithRelations,
FlowWithCounts,
PaginationMeta,
AliasScope,
} from './models';
// ========================================
// COMMON RESPONSE TYPES
// ========================================
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: {
message: string;
code?: string;
details?: unknown;
};
}
export interface PaginatedResponse<T> {
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<string, unknown> | null;
createdAt: string;
updatedAt: string;
}
export type CreateGenerationResponse = ApiResponse<GenerationResponse>;
export type GetGenerationResponse = ApiResponse<GenerationResponse>;
export type ListGenerationsResponse = PaginatedResponse<GenerationResponse>;
export type RetryGenerationResponse = ApiResponse<GenerationResponse>;
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<string, unknown> | 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<ImageResponse>;
export type GetImageResponse = ApiResponse<ImageResponse>;
export type ListImagesResponse = PaginatedResponse<ImageResponse>;
export type ResolveAliasResponse = ApiResponse<AliasResolutionResponse>;
export type UpdateImageResponse = ApiResponse<ImageResponse>;
export type DeleteImageResponse = ApiResponse<{ id: string; deletedAt: string | null }>;
// ========================================
// FLOW RESPONSES
// ========================================
export interface FlowResponse {
id: string;
projectId: string;
aliases: Record<string, string>;
generationCount: number;
imageCount: number;
meta: Record<string, unknown>;
createdAt: string;
updatedAt: string;
}
export interface FlowWithDetailsResponse extends FlowResponse {
generations?: GenerationResponse[];
images?: ImageResponse[];
resolvedAliases?: Record<string, ImageResponse>;
}
export type CreateFlowResponse = ApiResponse<FlowResponse>;
export type GetFlowResponse = ApiResponse<FlowWithDetailsResponse>;
export type ListFlowsResponse = PaginatedResponse<FlowResponse>;
export type UpdateFlowAliasesResponse = ApiResponse<FlowResponse>;
export type DeleteFlowAliasResponse = ApiResponse<FlowResponse>;
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<AnalyticsSummary>;
export type GetAnalyticsTimelineResponse = ApiResponse<AnalyticsTimeline>;
// ========================================
// 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<string, unknown>,
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<string, unknown>,
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<string, string>,
generationCount: flow.generationCount,
imageCount: flow.imageCount,
meta: flow.meta as Record<string, unknown>,
createdAt: flow.createdAt.toISOString(),
updatedAt: flow.updatedAt.toISOString(),
});

View File

@ -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;
};

View File

@ -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];

View File

@ -0,0 +1,3 @@
export * from './aliases';
export * from './limits';
export * from './errors';

View File

@ -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;

View File

@ -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, unknown>): string => {
const sortedKeys = Object.keys(params).sort();
const sortedParams: Record<string, unknown> = {};
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);
};

View File

@ -0,0 +1,3 @@
export * from './paginationBuilder';
export * from './hashHelper';
export * from './queryHelper';

View File

@ -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 = <T>(
data: T[],
total: number,
limit: number,
offset: number
): PaginatedResponse<T> => {
return {
success: true,
data,
pagination: buildPaginationMeta(total, limit, offset),
};
};

View File

@ -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 = <T extends { deletedAt: any }>(
table: T,
includeDeleted = false
): SQL | undefined => {
if (includeDeleted) {
return undefined;
}
return isNull(table.deletedAt as any);
};
export const buildEqCondition = <T, K extends keyof T>(
table: T,
column: K,
value: unknown
): SQL | undefined => {
if (value === undefined || value === null) {
return undefined;
}
return eq(table[column] as any, value);
};

View File

@ -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 };
};

View File

@ -0,0 +1,3 @@
export * from './aliasValidator';
export * from './paginationValidator';
export * from './queryValidator';

View File

@ -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,
},
};
};

View File

@ -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 };
};