feat: phase 3 part 1 - live scopes database schema and service
Implement foundation for live URL system with database schema, relations, and comprehensive service layer for live scope management. **Database Schema (Section 8.4):** - **liveScopes table** with fields: - id (UUID primary key) - projectId (foreign key to projects, cascade delete) - slug (text, unique within project) - allowNewGenerations (boolean, default: true) - newGenerationsLimit (integer, default: 30) - meta (JSONB for flexible metadata) - createdAt, updatedAt timestamps - **Constraints & Indexes:** - Unique constraint on (projectId, slug) combination - Index for project lookups - Index for project+slug lookups - **Relations:** - projects → liveScopes (one-to-many) - liveScopes → project (many-to-one) **Type Definitions:** - Added LiveScope, NewLiveScope types from database schema - Added LiveScopeWithStats interface with computed fields: - currentGenerations: number (count of images in scope) - lastGeneratedAt: Date | null (latest generation timestamp) - images?: Image[] (optional images array) - Added LiveScopeFilters interface for query filtering **LiveScopeService:** - **Core CRUD operations:** - create() - Create new scope - getById() - Get scope by UUID - getBySlug() - Get scope by slug within project - getByIdOrThrow() / getBySlugOrThrow() - With error handling - update() - Update scope settings - delete() - Hard delete scope (images preserved) - **Statistics & Computed Fields:** - getByIdWithStats() - Scope with generation count and last generated date - getBySlugWithStats() - Same but lookup by slug - list() - Paginated list with stats for each scope - **Business Logic:** - canGenerateNew() - Check if scope allows new generations - createOrGet() - Lazy creation with project defaults - Uses images.meta field for scope tracking (scope, isLiveUrl) **Technical Notes:** - Images track scope via meta.scope field (no schema changes to images table) - Scope statistics computed from images where meta.scope = slug and meta.isLiveUrl = true - Project settings (allowNewLiveScopes, newLiveScopesGenerationLimit) already added in Phase 2 - All code compiles with zero new TypeScript errors 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
7d87202934
commit
1ad5b483ef
|
|
@ -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<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: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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)
|
// Database model types (inferred from Drizzle schema)
|
||||||
export type Generation = typeof generations.$inferSelect;
|
export type Generation = typeof generations.$inferSelect;
|
||||||
export type Image = typeof images.$inferSelect;
|
export type Image = typeof images.$inferSelect;
|
||||||
export type Flow = typeof flows.$inferSelect;
|
export type Flow = typeof flows.$inferSelect;
|
||||||
export type PromptUrlCacheEntry = typeof promptUrlCache.$inferSelect;
|
export type PromptUrlCacheEntry = typeof promptUrlCache.$inferSelect;
|
||||||
|
export type LiveScope = typeof liveScopes.$inferSelect;
|
||||||
|
|
||||||
// Insert types (for creating new records)
|
// Insert types (for creating new records)
|
||||||
export type NewGeneration = typeof generations.$inferInsert;
|
export type NewGeneration = typeof generations.$inferInsert;
|
||||||
export type NewImage = typeof images.$inferInsert;
|
export type NewImage = typeof images.$inferInsert;
|
||||||
export type NewFlow = typeof flows.$inferInsert;
|
export type NewFlow = typeof flows.$inferInsert;
|
||||||
export type NewPromptUrlCacheEntry = typeof promptUrlCache.$inferInsert;
|
export type NewPromptUrlCacheEntry = typeof promptUrlCache.$inferInsert;
|
||||||
|
export type NewLiveScope = typeof liveScopes.$inferInsert;
|
||||||
|
|
||||||
// Generation status enum (matches DB schema)
|
// Generation status enum (matches DB schema)
|
||||||
export type GenerationStatus = 'pending' | 'processing' | 'success' | 'failed';
|
export type GenerationStatus = 'pending' | 'processing' | 'success' | 'failed';
|
||||||
|
|
@ -51,6 +53,13 @@ export interface FlowWithCounts extends Flow {
|
||||||
images?: Image[];
|
images?: Image[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Enhanced live scope with computed stats
|
||||||
|
export interface LiveScopeWithStats extends LiveScope {
|
||||||
|
currentGenerations: number;
|
||||||
|
lastGeneratedAt: Date | null;
|
||||||
|
images?: Image[];
|
||||||
|
}
|
||||||
|
|
||||||
// Pagination metadata
|
// Pagination metadata
|
||||||
export interface PaginationMeta {
|
export interface PaginationMeta {
|
||||||
total: number;
|
total: number;
|
||||||
|
|
@ -81,6 +90,12 @@ export interface FlowFilters {
|
||||||
projectId: string;
|
projectId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Query filters for live scopes
|
||||||
|
export interface LiveScopeFilters {
|
||||||
|
projectId: string;
|
||||||
|
slug?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
// Cache statistics
|
// Cache statistics
|
||||||
export interface CacheStats {
|
export interface CacheStats {
|
||||||
hits: number;
|
hits: number;
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { flows } from './flows';
|
||||||
import { images } from './images';
|
import { images } from './images';
|
||||||
import { generations } from './generations';
|
import { generations } from './generations';
|
||||||
import { promptUrlCache } from './promptUrlCache';
|
import { promptUrlCache } from './promptUrlCache';
|
||||||
|
import { liveScopes } from './liveScopes';
|
||||||
|
|
||||||
// Export all tables
|
// Export all tables
|
||||||
export * from './organizations';
|
export * from './organizations';
|
||||||
|
|
@ -15,6 +16,7 @@ export * from './flows';
|
||||||
export * from './images';
|
export * from './images';
|
||||||
export * from './generations';
|
export * from './generations';
|
||||||
export * from './promptUrlCache';
|
export * from './promptUrlCache';
|
||||||
|
export * from './liveScopes';
|
||||||
|
|
||||||
// Define relations
|
// Define relations
|
||||||
export const organizationsRelations = relations(organizations, ({ many }) => ({
|
export const organizationsRelations = relations(organizations, ({ many }) => ({
|
||||||
|
|
@ -32,6 +34,7 @@ export const projectsRelations = relations(projects, ({ one, many }) => ({
|
||||||
images: many(images),
|
images: many(images),
|
||||||
generations: many(generations),
|
generations: many(generations),
|
||||||
promptUrlCache: many(promptUrlCache),
|
promptUrlCache: many(promptUrlCache),
|
||||||
|
liveScopes: many(liveScopes),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
|
export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
|
||||||
|
|
@ -110,3 +113,10 @@ export const promptUrlCacheRelations = relations(promptUrlCache, ({ one }) => ({
|
||||||
references: [images.id],
|
references: [images.id],
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
export const liveScopesRelations = relations(liveScopes, ({ one }) => ({
|
||||||
|
project: one(projects, {
|
||||||
|
fields: [liveScopes.projectId],
|
||||||
|
references: [projects.id],
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
|
||||||
|
|
@ -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<Record<string, unknown>>().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;
|
||||||
Loading…
Reference in New Issue