diff --git a/apps/picture/apps/backend/src/app.module.ts b/apps/picture/apps/backend/src/app.module.ts index bc557a04e..0ebfbd29d 100644 --- a/apps/picture/apps/backend/src/app.module.ts +++ b/apps/picture/apps/backend/src/app.module.ts @@ -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 {} diff --git a/apps/picture/apps/backend/src/batch/batch.controller.ts b/apps/picture/apps/backend/src/batch/batch.controller.ts new file mode 100644 index 000000000..d2c1c266c --- /dev/null +++ b/apps/picture/apps/backend/src/batch/batch.controller.ts @@ -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); + } +} diff --git a/apps/picture/apps/backend/src/batch/batch.module.ts b/apps/picture/apps/backend/src/batch/batch.module.ts new file mode 100644 index 000000000..e19905e7b --- /dev/null +++ b/apps/picture/apps/backend/src/batch/batch.module.ts @@ -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 {} diff --git a/apps/picture/apps/backend/src/batch/batch.service.ts b/apps/picture/apps/backend/src/batch/batch.service.ts new file mode 100644 index 000000000..5e8b5b4e2 --- /dev/null +++ b/apps/picture/apps/backend/src/batch/batch.service.ts @@ -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 { + 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 { + 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 { + 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`count(*)`, + }) + .from(imageGenerations) + .where(eq(imageGenerations.batchId, batchId)) + .groupBy(imageGenerations.status); + + const statusCounts: Record = {}; + 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 { + 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 { + 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; + } + } +} diff --git a/apps/picture/apps/backend/src/batch/dto/batch.dto.ts b/apps/picture/apps/backend/src/batch/dto/batch.dto.ts new file mode 100644 index 000000000..65e4732f5 --- /dev/null +++ b/apps/picture/apps/backend/src/batch/dto/batch.dto.ts @@ -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; +} diff --git a/apps/picture/apps/backend/src/db/migrations/0000_clever_clint_barton.sql b/apps/picture/apps/backend/src/db/migrations/0000_clever_clint_barton.sql new file mode 100644 index 000000000..7181f265a --- /dev/null +++ b/apps/picture/apps/backend/src/db/migrations/0000_clever_clint_barton.sql @@ -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 +); diff --git a/apps/picture/apps/backend/src/db/migrations/0001_woozy_human_torch.sql b/apps/picture/apps/backend/src/db/migrations/0001_woozy_human_torch.sql new file mode 100644 index 000000000..644a141ce --- /dev/null +++ b/apps/picture/apps/backend/src/db/migrations/0001_woozy_human_torch.sql @@ -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 +); diff --git a/apps/picture/apps/backend/src/db/migrations/meta/0000_snapshot.json b/apps/picture/apps/backend/src/db/migrations/meta/0000_snapshot.json new file mode 100644 index 000000000..0803a907c --- /dev/null +++ b/apps/picture/apps/backend/src/db/migrations/meta/0000_snapshot.json @@ -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": {} + } +} \ No newline at end of file diff --git a/apps/picture/apps/backend/src/db/migrations/meta/0001_snapshot.json b/apps/picture/apps/backend/src/db/migrations/meta/0001_snapshot.json new file mode 100644 index 000000000..927f2962c --- /dev/null +++ b/apps/picture/apps/backend/src/db/migrations/meta/0001_snapshot.json @@ -0,0 +1,1086 @@ +{ + "id": "9183d23a-e051-4e21-943a-90e3df36ff6a", + "prevId": "8be49bb1-e2e5-474b-816d-8b27c6bb7c13", + "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.image_likes": { + "name": "image_likes", + "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 + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "image_likes_image_id_images_id_fk": { + "name": "image_likes_image_id_images_id_fk", + "tableFrom": "image_likes", + "tableTo": "images", + "columnsFrom": [ + "image_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "unique_image_user": { + "name": "unique_image_user", + "nullsNotDistinct": false, + "columns": [ + "image_id", + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.batch_generations": { + "name": "batch_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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "total_count": { + "name": "total_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "completed_count": { + "name": "completed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_count": { + "name": "processing_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "pending_count": { + "name": "pending_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "batch_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "model_id": { + "name": "model_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model_version": { + "name": "model_version", + "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": "integer", + "primaryKey": false, + "notNull": false + }, + "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 + }, + "public.profiles": { + "name": "profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "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.batch_status": { + "name": "batch_status", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "partial", + "failed" + ] + }, + "public.item_type": { + "name": "item_type", + "schema": "public", + "values": [ + "image", + "text" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/picture/apps/backend/src/db/migrations/meta/_journal.json b/apps/picture/apps/backend/src/db/migrations/meta/_journal.json new file mode 100644 index 000000000..8ce378cf6 --- /dev/null +++ b/apps/picture/apps/backend/src/db/migrations/meta/_journal.json @@ -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 + } + ] +} \ No newline at end of file diff --git a/apps/picture/apps/backend/src/db/schema/batch-generations.schema.ts b/apps/picture/apps/backend/src/db/schema/batch-generations.schema.ts new file mode 100644 index 000000000..6cb2e8fef --- /dev/null +++ b/apps/picture/apps/backend/src/db/schema/batch-generations.schema.ts @@ -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; diff --git a/apps/picture/apps/backend/src/db/schema/index.ts b/apps/picture/apps/backend/src/db/schema/index.ts index 4de4c222e..05355b406 100644 --- a/apps/picture/apps/backend/src/db/schema/index.ts +++ b/apps/picture/apps/backend/src/db/schema/index.ts @@ -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'; diff --git a/apps/picture/apps/mobile/services/api/batch.ts b/apps/picture/apps/mobile/services/api/batch.ts new file mode 100644 index 000000000..38ea6ce0d --- /dev/null +++ b/apps/picture/apps/mobile/services/api/batch.ts @@ -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 { + const { data, error } = await fetchApi('/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 { + const searchParams = new URLSearchParams({ + page: String(page), + limit: String(limit), + }); + + const { data, error } = await fetchApi(`/batch?${searchParams}`); + if (error) throw error; + return data || []; +} + +/** + * Get a specific batch by ID with its items + */ +export async function getBatch(batchId: string): Promise { + const { data, error } = await fetchApi(`/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 { + const { data, error } = await fetchApi(`/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 { + 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 { + const { error } = await fetchApi(`/batch/${batchId}`, { + method: 'DELETE', + }); + if (error) throw error; +} diff --git a/apps/picture/apps/mobile/services/api/client.ts b/apps/picture/apps/mobile/services/api/client.ts new file mode 100644 index 000000000..04c28357e --- /dev/null +++ b/apps/picture/apps/mobile/services/api/client.ts @@ -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 { + try { + return await SecureStore.getItemAsync('appToken'); + } catch { + return null; + } +} + +/** + * Generic API fetch function + */ +export async function fetchApi( + 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 = { + '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 }; diff --git a/apps/picture/apps/mobile/services/api/explore.ts b/apps/picture/apps/mobile/services/api/explore.ts new file mode 100644 index 000000000..ddd54f1cb --- /dev/null +++ b/apps/picture/apps/mobile/services/api/explore.ts @@ -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 { + const { + page = 1, + limit = 20, + sortBy = 'recent', + } = params; + + const searchParams = new URLSearchParams({ + page: String(page), + limit: String(limit), + sortBy, + }); + + const { data, error } = await fetchApi(`/explore?${searchParams}`); + if (error) throw error; + return data || []; +} + +/** + * Search public images + */ +export async function searchExploreImages(params: SearchParams): Promise { + 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(`/explore/search?${searchParams}`); + if (error) throw error; + return data || []; +} diff --git a/apps/picture/apps/mobile/services/api/generate.ts b/apps/picture/apps/mobile/services/api/generate.ts new file mode 100644 index 000000000..70fa8c32f --- /dev/null +++ b/apps/picture/apps/mobile/services/api/generate.ts @@ -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 { + const { data, error } = await fetchApi('/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 { + const { data, error } = await fetchApi(`/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 { + 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 { + 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, + onProgress?: (status: GenerationStatus) => void, +): Promise { + // 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); +} diff --git a/apps/picture/apps/mobile/services/api/images.ts b/apps/picture/apps/mobile/services/api/images.ts new file mode 100644 index 000000000..539781b0a --- /dev/null +++ b/apps/picture/apps/mobile/services/api/images.ts @@ -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 { + 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(`/images?${searchParams}`); + if (error) throw error; + return data || []; +} + +/** + * Get image by ID + */ +export async function getImageById(id: string): Promise { + const { data, error } = await fetchApi(`/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 { + const { data, error } = await fetchApi(`/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 { + const { data, error } = await fetchApi(`/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 { + const { error } = await fetchApi(`/images/${id}`, { + method: 'DELETE', + }); + if (error) throw error; +} + +/** + * Publish an image (make public) + */ +export async function publishImage(id: string): Promise { + const { data, error } = await fetchApi(`/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 { + const { data, error } = await fetchApi(`/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 { + const { data, error } = await fetchApi(`/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 { + 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 { + const { data, error } = await fetchApi(`/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 { + const { data, error } = await fetchApi(`/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 { + const { data, error } = await fetchApi(`/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 { + const { data, error } = await fetchApi( + `/images/generation/${generationId}` + ); + if (error) throw error; + return data || null; +} diff --git a/apps/picture/apps/mobile/services/api/index.ts b/apps/picture/apps/mobile/services/api/index.ts new file mode 100644 index 000000000..e1996d752 --- /dev/null +++ b/apps/picture/apps/mobile/services/api/index.ts @@ -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'; diff --git a/apps/picture/apps/mobile/services/api/models.ts b/apps/picture/apps/mobile/services/api/models.ts new file mode 100644 index 000000000..f490b00e0 --- /dev/null +++ b/apps/picture/apps/mobile/services/api/models.ts @@ -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 { + console.log('Fetching models from backend...'); + + const { data, error } = await fetchApi('/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 { + const { data, error } = await fetchApi(`/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 { + const models = await getActiveModels(); + return models.find(m => m.isDefault) || models[0] || null; +} diff --git a/apps/picture/apps/mobile/services/api/profiles.ts b/apps/picture/apps/mobile/services/api/profiles.ts new file mode 100644 index 000000000..a4fa1f5b5 --- /dev/null +++ b/apps/picture/apps/mobile/services/api/profiles.ts @@ -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 { + const { data, error } = await fetchApi('/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 { + const { data, error } = await fetchApi('/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 { + const { data, error } = await fetchApi('/profiles/stats'); + if (error) throw error; + return data || { + totalImages: 0, + favoriteImages: 0, + archivedImages: 0, + publicImages: 0, + }; +} diff --git a/apps/picture/apps/mobile/services/api/tags.ts b/apps/picture/apps/mobile/services/api/tags.ts new file mode 100644 index 000000000..1893d5b3c --- /dev/null +++ b/apps/picture/apps/mobile/services/api/tags.ts @@ -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 { + const { data, error } = await fetchApi('/tags'); + if (error) throw error; + return data || []; +} + +/** + * Create a new tag + */ +export async function createTag(tagData: CreateTagData): Promise { + const { data, error } = await fetchApi('/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 { + const { data, error } = await fetchApi(`/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 { + 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 { + const { data, error } = await fetchApi(`/tags/image/${imageId}`); + if (error) throw error; + return data || []; +} + +/** + * Add a tag to an image + */ +export async function addTagToImage(imageId: string, tagId: string): Promise { + 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 { + const { error } = await fetchApi(`/tags/image/${imageId}/${tagId}`, { + method: 'DELETE', + }); + if (error) throw error; +} diff --git a/apps/picture/apps/mobile/store/batchStore.ts b/apps/picture/apps/mobile/store/batchStore.ts index fdf7279d0..3717646b6 100644 --- a/apps/picture/apps/mobile/store/batchStore.ts +++ b/apps/picture/apps/mobile/store/batchStore.ts @@ -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 | null; + batchId: string; } interface BatchStore { // State activeBatches: Map; currentBatch: BatchGeneration | null; - subscriptions: Map; - + pollingStates: Map; + // UI State isBatchModalOpen: boolean; isCreatingBatch: boolean; - + // Actions createBatch: (prompts: BatchPrompt[], settings: SharedSettings, name?: string) => Promise; loadBatch: (batchId: string) => Promise; loadUserBatches: () => Promise; - - // 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; cancelBatch: (batchId: string) => Promise; deleteBatch: (batchId: string) => Promise; - + // 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((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((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((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((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((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((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((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 }); } -})); \ No newline at end of file +}));