459 lines
16 KiB
TypeScript
459 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 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) => r.value!).filter(Boolean).sort(),
|
|
niveaux: niveaux.map((r) => r.value!).filter(Boolean).sort(),
|
|
territoires: territoires.map((r) => 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) => r.value!).filter(Boolean).sort(),
|
|
departements: departements.map((r) => 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 => l.status === 'success').length;
|
|
const errors = allLogs.filter(l => l.status === 'error').length;
|
|
const totalNewRows = allLogs.reduce((sum, l) => 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;
|
|
}
|