feat(picture): migrate batch generation from Supabase to NestJS API

- Add BatchModule with controller, service, and DTOs
- Create batch_generations database schema and migration
- Add batch API client for mobile app
- Replace Supabase Realtime with polling in batchStore
- Implement full CRUD operations for batch generations

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Till-JS 2025-11-27 14:44:01 +01:00
parent 4b08c41547
commit 3a8d6bcf94
22 changed files with 3928 additions and 243 deletions

View file

@ -10,6 +10,8 @@ import { BoardItemModule } from './board-item/board-item.module';
import { UploadModule } from './upload/upload.module';
import { GenerateModule } from './generate/generate.module';
import { ExploreModule } from './explore/explore.module';
import { ProfileModule } from './profile/profile.module';
import { BatchModule } from './batch/batch.module';
@Module({
imports: [
@ -27,6 +29,8 @@ import { ExploreModule } from './explore/explore.module';
UploadModule,
GenerateModule,
ExploreModule,
ProfileModule,
BatchModule,
],
})
export class AppModule {}

View file

@ -0,0 +1,100 @@
import {
Controller,
Get,
Post,
Delete,
Param,
Query,
Body,
UseGuards,
} from '@nestjs/common';
import { BatchService } from './batch.service';
import { JwtAuthGuard } from '../common/guards/jwt-auth.guard';
import {
CurrentUser,
CurrentUserData,
} from '../common/decorators/current-user.decorator';
import { CreateBatchDto, GetBatchQueryDto } from './dto/batch.dto';
@Controller('batch')
@UseGuards(JwtAuthGuard)
export class BatchController {
constructor(private readonly batchService: BatchService) {}
/**
* Create a new batch generation
*/
@Post()
async createBatch(
@CurrentUser() user: CurrentUserData,
@Body() dto: CreateBatchDto,
) {
return this.batchService.createBatch(user.userId, dto);
}
/**
* Get all batches for the current user
*/
@Get()
async getUserBatches(
@CurrentUser() user: CurrentUserData,
@Query() query: GetBatchQueryDto,
) {
return this.batchService.getUserBatches(user.userId, query);
}
/**
* Get a specific batch by ID with its items
*/
@Get(':id')
async getBatch(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.getBatch(batchId, user.userId);
}
/**
* Get batch progress (for polling)
*/
@Get(':id/progress')
async getBatchProgress(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.getBatchProgress(batchId, user.userId);
}
/**
* Retry failed generations in a batch
*/
@Post(':id/retry')
async retryFailed(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.retryFailed(batchId, user.userId);
}
/**
* Cancel a batch
*/
@Post(':id/cancel')
async cancelBatch(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.cancelBatch(batchId, user.userId);
}
/**
* Delete a batch and all its generations
*/
@Delete(':id')
async deleteBatch(
@CurrentUser() user: CurrentUserData,
@Param('id') batchId: string,
) {
return this.batchService.deleteBatch(batchId, user.userId);
}
}

View file

@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { BatchController } from './batch.controller';
import { BatchService } from './batch.service';
import { DatabaseModule } from '../db/database.module';
@Module({
imports: [DatabaseModule],
controllers: [BatchController],
providers: [BatchService],
exports: [BatchService],
})
export class BatchModule {}

View file

@ -0,0 +1,445 @@
import {
Injectable,
Inject,
NotFoundException,
ForbiddenException,
Logger,
} from '@nestjs/common';
import { eq, and, desc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { type Database } from '../db/connection';
import {
batchGenerations,
imageGenerations,
type BatchGeneration,
type NewBatchGeneration,
} from '../db/schema';
import { CreateBatchDto, GetBatchQueryDto } from './dto/batch.dto';
export interface BatchWithItems extends BatchGeneration {
items?: {
id: string;
index: number;
prompt: string;
status: string;
errorMessage?: string | null;
retryCount?: number;
imageUrl?: string | null;
}[];
}
@Injectable()
export class BatchService {
private readonly logger = new Logger(BatchService.name);
constructor(@Inject(DATABASE_CONNECTION) private readonly db: Database) {}
/**
* Create a new batch generation
*/
async createBatch(
userId: string,
dto: CreateBatchDto,
): Promise<BatchWithItems> {
try {
// Create the batch record
const [batch] = await this.db
.insert(batchGenerations)
.values({
userId,
name: dto.batchName || `Batch ${new Date().toLocaleString()}`,
totalCount: dto.prompts.length,
completedCount: 0,
failedCount: 0,
processingCount: 0,
pendingCount: dto.prompts.length,
status: 'pending',
modelId: dto.sharedSettings.modelId,
modelVersion: dto.sharedSettings.modelVersion,
width: dto.sharedSettings.width,
height: dto.sharedSettings.height,
steps: dto.sharedSettings.steps,
guidanceScale: dto.sharedSettings.guidanceScale,
} as NewBatchGeneration)
.returning();
// Create individual generation records for each prompt
const generationRecords = dto.prompts.map((prompt, index) => ({
userId,
batchId: batch.id,
prompt: prompt.text,
negativePrompt: prompt.negativePrompt,
seed: prompt.seed,
model: dto.sharedSettings.modelVersion,
width: dto.sharedSettings.width,
height: dto.sharedSettings.height,
steps: dto.sharedSettings.steps,
guidanceScale: dto.sharedSettings.guidanceScale,
status: 'pending' as const,
priority: index,
}));
const generations = await this.db
.insert(imageGenerations)
.values(generationRecords)
.returning();
// Return batch with items
return {
...batch,
items: generations.map((gen, index) => ({
id: gen.id,
index,
prompt: gen.prompt,
status: gen.status,
errorMessage: gen.errorMessage,
retryCount: gen.retryCount,
imageUrl: null,
})),
};
} catch (error) {
this.logger.error('Error creating batch', error);
throw error;
}
}
/**
* Get a batch by ID with its items
*/
async getBatch(batchId: string, userId: string): Promise<BatchWithItems> {
try {
// Get batch
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Get items
const items = await this.db
.select({
id: imageGenerations.id,
prompt: imageGenerations.prompt,
status: imageGenerations.status,
errorMessage: imageGenerations.errorMessage,
retryCount: imageGenerations.retryCount,
priority: imageGenerations.priority,
})
.from(imageGenerations)
.where(eq(imageGenerations.batchId, batchId))
.orderBy(imageGenerations.priority);
return {
...batch,
items: items.map((item, index) => ({
id: item.id,
index,
prompt: item.prompt,
status: item.status,
errorMessage: item.errorMessage,
retryCount: item.retryCount ?? 0,
imageUrl: null, // TODO: Join with images table to get URL
})),
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error getting batch ${batchId}`, error);
throw error;
}
}
/**
* Get all batches for a user
*/
async getUserBatches(
userId: string,
query: GetBatchQueryDto,
): Promise<BatchGeneration[]> {
try {
const { page = 1, limit = 20 } = query;
const offset = (page - 1) * limit;
const batches = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.userId, userId))
.orderBy(desc(batchGenerations.createdAt))
.limit(limit)
.offset(offset);
return batches;
} catch (error) {
this.logger.error('Error getting user batches', error);
throw error;
}
}
/**
* Get batch progress (counts)
*/
async getBatchProgress(
batchId: string,
userId: string,
): Promise<{
totalCount: number;
completedCount: number;
failedCount: number;
processingCount: number;
pendingCount: number;
status: string;
}> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Get actual counts from image_generations
const counts = await this.db
.select({
status: imageGenerations.status,
count: sql<number>`count(*)`,
})
.from(imageGenerations)
.where(eq(imageGenerations.batchId, batchId))
.groupBy(imageGenerations.status);
const statusCounts: Record<string, number> = {};
counts.forEach((c) => {
statusCounts[c.status] = Number(c.count);
});
const completedCount = statusCounts['completed'] || 0;
const failedCount = statusCounts['failed'] || 0;
const processingCount = statusCounts['processing'] || 0;
const pendingCount =
(statusCounts['pending'] || 0) + (statusCounts['queued'] || 0);
// Determine overall status
let status: string = batch.status;
const totalCount = batch.totalCount;
if (completedCount === totalCount) {
status = 'completed';
} else if (failedCount === totalCount) {
status = 'failed';
} else if (completedCount > 0 && failedCount > 0) {
status = 'partial';
} else if (processingCount > 0 || pendingCount > 0) {
status = 'processing';
}
// Update batch if status changed
if (status !== batch.status) {
await this.db
.update(batchGenerations)
.set({
status: status as any,
completedCount,
failedCount,
processingCount,
pendingCount,
completedAt:
status === 'completed' || status === 'failed'
? new Date()
: null,
})
.where(eq(batchGenerations.id, batchId));
}
return {
totalCount,
completedCount,
failedCount,
processingCount,
pendingCount,
status,
};
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error getting batch progress ${batchId}`, error);
throw error;
}
}
/**
* Retry failed generations in a batch
*/
async retryFailed(batchId: string, userId: string): Promise<{ affected: number }> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Reset failed generations to pending
const result = await this.db
.update(imageGenerations)
.set({
status: 'pending',
errorMessage: null,
retryCount: 0,
})
.where(
and(
eq(imageGenerations.batchId, batchId),
eq(imageGenerations.status, 'failed'),
),
)
.returning();
// Update batch status
await this.db
.update(batchGenerations)
.set({
status: 'processing',
failedCount: 0,
})
.where(eq(batchGenerations.id, batchId));
return { affected: result.length };
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error retrying batch ${batchId}`, error);
throw error;
}
}
/**
* Cancel a batch
*/
async cancelBatch(batchId: string, userId: string): Promise<void> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Cancel pending generations
await this.db
.update(imageGenerations)
.set({
status: 'cancelled',
errorMessage: 'Cancelled by user',
})
.where(
and(
eq(imageGenerations.batchId, batchId),
eq(imageGenerations.status, 'pending'),
),
);
// Update batch status
await this.db
.update(batchGenerations)
.set({
status: 'failed',
completedAt: new Date(),
})
.where(eq(batchGenerations.id, batchId));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error cancelling batch ${batchId}`, error);
throw error;
}
}
/**
* Delete a batch and all its generations
*/
async deleteBatch(batchId: string, userId: string): Promise<void> {
try {
// Verify ownership
const [batch] = await this.db
.select()
.from(batchGenerations)
.where(eq(batchGenerations.id, batchId))
.limit(1);
if (!batch) {
throw new NotFoundException(`Batch with id ${batchId} not found`);
}
if (batch.userId !== userId) {
throw new ForbiddenException('Access denied');
}
// Delete generations first
await this.db
.delete(imageGenerations)
.where(eq(imageGenerations.batchId, batchId));
// Delete batch
await this.db
.delete(batchGenerations)
.where(eq(batchGenerations.id, batchId));
} catch (error) {
if (
error instanceof NotFoundException ||
error instanceof ForbiddenException
) {
throw error;
}
this.logger.error(`Error deleting batch ${batchId}`, error);
throw error;
}
}
}

View file

@ -0,0 +1,74 @@
import {
IsString,
IsOptional,
IsNumber,
IsArray,
ValidateNested,
IsUUID,
} from 'class-validator';
import { Type } from 'class-transformer';
export class BatchPromptDto {
@IsString()
text: string;
@IsString()
@IsOptional()
negativePrompt?: string;
@IsNumber()
@IsOptional()
seed?: number;
@IsArray()
@IsString({ each: true })
@IsOptional()
tags?: string[];
}
export class SharedSettingsDto {
@IsUUID()
modelId: string;
@IsString()
modelVersion: string;
@IsNumber()
width: number;
@IsNumber()
height: number;
@IsNumber()
steps: number;
@IsNumber()
guidanceScale: number;
}
export class CreateBatchDto {
@IsArray()
@ValidateNested({ each: true })
@Type(() => BatchPromptDto)
prompts: BatchPromptDto[];
@ValidateNested()
@Type(() => SharedSettingsDto)
sharedSettings: SharedSettingsDto;
@IsString()
@IsOptional()
batchName?: string;
}
export class GetBatchQueryDto {
@IsNumber()
@IsOptional()
@Type(() => Number)
page?: number = 1;
@IsNumber()
@IsOptional()
@Type(() => Number)
limit?: number = 20;
}

View file

@ -0,0 +1,131 @@
CREATE TYPE "public"."generation_status" AS ENUM('pending', 'queued', 'processing', 'completed', 'failed', 'cancelled');--> statement-breakpoint
CREATE TYPE "public"."item_type" AS ENUM('image', 'text');--> statement-breakpoint
CREATE TABLE "images" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"generation_id" uuid,
"source_image_id" uuid,
"prompt" text NOT NULL,
"negative_prompt" text,
"model" text,
"style" text,
"public_url" text,
"storage_path" text NOT NULL,
"filename" text NOT NULL,
"format" text,
"width" integer,
"height" integer,
"file_size" integer,
"blurhash" text,
"is_public" boolean DEFAULT false NOT NULL,
"is_favorite" boolean DEFAULT false NOT NULL,
"download_count" integer DEFAULT 0 NOT NULL,
"rating" integer,
"archived_at" timestamp with time zone,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "image_generations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"model_id" uuid,
"batch_id" uuid,
"prompt" text NOT NULL,
"negative_prompt" text,
"model" text,
"style" text,
"source_image_url" text,
"width" integer,
"height" integer,
"steps" integer,
"guidance_scale" real,
"seed" integer,
"generation_strength" real,
"status" "generation_status" DEFAULT 'pending' NOT NULL,
"replicate_prediction_id" text,
"error_message" text,
"generation_time_seconds" integer,
"retry_count" integer DEFAULT 0 NOT NULL,
"priority" integer DEFAULT 0 NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);
--> statement-breakpoint
CREATE TABLE "boards" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text NOT NULL,
"description" text,
"thumbnail_url" text,
"canvas_width" integer DEFAULT 2000 NOT NULL,
"canvas_height" integer DEFAULT 1500 NOT NULL,
"background_color" text DEFAULT '#ffffff' NOT NULL,
"is_public" boolean DEFAULT false NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "board_items" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"board_id" uuid NOT NULL,
"image_id" uuid,
"item_type" "item_type" DEFAULT 'image' NOT NULL,
"position_x" real DEFAULT 0 NOT NULL,
"position_y" real DEFAULT 0 NOT NULL,
"scale_x" real DEFAULT 1 NOT NULL,
"scale_y" real DEFAULT 1 NOT NULL,
"rotation" real DEFAULT 0 NOT NULL,
"z_index" integer DEFAULT 0 NOT NULL,
"opacity" real DEFAULT 1 NOT NULL,
"width" integer,
"height" integer,
"text_content" text,
"font_size" integer,
"color" text,
"properties" jsonb,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "image_tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"image_id" uuid NOT NULL,
"tag_id" uuid NOT NULL,
"added_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
CREATE TABLE "tags" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"color" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "tags_name_unique" UNIQUE("name")
);
--> statement-breakpoint
CREATE TABLE "models" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"name" text NOT NULL,
"display_name" text NOT NULL,
"description" text,
"replicate_id" text NOT NULL,
"version" text,
"default_width" integer DEFAULT 1024,
"default_height" integer DEFAULT 1024,
"default_steps" integer DEFAULT 25,
"default_guidance_scale" real DEFAULT 7.5,
"min_width" integer DEFAULT 512,
"min_height" integer DEFAULT 512,
"max_width" integer DEFAULT 2048,
"max_height" integer DEFAULT 2048,
"max_steps" integer DEFAULT 50,
"supports_negative_prompt" boolean DEFAULT true NOT NULL,
"supports_img2img" boolean DEFAULT false NOT NULL,
"supports_seed" boolean DEFAULT true NOT NULL,
"is_active" boolean DEFAULT true NOT NULL,
"is_default" boolean DEFAULT false NOT NULL,
"sort_order" integer DEFAULT 0 NOT NULL,
"cost_per_generation" real,
"estimated_time_seconds" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
);

View file

@ -0,0 +1,20 @@
CREATE TYPE "public"."batch_status" AS ENUM('pending', 'processing', 'completed', 'partial', 'failed');--> statement-breakpoint
CREATE TABLE "batch_generations" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"user_id" uuid NOT NULL,
"name" text,
"total_count" integer NOT NULL,
"completed_count" integer DEFAULT 0 NOT NULL,
"failed_count" integer DEFAULT 0 NOT NULL,
"processing_count" integer DEFAULT 0 NOT NULL,
"pending_count" integer DEFAULT 0 NOT NULL,
"status" "batch_status" DEFAULT 'pending' NOT NULL,
"model_id" uuid,
"model_version" text,
"width" integer,
"height" integer,
"steps" integer,
"guidance_scale" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"completed_at" timestamp with time zone
);

View file

@ -0,0 +1,839 @@
{
"id": "8be49bb1-e2e5-474b-816d-8b27c6bb7c13",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.images": {
"name": "images",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"generation_id": {
"name": "generation_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"source_image_id": {
"name": "source_image_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"negative_prompt": {
"name": "negative_prompt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"style": {
"name": "style",
"type": "text",
"primaryKey": false,
"notNull": false
},
"public_url": {
"name": "public_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"storage_path": {
"name": "storage_path",
"type": "text",
"primaryKey": false,
"notNull": true
},
"filename": {
"name": "filename",
"type": "text",
"primaryKey": false,
"notNull": true
},
"format": {
"name": "format",
"type": "text",
"primaryKey": false,
"notNull": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"file_size": {
"name": "file_size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"blurhash": {
"name": "blurhash",
"type": "text",
"primaryKey": false,
"notNull": false
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"is_favorite": {
"name": "is_favorite",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"download_count": {
"name": "download_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"rating": {
"name": "rating",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"archived_at": {
"name": "archived_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.image_generations": {
"name": "image_generations",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"model_id": {
"name": "model_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"batch_id": {
"name": "batch_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"prompt": {
"name": "prompt",
"type": "text",
"primaryKey": false,
"notNull": true
},
"negative_prompt": {
"name": "negative_prompt",
"type": "text",
"primaryKey": false,
"notNull": false
},
"model": {
"name": "model",
"type": "text",
"primaryKey": false,
"notNull": false
},
"style": {
"name": "style",
"type": "text",
"primaryKey": false,
"notNull": false
},
"source_image_url": {
"name": "source_image_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"steps": {
"name": "steps",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"guidance_scale": {
"name": "guidance_scale",
"type": "real",
"primaryKey": false,
"notNull": false
},
"seed": {
"name": "seed",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"generation_strength": {
"name": "generation_strength",
"type": "real",
"primaryKey": false,
"notNull": false
},
"status": {
"name": "status",
"type": "generation_status",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'pending'"
},
"replicate_prediction_id": {
"name": "replicate_prediction_id",
"type": "text",
"primaryKey": false,
"notNull": false
},
"error_message": {
"name": "error_message",
"type": "text",
"primaryKey": false,
"notNull": false
},
"generation_time_seconds": {
"name": "generation_time_seconds",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"retry_count": {
"name": "retry_count",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"priority": {
"name": "priority",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"completed_at": {
"name": "completed_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": false
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.boards": {
"name": "boards",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"user_id": {
"name": "user_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"thumbnail_url": {
"name": "thumbnail_url",
"type": "text",
"primaryKey": false,
"notNull": false
},
"canvas_width": {
"name": "canvas_width",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 2000
},
"canvas_height": {
"name": "canvas_height",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 1500
},
"background_color": {
"name": "background_color",
"type": "text",
"primaryKey": false,
"notNull": true,
"default": "'#ffffff'"
},
"is_public": {
"name": "is_public",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.board_items": {
"name": "board_items",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"board_id": {
"name": "board_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"image_id": {
"name": "image_id",
"type": "uuid",
"primaryKey": false,
"notNull": false
},
"item_type": {
"name": "item_type",
"type": "item_type",
"typeSchema": "public",
"primaryKey": false,
"notNull": true,
"default": "'image'"
},
"position_x": {
"name": "position_x",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"position_y": {
"name": "position_y",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"scale_x": {
"name": "scale_x",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 1
},
"scale_y": {
"name": "scale_y",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 1
},
"rotation": {
"name": "rotation",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 0
},
"z_index": {
"name": "z_index",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"opacity": {
"name": "opacity",
"type": "real",
"primaryKey": false,
"notNull": true,
"default": 1
},
"width": {
"name": "width",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"height": {
"name": "height",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"text_content": {
"name": "text_content",
"type": "text",
"primaryKey": false,
"notNull": false
},
"font_size": {
"name": "font_size",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"properties": {
"name": "properties",
"type": "jsonb",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.image_tags": {
"name": "image_tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"image_id": {
"name": "image_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"tag_id": {
"name": "tag_id",
"type": "uuid",
"primaryKey": false,
"notNull": true
},
"added_at": {
"name": "added_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.tags": {
"name": "tags",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"color": {
"name": "color",
"type": "text",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {
"tags_name_unique": {
"name": "tags_name_unique",
"nullsNotDistinct": false,
"columns": [
"name"
]
}
},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.models": {
"name": "models",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "uuid",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"display_name": {
"name": "display_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"description": {
"name": "description",
"type": "text",
"primaryKey": false,
"notNull": false
},
"replicate_id": {
"name": "replicate_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"version": {
"name": "version",
"type": "text",
"primaryKey": false,
"notNull": false
},
"default_width": {
"name": "default_width",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1024
},
"default_height": {
"name": "default_height",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 1024
},
"default_steps": {
"name": "default_steps",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 25
},
"default_guidance_scale": {
"name": "default_guidance_scale",
"type": "real",
"primaryKey": false,
"notNull": false,
"default": 7.5
},
"min_width": {
"name": "min_width",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 512
},
"min_height": {
"name": "min_height",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 512
},
"max_width": {
"name": "max_width",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 2048
},
"max_height": {
"name": "max_height",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 2048
},
"max_steps": {
"name": "max_steps",
"type": "integer",
"primaryKey": false,
"notNull": false,
"default": 50
},
"supports_negative_prompt": {
"name": "supports_negative_prompt",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"supports_img2img": {
"name": "supports_img2img",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"supports_seed": {
"name": "supports_seed",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"is_active": {
"name": "is_active",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": true
},
"is_default": {
"name": "is_default",
"type": "boolean",
"primaryKey": false,
"notNull": true,
"default": false
},
"sort_order": {
"name": "sort_order",
"type": "integer",
"primaryKey": false,
"notNull": true,
"default": 0
},
"cost_per_generation": {
"name": "cost_per_generation",
"type": "real",
"primaryKey": false,
"notNull": false
},
"estimated_time_seconds": {
"name": "estimated_time_seconds",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
},
"updated_at": {
"name": "updated_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {
"public.generation_status": {
"name": "generation_status",
"schema": "public",
"values": [
"pending",
"queued",
"processing",
"completed",
"failed",
"cancelled"
]
},
"public.item_type": {
"name": "item_type",
"schema": "public",
"values": [
"image",
"text"
]
}
},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1764242583001,
"tag": "0000_clever_clint_barton",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1764250151767,
"tag": "0001_woozy_human_torch",
"breakpoints": true
}
]
}

View file

@ -0,0 +1,46 @@
import {
pgTable,
uuid,
text,
timestamp,
integer,
pgEnum,
} from 'drizzle-orm/pg-core';
export const batchStatusEnum = pgEnum('batch_status', [
'pending',
'processing',
'completed',
'partial',
'failed',
]);
export const batchGenerations = pgTable('batch_generations', {
id: uuid('id').primaryKey().defaultRandom(),
userId: uuid('user_id').notNull(),
name: text('name'),
totalCount: integer('total_count').notNull(),
completedCount: integer('completed_count').default(0).notNull(),
failedCount: integer('failed_count').default(0).notNull(),
processingCount: integer('processing_count').default(0).notNull(),
pendingCount: integer('pending_count').default(0).notNull(),
status: batchStatusEnum('status').default('pending').notNull(),
// Shared settings for all generations in the batch
modelId: uuid('model_id'),
modelVersion: text('model_version'),
width: integer('width'),
height: integer('height'),
steps: integer('steps'),
guidanceScale: integer('guidance_scale'),
createdAt: timestamp('created_at', { withTimezone: true })
.defaultNow()
.notNull(),
completedAt: timestamp('completed_at', { withTimezone: true }),
});
export type BatchGeneration = typeof batchGenerations.$inferSelect;
export type NewBatchGeneration = typeof batchGenerations.$inferInsert;

View file

@ -1,6 +1,9 @@
export * from './images.schema';
export * from './image-generations.schema';
export * from './image-likes.schema';
export * from './batch-generations.schema';
export * from './boards.schema';
export * from './board-items.schema';
export * from './tags.schema';
export * from './models.schema';
export * from './profiles.schema';

View file

@ -0,0 +1,151 @@
/**
* Batch API - Using NestJS Backend
*/
import { fetchApi } from './client';
// Request DTOs (camelCase for API)
export interface BatchPromptDto {
text: string;
negativePrompt?: string;
seed?: number;
tags?: string[];
}
export interface SharedSettingsDto {
modelId: string;
modelVersion: string;
width: number;
height: number;
steps: number;
guidanceScale: number;
}
export interface CreateBatchDto {
prompts: BatchPromptDto[];
sharedSettings: SharedSettingsDto;
batchName?: string;
}
// Response types (camelCase from API)
export interface BatchItem {
id: string;
index: number;
prompt: string;
status: string;
errorMessage?: string | null;
retryCount?: number;
imageUrl?: string | null;
}
export interface BatchGeneration {
id: string;
userId: string;
name: string | null;
totalCount: number;
completedCount: number;
failedCount: number;
processingCount: number;
pendingCount: number;
status: 'pending' | 'processing' | 'completed' | 'partial' | 'failed';
modelId?: string | null;
modelVersion?: string | null;
width?: number | null;
height?: number | null;
steps?: number | null;
guidanceScale?: number | null;
createdAt: string;
completedAt?: string | null;
items?: BatchItem[];
}
export interface BatchProgress {
totalCount: number;
completedCount: number;
failedCount: number;
processingCount: number;
pendingCount: number;
status: string;
}
/**
* Create a new batch generation
*/
export async function createBatch(dto: CreateBatchDto): Promise<BatchGeneration> {
const { data, error } = await fetchApi<BatchGeneration>('/batch', {
method: 'POST',
body: dto,
});
if (error) throw error;
if (!data) throw new Error('Failed to create batch');
return data;
}
/**
* Get all batches for the current user
*/
export async function getUserBatches(
page = 1,
limit = 20
): Promise<BatchGeneration[]> {
const searchParams = new URLSearchParams({
page: String(page),
limit: String(limit),
});
const { data, error } = await fetchApi<BatchGeneration[]>(`/batch?${searchParams}`);
if (error) throw error;
return data || [];
}
/**
* Get a specific batch by ID with its items
*/
export async function getBatch(batchId: string): Promise<BatchGeneration> {
const { data, error } = await fetchApi<BatchGeneration>(`/batch/${batchId}`);
if (error) throw error;
if (!data) throw new Error('Batch not found');
return data;
}
/**
* Get batch progress (for polling)
*/
export async function getBatchProgress(batchId: string): Promise<BatchProgress> {
const { data, error } = await fetchApi<BatchProgress>(`/batch/${batchId}/progress`);
if (error) throw error;
if (!data) throw new Error('Failed to get batch progress');
return data;
}
/**
* Retry failed generations in a batch
*/
export async function retryFailed(batchId: string): Promise<{ affected: number }> {
const { data, error } = await fetchApi<{ affected: number }>(
`/batch/${batchId}/retry`,
{ method: 'POST' }
);
if (error) throw error;
return data || { affected: 0 };
}
/**
* Cancel a batch
*/
export async function cancelBatch(batchId: string): Promise<void> {
const { error } = await fetchApi(`/batch/${batchId}/cancel`, {
method: 'POST',
});
if (error) throw error;
}
/**
* Delete a batch and all its generations
*/
export async function deleteBatch(batchId: string): Promise<void> {
const { error } = await fetchApi(`/batch/${batchId}`, {
method: 'DELETE',
});
if (error) throw error;
}

View file

@ -0,0 +1,80 @@
/**
* API Client for Picture Backend
* Used by Mobile App to communicate with NestJS backend
*/
import * as SecureStore from 'expo-secure-store';
import Constants from 'expo-constants';
const API_BASE = Constants.expoConfig?.extra?.backendUrl || process.env.EXPO_PUBLIC_BACKEND_URL || 'http://localhost:3003';
type FetchOptions = {
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE';
body?: unknown;
token?: string;
};
/**
* Get auth token from secure storage
*/
async function getAuthToken(): Promise<string | null> {
try {
return await SecureStore.getItemAsync('appToken');
} catch {
return null;
}
}
/**
* Generic API fetch function
*/
export async function fetchApi<T>(
endpoint: string,
options: FetchOptions = {},
): Promise<{ data: T | null; error: Error | null }> {
const { method = 'GET', body, token } = options;
let authToken = token;
if (!authToken) {
authToken = await getAuthToken() || undefined;
}
try {
const headers: Record<string, string> = {
'Content-Type': 'application/json',
};
if (authToken) {
headers['Authorization'] = `Bearer ${authToken}`;
}
const response = await fetch(`${API_BASE}/api${endpoint}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
return {
data: null,
error: new Error(errorData.message || `API error: ${response.status}`),
};
}
// Handle empty responses (204 No Content)
if (response.status === 204) {
return { data: null, error: null };
}
const data = await response.json();
return { data, error: null };
} catch (error) {
return {
data: null,
error: error instanceof Error ? error : new Error('Unknown error'),
};
}
}
export { API_BASE };

View file

@ -0,0 +1,62 @@
/**
* Explore API - Public Gallery
*/
import { fetchApi } from './client';
import type { Image } from './images';
export type SortBy = 'recent' | 'popular' | 'trending';
export interface ExploreParams {
page?: number;
limit?: number;
sortBy?: SortBy;
}
export interface SearchParams extends ExploreParams {
searchTerm: string;
}
/**
* Get public images for explore/discover feed
*/
export async function getExploreImages(params: ExploreParams = {}): Promise<Image[]> {
const {
page = 1,
limit = 20,
sortBy = 'recent',
} = params;
const searchParams = new URLSearchParams({
page: String(page),
limit: String(limit),
sortBy,
});
const { data, error } = await fetchApi<Image[]>(`/explore?${searchParams}`);
if (error) throw error;
return data || [];
}
/**
* Search public images
*/
export async function searchExploreImages(params: SearchParams): Promise<Image[]> {
const {
searchTerm,
page = 1,
limit = 20,
sortBy = 'recent',
} = params;
const searchParams = new URLSearchParams({
searchTerm,
page: String(page),
limit: String(limit),
sortBy,
});
const { data, error } = await fetchApi<Image[]>(`/explore/search?${searchParams}`);
if (error) throw error;
return data || [];
}

View file

@ -0,0 +1,179 @@
/**
* Generate API - Using NestJS Backend
*/
import { fetchApi } from './client';
export interface GenerateImageParams {
prompt: string;
modelId: string;
modelVersion?: string;
negativePrompt?: string;
width?: number;
height?: number;
steps?: number;
guidanceScale?: number;
seed?: number;
sourceImageUrl?: string;
generationStrength?: number;
style?: string;
waitForResult?: boolean;
}
export interface GeneratedImage {
id: string;
userId: string;
generationId?: string;
prompt: string;
negativePrompt?: string;
model?: string;
style?: string;
publicUrl?: string;
storagePath: string;
filename: string;
format?: string;
width?: number;
height?: number;
fileSize?: number;
blurhash?: string;
isPublic: boolean;
isFavorite: boolean;
downloadCount: number;
rating?: number;
archivedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface GenerateImageResponse {
generationId: string;
status: 'pending' | 'processing' | 'completed' | 'failed' | 'cancelled';
image?: GeneratedImage;
}
export interface GenerationStatus {
id: string;
userId: string;
modelId?: string;
batchId?: string;
prompt: string;
negativePrompt?: string;
model?: string;
style?: string;
sourceImageUrl?: string;
width?: number;
height?: number;
steps?: number;
guidanceScale?: number;
seed?: number;
generationStrength?: number;
status: 'pending' | 'queued' | 'processing' | 'completed' | 'failed' | 'cancelled';
replicatePredictionId?: string;
errorMessage?: string;
generationTimeSeconds?: number;
retryCount: number;
priority: number;
createdAt: string;
completedAt?: string;
image?: GeneratedImage;
}
/**
* Start image generation
* Set waitForResult=true for synchronous generation (waits until complete)
*/
export async function generateImage(params: GenerateImageParams): Promise<GenerateImageResponse> {
const { data, error } = await fetchApi<GenerateImageResponse>('/generate', {
method: 'POST',
body: params,
});
if (error) {
console.error('Generate Image Error:', error);
throw error;
}
if (!data) {
throw new Error('Failed to start image generation');
}
return data;
}
/**
* Check generation status
*/
export async function checkGenerationStatus(generationId: string): Promise<GenerationStatus> {
const { data, error } = await fetchApi<GenerationStatus>(`/generate/${generationId}/status`);
if (error) throw error;
if (!data) throw new Error('Generation not found');
return data;
}
/**
* Cancel an in-progress generation
*/
export async function cancelGeneration(generationId: string): Promise<void> {
const { error } = await fetchApi(`/generate/${generationId}`, {
method: 'DELETE',
});
if (error) throw error;
}
/**
* Poll for generation completion
*/
export async function waitForGeneration(
generationId: string,
onProgress?: (status: GenerationStatus) => void,
pollInterval = 2000,
maxAttempts = 120,
): Promise<GenerationStatus> {
let attempts = 0;
while (attempts < maxAttempts) {
const status = await checkGenerationStatus(generationId);
if (onProgress) {
onProgress(status);
}
if (status.status === 'completed' || status.status === 'failed' || status.status === 'cancelled') {
return status;
}
await new Promise((resolve) => setTimeout(resolve, pollInterval));
attempts++;
}
throw new Error('Generation timed out');
}
/**
* Generate image and wait for completion (convenience function)
*/
export async function generateAndWait(
params: Omit<GenerateImageParams, 'waitForResult'>,
onProgress?: (status: GenerationStatus) => void,
): Promise<GenerationStatus> {
// Use synchronous mode for faster response
const response = await generateImage({ ...params, waitForResult: true });
if (response.status === 'completed' && response.image) {
return {
id: response.generationId,
userId: response.image.userId,
prompt: params.prompt,
status: 'completed',
image: response.image,
retryCount: 0,
priority: 0,
createdAt: new Date().toISOString(),
} as GenerationStatus;
}
// If not completed, poll for status
return waitForGeneration(response.generationId, onProgress);
}

View file

@ -0,0 +1,270 @@
/**
* Images API - Using NestJS Backend
*/
import { fetchApi } from './client';
export interface Image {
id: string;
userId: string;
generationId?: string;
sourceImageId?: string;
prompt: string;
negativePrompt?: string;
model?: string;
style?: string;
publicUrl?: string;
storagePath: string;
filename: string;
format?: string;
width?: number;
height?: number;
fileSize?: number;
blurhash?: string;
isPublic: boolean;
isFavorite: boolean;
downloadCount: number;
rating?: number;
archivedAt?: string;
createdAt: string;
updatedAt: string;
}
export interface GetImagesParams {
page?: number;
limit?: number;
archived?: boolean;
tagIds?: string[];
favoritesOnly?: boolean;
}
/**
* Get images for the current user
*/
export async function getImages(params: GetImagesParams = {}): Promise<Image[]> {
const {
page = 1,
limit = 20,
archived = false,
tagIds,
favoritesOnly = false,
} = params;
const searchParams = new URLSearchParams({
page: String(page),
limit: String(limit),
archived: String(archived),
favoritesOnly: String(favoritesOnly),
});
if (tagIds && tagIds.length > 0) {
searchParams.append('tagIds', tagIds.join(','));
}
const { data, error } = await fetchApi<Image[]>(`/images?${searchParams}`);
if (error) throw error;
return data || [];
}
/**
* Get image by ID
*/
export async function getImageById(id: string): Promise<Image> {
const { data, error } = await fetchApi<Image>(`/images/${id}`);
if (error) throw error;
if (!data) throw new Error('Image not found');
return data;
}
/**
* Archive an image
*/
export async function archiveImage(id: string): Promise<Image> {
const { data, error } = await fetchApi<Image>(`/images/${id}/archive`, {
method: 'PATCH',
});
if (error) throw error;
if (!data) throw new Error('Failed to archive image');
return data;
}
/**
* Restore (unarchive) an image
*/
export async function restoreImage(id: string): Promise<Image> {
const { data, error } = await fetchApi<Image>(`/images/${id}/unarchive`, {
method: 'PATCH',
});
if (error) throw error;
if (!data) throw new Error('Failed to restore image');
return data;
}
/**
* Delete an image permanently
*/
export async function deleteImage(id: string): Promise<void> {
const { error } = await fetchApi(`/images/${id}`, {
method: 'DELETE',
});
if (error) throw error;
}
/**
* Publish an image (make public)
*/
export async function publishImage(id: string): Promise<Image> {
const { data, error } = await fetchApi<Image>(`/images/${id}/publish`, {
method: 'PATCH',
});
if (error) throw error;
if (!data) throw new Error('Failed to publish image');
return data;
}
/**
* Unpublish an image (make private)
*/
export async function unpublishImage(id: string): Promise<Image> {
const { data, error } = await fetchApi<Image>(`/images/${id}/unpublish`, {
method: 'PATCH',
});
if (error) throw error;
if (!data) throw new Error('Failed to unpublish image');
return data;
}
/**
* Toggle favorite status
*/
export async function toggleFavorite(id: string, isFavorite: boolean): Promise<Image> {
const { data, error } = await fetchApi<Image>(`/images/${id}/favorite`, {
method: 'PATCH',
body: { isFavorite },
});
if (error) throw error;
if (!data) throw new Error('Failed to toggle favorite');
return data;
}
/**
* Get archived images count
*/
export async function getArchivedCount(): Promise<number> {
const { data, error } = await fetchApi<{ count: number }>('/images/archived/count');
if (error) throw error;
return data?.count || 0;
}
/**
* Get archived images
*/
export async function getArchivedImages(page = 1, limit = 20): Promise<{
items: Image[];
total: number;
hasMore: boolean;
}> {
const images = await getImages({ page, limit, archived: true });
// Note: Backend doesn't return total count in list, so we estimate hasMore
return {
items: images,
total: images.length,
hasMore: images.length === limit,
};
}
/**
* Batch archive multiple images
*/
export async function batchArchiveImages(imageIds: string[]): Promise<{ affected: number }> {
const { data, error } = await fetchApi<{ affected: number }>('/images/batch/archive', {
method: 'POST',
body: { imageIds },
});
if (error) throw error;
return data || { affected: 0 };
}
/**
* Batch restore multiple images
*/
export async function batchRestoreImages(imageIds: string[]): Promise<{ affected: number }> {
const { data, error } = await fetchApi<{ affected: number }>('/images/batch/restore', {
method: 'POST',
body: { imageIds },
});
if (error) throw error;
return data || { affected: 0 };
}
/**
* Batch delete multiple images
*/
export async function batchDeleteImages(imageIds: string[]): Promise<{ affected: number }> {
const { data, error } = await fetchApi<{ affected: number }>('/images/batch/delete', {
method: 'POST',
body: { imageIds },
});
if (error) throw error;
return data || { affected: 0 };
}
// ==================== LIKES ====================
export interface LikeStatus {
liked: boolean;
likeCount: number;
}
/**
* Like an image
*/
export async function likeImage(imageId: string): Promise<LikeStatus> {
const { data, error } = await fetchApi<LikeStatus>(`/images/${imageId}/like`, {
method: 'POST',
});
if (error) throw error;
return data || { liked: false, likeCount: 0 };
}
/**
* Unlike an image
*/
export async function unlikeImage(imageId: string): Promise<LikeStatus> {
const { data, error } = await fetchApi<LikeStatus>(`/images/${imageId}/like`, {
method: 'DELETE',
});
if (error) throw error;
return data || { liked: false, likeCount: 0 };
}
/**
* Get like status for an image
*/
export async function getLikeStatus(imageId: string): Promise<LikeStatus> {
const { data, error } = await fetchApi<LikeStatus>(`/images/${imageId}/likes`);
if (error) throw error;
return data || { liked: false, likeCount: 0 };
}
// ==================== GENERATION DETAILS ====================
export interface GenerationDetails {
steps: number | null;
guidanceScale: number | null;
generationTimeSeconds: number | null;
status: string;
}
/**
* Get generation details for an image
*/
export async function getGenerationDetails(
generationId: string
): Promise<GenerationDetails | null> {
const { data, error } = await fetchApi<GenerationDetails>(
`/images/generation/${generationId}`
);
if (error) throw error;
return data || null;
}

View file

@ -0,0 +1,13 @@
/**
* Backend API Client
*
* All API calls go through the NestJS backend instead of Supabase.
*/
export * from './client';
export * from './generate';
export * from './models';
export * from './images';
export * from './profiles';
export * from './explore';
export * from './tags';

View file

@ -0,0 +1,77 @@
/**
* Models API - Using NestJS Backend
*/
import { fetchApi } from './client';
export interface Model {
id: string;
name: string;
displayName: string;
description?: string;
replicateId: string;
version?: string;
defaultWidth?: number;
defaultHeight?: number;
defaultSteps?: number;
defaultGuidanceScale?: number;
minWidth?: number;
minHeight?: number;
maxWidth?: number;
maxHeight?: number;
maxSteps?: number;
supportsNegativePrompt: boolean;
supportsImg2Img: boolean;
supportsSeed: boolean;
isActive: boolean;
isDefault: boolean;
sortOrder: number;
costPerGeneration?: number;
estimatedTimeSeconds?: number;
createdAt: string;
updatedAt: string;
}
/**
* Get all active models
*/
export async function getActiveModels(): Promise<Model[]> {
console.log('Fetching models from backend...');
const { data, error } = await fetchApi<Model[]>('/models');
if (error) {
console.error('Error fetching models:', error);
throw error;
}
console.log(`Fetched ${data?.length || 0} models from backend`);
return data || [];
}
/**
* Get model by ID
*/
export async function getModelById(id: string): Promise<Model> {
const { data, error } = await fetchApi<Model>(`/models/${id}`);
if (error) {
console.error('Error fetching model:', error);
throw error;
}
if (!data) {
throw new Error(`Model with id ${id} not found`);
}
return data;
}
/**
* Get default model
*/
export async function getDefaultModel(): Promise<Model | null> {
const models = await getActiveModels();
return models.find(m => m.isDefault) || models[0] || null;
}

View file

@ -0,0 +1,63 @@
/**
* Profiles API - Using NestJS Backend
*/
import { fetchApi } from './client';
export interface Profile {
id: string;
username: string | null;
email: string;
avatarUrl: string | null;
createdAt: string;
updatedAt: string;
}
export interface UserStats {
totalImages: number;
favoriteImages: number;
archivedImages: number;
publicImages: number;
}
export interface UpdateProfileData {
username?: string;
avatarUrl?: string;
}
/**
* Get current user's profile
*/
export async function getMyProfile(): Promise<Profile> {
const { data, error } = await fetchApi<Profile>('/profiles/me');
if (error) throw error;
if (!data) throw new Error('Profile not found');
return data;
}
/**
* Update current user's profile
*/
export async function updateProfile(updateData: UpdateProfileData): Promise<Profile> {
const { data, error } = await fetchApi<Profile>('/profiles/me', {
method: 'PATCH',
body: updateData,
});
if (error) throw error;
if (!data) throw new Error('Failed to update profile');
return data;
}
/**
* Get user statistics (images count, favorites, etc.)
*/
export async function getUserStats(): Promise<UserStats> {
const { data, error } = await fetchApi<UserStats>('/profiles/stats');
if (error) throw error;
return data || {
totalImages: 0,
favoriteImages: 0,
archivedImages: 0,
publicImages: 0,
};
}

View file

@ -0,0 +1,96 @@
/**
* Tags API - Using NestJS Backend
*/
import { fetchApi } from './client';
export interface Tag {
id: string;
name: string;
color: string | null;
createdAt: string;
}
export interface CreateTagData {
name: string;
color?: string;
}
export interface UpdateTagData {
name?: string;
color?: string;
}
/**
* Get all tags for current user
*/
export async function getTags(): Promise<Tag[]> {
const { data, error } = await fetchApi<Tag[]>('/tags');
if (error) throw error;
return data || [];
}
/**
* Create a new tag
*/
export async function createTag(tagData: CreateTagData): Promise<Tag> {
const { data, error } = await fetchApi<Tag>('/tags', {
method: 'POST',
body: tagData,
});
if (error) throw error;
if (!data) throw new Error('Failed to create tag');
return data;
}
/**
* Update an existing tag
*/
export async function updateTag(id: string, tagData: UpdateTagData): Promise<Tag> {
const { data, error } = await fetchApi<Tag>(`/tags/${id}`, {
method: 'PATCH',
body: tagData,
});
if (error) throw error;
if (!data) throw new Error('Failed to update tag');
return data;
}
/**
* Delete a tag
*/
export async function deleteTag(id: string): Promise<void> {
const { error } = await fetchApi(`/tags/${id}`, {
method: 'DELETE',
});
if (error) throw error;
}
/**
* Get tags for a specific image
*/
export async function getImageTags(imageId: string): Promise<Tag[]> {
const { data, error } = await fetchApi<Tag[]>(`/tags/image/${imageId}`);
if (error) throw error;
return data || [];
}
/**
* Add a tag to an image
*/
export async function addTagToImage(imageId: string, tagId: string): Promise<void> {
const { error } = await fetchApi(`/tags/image/${imageId}/${tagId}`, {
method: 'POST',
});
if (error) throw error;
}
/**
* Remove a tag from an image
*/
export async function removeTagFromImage(imageId: string, tagId: string): Promise<void> {
const { error } = await fetchApi(`/tags/image/${imageId}/${tagId}`, {
method: 'DELETE',
});
if (error) throw error;
}

View file

@ -1,7 +1,22 @@
import { create } from 'zustand';
import { supabase } from '~/utils/supabase';
import { RealtimeChannel } from '@supabase/supabase-js';
import {
createBatch as apiCreateBatch,
getBatch as apiGetBatch,
getUserBatches as apiGetUserBatches,
getBatchProgress as apiGetBatchProgress,
retryFailed as apiRetryFailed,
cancelBatch as apiCancelBatch,
deleteBatch as apiDeleteBatch,
type BatchGeneration,
type BatchItem,
type BatchPromptDto,
type SharedSettingsDto,
} from '~/services/api/batch';
// Re-export types for consumers
export type { BatchGeneration, BatchItem };
// Legacy interfaces for backward compatibility (snake_case)
export interface BatchPrompt {
text: string;
negative_prompt?: string;
@ -18,69 +33,53 @@ export interface SharedSettings {
guidance_scale: number;
}
export interface BatchGeneration {
id: string;
name: string;
total_count: number;
completed_count: number;
failed_count: number;
processing_count?: number;
pending_count?: number;
status: 'pending' | 'processing' | 'completed' | 'partial' | 'failed';
created_at: string;
completed_at?: string;
items?: BatchItem[];
}
export interface BatchItem {
id: string;
index: number;
prompt: string;
status: string;
error_message?: string;
retry_count?: number;
image_url?: string;
interface PollingState {
intervalId: ReturnType<typeof setInterval> | null;
batchId: string;
}
interface BatchStore {
// State
activeBatches: Map<string, BatchGeneration>;
currentBatch: BatchGeneration | null;
subscriptions: Map<string, RealtimeChannel>;
pollingStates: Map<string, PollingState>;
// UI State
isBatchModalOpen: boolean;
isCreatingBatch: boolean;
// Actions
createBatch: (prompts: BatchPrompt[], settings: SharedSettings, name?: string) => Promise<string>;
loadBatch: (batchId: string) => Promise<void>;
loadUserBatches: () => Promise<void>;
// Subscriptions
subscribeToBatch: (batchId: string) => void;
unsubscribeFromBatch: (batchId: string) => void;
unsubscribeAll: () => void;
// Polling (replaces Realtime subscriptions)
startPolling: (batchId: string, intervalMs?: number) => void;
stopPolling: (batchId: string) => void;
stopAllPolling: () => void;
// Batch Actions
retryFailed: (batchId: string) => Promise<void>;
cancelBatch: (batchId: string) => Promise<void>;
deleteBatch: (batchId: string) => Promise<void>;
// UI Actions
openBatchModal: () => void;
closeBatchModal: () => void;
setCurrentBatch: (batch: BatchGeneration | null) => void;
// Cleanup
reset: () => void;
}
// Default polling interval (2 seconds)
const DEFAULT_POLL_INTERVAL = 2000;
export const useBatchStore = create<BatchStore>((set, get) => ({
// Initial State
activeBatches: new Map(),
currentBatch: null,
subscriptions: new Map(),
pollingStates: new Map(),
isBatchModalOpen: false,
isCreatingBatch: false,
@ -89,56 +88,43 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
set({ isCreatingBatch: true });
try {
// Get the session to ensure we have a valid token
const { data: { session } } = await supabase.auth.getSession();
if (!session) throw new Error('Not authenticated');
// Convert snake_case inputs to camelCase for API
const apiPrompts: BatchPromptDto[] = prompts.map(p => ({
text: p.text,
negativePrompt: p.negative_prompt,
seed: p.seed,
tags: p.tags,
}));
// Call the batch-generate edge function with explicit auth header
const response = await supabase.functions.invoke('batch-generate', {
body: {
prompts: prompts.map(p => ({
text: p.text,
negative_prompt: p.negative_prompt,
seed: p.seed,
tags: p.tags
})),
shared_settings: settings,
batch_name: name
},
headers: {
Authorization: `Bearer ${session.access_token}`,
}
const apiSettings: SharedSettingsDto = {
modelId: settings.model_id,
modelVersion: settings.model_version,
width: settings.width,
height: settings.height,
steps: settings.steps,
guidanceScale: settings.guidance_scale,
};
const batch = await apiCreateBatch({
prompts: apiPrompts,
sharedSettings: apiSettings,
batchName: name,
});
if (response.error) throw response.error;
const { batch } = response.data;
// Add to active batches
const newBatch: BatchGeneration = {
id: batch.id,
name: batch.name,
total_count: batch.total_count,
completed_count: 0,
failed_count: 0,
status: 'processing',
created_at: new Date().toISOString(),
items: batch.generations
};
set(state => {
const newBatches = new Map(state.activeBatches);
newBatches.set(batch.id, newBatch);
return {
newBatches.set(batch.id, batch);
return {
activeBatches: newBatches,
currentBatch: newBatch,
currentBatch: batch,
isCreatingBatch: false
};
});
// Subscribe to updates
get().subscribeToBatch(batch.id);
// Start polling for updates
get().startPolling(batch.id);
return batch.id;
} catch (error) {
console.error('Error creating batch:', error);
@ -150,36 +136,17 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
// Load a specific batch
loadBatch: async (batchId) => {
try {
const { data: batch, error } = await supabase
.from('batch_progress')
.select('*')
.eq('id', batchId)
.single();
const batch = await apiGetBatch(batchId);
if (error) throw error;
const batchData: BatchGeneration = {
id: batch.id,
name: batch.name,
total_count: batch.total_count,
completed_count: batch.completed_count,
failed_count: batch.failed_count,
processing_count: batch.processing_count,
pending_count: batch.pending_count,
status: batch.status,
created_at: batch.created_at,
items: batch.items
};
set(state => {
const newBatches = new Map(state.activeBatches);
newBatches.set(batchId, batchData);
return {
newBatches.set(batchId, batch);
return {
activeBatches: newBatches,
currentBatch: batchData
currentBatch: batch
};
});
} catch (error) {
console.error('Error loading batch:', error);
throw error;
@ -189,150 +156,117 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
// Load all user batches
loadUserBatches: async () => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) return;
const batches = await apiGetUserBatches();
const { data: batches, error } = await supabase
.from('batch_generations')
.select('*')
.eq('user_id', user.id)
.order('created_at', { ascending: false })
.limit(20);
if (error) throw error;
set(state => {
const newBatches = new Map(state.activeBatches);
batches?.forEach(batch => {
newBatches.set(batch.id, {
id: batch.id,
name: batch.name,
total_count: batch.total_count,
completed_count: batch.completed_count,
failed_count: batch.failed_count,
status: batch.status,
created_at: batch.created_at,
completed_at: batch.completed_at
});
batches.forEach(batch => {
newBatches.set(batch.id, batch);
});
return { activeBatches: newBatches };
});
} catch (error) {
console.error('Error loading batches:', error);
}
},
// Subscribe to batch updates
subscribeToBatch: (batchId) => {
// Start polling for batch updates
startPolling: (batchId, intervalMs = DEFAULT_POLL_INTERVAL) => {
const state = get();
// Don't subscribe if already subscribed
if (state.subscriptions.has(batchId)) return;
const channel = supabase
.channel(`batch_${batchId}`)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'batch_generations',
filter: `id=eq.${batchId}`
},
(payload) => {
console.log('Batch update:', payload);
if (payload.new) {
set(state => {
const newBatches = new Map(state.activeBatches);
const existing = newBatches.get(batchId);
if (existing) {
newBatches.set(batchId, {
...existing,
...payload.new,
});
}
return { activeBatches: newBatches };
// Don't start if already polling this batch
if (state.pollingStates.has(batchId)) return;
const pollBatch = async () => {
try {
const progress = await apiGetBatchProgress(batchId);
set(state => {
const newBatches = new Map(state.activeBatches);
const existing = newBatches.get(batchId);
if (existing) {
newBatches.set(batchId, {
...existing,
totalCount: progress.totalCount,
completedCount: progress.completedCount,
failedCount: progress.failedCount,
processingCount: progress.processingCount,
pendingCount: progress.pendingCount,
status: progress.status as BatchGeneration['status'],
});
// Update currentBatch if it's this batch
const updatedBatch = newBatches.get(batchId);
if (state.currentBatch?.id === batchId && updatedBatch) {
return {
activeBatches: newBatches,
currentBatch: updatedBatch
};
}
}
}
)
.on(
'postgres_changes',
{
event: '*',
schema: 'public',
table: 'image_generations',
filter: `batch_id=eq.${batchId}`
},
async (payload) => {
console.log('Generation update:', payload);
// Reload the batch to get updated items
return { activeBatches: newBatches };
});
// Stop polling if batch is complete or failed
if (progress.status === 'completed' || progress.status === 'failed' || progress.status === 'partial') {
// Load full batch details one more time
await get().loadBatch(batchId);
get().stopPolling(batchId);
}
)
.subscribe();
} catch (error) {
console.error('Error polling batch:', error);
// Stop polling on error to prevent spamming
get().stopPolling(batchId);
}
};
// Poll immediately, then at interval
pollBatch();
const intervalId = setInterval(pollBatch, intervalMs);
set(state => {
const newSubs = new Map(state.subscriptions);
newSubs.set(batchId, channel);
return { subscriptions: newSubs };
const newPolling = new Map(state.pollingStates);
newPolling.set(batchId, { intervalId, batchId });
return { pollingStates: newPolling };
});
},
// Unsubscribe from batch updates
unsubscribeFromBatch: (batchId) => {
// Stop polling for a specific batch
stopPolling: (batchId) => {
const state = get();
const channel = state.subscriptions.get(batchId);
if (channel) {
channel.unsubscribe();
const pollingState = state.pollingStates.get(batchId);
if (pollingState?.intervalId) {
clearInterval(pollingState.intervalId);
set(state => {
const newSubs = new Map(state.subscriptions);
newSubs.delete(batchId);
return { subscriptions: newSubs };
const newPolling = new Map(state.pollingStates);
newPolling.delete(batchId);
return { pollingStates: newPolling };
});
}
},
// Unsubscribe from all
unsubscribeAll: () => {
// Stop all polling
stopAllPolling: () => {
const state = get();
state.subscriptions.forEach(channel => channel.unsubscribe());
set({ subscriptions: new Map() });
state.pollingStates.forEach(pollingState => {
if (pollingState.intervalId) {
clearInterval(pollingState.intervalId);
}
});
set({ pollingStates: new Map() });
},
// Retry failed generations in a batch
retryFailed: async (batchId) => {
try {
// Reset failed generations to pending
const { error } = await supabase
.from('image_generations')
.update({
status: 'pending',
error_message: null,
retry_count: 0
})
.eq('batch_id', batchId)
.eq('status', 'failed');
await apiRetryFailed(batchId);
if (error) throw error;
// Update batch status
await supabase
.from('batch_generations')
.update({
status: 'processing',
failed_count: 0
})
.eq('id', batchId);
// Trigger queue processing
await supabase.functions.invoke('process-queue');
// Reload batch
// Reload batch and restart polling
await get().loadBatch(batchId);
get().startPolling(batchId);
} catch (error) {
console.error('Error retrying batch:', error);
throw error;
@ -342,25 +276,12 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
// Cancel a batch
cancelBatch: async (batchId) => {
try {
// Update pending generations to cancelled
await supabase
.from('image_generations')
.update({ status: 'failed', error_message: 'Cancelled by user' })
.eq('batch_id', batchId)
.in('status', ['pending']);
await apiCancelBatch(batchId);
// Update batch status
await supabase
.from('batch_generations')
.update({
status: 'failed',
completed_at: new Date().toISOString()
})
.eq('id', batchId);
// Reload batch
// Stop polling and reload batch
get().stopPolling(batchId);
await get().loadBatch(batchId);
} catch (error) {
console.error('Error cancelling batch:', error);
throw error;
@ -370,27 +291,20 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
// Delete a batch and all its generations
deleteBatch: async (batchId) => {
try {
// Delete will cascade to image_generations
const { error } = await supabase
.from('batch_generations')
.delete()
.eq('id', batchId);
await apiDeleteBatch(batchId);
// Stop polling and remove from state
get().stopPolling(batchId);
if (error) throw error;
// Remove from state
set(state => {
const newBatches = new Map(state.activeBatches);
newBatches.delete(batchId);
return {
return {
activeBatches: newBatches,
currentBatch: state.currentBatch?.id === batchId ? null : state.currentBatch
};
});
// Unsubscribe
get().unsubscribeFromBatch(batchId);
} catch (error) {
console.error('Error deleting batch:', error);
throw error;
@ -404,13 +318,13 @@ export const useBatchStore = create<BatchStore>((set, get) => ({
// Reset store
reset: () => {
get().unsubscribeAll();
get().stopAllPolling();
set({
activeBatches: new Map(),
currentBatch: null,
subscriptions: new Map(),
pollingStates: new Map(),
isBatchModalOpen: false,
isCreatingBatch: false
});
}
}));
}));