retrotoon-studio/server/db.ts
Ubuntu 69b3d7c074 feat(M1): Bibliothèque de versions non-destructive
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>
2026-05-21 06:35:45 +00:00

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));
}