import { eq, desc, and, sql, count, gte, lte, isNull, isNotNull, or, like, inArray } from "drizzle-orm"; import { drizzle } from "drizzle-orm/mysql2"; import { getAssociationCommuneVariants, normalizeAssociationCommune, type AssociationCommuneFilter } from "@shared/associationCommunes"; import { matchAssociationDirectoryEntry, normalizeDirectoryEmail, normalizeDirectoryRna, normalizeDirectorySiret, type AssociationDirectoryMatchInput, } from "./associationDirectoryMatcher"; import { InsertUser, users, associations, InsertAssociation, Association, associationDirectoryEntries, InsertAssociationDirectoryEntry, AssociationDirectoryEntry, associationDirectoryReviews, InsertAssociationDirectoryReview, AssociationDirectoryReview, associationDirectoryUpdateProposals, InsertAssociationDirectoryUpdateProposal, AssociationDirectoryUpdateProposal, associationInvitations, InsertAssociationInvitation, AssociationInvitation, documents, InsertDocument, Document, requests, InsertRequest, Request, requestTemplates, InsertRequestTemplate, RequestTemplate, requestHistory, InsertRequestHistory, RequestHistory, responseTemplates, InsertResponseTemplate, ResponseTemplate, auditLog, InsertAuditLog, AuditLog, adminNotifications, InsertAdminNotification, AdminNotification, portalSettings, InsertPortalSetting, PortalSetting, operationalRecapServices, InsertOperationalRecapService, OperationalRecapService, materialReturnFollowups, InsertMaterialReturnFollowup, MaterialReturnFollowup, emailActionTokens, InsertEmailActionToken, EmailActionToken } from "../drizzle/schema"; import { ENV } from './_core/env'; let _db: ReturnType | null = null; export function isDatabaseConfigured() { return Boolean(process.env.DATABASE_URL); } 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; } // ============== USER FUNCTIONS ============== export async function upsertUser(user: InsertUser): Promise { 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; } try { const values: InsertUser = { openId: user.openId, }; const updateSet: Record = {}; 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, }); } catch (error) { console.error("[Database] Failed to upsert user:", error); throw error; } } export async function getUserByOpenId(openId: string) { const db = await getDb(); if (!db) { console.warn("[Database] Cannot get user: database not available"); 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; } 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.length > 0 ? result[0] : undefined; } export async function getUserByMfaChallengeToken(token: string) { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(users).where(eq(users.mfaChallengeToken, token)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function getUsersScheduledForPurge(referenceDate: Date = new Date()) { const db = await getDb(); if (!db) return []; return db.select().from(users).where( and( isNotNull(users.purgeScheduledAt), lte(users.purgeScheduledAt, referenceDate), isNull(users.purgedAt), eq(users.legalHold, false) ) ); } export async function getAllUsers() { const db = await getDb(); if (!db) return []; return db.select().from(users).orderBy(desc(users.createdAt)); } export async function getAdminUsers() { const db = await getDb(); if (!db) return []; return db.select().from(users).where( or( eq(users.role, 'accueil'), eq(users.role, 'service_terrain'), eq(users.role, 'logistique_controle'), eq(users.role, 'admin'), eq(users.role, 'directrice'), eq(users.role, 'super_admin') ) ).orderBy(desc(users.createdAt)); } export async function updateUser(id: number, data: Partial): Promise { const db = await getDb(); if (!db) return; await db.update(users).set(data).where(eq(users.id, id)); } export async function deleteUser(id: number): Promise { const db = await getDb(); if (!db) return; await db.delete(users).where(eq(users.id, id)); } // ============== ASSOCIATION FUNCTIONS ============== export async function getAssociationByUserId(userId: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(associations).where(eq(associations.userId, userId)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function getAssociationById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(associations).where(eq(associations.id, id)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function getAssociationBySourceDirectoryEntryId(sourceDirectoryEntryId: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select() .from(associations) .where(eq(associations.sourceDirectoryEntryId, sourceDirectoryEntryId)) .limit(1); return result.length > 0 ? result[0] : undefined; } export async function createAssociation(data: InsertAssociation): Promise { const db = await getDb(); if (!db) throw new Error("Mode demo actif : la base de donnees n'est pas configuree."); const result = await db.insert(associations).values(data); return result[0].insertId; } export async function updateAssociation(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Mode demo actif : la base de donnees n'est pas configuree."); await db.update(associations).set(data).where(eq(associations.id, id)); } export async function getAllAssociations(): Promise { const db = await getDb(); if (!db) return []; return db.select().from(associations).orderBy(desc(associations.createdAt)); } export async function searchAssociations(params: { search?: string; ville?: string; profileComplete?: boolean; isActive?: boolean; limit?: number; offset?: number; }): Promise<{ data: Association[]; total: number }> { const db = await getDb(); if (!db) return { data: [], total: 0 }; const conditions = []; if (params.search) { conditions.push( or( like(associations.nomAssociation, `%${params.search}%`), like(associations.siret, `%${params.search}%`), like(associations.ville, `%${params.search}%`) ) ); } if (params.ville) { const communeVariants = getAssociationCommuneVariants(params.ville); if (communeVariants.length > 0) { conditions.push(or(...communeVariants.map(variant => eq(associations.ville, variant)))); } else { conditions.push(eq(associations.ville, params.ville)); } } if (params.profileComplete !== undefined) { conditions.push(eq(associations.profileComplete, params.profileComplete)); } if (params.isActive !== undefined) { conditions.push(eq(associations.isActive, params.isActive)); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; const [data, totalResult] = await Promise.all([ db.select() .from(associations) .where(whereClause) .orderBy(desc(associations.createdAt)) .limit(params.limit || 20) .offset(params.offset || 0), db.select({ count: count() }) .from(associations) .where(whereClause) ]); return { data, total: totalResult[0]?.count || 0 }; } export async function toggleAssociationStatus(id: number, isActive: boolean): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(associations).set({ isActive }).where(eq(associations.id, id)); } export async function getAssociationCommuneCounts(): Promise> { const db = await getDb(); const counts: Record = { all: 0, kourou: 0, sinnamary: 0, iracoubo: 0, saint_elie: 0, }; if (!db) { return counts; } const rows = await db.select({ ville: associations.ville }).from(associations); counts.all = rows.length; rows.forEach((row) => { const commune = normalizeAssociationCommune(row.ville); if (commune !== "all") { counts[commune] += 1; } }); return counts; } // ============== ASSOCIATION DIRECTORY FUNCTIONS ============== export async function getAssociationDirectoryEntryByNormalizedEmail(email: string): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select() .from(associationDirectoryEntries) .where(eq(associationDirectoryEntries.emailOfficielNormalise, email)) .limit(1); return result.length > 0 ? result[0] : undefined; } export async function getAssociationDirectoryEntryById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select() .from(associationDirectoryEntries) .where(eq(associationDirectoryEntries.id, id)) .limit(1); return result.length > 0 ? result[0] : undefined; } export async function getAssociationDirectoryEntryByFingerprint(sourceFingerprint: string): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select() .from(associationDirectoryEntries) .where(eq(associationDirectoryEntries.sourceFingerprint, sourceFingerprint)) .limit(1); return result.length > 0 ? result[0] : undefined; } export async function getAssociationDirectoryEntriesSummary() { const db = await getDb(); if (!db) { return { total: 0, withEmail: 0, withoutEmail: 0, registered: 0, unregistered: 0, lastImportAt: null as Date | null, }; } const [totalResult, withEmailResult, withoutEmailResult, registeredResult, latest] = await Promise.all([ db.select({ count: count() }).from(associationDirectoryEntries), db.select({ count: count() }).from(associationDirectoryEntries).where(sql`${associationDirectoryEntries.emailOfficielNormalise} is not null`), db.select({ count: count() }).from(associationDirectoryEntries).where(sql`${associationDirectoryEntries.emailOfficielNormalise} is null`), db.select({ count: count() }) .from(associationDirectoryEntries) .innerJoin(associations, eq(associations.sourceDirectoryEntryId, associationDirectoryEntries.id)), db.select().from(associationDirectoryEntries).orderBy(desc(associationDirectoryEntries.importedAt)).limit(1), ]); const total = totalResult[0]?.count || 0; const registered = registeredResult[0]?.count || 0; return { total, withEmail: withEmailResult[0]?.count || 0, withoutEmail: withoutEmailResult[0]?.count || 0, registered, unregistered: Math.max(total - registered, 0), lastImportAt: latest[0]?.importedAt || null, }; } export async function listAssociationDirectoryEntries(limit = 50): Promise { const db = await getDb(); if (!db) return []; return db.select() .from(associationDirectoryEntries) .orderBy(desc(associationDirectoryEntries.updatedAt)) .limit(limit); } export async function listAssociationDirectoryEntriesWithStatus(params?: { search?: string; registrationStatus?: "all" | "registered" | "unregistered"; commune?: AssociationCommuneFilter; thematique?: "all" | (typeof import("@shared/associationThematics").associationThematicValues)[number]; limit?: number; }) { const db = await getDb(); if (!db) return { data: [], total: 0 }; const conditions = []; if (params?.search) { conditions.push( or( like(associationDirectoryEntries.nomAssociation, `%${params.search}%`), like(associationDirectoryEntries.emailOfficiel, `%${params.search}%`), like(associationDirectoryEntries.siret, `%${params.search}%`), like(associationDirectoryEntries.rna, `%${params.search}%`), like(associationDirectoryEntries.ville, `%${params.search}%`) ) ); } if (params?.commune && params.commune !== "all") { const communeVariants = getAssociationCommuneVariants(params.commune); if (communeVariants.length > 0) { conditions.push(or(...communeVariants.map((variant) => eq(associationDirectoryEntries.ville, variant)))); } } if (params?.thematique && params.thematique !== "all") { conditions.push(like(associationDirectoryEntries.thematique, `%\"${params.thematique}\"%`)); } if (params?.registrationStatus === "registered") { conditions.push(isNotNull(associations.id)); } else if (params?.registrationStatus === "unregistered") { conditions.push(isNull(associations.id)); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; const [rows, totalResult] = await Promise.all([ db .select({ id: associationDirectoryEntries.id, nomAssociation: associationDirectoryEntries.nomAssociation, emailOfficiel: associationDirectoryEntries.emailOfficiel, siret: associationDirectoryEntries.siret, rna: associationDirectoryEntries.rna, thematique: associationDirectoryEntries.thematique, adresse: associationDirectoryEntries.adresse, codePostal: associationDirectoryEntries.codePostal, ville: associationDirectoryEntries.ville, latitude: associationDirectoryEntries.latitude, longitude: associationDirectoryEntries.longitude, geoSource: associationDirectoryEntries.geoSource, geoPrecision: associationDirectoryEntries.geoPrecision, siteWeb: associationDirectoryEntries.siteWeb, facebookUrl: associationDirectoryEntries.facebookUrl, instagramUrl: associationDirectoryEntries.instagramUrl, importedAt: associationDirectoryEntries.importedAt, updatedAt: associationDirectoryEntries.updatedAt, sourceFileName: associationDirectoryEntries.sourceFileName, externalSourceStatus: associationDirectoryEntries.externalSourceStatus, externalSourceLabel: associationDirectoryEntries.externalSourceLabel, registeredAssociationId: associations.id, registeredAssociationName: associations.nomAssociation, registeredAt: associations.createdAt, }) .from(associationDirectoryEntries) .leftJoin(associations, eq(associations.sourceDirectoryEntryId, associationDirectoryEntries.id)) .where(whereClause) .orderBy(desc(associationDirectoryEntries.updatedAt)) .limit(params?.limit || 50), db .select({ count: count() }) .from(associationDirectoryEntries) .leftJoin(associations, eq(associations.sourceDirectoryEntryId, associationDirectoryEntries.id)) .where(whereClause), ]); return { data: rows.map((row) => ({ ...row, registered: Boolean(row.registeredAssociationId), })), total: totalResult[0]?.count || 0, }; } export async function upsertAssociationDirectoryEntry(data: InsertAssociationDirectoryEntry): Promise<"created" | "updated"> { const db = await getDb(); if (!db) throw new Error("Database not available"); const existing = data.emailOfficielNormalise ? await getAssociationDirectoryEntryByNormalizedEmail(data.emailOfficielNormalise) : data.sourceFingerprint ? await getAssociationDirectoryEntryByFingerprint(data.sourceFingerprint) : undefined; if (existing) { await db.update(associationDirectoryEntries) .set({ ...data, importedAt: new Date() }) .where(eq(associationDirectoryEntries.id, existing.id)); return "updated"; } await db.insert(associationDirectoryEntries).values(data); return "created"; } export async function updateAssociationDirectoryEntry(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(associationDirectoryEntries).set(data).where(eq(associationDirectoryEntries.id, id)); } export async function createAssociationDirectoryEntry(data: InsertAssociationDirectoryEntry): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(associationDirectoryEntries).values(data); return result[0].insertId; } export async function listAssociationDirectoryEntriesForMatching() { const db = await getDb(); if (!db) return []; return db .select({ id: associationDirectoryEntries.id, nomAssociation: associationDirectoryEntries.nomAssociation, emailOfficiel: associationDirectoryEntries.emailOfficiel, siret: associationDirectoryEntries.siret, rna: associationDirectoryEntries.rna, ville: associationDirectoryEntries.ville, telephone: associationDirectoryEntries.telephone, nomRepresentant: associationDirectoryEntries.nomRepresentant, }) .from(associationDirectoryEntries) .where(eq(associationDirectoryEntries.isActive, true)); } export async function findAssociationDirectoryMatch(input: AssociationDirectoryMatchInput) { const entries = await listAssociationDirectoryEntriesForMatching(); return matchAssociationDirectoryEntry(entries, input); } export async function createAssociationDirectoryReview(data: InsertAssociationDirectoryReview): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(associationDirectoryReviews).values(data); return result[0].insertId; } export async function getAssociationDirectoryReviewById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(associationDirectoryReviews).where(eq(associationDirectoryReviews.id, id)).limit(1); return result[0]; } export async function getPendingAssociationDirectoryReviewByUserId(userId: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(associationDirectoryReviews) .where(and(eq(associationDirectoryReviews.userId, userId), eq(associationDirectoryReviews.status, "pending"))) .orderBy(desc(associationDirectoryReviews.createdAt)) .limit(1); return result[0]; } export async function updateAssociationDirectoryReview(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(associationDirectoryReviews).set(data).where(eq(associationDirectoryReviews.id, id)); } export async function listAssociationDirectoryReviews(params?: { status?: "pending" | "linked" | "created" | "ignored"; sourceType?: "portal_signup" | "helloasso"; limit?: number; }) { const db = await getDb(); if (!db) return []; const conditions = []; if (params?.status) { conditions.push(eq(associationDirectoryReviews.status, params.status)); } if (params?.sourceType) { conditions.push(eq(associationDirectoryReviews.sourceType, params.sourceType)); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; return db.select().from(associationDirectoryReviews) .where(whereClause) .orderBy(desc(associationDirectoryReviews.createdAt)) .limit(params?.limit || 50); } export async function createAssociationDirectoryUpdateProposal(data: InsertAssociationDirectoryUpdateProposal): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(associationDirectoryUpdateProposals).values(data); return result[0].insertId; } export async function updateAssociationDirectoryUpdateProposal( id: number, data: Partial ): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(associationDirectoryUpdateProposals).set(data).where(eq(associationDirectoryUpdateProposals.id, id)); } export async function getAssociationDirectoryUpdateProposalById( id: number ): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(associationDirectoryUpdateProposals) .where(eq(associationDirectoryUpdateProposals.id, id)) .limit(1); return result[0]; } export async function getLatestPendingAssociationDirectoryUpdateProposalByEntryId( directoryEntryId: number, sourceType?: "official_registry" | "helloasso" ): Promise { const db = await getDb(); if (!db) return undefined; const conditions = [ eq(associationDirectoryUpdateProposals.directoryEntryId, directoryEntryId), eq(associationDirectoryUpdateProposals.status, "pending"), ]; if (sourceType) { conditions.push(eq(associationDirectoryUpdateProposals.sourceType, sourceType)); } const result = await db.select().from(associationDirectoryUpdateProposals) .where(and(...conditions)) .orderBy(desc(associationDirectoryUpdateProposals.createdAt)) .limit(1); return result[0]; } export async function listAssociationDirectoryUpdateProposals(params?: { directoryEntryId?: number; status?: "pending" | "applied" | "dismissed"; sourceType?: "official_registry" | "helloasso"; limit?: number; }) { const db = await getDb(); if (!db) return []; const conditions = []; if (params?.directoryEntryId) { conditions.push(eq(associationDirectoryUpdateProposals.directoryEntryId, params.directoryEntryId)); } if (params?.status) { conditions.push(eq(associationDirectoryUpdateProposals.status, params.status)); } if (params?.sourceType) { conditions.push(eq(associationDirectoryUpdateProposals.sourceType, params.sourceType)); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; return db.select().from(associationDirectoryUpdateProposals) .where(whereClause) .orderBy(desc(associationDirectoryUpdateProposals.createdAt)) .limit(params?.limit || 50); } export async function getAssociationDirectoryEntryDetails(id: number) { const entry = await getAssociationDirectoryEntryById(id); if (!entry) return null; const linkedAssociation = await getAssociationBySourceDirectoryEntryId(id); if (!linkedAssociation) { return { entry, association: null, documents: [], requests: [], }; } const [documents, requests] = await Promise.all([ getDocumentsByAssociationId(linkedAssociation.id), getRequestsByAssociationId(linkedAssociation.id), ]); return { entry, association: linkedAssociation, documents, requests, }; } export async function listAssociationDirectoryMapEntries(params?: { search?: string; commune?: AssociationCommuneFilter; registrationStatus?: "all" | "registered" | "unregistered"; thematique?: "all" | (typeof import("@shared/associationThematics").associationThematicValues)[number]; limit?: number; }) { const db = await getDb(); if (!db) return { data: [], total: 0 }; const conditions: Array = [ isNotNull(associationDirectoryEntries.latitude), isNotNull(associationDirectoryEntries.longitude), ]; if (params?.search) { conditions.push( or( like(associationDirectoryEntries.nomAssociation, `%${params.search}%`), like(associationDirectoryEntries.siret, `%${params.search}%`), like(associationDirectoryEntries.rna, `%${params.search}%`), like(associationDirectoryEntries.ville, `%${params.search}%`), like(associationDirectoryEntries.objetAssociation, `%${params.search}%`) ) ); } if (params?.commune && params.commune !== "all") { const communeVariants = getAssociationCommuneVariants(params.commune); if (communeVariants.length > 0) { conditions.push(or(...communeVariants.map((variant) => eq(associationDirectoryEntries.ville, variant)))); } } if (params?.thematique && params.thematique !== "all") { conditions.push(like(associationDirectoryEntries.thematique, `%\"${params.thematique}\"%`)); } if (params?.registrationStatus === "registered") { conditions.push(isNotNull(associations.id)); } else if (params?.registrationStatus === "unregistered") { conditions.push(isNull(associations.id)); } const whereClause = and(...conditions); const [rows, totalResult] = await Promise.all([ db .select({ id: associationDirectoryEntries.id, nomAssociation: associationDirectoryEntries.nomAssociation, ville: associationDirectoryEntries.ville, thematique: associationDirectoryEntries.thematique, objetAssociation: associationDirectoryEntries.objetAssociation, siteWeb: associationDirectoryEntries.siteWeb, latitude: associationDirectoryEntries.latitude, longitude: associationDirectoryEntries.longitude, geoSource: associationDirectoryEntries.geoSource, geoPrecision: associationDirectoryEntries.geoPrecision, externalSourceLabel: associationDirectoryEntries.externalSourceLabel, registeredAssociationId: associations.id, registeredAt: associations.createdAt, }) .from(associationDirectoryEntries) .leftJoin(associations, eq(associations.sourceDirectoryEntryId, associationDirectoryEntries.id)) .where(whereClause) .orderBy(desc(associationDirectoryEntries.updatedAt)) .limit(params?.limit || 500), db .select({ count: count() }) .from(associationDirectoryEntries) .leftJoin(associations, eq(associations.sourceDirectoryEntryId, associationDirectoryEntries.id)) .where(whereClause), ]); return { data: rows.map((row) => ({ ...row, latitude: row.latitude ? Number(row.latitude) : null, longitude: row.longitude ? Number(row.longitude) : null, registered: Boolean(row.registeredAssociationId), publicUrl: `/associations/${row.id}`, })), total: totalResult[0]?.count || 0, }; } // ============== DOCUMENT FUNCTIONS ============== export async function getDocumentsByAssociationId(associationId: number): Promise { const db = await getDb(); if (!db) return []; return db.select().from(documents).where(eq(documents.associationId, associationId)).orderBy(desc(documents.uploadedAt)); } export async function getDocumentById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(documents).where(eq(documents.id, id)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function createDocument(data: InsertDocument): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(documents).values(data); return result[0].insertId; } export async function deleteDocument(id: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.delete(documents).where(eq(documents.id, id)); } // ============== REQUEST FUNCTIONS ============== export async function getRequestsByAssociationId(associationId: number): Promise { const db = await getDb(); if (!db) return []; return db.select().from(requests).where(eq(requests.associationId, associationId)).orderBy(desc(requests.createdAt)); } export async function getRequestById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(requests).where(eq(requests.id, id)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function createRequest(data: InsertRequest): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(requests).values(data); return result[0].insertId; } export async function updateRequest(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(requests).set(data).where(eq(requests.id, id)); } export async function getAllRequests(): Promise { const db = await getDb(); if (!db) return []; return db.select().from(requests).orderBy(desc(requests.createdAt)); } export async function getRequestsByStatus(status: string): Promise { const db = await getDb(); if (!db) return []; return db.select().from(requests).where(eq(requests.status, status as any)).orderBy(desc(requests.createdAt)); } export async function searchRequests(params: { search?: string; type?: string; status?: string; priority?: string; assigneA?: number; associationId?: number; dateFrom?: Date; dateTo?: Date; limit?: number; offset?: number; }): Promise<{ data: Request[]; total: number }> { const db = await getDb(); if (!db) return { data: [], total: 0 }; const conditions = []; if (params.search) { conditions.push(like(requests.titre, `%${params.search}%`)); } if (params.type) { conditions.push(eq(requests.type, params.type as any)); } if (params.status) { conditions.push(eq(requests.status, params.status as any)); } if (params.priority) { conditions.push(eq(requests.priority, params.priority as any)); } if (params.assigneA) { conditions.push(eq(requests.assigneA, params.assigneA)); } if (params.associationId) { conditions.push(eq(requests.associationId, params.associationId)); } if (params.dateFrom) { conditions.push(gte(requests.createdAt, params.dateFrom)); } if (params.dateTo) { conditions.push(lte(requests.createdAt, params.dateTo)); } const whereClause = conditions.length > 0 ? and(...conditions) : undefined; const [data, totalResult] = await Promise.all([ db.select() .from(requests) .where(whereClause) .orderBy(desc(requests.createdAt)) .limit(params.limit || 20) .offset(params.offset || 0), db.select({ count: count() }) .from(requests) .where(whereClause) ]); return { data, total: totalResult[0]?.count || 0 }; } export async function getPendingRequests(): Promise { const db = await getDb(); if (!db) return []; return db.select() .from(requests) .where(eq(requests.status, 'soumise')) .orderBy(requests.dateSubmission); } export async function getOverdueRequests(): Promise { const db = await getDb(); if (!db) return []; const now = new Date(); return db.select() .from(requests) .where( and( or(eq(requests.status, 'soumise'), eq(requests.status, 'en_cours_traitement')), lte(requests.dateLimiteTraitement, now) ) ) .orderBy(requests.dateLimiteTraitement); } export async function assignRequest(id: number, adminId: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(requests).set({ assigneA: adminId, status: 'en_cours_traitement' }).where(eq(requests.id, id)); } // ============== REQUEST HISTORY FUNCTIONS ============== export async function createRequestHistory(data: InsertRequestHistory): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(requestHistory).values(data); return result[0].insertId; } export async function getRequestHistoryByRequestId(requestId: number): Promise { const db = await getDb(); if (!db) return []; return db.select().from(requestHistory).where(eq(requestHistory.requestId, requestId)).orderBy(desc(requestHistory.createdAt)); } // ============== RESPONSE TEMPLATE FUNCTIONS ============== export async function getAllResponseTemplates(): Promise { const db = await getDb(); if (!db) return []; return db.select().from(responseTemplates).where(eq(responseTemplates.actif, true)).orderBy(responseTemplates.nom); } export async function getResponseTemplateById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(responseTemplates).where(eq(responseTemplates.id, id)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function createResponseTemplate(data: InsertResponseTemplate): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(responseTemplates).values(data); return result[0].insertId; } export async function updateResponseTemplate(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(responseTemplates).set(data).where(eq(responseTemplates.id, id)); } export async function deleteResponseTemplate(id: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(responseTemplates).set({ actif: false }).where(eq(responseTemplates.id, id)); } // ============== REQUEST TEMPLATE FUNCTIONS ============== export async function getAllRequestTemplates(): Promise { const db = await getDb(); if (!db) return []; return db.select().from(requestTemplates).where(eq(requestTemplates.actif, true)).orderBy(requestTemplates.nom); } export async function getRequestTemplateById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(requestTemplates).where(eq(requestTemplates.id, id)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function getRequestTemplateByType(type: string): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(requestTemplates).where(eq(requestTemplates.type, type as any)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function createRequestTemplate(data: InsertRequestTemplate): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(requestTemplates).values(data); return result[0].insertId; } export async function updateRequestTemplate(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(requestTemplates).set(data).where(eq(requestTemplates.id, id)); } // ============== AUDIT LOG FUNCTIONS ============== export async function createAuditLog(data: InsertAuditLog): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(auditLog).values(data); return result[0].insertId; } export async function getAuditLogs(params: { userId?: number; entityType?: string; entityId?: number; limit?: number; offset?: number; }): Promise<{ data: AuditLog[]; total: number }> { const db = await getDb(); if (!db) return { data: [], total: 0 }; const conditions = []; if (params.userId) conditions.push(eq(auditLog.userId, params.userId)); if (params.entityType) conditions.push(eq(auditLog.entityType, params.entityType)); if (params.entityId) conditions.push(eq(auditLog.entityId, params.entityId)); const whereClause = conditions.length > 0 ? and(...conditions) : undefined; const [data, totalResult] = await Promise.all([ db.select() .from(auditLog) .where(whereClause) .orderBy(desc(auditLog.createdAt)) .limit(params.limit || 50) .offset(params.offset || 0), db.select({ count: count() }) .from(auditLog) .where(whereClause) ]); return { data, total: totalResult[0]?.count || 0 }; } // ============== ADMIN NOTIFICATIONS FUNCTIONS ============== export async function createAdminNotification(data: InsertAdminNotification): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(adminNotifications).values(data); return result[0].insertId; } export async function getAdminNotifications(userId: number, unreadOnly: boolean = false): Promise { const db = await getDb(); if (!db) return []; const conditions = [ or(eq(adminNotifications.userId, userId), isNull(adminNotifications.userId)) ]; if (unreadOnly) { conditions.push(eq(adminNotifications.lu, false)); } return db.select() .from(adminNotifications) .where(and(...conditions)) .orderBy(desc(adminNotifications.createdAt)) .limit(50); } export async function markNotificationAsRead(id: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(adminNotifications).set({ lu: true }).where(eq(adminNotifications.id, id)); } export async function markAllNotificationsAsRead(userId: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(adminNotifications) .set({ lu: true }) .where( and( or(eq(adminNotifications.userId, userId), isNull(adminNotifications.userId)), eq(adminNotifications.lu, false) ) ); } export async function getUnreadNotificationCount(userId: number): Promise { const db = await getDb(); if (!db) return 0; const result = await db.select({ count: count() }) .from(adminNotifications) .where( and( or(eq(adminNotifications.userId, userId), isNull(adminNotifications.userId)), eq(adminNotifications.lu, false) ) ); return result[0]?.count || 0; } // ============== PORTAL SETTINGS FUNCTIONS ============== export async function getPortalSetting(key: string): Promise { const db = await getDb(); if (!db) return null; const result = await db.select().from(portalSettings).where(eq(portalSettings.cle, key)).limit(1); return result.length > 0 ? result[0].valeur : null; } export async function getPortalSettingRecord(key: string): Promise { const db = await getDb(); if (!db) return null; const result = await db.select().from(portalSettings).where(eq(portalSettings.cle, key)).limit(1); return result[0] ?? null; } export async function setPortalSetting(key: string, value: string, description?: string): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.insert(portalSettings) .values({ cle: key, valeur: value, description }) .onDuplicateKeyUpdate({ set: { valeur: value, description } }); } export async function getAllPortalSettings(): Promise { const db = await getDb(); if (!db) return []; return db.select().from(portalSettings).orderBy(portalSettings.cle); } // ============== OPERATIONAL RECAP SERVICES FUNCTIONS ============== export async function getAllOperationalRecapServices(): Promise { const db = await getDb(); if (!db) return []; return db.select().from(operationalRecapServices).orderBy(operationalRecapServices.label); } export async function getOperationalRecapServiceById(id: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(operationalRecapServices).where(eq(operationalRecapServices.id, id)).limit(1); return result[0]; } export async function createOperationalRecapService(data: InsertOperationalRecapService): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(operationalRecapServices).values(data); return result[0].insertId; } export async function updateOperationalRecapService(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(operationalRecapServices).set(data).where(eq(operationalRecapServices.id, id)); } export async function deleteOperationalRecapService(id: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.delete(operationalRecapServices).where(eq(operationalRecapServices.id, id)); } // ============== STATISTICS FUNCTIONS ============== export async function getDashboardStats() { const db = await getDb(); if (!db) return null; const now = new Date(); const thirtyDaysAgo = new Date(now.getTime() - 30 * 24 * 60 * 60 * 1000); const sevenDaysAgo = new Date(now.getTime() - 7 * 24 * 60 * 60 * 1000); const [ totalAssociations, activeAssociations, totalRequests, pendingRequests, validatedRequests, rejectedRequests, newAssociationsThisMonth, newRequestsThisMonth, requestsThisWeek, overdueRequests ] = await Promise.all([ db.select({ count: count() }).from(associations), db.select({ count: count() }).from(associations).where(eq(associations.isActive, true)), db.select({ count: count() }).from(requests), db.select({ count: count() }).from(requests).where(eq(requests.status, 'soumise')), db.select({ count: count() }).from(requests).where(eq(requests.status, 'validee')), db.select({ count: count() }).from(requests).where(eq(requests.status, 'refusee')), db.select({ count: count() }).from(associations).where(gte(associations.createdAt, thirtyDaysAgo)), db.select({ count: count() }).from(requests).where(gte(requests.createdAt, thirtyDaysAgo)), db.select({ count: count() }).from(requests).where(gte(requests.createdAt, sevenDaysAgo)), db.select({ count: count() }).from(requests).where( and( or(eq(requests.status, 'soumise'), eq(requests.status, 'en_cours_traitement')), lte(requests.dateLimiteTraitement, now) ) ) ]); const totalValidatedAndRejected = (validatedRequests[0]?.count || 0) + (rejectedRequests[0]?.count || 0); const acceptanceRate = totalValidatedAndRejected > 0 ? Math.round((validatedRequests[0]?.count || 0) / totalValidatedAndRejected * 100) : 0; return { totalAssociations: totalAssociations[0]?.count || 0, activeAssociations: activeAssociations[0]?.count || 0, totalRequests: totalRequests[0]?.count || 0, pendingRequests: pendingRequests[0]?.count || 0, validatedRequests: validatedRequests[0]?.count || 0, rejectedRequests: rejectedRequests[0]?.count || 0, newAssociationsThisMonth: newAssociationsThisMonth[0]?.count || 0, newRequestsThisMonth: newRequestsThisMonth[0]?.count || 0, requestsThisWeek: requestsThisWeek[0]?.count || 0, overdueRequests: overdueRequests[0]?.count || 0, acceptanceRate }; } export async function getRequestsPerMonth(months: number = 12) { const db = await getDb(); if (!db) return []; const result = await db.execute(sql` SELECT DATE_FORMAT(createdAt, '%Y-%m') as month, COUNT(*) as total, SUM(CASE WHEN status = 'validee' THEN 1 ELSE 0 END) as validated, SUM(CASE WHEN status = 'refusee' THEN 1 ELSE 0 END) as rejected, SUM(CASE WHEN status IN ('soumise', 'en_cours_traitement') THEN 1 ELSE 0 END) as pending FROM requests WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ${months} MONTH) GROUP BY DATE_FORMAT(createdAt, '%Y-%m') ORDER BY month ASC `); return (result as any)[0] as any[]; } export async function getAssociationsPerMonth(months: number = 12) { const db = await getDb(); if (!db) return []; const result = await db.execute(sql` SELECT DATE_FORMAT(createdAt, '%Y-%m') as month, COUNT(*) as total FROM associations WHERE createdAt >= DATE_SUB(NOW(), INTERVAL ${months} MONTH) GROUP BY DATE_FORMAT(createdAt, '%Y-%m') ORDER BY month ASC `); return (result as any)[0] as any[]; } export async function getRequestsByTypeStats() { const db = await getDb(); if (!db) return []; const result = await db.execute(sql` SELECT type, COUNT(*) as total, SUM(CASE WHEN status = 'validee' THEN 1 ELSE 0 END) as validated, SUM(CASE WHEN montantAccorde IS NOT NULL THEN montantAccorde ELSE 0 END) as totalMontantAccorde FROM requests GROUP BY type ORDER BY total DESC `); return (result as any)[0] as any[]; } export async function getAverageProcessingTime() { const db = await getDb(); if (!db) return null; const result = await db.execute(sql` SELECT AVG(TIMESTAMPDIFF(DAY, dateSubmission, dateTraitement)) as avgDays FROM requests WHERE dateSubmission IS NOT NULL AND dateTraitement IS NOT NULL AND status IN ('validee', 'refusee') `); const rows = (result as any)[0] as any[]; return rows[0]?.avgDays || null; } export type AssociationInvitationStatusSummary = { directoryEntryId: number; status: "never_sent" | "sent" | "expired" | "accepted"; sentAt: Date | null; expiresAt: Date | null; usedAt: Date | null; revokedAt: Date | null; emailSent: boolean; deliveryMode: "email" | "manual_link" | null; }; // ============== ASSOCIATION INVITATION FUNCTIONS ============== export async function createAssociationInvitation(data: InsertAssociationInvitation): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result = await db.insert(associationInvitations).values(data); return result[0].insertId; } export async function getAssociationInvitationByToken(token: string): Promise { const db = await getDb(); if (!db) return null; const result = await db.select() .from(associationInvitations) .where(eq(associationInvitations.token, token)) .limit(1); return result[0] ?? null; } export async function getActiveAssociationInvitationByDirectoryEntryId(directoryEntryId: number): Promise { const db = await getDb(); if (!db) return null; const result = await db.select() .from(associationInvitations) .where(and( eq(associationInvitations.directoryEntryId, directoryEntryId), isNull(associationInvitations.usedAt), isNull(associationInvitations.revokedAt), gte(associationInvitations.expiresAt, new Date()) )) .orderBy(desc(associationInvitations.createdAt)) .limit(1); return result[0] ?? null; } export async function getLatestAssociationInvitationByDirectoryEntryId(directoryEntryId: number): Promise { const db = await getDb(); if (!db) return null; const result = await db.select() .from(associationInvitations) .where(eq(associationInvitations.directoryEntryId, directoryEntryId)) .orderBy(desc(associationInvitations.createdAt)) .limit(1); return result[0] ?? null; } export async function revokeAssociationInvitationsByDirectoryEntryId(directoryEntryId: number): Promise { const db = await getDb(); if (!db) return; await db.update(associationInvitations) .set({ revokedAt: new Date() }) .where(and( eq(associationInvitations.directoryEntryId, directoryEntryId), isNull(associationInvitations.usedAt), isNull(associationInvitations.revokedAt) )); } export async function markAssociationInvitationUsed(token: string, acceptedByUserId: number): Promise { const db = await getDb(); if (!db) return; await db.update(associationInvitations) .set({ usedAt: new Date(), acceptedByUserId, }) .where(eq(associationInvitations.token, token)); } export async function getAssociationInvitationSummariesForDirectoryEntryIds(directoryEntryIds: number[]): Promise> { if (directoryEntryIds.length === 0) return {}; const db = await getDb(); if (!db) return {}; const rows = await db.select() .from(associationInvitations) .where(inArray(associationInvitations.directoryEntryId, directoryEntryIds)) .orderBy(desc(associationInvitations.createdAt)); const now = new Date(); const summaries: Record = {}; rows.forEach((invitation) => { if (summaries[invitation.directoryEntryId]) return; let status: AssociationInvitationStatusSummary["status"] = "expired"; if (invitation.usedAt) { status = "accepted"; } else if (!invitation.revokedAt && invitation.expiresAt >= now) { status = "sent"; } summaries[invitation.directoryEntryId] = { directoryEntryId: invitation.directoryEntryId, status, sentAt: invitation.sentAt, expiresAt: invitation.expiresAt, usedAt: invitation.usedAt, revokedAt: invitation.revokedAt, emailSent: invitation.emailSent, deliveryMode: invitation.deliveryMode, }; }); directoryEntryIds.forEach((directoryEntryId) => { if (!summaries[directoryEntryId]) { summaries[directoryEntryId] = { directoryEntryId, status: "never_sent", sentAt: null, expiresAt: null, usedAt: null, revokedAt: null, emailSent: false, deliveryMode: null, }; } }); return summaries; } // ============== EMAIL ACTION TOKEN FUNCTIONS ============== export async function createEmailActionToken(data: { token: string; requestId: number; action: 'validee' | 'refusee' | 'acceptation_devis_salle' | 'refus_devis_salle'; expiresAt: Date; }) { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.insert(emailActionTokens).values({ token: data.token, requestId: data.requestId, action: data.action, expiresAt: data.expiresAt, }); return data.token; } export async function getEmailActionToken(token: string) { const db = await getDb(); if (!db) return null; const result = await db.select().from(emailActionTokens).where(eq(emailActionTokens.token, token)).limit(1); return result.length > 0 ? result[0] : null; } export async function markTokenAsUsed(token: string, userId: number) { const db = await getDb(); if (!db) return; await db.update(emailActionTokens) .set({ used: true, usedAt: new Date(), usedBy: userId }) .where(eq(emailActionTokens.token, token)); } // ============== RESERVATION CALENDAR FUNCTIONS ============== export async function getApprovedReservations(): Promise<(Request & { associationName: string; associationEmail: string | null })[]> { const db = await getDb(); if (!db) return []; const result = await db .select({ id: requests.id, associationId: requests.associationId, type: requests.type, titre: requests.titre, description: requests.description, formData: requests.formData, status: requests.status, priority: requests.priority, montantDemande: requests.montantDemande, montantAccorde: requests.montantAccorde, dateSubmission: requests.dateSubmission, dateTraitement: requests.dateTraitement, dateLimiteTraitement: requests.dateLimiteTraitement, assigneA: requests.assigneA, traitePar: requests.traitePar, commentaireAdmin: requests.commentaireAdmin, documentsJoints: requests.documentsJoints, createdAt: requests.createdAt, updatedAt: requests.updatedAt, associationName: associations.nomAssociation, associationEmail: associations.emailContact, }) .from(requests) .innerJoin(associations, eq(requests.associationId, associations.id)) .where( and( eq(requests.type, 'demande_salle'), eq(requests.status, 'validee') ) ) .orderBy(desc(requests.dateTraitement)); return result as any; } export async function updateRequestSalles(id: number, newFormData: string): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(requests) .set({ formData: newFormData, updatedAt: new Date() }) .where(eq(requests.id, id)); } export async function getUserByAssociationId(associationId: number): Promise<{ userId: number } | undefined> { const db = await getDb(); if (!db) return undefined; const result = await db.select({ userId: associations.userId }).from(associations).where(eq(associations.id, associationId)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function deleteRequest(id: number): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); // Delete associated history first await db.delete(requestHistory).where(eq(requestHistory.requestId, id)); // Delete the request await db.delete(requests).where(eq(requests.id, id)); } export async function getAssociationByRequestId(requestId: number): Promise { const db = await getDb(); if (!db) return undefined; const request = await getRequestById(requestId); if (!request) return undefined; return getAssociationById(request.associationId); } // ============== MATERIAL RETURN FOLLOWUPS ============== export async function getMaterialReturnFollowupByRequestId(requestId: number): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(materialReturnFollowups).where(eq(materialReturnFollowups.requestId, requestId)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function getMaterialReturnFollowupByToken(token: string): Promise { const db = await getDb(); if (!db) return undefined; const result = await db.select().from(materialReturnFollowups).where(eq(materialReturnFollowups.uploadToken, token)).limit(1); return result.length > 0 ? result[0] : undefined; } export async function upsertMaterialReturnFollowup( requestId: number, data: Omit ): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const existing = await getMaterialReturnFollowupByRequestId(requestId); if (existing) { await db.update(materialReturnFollowups) .set(data) .where(eq(materialReturnFollowups.requestId, requestId)); return existing.id; } const result = await db.insert(materialReturnFollowups).values({ requestId, ...data, }); return result[0].insertId; } export async function updateMaterialReturnFollowup(id: number, data: Partial): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); await db.update(materialReturnFollowups).set(data).where(eq(materialReturnFollowups.id, id)); } export async function listPendingMaterialReturnFollowups(now: Date): Promise { const db = await getDb(); if (!db) return []; return db.select() .from(materialReturnFollowups) .where( and( eq(materialReturnFollowups.status, "planifie"), lte(materialReturnFollowups.plannedSendAt, now) ) ) .orderBy(materialReturnFollowups.plannedSendAt); } export async function listMaterialReturnFollowupsToEscalate(): Promise { const db = await getDb(); if (!db) return []; return db.select() .from(materialReturnFollowups) .where(eq(materialReturnFollowups.status, "en_attente")); }