import { int, mysqlEnum, mysqlTable, text, timestamp, varchar, boolean } from "drizzle-orm/mysql-core"; import { associationGeoPrecisions, associationGeoSources } from "@shared/associationGeo"; import { associationThematicValues } from "@shared/associationThematics"; /** * Core user table backing auth flow. */ export const users = mysqlTable("users", { id: int("id").autoincrement().primaryKey(), openId: varchar("openId", { length: 64 }).notNull().unique(), name: text("name"), email: varchar("email", { length: 320 }), passwordHash: varchar("passwordHash", { length: 255 }), loginMethod: varchar("loginMethod", { length: 64 }), role: mysqlEnum("role", ["user", "accueil", "service_terrain", "logistique_controle", "admin", "directrice", "super_admin"]).default("user").notNull(), canManageLogistics: boolean("canManageLogistics").default(false).notNull(), delegatedSalleSignerUserId: int("delegatedSalleSignerUserId"), isActive: boolean("isActive").default(true).notNull(), failedLoginAttempts: int("failedLoginAttempts").default(0).notNull(), lockedUntil: timestamp("lockedUntil"), mfaEnabled: boolean("mfaEnabled").default(false).notNull(), mfaMethod: varchar("mfaMethod", { length: 32 }), mfaChallengeToken: varchar("mfaChallengeToken", { length: 96 }), mfaCodeHash: varchar("mfaCodeHash", { length: 255 }), mfaCodeExpiresAt: timestamp("mfaCodeExpiresAt"), mfaCodeAttempts: int("mfaCodeAttempts").default(0).notNull(), mfaTotpSecretEncrypted: varchar("mfaTotpSecretEncrypted", { length: 512 }), mfaTotpPendingSecretEncrypted: varchar("mfaTotpPendingSecretEncrypted", { length: 512 }), deletionRequestedAt: timestamp("deletionRequestedAt"), purgeScheduledAt: timestamp("purgeScheduledAt"), purgedAt: timestamp("purgedAt"), legalHold: boolean("legalHold").default(false).notNull(), legalHoldReason: text("legalHoldReason"), privacyConsentVersion: varchar("privacyConsentVersion", { length: 32 }), privacyConsentAcceptedAt: timestamp("privacyConsentAcceptedAt"), privacyConsentContext: varchar("privacyConsentContext", { length: 64 }), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), lastSignedIn: timestamp("lastSignedIn").defaultNow().notNull(), }); export type User = typeof users.$inferSelect; export type InsertUser = typeof users.$inferInsert; /** * Association profile table - stores complete association information */ export const associations = mysqlTable("associations", { id: int("id").autoincrement().primaryKey(), userId: int("userId").notNull().unique(), sourceDirectoryEntryId: int("sourceDirectoryEntryId"), // Basic information nomAssociation: varchar("nomAssociation", { length: 255 }).notNull(), siret: varchar("siret", { length: 14 }), rna: varchar("rna", { length: 10 }), // Numéro RNA (W + 9 chiffres) thematique: text("thematique"), // Address adresse: text("adresse"), codePostal: varchar("codePostal", { length: 10 }), ville: varchar("ville", { length: 100 }), // Contact telephone: varchar("telephone", { length: 20 }), emailContact: varchar("emailContact", { length: 320 }), siteWeb: varchar("siteWeb", { length: 255 }), facebookUrl: varchar("facebookUrl", { length: 255 }), instagramUrl: varchar("instagramUrl", { length: 255 }), // Legal information dateCreation: timestamp("dateCreation"), objetAssociation: text("objetAssociation"), statutJuridique: mysqlEnum("statutJuridique", [ "association_loi_1901", "association_reconnue_utilite_publique", "fondation", "autre" ]).default("association_loi_1901"), // Representative nomRepresentant: varchar("nomRepresentant", { length: 255 }), fonctionRepresentant: varchar("fonctionRepresentant", { length: 100 }), gouvernance: text("gouvernance"), // Profile completion and status profileComplete: boolean("profileComplete").default(false), isActive: boolean("isActive").default(true).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type Association = typeof associations.$inferSelect; export type InsertAssociation = typeof associations.$inferInsert; /** * Imported association directory - source of truth from the Savanes registry. */ export const associationDirectoryEntries = mysqlTable("associationDirectoryEntries", { id: int("id").autoincrement().primaryKey(), nomAssociation: varchar("nomAssociation", { length: 255 }).notNull(), emailOfficiel: varchar("emailOfficiel", { length: 320 }), emailOfficielNormalise: varchar("emailOfficielNormalise", { length: 320 }).unique(), siret: varchar("siret", { length: 14 }), rna: varchar("rna", { length: 10 }), thematique: text("thematique"), adresse: text("adresse"), codePostal: varchar("codePostal", { length: 10 }), ville: varchar("ville", { length: 100 }), telephone: varchar("telephone", { length: 20 }), siteWeb: varchar("siteWeb", { length: 255 }), facebookUrl: varchar("facebookUrl", { length: 255 }), instagramUrl: varchar("instagramUrl", { length: 255 }), dateCreation: timestamp("dateCreation"), objetAssociation: text("objetAssociation"), statutJuridique: mysqlEnum("statutJuridique", [ "association_loi_1901", "association_reconnue_utilite_publique", "fondation", "autre" ]).default("association_loi_1901"), nomRepresentant: varchar("nomRepresentant", { length: 255 }), fonctionRepresentant: varchar("fonctionRepresentant", { length: 100 }), gouvernance: text("gouvernance"), latitude: varchar("latitude", { length: 32 }), longitude: varchar("longitude", { length: 32 }), geoSource: mysqlEnum("geoSource", associationGeoSources), geoPrecision: mysqlEnum("geoPrecision", associationGeoPrecisions).default("commune_center").notNull(), geoLastSyncedAt: timestamp("geoLastSyncedAt"), externalSourceStatus: varchar("externalSourceStatus", { length: 100 }), externalSourceLabel: varchar("externalSourceLabel", { length: 255 }), associationStatus: varchar("associationStatus", { length: 40 }), registryLastUpdatedAt: timestamp("registryLastUpdatedAt"), referenceLastCheckedAt: timestamp("referenceLastCheckedAt"), referenceStatus: varchar("referenceStatus", { length: 100 }), referenceSourceLabel: varchar("referenceSourceLabel", { length: 255 }), sourceFileName: varchar("sourceFileName", { length: 255 }), sourceRowNumber: int("sourceRowNumber"), sourceFingerprint: varchar("sourceFingerprint", { length: 64 }), isActive: boolean("isActive").default(true).notNull(), importedAt: timestamp("importedAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type AssociationDirectoryEntry = typeof associationDirectoryEntries.$inferSelect; export type InsertAssociationDirectoryEntry = typeof associationDirectoryEntries.$inferInsert; export const associationDirectoryReviewSourceTypes = ["portal_signup", "helloasso"] as const; export const associationDirectoryReviewStatuses = ["pending", "linked", "created", "ignored"] as const; export const associationDirectoryReviews = mysqlTable("associationDirectoryReviews", { id: int("id").autoincrement().primaryKey(), sourceType: mysqlEnum("sourceType", associationDirectoryReviewSourceTypes).notNull(), sourceLabel: varchar("sourceLabel", { length: 255 }), userId: int("userId"), resolvedDirectoryEntryId: int("resolvedDirectoryEntryId"), proposedNomAssociation: varchar("proposedNomAssociation", { length: 255 }).notNull(), proposedEmail: varchar("proposedEmail", { length: 320 }), proposedEmailNormalise: varchar("proposedEmailNormalise", { length: 320 }), proposedSiret: varchar("proposedSiret", { length: 14 }), proposedRna: varchar("proposedRna", { length: 10 }), proposedVille: varchar("proposedVille", { length: 100 }), matchStatus: varchar("matchStatus", { length: 40 }), payload: text("payload"), status: mysqlEnum("status", associationDirectoryReviewStatuses).default("pending").notNull(), resolutionNote: text("resolutionNote"), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type AssociationDirectoryReview = typeof associationDirectoryReviews.$inferSelect; export type InsertAssociationDirectoryReview = typeof associationDirectoryReviews.$inferInsert; export const associationDirectoryUpdateProposalSourceTypes = ["official_registry", "helloasso"] as const; export const associationDirectoryUpdateProposalStatuses = ["pending", "applied", "dismissed"] as const; export const associationDirectoryUpdateProposals = mysqlTable("associationDirectoryUpdateProposals", { id: int("id").autoincrement().primaryKey(), directoryEntryId: int("directoryEntryId").notNull(), sourceType: mysqlEnum("sourceType", associationDirectoryUpdateProposalSourceTypes).notNull(), sourceLabel: varchar("sourceLabel", { length: 255 }), summary: varchar("summary", { length: 255 }), payload: text("payload").notNull(), status: mysqlEnum("status", associationDirectoryUpdateProposalStatuses).default("pending").notNull(), appliedAt: timestamp("appliedAt"), dismissedAt: timestamp("dismissedAt"), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type AssociationDirectoryUpdateProposal = typeof associationDirectoryUpdateProposals.$inferSelect; export type InsertAssociationDirectoryUpdateProposal = typeof associationDirectoryUpdateProposals.$inferInsert; /** * Document types for associations */ export const documentTypes = [ "statuts", "recepisse_declaration", "rib", "rapport_activite", "rapport_financier", "pv_assemblee", "liste_dirigeants", "attestation_assurance", "autre" ] as const; /** * Documents table - stores document metadata (files in S3) */ export const documents = mysqlTable("documents", { id: int("id").autoincrement().primaryKey(), associationId: int("associationId").notNull(), nom: varchar("nom", { length: 255 }).notNull(), type: mysqlEnum("type", documentTypes).notNull(), description: text("description"), // S3 storage fileKey: varchar("fileKey", { length: 512 }).notNull(), fileUrl: varchar("fileUrl", { length: 1024 }).notNull(), mimeType: varchar("mimeType", { length: 100 }), fileSize: int("fileSize"), // in bytes uploadedAt: timestamp("uploadedAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type Document = typeof documents.$inferSelect; export type InsertDocument = typeof documents.$inferInsert; /** * Request types available */ export const requestTypes = [ "subvention_fonctionnement", "subvention_projet", "agrement_jeunesse_education", "agrement_sport", "autorisation_occupation", "demande_salle", "demande_materiel_evenementiel", "autre" ] as const; /** * Request status */ export const requestStatuses = [ "brouillon", "soumise", "en_cours_traitement", "information_complementaire", "validee", "refusee", "annulee" ] as const; /** * Request priority levels */ export const requestPriorities = [ "basse", "normale", "haute", "urgente" ] as const; /** * Requests table - stores all requests from associations */ export const requests = mysqlTable("requests", { id: int("id").autoincrement().primaryKey(), associationId: int("associationId").notNull(), // Request info type: mysqlEnum("type", requestTypes).notNull(), titre: varchar("titre", { length: 255 }).notNull(), description: text("description"), // Form data stored as JSON formData: text("formData"), // JSON string // Status tracking status: mysqlEnum("status", requestStatuses).default("brouillon").notNull(), priority: mysqlEnum("priority", requestPriorities).default("normale").notNull(), // Financial (for subventions) montantDemande: int("montantDemande"), // in cents montantAccorde: int("montantAccorde"), // in cents // Processing dateSubmission: timestamp("dateSubmission"), dateTraitement: timestamp("dateTraitement"), dateLimiteTraitement: timestamp("dateLimiteTraitement"), assigneA: int("assigneA"), // admin user id assigned to process traitePar: int("traitePar"), // admin user id who processed commentaireAdmin: text("commentaireAdmin"), // Attached documents (JSON array of document IDs) documentsJoints: text("documentsJoints"), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type Request = typeof requests.$inferSelect; export type InsertRequest = typeof requests.$inferInsert; /** * Request history - tracks all actions on a request */ export const requestHistory = mysqlTable("requestHistory", { id: int("id").autoincrement().primaryKey(), requestId: int("requestId").notNull(), action: mysqlEnum("action", [ "creation", "soumission", "assignation", "changement_statut", "ajout_commentaire", "modification", "validation", "refus", "changement_salle", "annulation" ]).notNull(), ancienStatut: mysqlEnum("ancienStatut", requestStatuses), nouveauStatut: mysqlEnum("nouveauStatut", requestStatuses), commentaire: text("commentaire"), userId: int("userId").notNull(), // who performed the action createdAt: timestamp("createdAt").defaultNow().notNull(), }); export type RequestHistory = typeof requestHistory.$inferSelect; export type InsertRequestHistory = typeof requestHistory.$inferInsert; /** * Response templates - predefined responses for admins */ export const responseTemplates = mysqlTable("responseTemplates", { id: int("id").autoincrement().primaryKey(), nom: varchar("nom", { length: 255 }).notNull(), type: mysqlEnum("type", ["validation", "refus", "information_complementaire", "autre"]).notNull(), sujet: varchar("sujet", { length: 255 }), contenu: text("contenu").notNull(), actif: boolean("actif").default(true), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type ResponseTemplate = typeof responseTemplates.$inferSelect; export type InsertResponseTemplate = typeof responseTemplates.$inferInsert; /** * Request templates - predefined form templates */ export const requestTemplates = mysqlTable("requestTemplates", { id: int("id").autoincrement().primaryKey(), type: mysqlEnum("type", requestTypes).notNull(), nom: varchar("nom", { length: 255 }).notNull(), description: text("description"), // Form schema as JSON formSchema: text("formSchema").notNull(), // Required documents documentsRequis: text("documentsRequis"), // JSON array // Service destinataire serviceDestinataire: varchar("serviceDestinataire", { length: 255 }), emailDestinataire: varchar("emailDestinataire", { length: 320 }), // Processing settings delaiTraitementJours: int("delaiTraitementJours").default(30), actif: boolean("actif").default(true), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type RequestTemplate = typeof requestTemplates.$inferSelect; export type InsertRequestTemplate = typeof requestTemplates.$inferInsert; /** * Audit log - tracks all admin actions */ export const auditLog = mysqlTable("auditLog", { id: int("id").autoincrement().primaryKey(), userId: int("userId").notNull(), action: varchar("action", { length: 100 }).notNull(), entityType: varchar("entityType", { length: 50 }).notNull(), // association, request, user, etc. entityId: int("entityId"), details: text("details"), // JSON with action details ipAddress: varchar("ipAddress", { length: 45 }), createdAt: timestamp("createdAt").defaultNow().notNull(), }); export type AuditLog = typeof auditLog.$inferSelect; export type InsertAuditLog = typeof auditLog.$inferInsert; /** * Admin notifications */ export const adminNotifications = mysqlTable("adminNotifications", { id: int("id").autoincrement().primaryKey(), userId: int("userId"), // null = all admins type: mysqlEnum("type", [ "nouvelle_demande", "demande_en_retard", "dossier_incomplet", "nouvelle_association", "systeme", "changement_salle", "annulation_demande", "modification_demande" ]).notNull(), titre: varchar("titre", { length: 255 }).notNull(), message: text("message").notNull(), lien: varchar("lien", { length: 512 }), lu: boolean("lu").default(false), createdAt: timestamp("createdAt").defaultNow().notNull(), }); export type AdminNotification = typeof adminNotifications.$inferSelect; export type InsertAdminNotification = typeof adminNotifications.$inferInsert; /** * Portal settings - configurable settings */ export const portalSettings = mysqlTable("portalSettings", { id: int("id").autoincrement().primaryKey(), cle: varchar("cle", { length: 100 }).notNull().unique(), valeur: text("valeur").notNull(), description: text("description"), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type PortalSetting = typeof portalSettings.$inferSelect; export type InsertPortalSetting = typeof portalSettings.$inferInsert; /** * Operational recap services - reusable internal distribution lists for processed requests. */ export const operationalRecapServices = mysqlTable("operationalRecapServices", { id: int("id").autoincrement().primaryKey(), label: varchar("label", { length: 255 }).notNull(), usage: mysqlEnum("usage", ["terrain", "controle"]).default("terrain").notNull(), description: text("description"), recipientEmails: text("recipientEmails").notNull(), // JSON array of emails actif: boolean("actif").default(true).notNull(), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type OperationalRecapService = typeof operationalRecapServices.$inferSelect; export type InsertOperationalRecapService = typeof operationalRecapServices.$inferInsert; /** * Association invitations - secure one-time invitations for directory onboarding. */ export const associationInvitations = mysqlTable("associationInvitations", { id: int("id").autoincrement().primaryKey(), directoryEntryId: int("directoryEntryId").notNull(), emailOfficiel: varchar("emailOfficiel", { length: 320 }).notNull(), emailOfficielNormalise: varchar("emailOfficielNormalise", { length: 320 }).notNull(), token: varchar("token", { length: 128 }).notNull().unique(), deliveryMode: mysqlEnum("deliveryMode", ["email", "manual_link"]).notNull(), emailSent: boolean("emailSent").default(false).notNull(), sentByUserId: int("sentByUserId").notNull(), sentAt: timestamp("sentAt").defaultNow().notNull(), expiresAt: timestamp("expiresAt").notNull(), usedAt: timestamp("usedAt"), revokedAt: timestamp("revokedAt"), acceptedByUserId: int("acceptedByUserId"), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type AssociationInvitation = typeof associationInvitations.$inferSelect; export type InsertAssociationInvitation = typeof associationInvitations.$inferInsert; /** * Email action tokens - secure one-time tokens for email-based actions (validate/refuse) */ export const emailActionTokens = mysqlTable("emailActionTokens", { id: int("id").autoincrement().primaryKey(), token: varchar("token", { length: 64 }).notNull().unique(), requestId: int("requestId").notNull(), action: mysqlEnum("action", ["validee", "refusee", "acceptation_devis_salle", "refus_devis_salle"]).notNull(), used: boolean("used").default(false).notNull(), expiresAt: timestamp("expiresAt").notNull(), usedAt: timestamp("usedAt"), usedBy: int("usedBy"), // admin user id createdAt: timestamp("createdAt").defaultNow().notNull(), }); export type EmailActionToken = typeof emailActionTokens.$inferSelect; export type InsertEmailActionToken = typeof emailActionTokens.$inferInsert; export const materialReturnStatuses = [ "planifie", "en_attente", "en_cours", "cloture", ] as const; export const materialReturnComplianceValues = [ "conforme", "non_conforme", ] as const; export const materialReturnLitigationStatuses = [ "none", "pending", "resolved", ] as const; export const materialReturnArbitrationDecisions = [ "partial_retention", "full_retention", "dismissed", ] as const; export const materialReturnFollowups = mysqlTable("materialReturnFollowups", { id: int("id").autoincrement().primaryKey(), requestId: int("requestId").notNull().unique(), associationId: int("associationId").notNull(), serviceLabel: varchar("serviceLabel", { length: 255 }), recipientEmails: text("recipientEmails"), // JSON array supervisionServiceLabel: varchar("supervisionServiceLabel", { length: 255 }), supervisionRecipientEmails: text("supervisionRecipientEmails"), // JSON array restitutionDate: timestamp("restitutionDate").notNull(), plannedSendAt: timestamp("plannedSendAt").notNull(), sentAt: timestamp("sentAt"), status: mysqlEnum("status", materialReturnStatuses).default("planifie").notNull(), uploadToken: varchar("uploadToken", { length: 96 }).notNull().unique(), uploadTokenExpiresAt: timestamp("uploadTokenExpiresAt").notNull(), signedFileKey: varchar("signedFileKey", { length: 512 }), signedFileUrl: varchar("signedFileUrl", { length: 1024 }), signedFileName: varchar("signedFileName", { length: 255 }), signedMimeType: varchar("signedMimeType", { length: 100 }), uploadedByName: varchar("uploadedByName", { length: 255 }), uploadedByEmail: varchar("uploadedByEmail", { length: 320 }), agentUserId: int("agentUserId"), agentRole: varchar("agentRole", { length: 100 }), borrowerName: varchar("borrowerName", { length: 255 }), borrowerRole: varchar("borrowerRole", { length: 120 }), compliance: mysqlEnum("compliance", materialReturnComplianceValues), discrepancyCategories: text("discrepancyCategories"), discrepancyDetails: text("discrepancyDetails"), issueFlag: boolean("issueFlag").default(false).notNull(), litigationStatus: mysqlEnum("litigationStatus", materialReturnLitigationStatuses).default("none").notNull(), blockedItems: text("blockedItems"), estimatedDamageAmount: int("estimatedDamageAmount"), estimatedDamageSource: varchar("estimatedDamageSource", { length: 32 }), arbitrationDecision: mysqlEnum("arbitrationDecision", materialReturnArbitrationDecisions), arbitrationAmount: int("arbitrationAmount"), arbitrationNotes: text("arbitrationNotes"), arbitratedByUserId: int("arbitratedByUserId"), arbitratedAt: timestamp("arbitratedAt"), litigationLetterKey: varchar("litigationLetterKey", { length: 512 }), litigationLetterUrl: varchar("litigationLetterUrl", { length: 1024 }), litigationLetterName: varchar("litigationLetterName", { length: 255 }), agentSignatureKey: varchar("agentSignatureKey", { length: 512 }), agentSignatureUrl: varchar("agentSignatureUrl", { length: 1024 }), borrowerSignatureKey: varchar("borrowerSignatureKey", { length: 512 }), borrowerSignatureUrl: varchar("borrowerSignatureUrl", { length: 1024 }), finalPdfKey: varchar("finalPdfKey", { length: 512 }), finalPdfUrl: varchar("finalPdfUrl", { length: 1024 }), finalPdfName: varchar("finalPdfName", { length: 255 }), geoLatitude: varchar("geoLatitude", { length: 64 }), geoLongitude: varchar("geoLongitude", { length: 64 }), geoStatus: varchar("geoStatus", { length: 32 }), geoFailureReason: text("geoFailureReason"), uploadedAt: timestamp("uploadedAt"), validatedAt: timestamp("validatedAt"), closedAt: timestamp("closedAt"), lastReminderSentAt: timestamp("lastReminderSentAt"), reactivatedByUserId: int("reactivatedByUserId"), reactivatedAt: timestamp("reactivatedAt"), reactivationReason: text("reactivationReason"), createdAt: timestamp("createdAt").defaultNow().notNull(), updatedAt: timestamp("updatedAt").defaultNow().onUpdateNow().notNull(), }); export type MaterialReturnFollowup = typeof materialReturnFollowups.$inferSelect; export type InsertMaterialReturnFollowup = typeof materialReturnFollowups.$inferInsert;