272 lines
7.7 KiB
TypeScript
272 lines
7.7 KiB
TypeScript
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<LiveScope> {
|
|
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<LiveScope | null> {
|
|
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<LiveScope | null> {
|
|
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<LiveScope> {
|
|
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<LiveScope> {
|
|
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<LiveScopeWithStats> {
|
|
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<LiveScopeWithStats> {
|
|
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<string, unknown>;
|
|
},
|
|
): Promise<LiveScope> {
|
|
// 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<void> {
|
|
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<boolean> {
|
|
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<LiveScope> {
|
|
// 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: {},
|
|
});
|
|
}
|
|
}
|