Le pipeline de génération écrasait silencieusement les versions précédentes. Maintenant chaque génération crée une variante sans perdre l'historique. Backend: - Nouvelle table frameVariants (id, frameId, type, url, prompt, provider, generationTimeMs, isActive, isPinned, label, metadata) - 5 helpers DB: createFrameVariant, listFrameVariants, setActiveVariant, deleteFrameVariant, toggleVariantPin, renameVariant - Routeur tRPC frameVariants: list, setActive, delete, togglePin, rename - regenerateBackground/regenerateCharacter/composeFrame créent désormais une variante + désactivent les anciennes du même type - Les champs legacy (regeneratedBgUrl, etc.) restent synchronisés avec la variante active pour backward-compat - Tracking des metadata (style, characterId, sourceLayers, etc.) - Mesure du generationTimeMs par variante Frontend: - Nouveau composant VersionsGallery (thumbnails horizontaux avec active radio, pin star, delete, tooltip prompt+meta+time) - Intégré dans GenerationPanel sous chaque bloc (fond, perso, composite) - Click sur une variante = la désactive les autres et l'active - Hover = actions (épingler, supprimer) - Tooltip détaillé (prompt, provider, durée, date) - Pinned en premier, puis chronologique desc Migration: - Script migre les regen URLs existantes en variants (12 variantes migrées sur 8 frames du projet 90001) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
356 lines
12 KiB
TypeScript
356 lines
12 KiB
TypeScript
import { eq, and, desc, asc } from "drizzle-orm";
|
|
import { drizzle } from "drizzle-orm/mysql2";
|
|
import {
|
|
InsertUser, users,
|
|
projects, InsertProject, Project,
|
|
sequences, InsertSequence,
|
|
frames, InsertFrame,
|
|
layers, InsertLayer,
|
|
characters, InsertCharacter,
|
|
aiEngines, InsertAiEngine,
|
|
generationJobs, InsertGenerationJob,
|
|
assistantMessages, InsertAssistantMessage,
|
|
frameVariants, InsertFrameVariant,
|
|
} from "../drizzle/schema";
|
|
import { ENV } from './_core/env';
|
|
|
|
let _db: ReturnType<typeof drizzle> | null = null;
|
|
|
|
export async function getDb() {
|
|
if (!_db && process.env.DATABASE_URL) {
|
|
try {
|
|
_db = drizzle(process.env.DATABASE_URL);
|
|
} catch (error) {
|
|
console.warn("[Database] Failed to connect:", error);
|
|
_db = null;
|
|
}
|
|
}
|
|
return _db;
|
|
}
|
|
|
|
// ============ USERS ============
|
|
export async function upsertUser(user: InsertUser): Promise<void> {
|
|
if (!user.openId) throw new Error("User openId is required for upsert");
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
|
|
const values: InsertUser = { openId: user.openId };
|
|
const updateSet: Record<string, unknown> = {};
|
|
const textFields = ["name", "email", "passwordHash", "loginMethod"] as const;
|
|
type TextField = (typeof textFields)[number];
|
|
const assignNullable = (field: TextField) => {
|
|
const value = user[field];
|
|
if (value === undefined) return;
|
|
const normalized = value ?? null;
|
|
values[field] = normalized;
|
|
updateSet[field] = normalized;
|
|
};
|
|
textFields.forEach(assignNullable);
|
|
if (user.lastSignedIn !== undefined) { values.lastSignedIn = user.lastSignedIn; updateSet.lastSignedIn = user.lastSignedIn; }
|
|
if (user.role !== undefined) { values.role = user.role; updateSet.role = user.role; }
|
|
else if (user.openId === ENV.ownerOpenId) { values.role = 'admin'; updateSet.role = 'admin'; }
|
|
if (!values.lastSignedIn) values.lastSignedIn = new Date();
|
|
if (Object.keys(updateSet).length === 0) updateSet.lastSignedIn = new Date();
|
|
await db.insert(users).values(values).onDuplicateKeyUpdate({ set: updateSet });
|
|
}
|
|
|
|
export async function getUserByOpenId(openId: string) {
|
|
const db = await getDb();
|
|
if (!db) return undefined;
|
|
const result = await db.select().from(users).where(eq(users.openId, openId)).limit(1);
|
|
return result.length > 0 ? result[0] : undefined;
|
|
}
|
|
|
|
export async function getUserByEmail(email: string) {
|
|
const db = await getDb();
|
|
if (!db) return undefined;
|
|
const result = await db.select().from(users).where(eq(users.email, email)).limit(1);
|
|
return result.length > 0 ? result[0] : undefined;
|
|
}
|
|
|
|
// ============ PROJECTS ============
|
|
export async function createProject(data: InsertProject) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
const result = await db.insert(projects).values(data);
|
|
return { id: result[0].insertId };
|
|
}
|
|
|
|
export async function getProject(id: number) {
|
|
const db = await getDb();
|
|
if (!db) return null;
|
|
const result = await db.select().from(projects).where(eq(projects.id, id)).limit(1);
|
|
return result[0] ?? null;
|
|
}
|
|
|
|
export async function listProjects(userId: number) {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(projects).where(eq(projects.userId, userId)).orderBy(desc(projects.createdAt));
|
|
}
|
|
|
|
export async function listAllProjects() {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(projects).orderBy(desc(projects.createdAt));
|
|
}
|
|
|
|
export async function updateProject(id: number, data: Partial<InsertProject>) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.update(projects).set(data).where(eq(projects.id, id));
|
|
}
|
|
|
|
export async function deleteProject(id: number) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
// Delete related data first (cascade)
|
|
await db.delete(assistantMessages).where(eq(assistantMessages.projectId, id));
|
|
await db.delete(generationJobs).where(eq(generationJobs.projectId, id));
|
|
await db.delete(layers).where(eq(layers.projectId, id));
|
|
await db.delete(characters).where(eq(characters.projectId, id));
|
|
await db.delete(sequences).where(eq(sequences.projectId, id));
|
|
await db.delete(frames).where(eq(frames.projectId, id));
|
|
await db.delete(projects).where(eq(projects.id, id));
|
|
}
|
|
|
|
// ============ SEQUENCES ============
|
|
export async function createSequence(data: InsertSequence) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
const result = await db.insert(sequences).values(data);
|
|
return { id: result[0].insertId };
|
|
}
|
|
|
|
export async function listSequences(projectId: number) {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(sequences).where(eq(sequences.projectId, projectId)).orderBy(asc(sequences.startFrame));
|
|
}
|
|
|
|
export async function updateSequence(id: number, data: Partial<InsertSequence>) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.update(sequences).set(data).where(eq(sequences.id, id));
|
|
}
|
|
|
|
// ============ FRAMES ============
|
|
export async function createFrames(data: InsertFrame[]) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
if (data.length === 0) return;
|
|
await db.insert(frames).values(data);
|
|
}
|
|
|
|
export async function listFrames(projectId: number) {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(frames).where(eq(frames.projectId, projectId)).orderBy(asc(frames.frameIndex));
|
|
}
|
|
|
|
export async function getFrame(projectId: number, frameIndex: number) {
|
|
const db = await getDb();
|
|
if (!db) return null;
|
|
const result = await db.select().from(frames)
|
|
.where(and(eq(frames.projectId, projectId), eq(frames.frameIndex, frameIndex)))
|
|
.limit(1);
|
|
return result[0] ?? null;
|
|
}
|
|
|
|
export async function updateFrame(id: number, data: Partial<InsertFrame>) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.update(frames).set(data).where(eq(frames.id, id));
|
|
}
|
|
|
|
// ============ LAYERS ============
|
|
export async function createLayer(data: InsertLayer) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
const result = await db.insert(layers).values(data);
|
|
return { id: result[0].insertId };
|
|
}
|
|
|
|
export async function listLayers(projectId: number) {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(layers).where(eq(layers.projectId, projectId)).orderBy(asc(layers.order));
|
|
}
|
|
|
|
export async function updateLayer(id: number, data: Partial<InsertLayer>) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.update(layers).set(data).where(eq(layers.id, id));
|
|
}
|
|
|
|
export async function deleteLayer(id: number) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.delete(layers).where(eq(layers.id, id));
|
|
}
|
|
|
|
export async function reorderLayers(ids: number[]) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
for (let i = 0; i < ids.length; i++) {
|
|
await db.update(layers).set({ order: i }).where(eq(layers.id, ids[i]));
|
|
}
|
|
}
|
|
|
|
// ============ CHARACTERS ============
|
|
export async function createCharacter(data: InsertCharacter) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
const result = await db.insert(characters).values(data);
|
|
return { id: result[0].insertId };
|
|
}
|
|
|
|
export async function listCharacters(projectId: number) {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(characters).where(eq(characters.projectId, projectId));
|
|
}
|
|
|
|
export async function updateCharacter(id: number, data: Partial<InsertCharacter>) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.update(characters).set(data).where(eq(characters.id, id));
|
|
}
|
|
|
|
// ============ AI ENGINES ============
|
|
export async function createAiEngine(data: InsertAiEngine) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
const result = await db.insert(aiEngines).values(data);
|
|
return { id: result[0].insertId };
|
|
}
|
|
|
|
export async function listAiEngines() {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(aiEngines).orderBy(desc(aiEngines.createdAt));
|
|
}
|
|
|
|
export async function updateAiEngine(id: number, data: Partial<InsertAiEngine>) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.update(aiEngines).set(data).where(eq(aiEngines.id, id));
|
|
}
|
|
|
|
export async function deleteAiEngine(id: number) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.delete(aiEngines).where(eq(aiEngines.id, id));
|
|
}
|
|
|
|
export async function getDefaultEngine(taskType: string) {
|
|
const db = await getDb();
|
|
if (!db) return undefined;
|
|
const result = await db.select().from(aiEngines)
|
|
.where(and(eq(aiEngines.taskType, taskType as any), eq(aiEngines.isDefault, true), eq(aiEngines.isActive, true)))
|
|
.limit(1);
|
|
return result[0];
|
|
}
|
|
|
|
// ============ GENERATION JOBS ============
|
|
export async function createGenerationJob(data: InsertGenerationJob) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
const result = await db.insert(generationJobs).values(data);
|
|
return { id: result[0].insertId };
|
|
}
|
|
|
|
export async function listGenerationJobs(projectId: number) {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(generationJobs).where(eq(generationJobs.projectId, projectId)).orderBy(desc(generationJobs.createdAt));
|
|
}
|
|
|
|
export async function updateGenerationJob(id: number, data: Partial<InsertGenerationJob>) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.update(generationJobs).set(data).where(eq(generationJobs.id, id));
|
|
}
|
|
|
|
// ============ ASSISTANT MESSAGES ============
|
|
export async function createAssistantMessage(data: InsertAssistantMessage) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
await db.insert(assistantMessages).values(data);
|
|
}
|
|
|
|
export async function listAssistantMessages(projectId: number) {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(assistantMessages).where(eq(assistantMessages.projectId, projectId)).orderBy(asc(assistantMessages.createdAt));
|
|
}
|
|
|
|
// ============ FRAME VARIANTS (non-destructive version history) ============
|
|
export async function createFrameVariant(data: InsertFrameVariant) {
|
|
const db = await getDb();
|
|
if (!db) throw new Error("DB not available");
|
|
|
|
// If isActive=true, deactivate other variants of the same type for this frame
|
|
if (data.isActive) {
|
|
await db
|
|
.update(frameVariants)
|
|
.set({ isActive: false })
|
|
.where(and(eq(frameVariants.frameId, data.frameId), eq(frameVariants.type, data.type)));
|
|
}
|
|
|
|
const result = await db.insert(frameVariants).values(data);
|
|
return { id: result[0].insertId };
|
|
}
|
|
|
|
export async function listFrameVariants(frameId: number, type?: "background" | "character" | "composite") {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
const whereClause = type
|
|
? and(eq(frameVariants.frameId, frameId), eq(frameVariants.type, type))
|
|
: eq(frameVariants.frameId, frameId);
|
|
return db.select().from(frameVariants).where(whereClause).orderBy(desc(frameVariants.createdAt));
|
|
}
|
|
|
|
export async function getActiveVariant(frameId: number, type: "background" | "character" | "composite") {
|
|
const db = await getDb();
|
|
if (!db) return undefined;
|
|
const result = await db
|
|
.select()
|
|
.from(frameVariants)
|
|
.where(and(eq(frameVariants.frameId, frameId), eq(frameVariants.type, type), eq(frameVariants.isActive, true)))
|
|
.limit(1);
|
|
return result[0];
|
|
}
|
|
|
|
export async function setActiveVariant(variantId: number) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
const [variant] = await db.select().from(frameVariants).where(eq(frameVariants.id, variantId)).limit(1);
|
|
if (!variant) return;
|
|
await db
|
|
.update(frameVariants)
|
|
.set({ isActive: false })
|
|
.where(and(eq(frameVariants.frameId, variant.frameId), eq(frameVariants.type, variant.type)));
|
|
await db.update(frameVariants).set({ isActive: true }).where(eq(frameVariants.id, variantId));
|
|
return variant;
|
|
}
|
|
|
|
export async function deleteFrameVariant(variantId: number) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.delete(frameVariants).where(eq(frameVariants.id, variantId));
|
|
}
|
|
|
|
export async function toggleVariantPin(variantId: number) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
const [v] = await db.select({ isPinned: frameVariants.isPinned }).from(frameVariants).where(eq(frameVariants.id, variantId)).limit(1);
|
|
if (!v) return;
|
|
await db.update(frameVariants).set({ isPinned: !v.isPinned }).where(eq(frameVariants.id, variantId));
|
|
}
|
|
|
|
export async function renameVariant(variantId: number, label: string | null) {
|
|
const db = await getDb();
|
|
if (!db) return;
|
|
await db.update(frameVariants).set({ label }).where(eq(frameVariants.id, variantId));
|
|
}
|