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: {}, }); } }