1649 lines
56 KiB
TypeScript
1649 lines
56 KiB
TypeScript
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<typeof drizzle> | 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<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;
|
|
}
|
|
|
|
try {
|
|
const values: InsertUser = {
|
|
openId: user.openId,
|
|
};
|
|
const updateSet: Record<string, unknown> = {};
|
|
|
|
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<InsertUser>): Promise<void> {
|
|
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<void> {
|
|
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<Association | undefined> {
|
|
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<Association | undefined> {
|
|
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<Association | undefined> {
|
|
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<number> {
|
|
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<InsertAssociation>): Promise<void> {
|
|
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<Association[]> {
|
|
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<void> {
|
|
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<Record<AssociationCommuneFilter, number>> {
|
|
const db = await getDb();
|
|
const counts: Record<AssociationCommuneFilter, number> = {
|
|
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<AssociationDirectoryEntry | undefined> {
|
|
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<AssociationDirectoryEntry | undefined> {
|
|
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<AssociationDirectoryEntry | undefined> {
|
|
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<AssociationDirectoryEntry[]> {
|
|
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<InsertAssociationDirectoryEntry>): Promise<void> {
|
|
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<number> {
|
|
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<number> {
|
|
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<AssociationDirectoryReview | undefined> {
|
|
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<AssociationDirectoryReview | undefined> {
|
|
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<InsertAssociationDirectoryReview>): Promise<void> {
|
|
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<number> {
|
|
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<InsertAssociationDirectoryUpdateProposal>
|
|
): Promise<void> {
|
|
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<AssociationDirectoryUpdateProposal | undefined> {
|
|
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<AssociationDirectoryUpdateProposal | undefined> {
|
|
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<any> = [
|
|
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<Document[]> {
|
|
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<Document | undefined> {
|
|
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<number> {
|
|
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<void> {
|
|
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<Request[]> {
|
|
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<Request | undefined> {
|
|
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<number> {
|
|
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<InsertRequest>): Promise<void> {
|
|
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<Request[]> {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(requests).orderBy(desc(requests.createdAt));
|
|
}
|
|
|
|
export async function getRequestsByStatus(status: string): Promise<Request[]> {
|
|
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<Request[]> {
|
|
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<Request[]> {
|
|
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<void> {
|
|
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<number> {
|
|
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<RequestHistory[]> {
|
|
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<ResponseTemplate[]> {
|
|
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<ResponseTemplate | undefined> {
|
|
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<number> {
|
|
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<InsertResponseTemplate>): Promise<void> {
|
|
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<void> {
|
|
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<RequestTemplate[]> {
|
|
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<RequestTemplate | undefined> {
|
|
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<RequestTemplate | undefined> {
|
|
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<number> {
|
|
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<InsertRequestTemplate>): Promise<void> {
|
|
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<number> {
|
|
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<number> {
|
|
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<AdminNotification[]> {
|
|
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<void> {
|
|
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<void> {
|
|
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<number> {
|
|
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<string | null> {
|
|
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<PortalSetting | null> {
|
|
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<void> {
|
|
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<PortalSetting[]> {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(portalSettings).orderBy(portalSettings.cle);
|
|
}
|
|
|
|
// ============== OPERATIONAL RECAP SERVICES FUNCTIONS ==============
|
|
|
|
export async function getAllOperationalRecapServices(): Promise<OperationalRecapService[]> {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select().from(operationalRecapServices).orderBy(operationalRecapServices.label);
|
|
}
|
|
|
|
export async function getOperationalRecapServiceById(id: number): Promise<OperationalRecapService | undefined> {
|
|
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<number> {
|
|
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<InsertOperationalRecapService>): Promise<void> {
|
|
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<void> {
|
|
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<number> {
|
|
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<AssociationInvitation | null> {
|
|
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<AssociationInvitation | null> {
|
|
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<AssociationInvitation | null> {
|
|
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<void> {
|
|
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<void> {
|
|
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<Record<number, AssociationInvitationStatusSummary>> {
|
|
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<number, AssociationInvitationStatusSummary> = {};
|
|
|
|
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<void> {
|
|
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<void> {
|
|
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<Association | undefined> {
|
|
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<MaterialReturnFollowup | undefined> {
|
|
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<MaterialReturnFollowup | undefined> {
|
|
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<InsertMaterialReturnFollowup, "requestId">
|
|
): Promise<number> {
|
|
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<InsertMaterialReturnFollowup>): Promise<void> {
|
|
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<MaterialReturnFollowup[]> {
|
|
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<MaterialReturnFollowup[]> {
|
|
const db = await getDb();
|
|
if (!db) return [];
|
|
return db.select()
|
|
.from(materialReturnFollowups)
|
|
.where(eq(materialReturnFollowups.status, "en_attente"));
|
|
}
|