Files
veille-reglementaire/server/db.ts

460 lines
16 KiB
TypeScript

import { eq, desc, and, like, gte, lte, or, sql } from "drizzle-orm";
import { drizzle } from "drizzle-orm/mysql2";
import mysql from "mysql2/promise";
import {
InsertUser,
users,
localUsers,
veilleItems,
aapItems,
appSettings,
importLogs,
InsertLocalUser,
ideas,
InsertIdea,
rssFeeds,
rssSettings,
type InsertRssFeed,
type InsertRssSettings,
type ImportLog,
type RssFeed,
type RssSettings,
} from "../drizzle/schema";
import { ENV } from "./_core/env";
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let _db: any = null;
export async function getDb() {
if (!_db && process.env.DATABASE_URL) {
try {
const pool = mysql.createPool({
uri: process.env.DATABASE_URL,
waitForConnections: true,
connectionLimit: 10,
enableKeepAlive: true,
});
_db = drizzle(pool);
} catch (error) {
console.warn("[Database] Failed to connect:", error);
_db = null;
}
}
return _db;
}
// ─── Users (Manus OAuth) ─────────────────────────────────────────────────────
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) { console.warn("[Database] Cannot upsert user: database not available"); return; }
const values: InsertUser = { openId: user.openId };
const updateSet: Record<string, unknown> = {};
const textFields = ["name", "email", "loginMethod"] as const;
for (const field of textFields) {
const value = user[field];
if (value === undefined) continue;
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];
}
// ─── Local Users ─────────────────────────────────────────────────────────────
export async function getLocalUsers() {
const db = await getDb();
if (!db) return [];
return db
.select({
id: localUsers.id,
name: localUsers.name,
username: localUsers.username,
email: localUsers.email,
role: localUsers.role,
isActive: localUsers.isActive,
createdAt: localUsers.createdAt,
lastSignedIn: localUsers.lastSignedIn,
})
.from(localUsers)
.orderBy(desc(localUsers.createdAt));
}
export async function createLocalUser(data: Omit<InsertLocalUser, "id" | "createdAt" | "updatedAt">) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.insert(localUsers).values(data);
}
export async function updateLocalUser(id: number, data: Partial<InsertLocalUser>) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.update(localUsers).set(data).where(eq(localUsers.id, id));
}
export async function deleteLocalUser(id: number) {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db.delete(localUsers).where(eq(localUsers.id, id));
}
// ─── Veille Items ─────────────────────────────────────────────────────────────
export interface VeilleFilters {
typeVeille?: string;
categorie?: string;
niveau?: string;
territoire?: string;
search?: string;
dateFrom?: Date;
dateTo?: Date;
page?: number;
pageSize?: number;
}
export async function getVeilleItems(filters: VeilleFilters = {}) {
const db = await getDb();
if (!db) return { items: [], total: 0 };
const { page = 1, pageSize = 50, ...f } = filters;
const offset = (page - 1) * pageSize;
const conditions = [];
if (f.typeVeille) conditions.push(eq(veilleItems.typeVeille, f.typeVeille as "reglementaire" | "concurrentielle" | "technologique" | "generale"));
if (f.categorie) conditions.push(like(veilleItems.categorie, `%${f.categorie}%`));
if (f.niveau) conditions.push(like(veilleItems.niveau, `%${f.niveau}%`));
if (f.territoire) conditions.push(like(veilleItems.territoire, `%${f.territoire}%`));
if (f.search) {
conditions.push(
or(
like(veilleItems.titre, `%${f.search}%`),
like(veilleItems.resume, `%${f.search}%`)
)
);
}
if (f.dateFrom) conditions.push(gte(veilleItems.datePublication, f.dateFrom));
if (f.dateTo) conditions.push(lte(veilleItems.datePublication, f.dateTo));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [items, countResult] = await Promise.all([
db
.select()
.from(veilleItems)
.where(where)
.orderBy(desc(veilleItems.datePublication), desc(veilleItems.importedAt))
.limit(pageSize)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(veilleItems)
.where(where),
]);
return { items, total: Number(countResult[0]?.count ?? 0) };
}
export async function getVeilleDistinctValues() {
const db = await getDb();
if (!db) return { categories: [], niveaux: [], territoires: [] };
const [cats, niveaux, territoires] = await Promise.all([
db.selectDistinct({ value: veilleItems.categorie }).from(veilleItems).where(sql`${veilleItems.categorie} IS NOT NULL`),
db.selectDistinct({ value: veilleItems.niveau }).from(veilleItems).where(sql`${veilleItems.niveau} IS NOT NULL`),
db.selectDistinct({ value: veilleItems.territoire }).from(veilleItems).where(sql`${veilleItems.territoire} IS NOT NULL`),
]);
return {
categories: cats.map((r: { value: string | null }) => r.value!).filter(Boolean).sort(),
niveaux: niveaux.map((r: { value: string | null }) => r.value!).filter(Boolean).sort(),
territoires: territoires.map((r: { value: string | null }) => r.value!).filter(Boolean).sort(),
};
}
// ─── AAP Items ────────────────────────────────────────────────────────────────
export interface AapFilters {
categorie?: string;
region?: string;
departement?: string;
search?: string;
dateFrom?: Date;
dateTo?: Date;
clotureFrom?: Date;
clotureTo?: Date;
page?: number;
pageSize?: number;
}
export async function getAapItems(filters: AapFilters = {}) {
const db = await getDb();
if (!db) return { items: [], total: 0 };
const { page = 1, pageSize = 50, ...f } = filters;
const offset = (page - 1) * pageSize;
const conditions = [];
if (f.categorie) conditions.push(eq(aapItems.categorie, f.categorie as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"));
if (f.region) conditions.push(like(aapItems.region, `%${f.region}%`));
if (f.departement) conditions.push(like(aapItems.departement, `%${f.departement}%`));
if (f.search) conditions.push(like(aapItems.titre, `%${f.search}%`));
if (f.dateFrom) conditions.push(gte(aapItems.datePublication, f.dateFrom));
if (f.dateTo) conditions.push(lte(aapItems.datePublication, f.dateTo));
if (f.clotureFrom) conditions.push(gte(aapItems.dateCloture, f.clotureFrom));
if (f.clotureTo) conditions.push(lte(aapItems.dateCloture, f.clotureTo));
const where = conditions.length > 0 ? and(...conditions) : undefined;
const [items, countResult] = await Promise.all([
db
.select()
.from(aapItems)
.where(where)
.orderBy(desc(aapItems.datePublication), desc(aapItems.importedAt))
.limit(pageSize)
.offset(offset),
db
.select({ count: sql<number>`count(*)` })
.from(aapItems)
.where(where),
]);
return { items, total: Number(countResult[0]?.count ?? 0) };
}
export async function getAapDistinctValues() {
const db = await getDb();
if (!db) return { regions: [], departements: [] };
const [regions, departements] = await Promise.all([
db.selectDistinct({ value: aapItems.region }).from(aapItems).where(sql`${aapItems.region} IS NOT NULL`),
db.selectDistinct({ value: aapItems.departement }).from(aapItems).where(sql`${aapItems.departement} IS NOT NULL`),
]);
return {
regions: regions.map((r: { value: string | null }) => r.value!).filter(Boolean).sort(),
departements: departements.map((r: { value: string | null }) => r.value!).filter(Boolean).sort(),
};
}
// ─── App Settings ─────────────────────────────────────────────────────────────
export async function getSetting(key: string): Promise<string | null> {
const db = await getDb();
if (!db) return null;
const rows = await db.select().from(appSettings).where(eq(appSettings.key, key)).limit(1);
return rows[0]?.value ?? null;
}
export async function getAllSettings(): Promise<Record<string, string>> {
const db = await getDb();
if (!db) return {};
const rows = await db.select().from(appSettings);
const map: Record<string, string> = {};
for (const r of rows) {
if (r.key && r.value !== null && r.value !== undefined) map[r.key] = r.value;
}
return map;
}
export async function setSetting(key: string, value: string): Promise<void> {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
await db
.insert(appSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
}
export async function setSettings(settings: Record<string, string>): Promise<void> {
const db = await getDb();
if (!db) throw new Error("DB unavailable");
for (const [key, value] of Object.entries(settings)) {
await db
.insert(appSettings)
.values({ key, value })
.onDuplicateKeyUpdate({ set: { value } });
}
}
// ─── Import Logs ──────────────────────────────────────────────────────────────
export async function getImportLogs(limit = 50) {
const db = await getDb();
if (!db) return [];
return db
.select()
.from(importLogs)
.orderBy(desc(importLogs.startedAt))
.limit(limit);
}
export async function getImportStats() {
const db = await getDb();
if (!db) return { totalVeille: 0, totalAap: 0, lastImport: null, total: 0, success: 0, errors: 0, totalNewRows: 0 };
const [veilleCount, aapCount, lastLog, allLogs] = await Promise.all([
db.select({ count: sql<number>`count(*)` }).from(veilleItems),
db.select({ count: sql<number>`count(*)` }).from(aapItems),
db.select().from(importLogs).orderBy(desc(importLogs.startedAt)).limit(1),
db.select().from(importLogs),
]);
const total = allLogs.length;
const success = allLogs.filter((l: ImportLog) => l.status === 'success').length;
const errors = allLogs.filter((l: ImportLog) => l.status === 'error').length;
const totalNewRows = allLogs.reduce((sum: number, l: ImportLog) => sum + (l.newRows ?? 0), 0);
return {
totalVeille: Number(veilleCount[0]?.count ?? 0),
totalAap: Number(aapCount[0]?.count ?? 0),
lastImport: lastLog[0] ?? null,
total,
success,
errors,
totalNewRows,
};
}
// ─── Boîte à idées ────────────────────────────────────────────────────────────
export async function createIdea(data: InsertIdea) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.insert(ideas).values(data);
}
export async function getAllIdeas() {
const db = await getDb();
if (!db) return [];
return db.select().from(ideas).orderBy(desc(ideas.createdAt));
}
export async function getIdeasByUser(userId: number) {
const db = await getDb();
if (!db) return [];
return db.select().from(ideas).where(eq(ideas.userId, userId)).orderBy(desc(ideas.createdAt));
}
export async function repondreIdea(
id: number,
reponseAdmin: string,
reponduPar: string,
statut: "ouvert" | "en_cours" | "resolu" | "ferme"
) {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(ideas).set({
reponseAdmin,
reponduPar,
reponduAt: new Date(),
statut,
}).where(eq(ideas.id, id));
}
export async function updateIdeaStatut(id: number, statut: "ouvert" | "en_cours" | "resolu" | "ferme") {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(ideas).set({ statut }).where(eq(ideas.id, id));
}
// ─── Flux RSS ────────────────────────────────────────────────────────────────────────────────────
export async function getRssFeeds(): Promise<RssFeed[]> {
const db = await getDb();
if (!db) return [];
return db.select().from(rssFeeds).orderBy(rssFeeds.name);
}
export async function getRssFeedById(id: number): Promise<RssFeed | null> {
const db = await getDb();
if (!db) return null;
const rows = await db.select().from(rssFeeds).where(eq(rssFeeds.id, id)).limit(1);
return rows[0] ?? null;
}
export async function createRssFeed(data: Omit<InsertRssFeed, "id" | "createdAt" | "updatedAt">): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.insert(rssFeeds).values(data);
return (result[0] as any).insertId as number;
}
export async function updateRssFeed(id: number, data: Partial<Omit<InsertRssFeed, "id" | "createdAt" | "updatedAt">>): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.update(rssFeeds).set(data).where(eq(rssFeeds.id, id));
}
export async function deleteRssFeed(id: number): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
await db.delete(rssFeeds).where(eq(rssFeeds.id, id));
}
export async function getRssSettings(): Promise<RssSettings | null> {
const db = await getDb();
if (!db) return null;
const rows = await db.select().from(rssSettings).limit(1);
if (rows.length > 0) return rows[0];
// Créer les paramètres par défaut si inexistants
await db.insert(rssSettings).values({
fetchIntervalMinutes: 360,
scheduledTime: "06:00",
fetchMode: "scheduled",
autoFetchEnabled: true,
});
const newRows = await db.select().from(rssSettings).limit(1);
return newRows[0] ?? null;
}
export async function saveRssSettings(data: Partial<Omit<InsertRssSettings, "id" | "updatedAt">>): Promise<void> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const existing = await db.select().from(rssSettings).limit(1);
if (existing.length > 0) {
await db.update(rssSettings).set(data).where(eq(rssSettings.id, existing[0].id));
} else {
await db.insert(rssSettings).values({
fetchIntervalMinutes: 360,
scheduledTime: "06:00",
fetchMode: "scheduled",
autoFetchEnabled: true,
...data,
});
}
}
// ─── Purge ───────────────────────────────────────────────────────────────────
export async function purgeVeilleItems(): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.delete(veilleItems);
return (result as any).affectedRows ?? 0;
}
export async function purgeAapItems(): Promise<number> {
const db = await getDb();
if (!db) throw new Error("Database not available");
const result = await db.delete(aapItems);
return (result as any).affectedRows ?? 0;
}