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)
381 lines
12 KiB
TypeScript
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),
|
|
};
|
|
}
|