diff --git a/apps/api-service/src/services/core/LiveScopeService.ts b/apps/api-service/src/services/core/LiveScopeService.ts new file mode 100644 index 0000000..87e5bda --- /dev/null +++ b/apps/api-service/src/services/core/LiveScopeService.ts @@ -0,0 +1,271 @@ +import { eq, desc, count, and, isNull, sql } from 'drizzle-orm'; +import { db } from '@/db'; +import { liveScopes, images } from '@banatie/database'; +import type { LiveScope, NewLiveScope, LiveScopeFilters, LiveScopeWithStats } from '@/types/models'; +import { buildWhereClause, buildEqCondition } from '@/utils/helpers'; +import { ERROR_MESSAGES } from '@/utils/constants'; + +export class LiveScopeService { + /** + * Create new live scope + * @param data - New scope data (projectId, slug, settings) + * @returns Created scope record + */ + async create(data: NewLiveScope): Promise { + const [scope] = await db.insert(liveScopes).values(data).returning(); + if (!scope) { + throw new Error('Failed to create live scope record'); + } + return scope; + } + + /** + * Get scope by ID + * @param id - Scope UUID + * @returns Scope record or null + */ + async getById(id: string): Promise { + const scope = await db.query.liveScopes.findFirst({ + where: eq(liveScopes.id, id), + }); + return scope || null; + } + + /** + * Get scope by slug within a project + * @param projectId - Project UUID + * @param slug - Scope slug + * @returns Scope record or null + */ + async getBySlug(projectId: string, slug: string): Promise { + const scope = await db.query.liveScopes.findFirst({ + where: and(eq(liveScopes.projectId, projectId), eq(liveScopes.slug, slug)), + }); + return scope || null; + } + + /** + * Get scope by ID or throw error + * @param id - Scope UUID + * @returns Scope record + * @throws Error if not found + */ + async getByIdOrThrow(id: string): Promise { + const scope = await this.getById(id); + if (!scope) { + throw new Error('Live scope not found'); + } + return scope; + } + + /** + * Get scope by slug or throw error + * @param projectId - Project UUID + * @param slug - Scope slug + * @returns Scope record + * @throws Error if not found + */ + async getBySlugOrThrow(projectId: string, slug: string): Promise { + const scope = await this.getBySlug(projectId, slug); + if (!scope) { + throw new Error('Live scope not found'); + } + return scope; + } + + /** + * Get scope with computed statistics + * @param id - Scope UUID + * @returns Scope with currentGenerations count and lastGeneratedAt + */ + async getByIdWithStats(id: string): Promise { + const scope = await this.getByIdOrThrow(id); + + // Count images in this scope (use meta field: { scope: slug, isLiveUrl: true }) + const scopeImages = await db.query.images.findMany({ + where: and( + eq(images.projectId, scope.projectId), + isNull(images.deletedAt), + sql`${images.meta}->>'scope' = ${scope.slug}`, + sql`(${images.meta}->>'isLiveUrl')::boolean = true`, + ), + orderBy: [desc(images.createdAt)], + }); + + const currentGenerations = scopeImages.length; + const lastGeneratedAt = scopeImages.length > 0 ? scopeImages[0]!.createdAt : null; + + return { + ...scope, + currentGenerations, + lastGeneratedAt, + images: scopeImages, + }; + } + + /** + * Get scope by slug with computed statistics + * @param projectId - Project UUID + * @param slug - Scope slug + * @returns Scope with statistics + */ + async getBySlugWithStats(projectId: string, slug: string): Promise { + const scope = await this.getBySlugOrThrow(projectId, slug); + return this.getByIdWithStats(scope.id); + } + + /** + * List scopes in a project with pagination + * @param filters - Query filters (projectId, optional slug) + * @param limit - Max results to return + * @param offset - Number of results to skip + * @returns Array of scopes with stats and total count + */ + async list( + filters: LiveScopeFilters, + limit: number, + offset: number, + ): Promise<{ scopes: LiveScopeWithStats[]; total: number }> { + const conditions = [ + buildEqCondition(liveScopes, 'projectId', filters.projectId), + buildEqCondition(liveScopes, 'slug', filters.slug), + ]; + + const whereClause = buildWhereClause(conditions); + + const [scopesList, countResult] = await Promise.all([ + db.query.liveScopes.findMany({ + where: whereClause, + orderBy: [desc(liveScopes.createdAt)], + limit, + offset, + }), + db.select({ count: count() }).from(liveScopes).where(whereClause), + ]); + + const totalCount = countResult[0]?.count || 0; + + // Compute stats for each scope + const scopesWithStats = await Promise.all( + scopesList.map(async (scope) => { + const scopeImages = await db.query.images.findMany({ + where: and( + eq(images.projectId, scope.projectId), + isNull(images.deletedAt), + sql`${images.meta}->>'scope' = ${scope.slug}`, + sql`(${images.meta}->>'isLiveUrl')::boolean = true`, + ), + orderBy: [desc(images.createdAt)], + }); + + return { + ...scope, + currentGenerations: scopeImages.length, + lastGeneratedAt: scopeImages.length > 0 ? scopeImages[0]!.createdAt : null, + }; + }), + ); + + return { + scopes: scopesWithStats, + total: Number(totalCount), + }; + } + + /** + * Update scope settings + * @param id - Scope UUID + * @param updates - Fields to update (allowNewGenerations, newGenerationsLimit, meta) + * @returns Updated scope record + */ + async update( + id: string, + updates: { + allowNewGenerations?: boolean; + newGenerationsLimit?: number; + meta?: Record; + }, + ): Promise { + // Verify scope exists + await this.getByIdOrThrow(id); + + const [updated] = await db + .update(liveScopes) + .set({ + ...updates, + updatedAt: new Date(), + }) + .where(eq(liveScopes.id, id)) + .returning(); + + if (!updated) { + throw new Error('Failed to update live scope'); + } + + return updated; + } + + /** + * Delete scope (hard delete) + * Note: Images in this scope are preserved with meta.scope field + * @param id - Scope UUID + */ + async delete(id: string): Promise { + await db.delete(liveScopes).where(eq(liveScopes.id, id)); + } + + /** + * Check if scope can accept new generations + * @param scope - Scope record + * @param currentCount - Current number of generations (optional, will query if not provided) + * @returns true if new generations are allowed + */ + async canGenerateNew(scope: LiveScope, currentCount?: number): Promise { + if (!scope.allowNewGenerations) { + return false; + } + + if (currentCount === undefined) { + const stats = await this.getByIdWithStats(scope.id); + currentCount = stats.currentGenerations; + } + + return currentCount < scope.newGenerationsLimit; + } + + /** + * Create scope automatically (lazy creation) with project defaults + * @param projectId - Project UUID + * @param slug - Scope slug + * @param projectDefaults - Default settings from project (allowNewGenerations, limit) + * @returns Created scope or existing scope if already exists + */ + async createOrGet( + projectId: string, + slug: string, + projectDefaults: { + allowNewLiveScopes: boolean; + newLiveScopesGenerationLimit: number; + }, + ): Promise { + // Check if scope already exists + const existing = await this.getBySlug(projectId, slug); + if (existing) { + return existing; + } + + // Check if project allows new scope creation + if (!projectDefaults.allowNewLiveScopes) { + throw new Error(ERROR_MESSAGES.SCOPE_CREATION_DISABLED); + } + + // Create new scope with project defaults + return this.create({ + projectId, + slug, + allowNewGenerations: true, + newGenerationsLimit: projectDefaults.newLiveScopesGenerationLimit, + meta: {}, + }); + } +} diff --git a/apps/api-service/src/types/models.ts b/apps/api-service/src/types/models.ts index 2972700..e63231c 100644 --- a/apps/api-service/src/types/models.ts +++ b/apps/api-service/src/types/models.ts @@ -1,16 +1,18 @@ -import type { generations, images, flows, promptUrlCache } from '@banatie/database'; +import type { generations, images, flows, promptUrlCache, liveScopes } from '@banatie/database'; // Database model types (inferred from Drizzle schema) export type Generation = typeof generations.$inferSelect; export type Image = typeof images.$inferSelect; export type Flow = typeof flows.$inferSelect; export type PromptUrlCacheEntry = typeof promptUrlCache.$inferSelect; +export type LiveScope = typeof liveScopes.$inferSelect; // Insert types (for creating new records) export type NewGeneration = typeof generations.$inferInsert; export type NewImage = typeof images.$inferInsert; export type NewFlow = typeof flows.$inferInsert; export type NewPromptUrlCacheEntry = typeof promptUrlCache.$inferInsert; +export type NewLiveScope = typeof liveScopes.$inferInsert; // Generation status enum (matches DB schema) export type GenerationStatus = 'pending' | 'processing' | 'success' | 'failed'; @@ -51,6 +53,13 @@ export interface FlowWithCounts extends Flow { images?: Image[]; } +// Enhanced live scope with computed stats +export interface LiveScopeWithStats extends LiveScope { + currentGenerations: number; + lastGeneratedAt: Date | null; + images?: Image[]; +} + // Pagination metadata export interface PaginationMeta { total: number; @@ -81,6 +90,12 @@ export interface FlowFilters { projectId: string; } +// Query filters for live scopes +export interface LiveScopeFilters { + projectId: string; + slug?: string | undefined; +} + // Cache statistics export interface CacheStats { hits: number; diff --git a/packages/database/src/schema/index.ts b/packages/database/src/schema/index.ts index 3955db1..596abdc 100644 --- a/packages/database/src/schema/index.ts +++ b/packages/database/src/schema/index.ts @@ -6,6 +6,7 @@ import { flows } from './flows'; import { images } from './images'; import { generations } from './generations'; import { promptUrlCache } from './promptUrlCache'; +import { liveScopes } from './liveScopes'; // Export all tables export * from './organizations'; @@ -15,6 +16,7 @@ export * from './flows'; export * from './images'; export * from './generations'; export * from './promptUrlCache'; +export * from './liveScopes'; // Define relations export const organizationsRelations = relations(organizations, ({ many }) => ({ @@ -32,6 +34,7 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({ images: many(images), generations: many(generations), promptUrlCache: many(promptUrlCache), + liveScopes: many(liveScopes), })); export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({ @@ -110,3 +113,10 @@ export const promptUrlCacheRelations = relations(promptUrlCache, ({ one }) => ({ references: [images.id], }), })); + +export const liveScopesRelations = relations(liveScopes, ({ one }) => ({ + project: one(projects, { + fields: [liveScopes.projectId], + references: [projects.id], + }), +})); diff --git a/packages/database/src/schema/liveScopes.ts b/packages/database/src/schema/liveScopes.ts new file mode 100644 index 0000000..9f77acc --- /dev/null +++ b/packages/database/src/schema/liveScopes.ts @@ -0,0 +1,57 @@ +import { pgTable, uuid, text, boolean, integer, jsonb, timestamp, index, unique } from 'drizzle-orm/pg-core'; +import { projects } from './projects'; + +/** + * Live Scopes Table (Section 8.4) + * + * Live scopes organize and control image generation via CDN live URLs. + * Each scope represents a logical separation within a project (e.g., "hero-section", "product-gallery"). + * + * Live URL format: /cdn/:orgSlug/:projectSlug/live/:scope?prompt=...&aspectRatio=... + */ +export const liveScopes = pgTable( + 'live_scopes', + { + id: uuid('id').primaryKey().defaultRandom(), + + // Relations + projectId: uuid('project_id') + .notNull() + .references(() => projects.id, { onDelete: 'cascade' }), + + // Scope identifier used in URLs (alphanumeric + hyphens + underscores) + // Must be unique within project + slug: text('slug').notNull(), + + // Controls whether new generations can be triggered in this scope + // Already generated images are ALWAYS served publicly regardless of this setting + allowNewGenerations: boolean('allow_new_generations').notNull().default(true), + + // Maximum number of generations allowed in this scope + // Only affects NEW generations, does not affect regeneration + newGenerationsLimit: integer('new_generations_limit').notNull().default(30), + + // Flexible metadata storage + meta: jsonb('meta').$type>().notNull().default({}), + + // Timestamps + createdAt: timestamp('created_at').notNull().defaultNow(), + updatedAt: timestamp('updated_at') + .notNull() + .defaultNow() + .$onUpdate(() => new Date()), + }, + (table) => ({ + // Unique constraint: slug must be unique within project + projectSlugUnique: unique('live_scopes_project_slug_unique').on(table.projectId, table.slug), + + // Index for querying scopes by project + projectIdx: index('idx_live_scopes_project').on(table.projectId), + + // Index for slug lookups within project + projectSlugIdx: index('idx_live_scopes_project_slug').on(table.projectId, table.slug), + }), +); + +export type LiveScope = typeof liveScopes.$inferSelect; +export type NewLiveScope = typeof liveScopes.$inferInsert;