Files
manus-admin aab11c8308 Initial commit: itinova-podcasts v1
Stack: Node.js/Express + React/Vite + tRPC + MySQL (Drizzle ORM)
Features: Gestion de podcasts, établissements, mots-clés, upload audio S3
Migrations: 0000-0002 (users, etablissements, mots_cles, podcasts, podcast_mots_cles)
2026-04-12 18:34:56 -04:00

381 lines
12 KiB
TypeScript

import { and, desc, eq, inArray, like, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import {
etablissements,
InsertEtablissement,
InsertMotCle,
InsertPodcast,
InsertUser,
motsCles,
podcastMotsCles,
podcasts,
users,
} 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", "loginMethod"] as const;
textFields.forEach((field) => {
const value = user[field];
if (value === undefined) return;
const normalized = value ?? null;
values[field] = normalized;
updateSet[field] = normalized;
});
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[0];
}
export async function getAllUsers() {
const db = await getDb();
if (!db) return [];
return db.select().from(users).orderBy(desc(users.createdAt));
}
export async function updateUserRole(userId: number, role: "user" | "admin") {
const db = await getDb();
if (!db) return;
// Protect immutable accounts
const target = await db.select().from(users).where(eq(users.id, userId)).limit(1);
if (target[0]?.immutable) throw new Error("Ce compte est immuable et ne peut pas être modifié");
await db.update(users).set({ role }).where(eq(users.id, userId));
}
export async function getUserByUsername(username: string) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.username, username)).limit(1);
return result[0];
}
export async function createLocalUser(data: {
username: string;
passwordHash: string;
name: string;
role: "user" | "admin";
immutable?: boolean;
openId: string;
}) {
const db = await getDb();
if (!db) throw new Error("DB not available");
await db.insert(users).values({
openId: data.openId,
username: data.username,
passwordHash: data.passwordHash,
name: data.name,
role: data.role,
immutable: data.immutable ?? false,
loginMethod: "local",
lastSignedIn: new Date(),
}).onDuplicateKeyUpdate({
set: { name: data.name, role: data.role },
});
}
export async function updateUser(
id: number,
data: { name?: string; username?: string; passwordHash?: string; role?: "user" | "admin" }
) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const target = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!target[0]) throw new Error("Utilisateur introuvable");
if (target[0].immutable) throw new Error("Ce compte est immuable et ne peut pas être modifié");
const updateData: Record<string, unknown> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.username !== undefined) updateData.username = data.username;
if (data.passwordHash !== undefined) updateData.passwordHash = data.passwordHash;
if (data.role !== undefined) updateData.role = data.role;
if (Object.keys(updateData).length > 0) {
await db.update(users).set(updateData).where(eq(users.id, id));
}
}
export async function deleteUser(id: number) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const target = await db.select().from(users).where(eq(users.id, id)).limit(1);
if (!target[0]) throw new Error("Utilisateur introuvable");
if (target[0].immutable) throw new Error("Ce compte est immuable et ne peut pas être supprimé");
await db.delete(users).where(eq(users.id, id));
}
export async function getUserById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(users).where(eq(users.id, id)).limit(1);
return result[0];
}
// ─── Établissements ────────────────────────────────────────────────────────────
export async function getAllEtablissements() {
const db = await getDb();
if (!db) return [];
return db.select().from(etablissements).orderBy(etablissements.nom);
}
export async function getEtablissementById(id: number) {
const db = await getDb();
if (!db) return undefined;
const result = await db.select().from(etablissements).where(eq(etablissements.id, id)).limit(1);
return result[0];
}
export async function createEtablissement(data: InsertEtablissement) {
const db = await getDb();
if (!db) throw new Error("DB not available");
const result = await db.insert(etablissements).values(data);
return result[0];
}
export async function updateEtablissement(id: number, data: Partial<InsertEtablissement>) {
const db = await getDb();
if (!db) throw new Error("DB not available");
await db.update(etablissements).set(data).where(eq(etablissements.id, id));
}
export async function deleteEtablissement(id: number) {
const db = await getDb();
if (!db) throw new Error("DB not available");
await db.delete(etablissements).where(eq(etablissements.id, id));
}
// ─── Mots-clés ─────────────────────────────────────────────────────────────────
export async function getAllMotsCles() {
const db = await getDb();
if (!db) return [];
return db.select().from(motsCles).orderBy(motsCles.label);
}
export async function createMotCle(data: InsertMotCle) {
const db = await getDb();
if (!db) throw new Error("DB not available");
await db.insert(motsCles).values(data);
}
export async function deleteMotCle(id: number) {
const db = await getDb();
if (!db) throw new Error("DB not available");
await db.delete(motsCles).where(eq(motsCles.id, id));
}
// ─── Podcasts ──────────────────────────────────────────────────────────────────
export interface PodcastWithRelations {
id: number;
titre: string;
resume: string;
etablissementId: number;
etablissementNom: string;
audioUrl: string | null;
audioKey: string | null;
dureeSecondes: number | null;
statut: "brouillon" | "publie";
auteurId: number | null;
auteurNom: string | null;
imageUrl: string | null;
createdAt: Date;
updatedAt: Date;
motsCles: { id: number; label: string }[];
}
export async function getPodcasts(opts?: {
etablissementId?: number;
motCleIds?: number[];
search?: string;
statut?: "brouillon" | "publie";
auteurId?: number;
}): Promise<PodcastWithRelations[]> {
const db = await getDb();
if (!db) return [];
const conditions = [];
if (opts?.etablissementId) conditions.push(eq(podcasts.etablissementId, opts.etablissementId));
if (opts?.statut) conditions.push(eq(podcasts.statut, opts.statut));
if (opts?.auteurId) conditions.push(eq(podcasts.auteurId, opts.auteurId));
if (opts?.search) {
conditions.push(
or(
like(podcasts.titre, `%${opts.search}%`),
like(podcasts.resume, `%${opts.search}%`)
)
);
}
const rows = await db
.select({
id: podcasts.id,
titre: podcasts.titre,
resume: podcasts.resume,
etablissementId: podcasts.etablissementId,
etablissementNom: etablissements.nom,
audioUrl: podcasts.audioUrl,
audioKey: podcasts.audioKey,
dureeSecondes: podcasts.dureeSecondes,
statut: podcasts.statut,
auteurId: podcasts.auteurId,
auteurNom: users.name,
imageUrl: podcasts.imageUrl,
createdAt: podcasts.createdAt,
updatedAt: podcasts.updatedAt,
})
.from(podcasts)
.leftJoin(etablissements, eq(podcasts.etablissementId, etablissements.id))
.leftJoin(users, eq(podcasts.auteurId, users.id))
.where(conditions.length > 0 ? and(...conditions) : undefined)
.orderBy(desc(podcasts.createdAt));
// Charger les mots-clés pour chaque podcast
const podcastIds = rows.map((r) => r.id);
let keywordsMap: Map<number, { id: number; label: string }[]> = new Map();
if (podcastIds.length > 0) {
const kwRows = await db
.select({
podcastId: podcastMotsCles.podcastId,
motCleId: motsCles.id,
label: motsCles.label,
})
.from(podcastMotsCles)
.innerJoin(motsCles, eq(podcastMotsCles.motCleId, motsCles.id))
.where(inArray(podcastMotsCles.podcastId, podcastIds));
for (const kw of kwRows) {
if (!keywordsMap.has(kw.podcastId)) keywordsMap.set(kw.podcastId, []);
keywordsMap.get(kw.podcastId)!.push({ id: kw.motCleId, label: kw.label });
}
}
let result = rows.map((r) => ({
...r,
etablissementNom: r.etablissementNom ?? "",
auteurNom: r.auteurNom ?? null,
motsCles: keywordsMap.get(r.id) ?? [],
}));
// Filtrer par mots-clés si demandé
if (opts?.motCleIds && opts.motCleIds.length > 0) {
result = result.filter((p) =>
opts.motCleIds!.some((id) => p.motsCles.some((mk) => mk.id === id))
);
}
return result;
}
export async function getPodcastById(id: number): Promise<PodcastWithRelations | undefined> {
const results = await getPodcasts();
return results.find((p) => p.id === id);
}
export async function createPodcast(
data: InsertPodcast,
motCleIds: number[]
): Promise<number> {
const db = await getDb();
if (!db) throw new Error("DB not available");
const result = await db.insert(podcasts).values(data);
const insertId = (result[0] as any).insertId as number;
if (motCleIds.length > 0) {
await db.insert(podcastMotsCles).values(
motCleIds.map((motCleId) => ({ podcastId: insertId, motCleId }))
);
}
return insertId;
}
export async function updatePodcast(
id: number,
data: Partial<InsertPodcast>,
motCleIds?: number[]
) {
const db = await getDb();
if (!db) throw new Error("DB not available");
await db.update(podcasts).set(data).where(eq(podcasts.id, id));
if (motCleIds !== undefined) {
await db.delete(podcastMotsCles).where(eq(podcastMotsCles.podcastId, id));
if (motCleIds.length > 0) {
await db.insert(podcastMotsCles).values(
motCleIds.map((motCleId) => ({ podcastId: id, motCleId }))
);
}
}
}
export async function deletePodcast(id: number) {
const db = await getDb();
if (!db) throw new Error("DB not available");
await db.delete(podcasts).where(eq(podcasts.id, id));
}
export async function getPodcastStats() {
const db = await getDb();
if (!db) return { total: 0, publies: 0, brouillons: 0, etablissements: 0 };
const [totalRow] = await db.select({ count: sql<number>`count(*)` }).from(podcasts);
const [publiesRow] = await db
.select({ count: sql<number>`count(*)` })
.from(podcasts)
.where(eq(podcasts.statut, "publie"));
const [etabRow] = await db.select({ count: sql<number>`count(*)` }).from(etablissements);
const total = Number(totalRow?.count ?? 0);
const publies = Number(publiesRow?.count ?? 0);
return {
total,
publies,
brouillons: total - publies,
etablissements: Number(etabRow?.count ?? 0),
};
}