feature/api-development #1
|
|
@ -43,6 +43,7 @@
|
||||||
"@google/genai": "^1.22.0",
|
"@google/genai": "^1.22.0",
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"dotenv": "^17.2.2",
|
"dotenv": "^17.2.2",
|
||||||
|
"drizzle-orm": "^0.36.4",
|
||||||
"express": "^5.1.0",
|
"express": "^5.1.0",
|
||||||
"express-rate-limit": "^7.4.1",
|
"express-rate-limit": "^7.4.1",
|
||||||
"express-validator": "^7.2.0",
|
"express-validator": "^7.2.0",
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
export * from './AliasService';
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
||||||
|
});
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
|
@ -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];
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './aliases';
|
||||||
|
export * from './limits';
|
||||||
|
export * from './errors';
|
||||||
|
|
@ -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;
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './paginationBuilder';
|
||||||
|
export * from './hashHelper';
|
||||||
|
export * from './queryHelper';
|
||||||
|
|
@ -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),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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);
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
export * from './aliasValidator';
|
||||||
|
export * from './paginationValidator';
|
||||||
|
export * from './queryValidator';
|
||||||
|
|
@ -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,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -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 };
|
||||||
|
};
|
||||||
Loading…
Reference in New Issue