feature/api-development #1
|
|
@ -7,6 +7,7 @@ import { imagesRouter } from './routes/images';
|
|||
import { uploadRouter } from './routes/upload';
|
||||
import bootstrapRoutes from './routes/bootstrap';
|
||||
import adminKeysRoutes from './routes/admin/keys';
|
||||
import { v1Router } from './routes/v1';
|
||||
import { errorHandler, notFoundHandler } from './middleware/errorHandler';
|
||||
|
||||
// Load environment variables
|
||||
|
|
@ -116,7 +117,10 @@ export const createApp = (): Application => {
|
|||
// Admin routes (require master key)
|
||||
app.use('/api/admin/keys', adminKeysRoutes);
|
||||
|
||||
// Protected API routes (require valid API key)
|
||||
// API v1 routes (versioned, require valid API key)
|
||||
app.use('/api/v1', v1Router);
|
||||
|
||||
// Protected API routes (require valid API key) - Legacy
|
||||
app.use('/api', textToImageRouter);
|
||||
app.use('/api', imagesRouter);
|
||||
app.use('/api', uploadRouter);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,249 @@
|
|||
import { Response, Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { GenerationService } from '@/services/core';
|
||||
import { asyncHandler } from '@/middleware/errorHandler';
|
||||
import { validateApiKey } from '@/middleware/auth/validateApiKey';
|
||||
import { requireProjectKey } from '@/middleware/auth/requireProjectKey';
|
||||
import { rateLimitByApiKey } from '@/middleware/auth/rateLimiter';
|
||||
import { validateAndNormalizePagination } from '@/utils/validators';
|
||||
import { buildPaginatedResponse } from '@/utils/helpers';
|
||||
import { toGenerationResponse } from '@/types/responses';
|
||||
import type {
|
||||
CreateGenerationResponse,
|
||||
ListGenerationsResponse,
|
||||
GetGenerationResponse,
|
||||
} from '@/types/responses';
|
||||
|
||||
export const generationsRouter: RouterType = Router();
|
||||
|
||||
let generationService: GenerationService;
|
||||
|
||||
const getGenerationService = (): GenerationService => {
|
||||
if (!generationService) {
|
||||
generationService = new GenerationService();
|
||||
}
|
||||
return generationService;
|
||||
};
|
||||
|
||||
/**
|
||||
* POST /api/v1/generations
|
||||
* Create a new image generation with optional reference images and aliases
|
||||
*/
|
||||
generationsRouter.post(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const {
|
||||
prompt,
|
||||
referenceImages,
|
||||
aspectRatio,
|
||||
flowId,
|
||||
outputAlias,
|
||||
flowAliases,
|
||||
autoEnhance,
|
||||
meta,
|
||||
} = req.body;
|
||||
|
||||
if (!prompt || typeof prompt !== 'string') {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Prompt is required and must be a string',
|
||||
code: 'VALIDATION_ERROR',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const projectId = req.apiKey.projectId;
|
||||
const apiKeyId = req.apiKey.id;
|
||||
|
||||
const generation = await service.create({
|
||||
projectId,
|
||||
apiKeyId,
|
||||
prompt,
|
||||
referenceImages,
|
||||
aspectRatio,
|
||||
flowId,
|
||||
outputAlias,
|
||||
flowAliases,
|
||||
autoEnhance,
|
||||
meta,
|
||||
requestId: req.requestId,
|
||||
});
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: toGenerationResponse(generation),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/generations
|
||||
* List generations with filters and pagination
|
||||
*/
|
||||
generationsRouter.get(
|
||||
'/',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<ListGenerationsResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const { flowId, status, limit, offset, includeDeleted } = req.query;
|
||||
|
||||
const paginationResult = validateAndNormalizePagination(limit, offset);
|
||||
if (!paginationResult.valid) {
|
||||
res.status(400).json({
|
||||
success: false,
|
||||
data: [],
|
||||
pagination: { total: 0, limit: 20, offset: 0, hasMore: false },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { limit: validatedLimit, offset: validatedOffset } = paginationResult.params!;
|
||||
const projectId = req.apiKey.projectId;
|
||||
|
||||
const result = await service.list(
|
||||
{
|
||||
projectId,
|
||||
flowId: flowId as string | undefined,
|
||||
status: status as 'pending' | 'processing' | 'success' | 'failed' | undefined,
|
||||
deleted: includeDeleted === 'true' ? true : undefined,
|
||||
},
|
||||
validatedLimit,
|
||||
validatedOffset
|
||||
);
|
||||
|
||||
const responseData = result.generations.map((gen) => toGenerationResponse(gen));
|
||||
|
||||
res.json(
|
||||
buildPaginatedResponse(responseData, result.total, validatedLimit, validatedOffset)
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/v1/generations/:id
|
||||
* Get a single generation by ID with full details
|
||||
*/
|
||||
generationsRouter.get(
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response<GetGenerationResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const { id } = req.params;
|
||||
|
||||
const generation = await service.getByIdWithRelations(id);
|
||||
|
||||
if (generation.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: toGenerationResponse(generation),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* POST /api/v1/generations/:id/retry
|
||||
* Retry a failed generation
|
||||
*/
|
||||
generationsRouter.post(
|
||||
'/:id/retry',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
rateLimitByApiKey,
|
||||
asyncHandler(async (req: any, res: Response<CreateGenerationResponse>) => {
|
||||
const service = getGenerationService();
|
||||
const { id } = req.params;
|
||||
const { prompt, aspectRatio } = req.body;
|
||||
|
||||
const original = await service.getById(id);
|
||||
if (!original) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (original.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const newGeneration = await service.retry(id, { prompt, aspectRatio });
|
||||
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: toGenerationResponse(newGeneration),
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
/**
|
||||
* DELETE /api/v1/generations/:id
|
||||
* Delete a generation and its output image
|
||||
*/
|
||||
generationsRouter.delete(
|
||||
'/:id',
|
||||
validateApiKey,
|
||||
requireProjectKey,
|
||||
asyncHandler(async (req: any, res: Response) => {
|
||||
const service = getGenerationService();
|
||||
const { id } = req.params;
|
||||
|
||||
const generation = await service.getById(id);
|
||||
if (!generation) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (generation.projectId !== req.apiKey.projectId) {
|
||||
res.status(404).json({
|
||||
success: false,
|
||||
error: {
|
||||
message: 'Generation not found',
|
||||
code: 'GENERATION_NOT_FOUND',
|
||||
},
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
await service.delete(id);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: { id },
|
||||
});
|
||||
})
|
||||
);
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { Router } from 'express';
|
||||
import type { Router as RouterType } from 'express';
|
||||
import { generationsRouter } from './generations';
|
||||
|
||||
export const v1Router: RouterType = Router();
|
||||
|
||||
// Mount v1 routes
|
||||
v1Router.use('/generations', generationsRouter);
|
||||
|
|
@ -0,0 +1,355 @@
|
|||
import { eq, desc, count } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { generations, flows } from '@banatie/database';
|
||||
import type {
|
||||
Generation,
|
||||
NewGeneration,
|
||||
GenerationWithRelations,
|
||||
GenerationFilters,
|
||||
} from '@/types/models';
|
||||
import { ImageService } from './ImageService';
|
||||
import { AliasService } from './AliasService';
|
||||
import { ImageGenService } from '../ImageGenService';
|
||||
import { buildWhereClause, buildEqCondition } from '@/utils/helpers';
|
||||
import { ERROR_MESSAGES, GENERATION_LIMITS } from '@/utils/constants';
|
||||
import type { ReferenceImage } from '@/types/api';
|
||||
|
||||
export interface CreateGenerationParams {
|
||||
projectId: string;
|
||||
apiKeyId: string;
|
||||
prompt: string;
|
||||
referenceImages?: string[] | undefined; // Aliases to resolve
|
||||
aspectRatio?: string | undefined;
|
||||
flowId?: string | undefined;
|
||||
outputAlias?: string | undefined;
|
||||
flowAliases?: Record<string, string> | undefined;
|
||||
autoEnhance?: boolean | undefined;
|
||||
enhancedPrompt?: string | undefined;
|
||||
meta?: Record<string, unknown> | undefined;
|
||||
requestId?: string | undefined;
|
||||
}
|
||||
|
||||
export class GenerationService {
|
||||
private imageService: ImageService;
|
||||
private aliasService: AliasService;
|
||||
private imageGenService: ImageGenService;
|
||||
|
||||
constructor() {
|
||||
this.imageService = new ImageService();
|
||||
this.aliasService = new AliasService();
|
||||
|
||||
const geminiApiKey = process.env['GEMINI_API_KEY'];
|
||||
if (!geminiApiKey) {
|
||||
throw new Error('GEMINI_API_KEY environment variable is required');
|
||||
}
|
||||
this.imageGenService = new ImageGenService(geminiApiKey);
|
||||
}
|
||||
|
||||
async create(params: CreateGenerationParams): Promise<GenerationWithRelations> {
|
||||
const startTime = Date.now();
|
||||
|
||||
const generationRecord: NewGeneration = {
|
||||
projectId: params.projectId,
|
||||
flowId: params.flowId || null,
|
||||
apiKeyId: params.apiKeyId,
|
||||
status: 'pending',
|
||||
originalPrompt: params.prompt,
|
||||
enhancedPrompt: params.enhancedPrompt || null,
|
||||
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
referencedImages: null,
|
||||
requestId: params.requestId || null,
|
||||
meta: params.meta || {},
|
||||
};
|
||||
|
||||
const [generation] = await db
|
||||
.insert(generations)
|
||||
.values(generationRecord)
|
||||
.returning();
|
||||
|
||||
if (!generation) {
|
||||
throw new Error('Failed to create generation record');
|
||||
}
|
||||
|
||||
try {
|
||||
await this.updateStatus(generation.id, 'processing');
|
||||
|
||||
let referenceImageBuffers: ReferenceImage[] = [];
|
||||
let referencedImagesMetadata: Array<{ imageId: string; alias: string }> = [];
|
||||
|
||||
if (params.referenceImages && params.referenceImages.length > 0) {
|
||||
const resolved = await this.resolveReferenceImages(
|
||||
params.referenceImages,
|
||||
params.projectId,
|
||||
params.flowId
|
||||
);
|
||||
referenceImageBuffers = resolved.buffers;
|
||||
referencedImagesMetadata = resolved.metadata;
|
||||
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ referencedImages: referencedImagesMetadata })
|
||||
.where(eq(generations.id, generation.id));
|
||||
}
|
||||
|
||||
const genResult = await this.imageGenService.generateImage({
|
||||
prompt: params.enhancedPrompt || params.prompt,
|
||||
filename: `gen_${generation.id}`,
|
||||
referenceImages: referenceImageBuffers,
|
||||
aspectRatio: params.aspectRatio || GENERATION_LIMITS.DEFAULT_ASPECT_RATIO,
|
||||
orgId: 'default',
|
||||
projectId: params.projectId,
|
||||
meta: params.meta || {},
|
||||
});
|
||||
|
||||
if (!genResult.success) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(generation.id, 'failed', {
|
||||
errorMessage: genResult.error || 'Generation failed',
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
throw new Error(genResult.error || 'Generation failed');
|
||||
}
|
||||
|
||||
const storageKey = genResult.filepath!;
|
||||
// TODO: Add file hash computation when we have a helper to download by storageKey
|
||||
const fileHash = null;
|
||||
|
||||
const imageRecord = await this.imageService.create({
|
||||
projectId: params.projectId,
|
||||
flowId: params.flowId || null,
|
||||
generationId: generation.id,
|
||||
apiKeyId: params.apiKeyId,
|
||||
storageKey,
|
||||
storageUrl: genResult.url!,
|
||||
mimeType: 'image/jpeg',
|
||||
fileSize: 0, // TODO: Get actual file size from storage
|
||||
fileHash,
|
||||
source: 'generated',
|
||||
alias: params.outputAlias || null,
|
||||
meta: params.meta || {},
|
||||
});
|
||||
|
||||
if (params.flowAliases && params.flowId) {
|
||||
await this.assignFlowAliases(params.flowId, params.flowAliases, imageRecord.id);
|
||||
}
|
||||
|
||||
if (params.flowId) {
|
||||
await db
|
||||
.update(flows)
|
||||
.set({ updatedAt: new Date() })
|
||||
.where(eq(flows.id, params.flowId));
|
||||
}
|
||||
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(generation.id, 'success', {
|
||||
outputImageId: imageRecord.id,
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
|
||||
return await this.getByIdWithRelations(generation.id);
|
||||
} catch (error) {
|
||||
const processingTime = Date.now() - startTime;
|
||||
await this.updateStatus(generation.id, 'failed', {
|
||||
errorMessage: error instanceof Error ? error.message : 'Unknown error',
|
||||
processingTimeMs: processingTime,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async resolveReferenceImages(
|
||||
aliases: string[],
|
||||
projectId: string,
|
||||
flowId?: string
|
||||
): Promise<{
|
||||
buffers: ReferenceImage[];
|
||||
metadata: Array<{ imageId: string; alias: string }>;
|
||||
}> {
|
||||
const resolutions = await this.aliasService.resolveMultiple(aliases, projectId, flowId);
|
||||
|
||||
const buffers: ReferenceImage[] = [];
|
||||
const metadata: Array<{ imageId: string; alias: string }> = [];
|
||||
|
||||
// TODO: Implement proper storage key parsing and download
|
||||
// For now, we'll skip reference image buffers and just store metadata
|
||||
for (const [alias, resolution] of resolutions) {
|
||||
if (!resolution.image) {
|
||||
throw new Error(`${ERROR_MESSAGES.ALIAS_NOT_FOUND}: ${alias}`);
|
||||
}
|
||||
|
||||
metadata.push({
|
||||
imageId: resolution.imageId,
|
||||
alias,
|
||||
});
|
||||
}
|
||||
|
||||
return { buffers, metadata };
|
||||
}
|
||||
|
||||
private async assignFlowAliases(
|
||||
flowId: string,
|
||||
flowAliases: Record<string, string>,
|
||||
imageId: string
|
||||
): Promise<void> {
|
||||
const flow = await db.query.flows.findFirst({
|
||||
where: eq(flows.id, flowId),
|
||||
});
|
||||
|
||||
if (!flow) {
|
||||
throw new Error(ERROR_MESSAGES.FLOW_NOT_FOUND);
|
||||
}
|
||||
|
||||
const currentAliases = (flow.aliases as Record<string, string>) || {};
|
||||
const updatedAliases = { ...currentAliases };
|
||||
|
||||
for (const [alias, value] of Object.entries(flowAliases)) {
|
||||
if (value === '@output' || value === imageId) {
|
||||
updatedAliases[alias] = imageId;
|
||||
}
|
||||
}
|
||||
|
||||
await db
|
||||
.update(flows)
|
||||
.set({ aliases: updatedAliases, updatedAt: new Date() })
|
||||
.where(eq(flows.id, flowId));
|
||||
}
|
||||
|
||||
private async updateStatus(
|
||||
id: string,
|
||||
status: 'pending' | 'processing' | 'success' | 'failed',
|
||||
additionalUpdates?: {
|
||||
errorMessage?: string;
|
||||
outputImageId?: string;
|
||||
processingTimeMs?: number;
|
||||
}
|
||||
): Promise<void> {
|
||||
await db
|
||||
.update(generations)
|
||||
.set({
|
||||
status,
|
||||
...additionalUpdates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(generations.id, id));
|
||||
}
|
||||
|
||||
async getById(id: string): Promise<Generation | null> {
|
||||
const generation = await db.query.generations.findFirst({
|
||||
where: eq(generations.id, id),
|
||||
});
|
||||
|
||||
return generation || null;
|
||||
}
|
||||
|
||||
async getByIdWithRelations(id: string): Promise<GenerationWithRelations> {
|
||||
const generation = await db.query.generations.findFirst({
|
||||
where: eq(generations.id, id),
|
||||
with: {
|
||||
outputImage: true,
|
||||
flow: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (!generation) {
|
||||
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (generation.referencedImages && Array.isArray(generation.referencedImages)) {
|
||||
const refImageIds = (generation.referencedImages as Array<{ imageId: string; alias: string }>)
|
||||
.map((ref) => ref.imageId);
|
||||
const refImages = await this.imageService.getMultipleByIds(refImageIds);
|
||||
return {
|
||||
...generation,
|
||||
referenceImages: refImages,
|
||||
} as GenerationWithRelations;
|
||||
}
|
||||
|
||||
return generation as GenerationWithRelations;
|
||||
}
|
||||
|
||||
async list(
|
||||
filters: GenerationFilters,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ generations: GenerationWithRelations[]; total: number }> {
|
||||
const conditions = [
|
||||
buildEqCondition(generations, 'projectId', filters.projectId),
|
||||
buildEqCondition(generations, 'flowId', filters.flowId),
|
||||
buildEqCondition(generations, 'status', filters.status),
|
||||
];
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const [generationsList, countResult] = await Promise.all([
|
||||
db.query.generations.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(generations.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
with: {
|
||||
outputImage: true,
|
||||
flow: true,
|
||||
},
|
||||
}),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(generations)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const totalCount = countResult[0]?.count || 0;
|
||||
|
||||
return {
|
||||
generations: generationsList as GenerationWithRelations[],
|
||||
total: Number(totalCount),
|
||||
};
|
||||
}
|
||||
|
||||
async retry(id: string, overrides?: { prompt?: string; aspectRatio?: string }): Promise<GenerationWithRelations> {
|
||||
const original = await this.getByIdWithRelations(id);
|
||||
|
||||
if (original.status === 'success') {
|
||||
throw new Error(ERROR_MESSAGES.GENERATION_ALREADY_SUCCEEDED);
|
||||
}
|
||||
|
||||
if (original.retryCount >= GENERATION_LIMITS.MAX_RETRY_COUNT) {
|
||||
throw new Error(ERROR_MESSAGES.MAX_RETRY_COUNT_EXCEEDED);
|
||||
}
|
||||
|
||||
if (!original.apiKeyId) {
|
||||
throw new Error('Cannot retry generation without API key');
|
||||
}
|
||||
|
||||
const newParams: CreateGenerationParams = {
|
||||
projectId: original.projectId,
|
||||
apiKeyId: original.apiKeyId,
|
||||
prompt: overrides?.prompt || original.originalPrompt,
|
||||
aspectRatio: overrides?.aspectRatio || original.aspectRatio || undefined,
|
||||
flowId: original.flowId || undefined,
|
||||
enhancedPrompt: original.enhancedPrompt || undefined,
|
||||
meta: original.meta as Record<string, unknown>,
|
||||
};
|
||||
|
||||
const newGeneration = await this.create(newParams);
|
||||
|
||||
await db
|
||||
.update(generations)
|
||||
.set({ retryCount: original.retryCount + 1 })
|
||||
.where(eq(generations.id, newGeneration.id));
|
||||
|
||||
return newGeneration;
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
const generation = await this.getById(id);
|
||||
if (!generation) {
|
||||
throw new Error(ERROR_MESSAGES.GENERATION_NOT_FOUND);
|
||||
}
|
||||
|
||||
if (generation.outputImageId) {
|
||||
await this.imageService.softDelete(generation.outputImageId);
|
||||
}
|
||||
|
||||
await db.delete(generations).where(eq(generations.id, id));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,197 @@
|
|||
import { eq, and, isNull, desc, count, sql } from 'drizzle-orm';
|
||||
import { db } from '@/db';
|
||||
import { images } from '@banatie/database';
|
||||
import type { Image, NewImage, ImageFilters } from '@/types/models';
|
||||
import { buildWhereClause, buildEqCondition, withoutDeleted } from '@/utils/helpers';
|
||||
import { ERROR_MESSAGES } from '@/utils/constants';
|
||||
import { AliasService } from './AliasService';
|
||||
|
||||
export class ImageService {
|
||||
private aliasService: AliasService;
|
||||
|
||||
constructor() {
|
||||
this.aliasService = new AliasService();
|
||||
}
|
||||
|
||||
async create(data: NewImage): Promise<Image> {
|
||||
const [image] = await db.insert(images).values(data).returning();
|
||||
if (!image) {
|
||||
throw new Error('Failed to create image record');
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
async getById(id: string, includeDeleted = false): Promise<Image | null> {
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.id, id),
|
||||
includeDeleted ? undefined : isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
return image || null;
|
||||
}
|
||||
|
||||
async getByIdOrThrow(id: string, includeDeleted = false): Promise<Image> {
|
||||
const image = await this.getById(id, includeDeleted);
|
||||
if (!image) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
return image;
|
||||
}
|
||||
|
||||
async list(
|
||||
filters: ImageFilters,
|
||||
limit: number,
|
||||
offset: number
|
||||
): Promise<{ images: Image[]; total: number }> {
|
||||
const conditions = [
|
||||
buildEqCondition(images, 'projectId', filters.projectId),
|
||||
buildEqCondition(images, 'flowId', filters.flowId),
|
||||
buildEqCondition(images, 'source', filters.source),
|
||||
buildEqCondition(images, 'alias', filters.alias),
|
||||
withoutDeleted(images, filters.deleted),
|
||||
];
|
||||
|
||||
const whereClause = buildWhereClause(conditions);
|
||||
|
||||
const [imagesList, countResult] = await Promise.all([
|
||||
db.query.images.findMany({
|
||||
where: whereClause,
|
||||
orderBy: [desc(images.createdAt)],
|
||||
limit,
|
||||
offset,
|
||||
}),
|
||||
db
|
||||
.select({ count: count() })
|
||||
.from(images)
|
||||
.where(whereClause),
|
||||
]);
|
||||
|
||||
const totalCount = countResult[0]?.count || 0;
|
||||
|
||||
return {
|
||||
images: imagesList,
|
||||
total: Number(totalCount),
|
||||
};
|
||||
}
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
updates: {
|
||||
alias?: string;
|
||||
focalPoint?: { x: number; y: number };
|
||||
meta?: Record<string, unknown>;
|
||||
}
|
||||
): Promise<Image> {
|
||||
const existing = await this.getByIdOrThrow(id);
|
||||
|
||||
if (updates.alias && updates.alias !== existing.alias) {
|
||||
await this.aliasService.validateAliasForAssignment(
|
||||
updates.alias,
|
||||
existing.projectId,
|
||||
existing.flowId || undefined
|
||||
);
|
||||
}
|
||||
|
||||
const [updated] = await db
|
||||
.update(images)
|
||||
.set({
|
||||
...updates,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(images.id, id))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async softDelete(id: string): Promise<Image> {
|
||||
const [deleted] = await db
|
||||
.update(images)
|
||||
.set({
|
||||
deletedAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(images.id, id))
|
||||
.returning();
|
||||
|
||||
if (!deleted) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return deleted;
|
||||
}
|
||||
|
||||
async hardDelete(id: string): Promise<void> {
|
||||
await db.delete(images).where(eq(images.id, id));
|
||||
}
|
||||
|
||||
async assignProjectAlias(imageId: string, alias: string): Promise<Image> {
|
||||
const image = await this.getByIdOrThrow(imageId);
|
||||
|
||||
if (image.flowId) {
|
||||
throw new Error('Cannot assign project alias to flow-scoped image');
|
||||
}
|
||||
|
||||
await this.aliasService.validateAliasForAssignment(
|
||||
alias,
|
||||
image.projectId
|
||||
);
|
||||
|
||||
const [updated] = await db
|
||||
.update(images)
|
||||
.set({
|
||||
alias,
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.where(eq(images.id, imageId))
|
||||
.returning();
|
||||
|
||||
if (!updated) {
|
||||
throw new Error(ERROR_MESSAGES.IMAGE_NOT_FOUND);
|
||||
}
|
||||
|
||||
return updated;
|
||||
}
|
||||
|
||||
async getByStorageKey(storageKey: string): Promise<Image | null> {
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.storageKey, storageKey),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
return image || null;
|
||||
}
|
||||
|
||||
async getByFileHash(fileHash: string, projectId: string): Promise<Image | null> {
|
||||
const image = await db.query.images.findFirst({
|
||||
where: and(
|
||||
eq(images.fileHash, fileHash),
|
||||
eq(images.projectId, projectId),
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
|
||||
return image || null;
|
||||
}
|
||||
|
||||
async getMultipleByIds(ids: string[]): Promise<Image[]> {
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return await db.query.images.findMany({
|
||||
where: and(
|
||||
sql`${images.id} = ANY(${ids})`,
|
||||
isNull(images.deletedAt)
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +1,3 @@
|
|||
export * from './AliasService';
|
||||
export * from './ImageService';
|
||||
export * from './GenerationService';
|
||||
|
|
|
|||
|
|
@ -71,9 +71,9 @@ export interface ImageFilters {
|
|||
// Query filters for generations
|
||||
export interface GenerationFilters {
|
||||
projectId: string;
|
||||
flowId?: string;
|
||||
status?: GenerationStatus;
|
||||
deleted?: boolean;
|
||||
flowId?: string | undefined;
|
||||
status?: GenerationStatus | undefined;
|
||||
deleted?: boolean | undefined;
|
||||
}
|
||||
|
||||
// Query filters for flows
|
||||
|
|
|
|||
Loading…
Reference in New Issue