Administration Mairie
Le module admin sert l'espace d'administration de la Mairie d'EtherBay : tableau de bord, disjoncteur du smart contract, reversement de la commission à la trésorerie, suspension des partenaires, gestion des comptes Mairie et partenaires, vérification des pièces d'identité, export de rapports et journal d'audit. Le tirage et l'annulation d'un tour, aussi réservés à l'ADMIN, sont dans le module Loterie.
| Fichier | Contenu |
|---|---|
backend/src/api/admin/admin.routes.ts |
12 routes, protection au niveau du routeur |
backend/src/api/admin/admin.validators.ts |
9 schémas Zod |
backend/src/api/admin/admin.services.ts |
Règles métier et écriture de l'audit |
backend/src/api/admin/admin.repo.ts |
Agrégats, audit, statut des partenaires, demandes de vérification d'identité, comptes Mairie et partenaires |
backend/src/services/blockchain.service.ts |
Disjoncteur et trésorerie en mode simulé |
Protection
Le routeur applique router.use(authenticate, requireRoles('ADMIN')) avant toutes ses routes. Une requête sur n'importe quel chemin sous /admin, même inexistant, passe donc d'abord par ces deux contrôles : un USER reçoit 403 et non 404.
| Contrôle | Échec |
|---|---|
| Jeton présent et valide | 401 |
Rôle du jeton égal à ADMIN |
403 Accès refusé (RBAC) : Rôle ADMIN requis pour cette ressource |
| Compte encore présent, non supprimé, rôle inchangé en base | 403 Droits révoqués ou modifiés : reconnectez-vous |
2FA active sur le compte si ADMIN_MFA_REQUIRED=true |
403 La double authentification est obligatoire pour les comptes d'administration. Activez-la depuis votre profil., avec errors.code à ADMIN_MFA_REQUIRED |
| Base de données joignable pour ces vérifications | 503 Vérification des droits impossible |
Pendant une maintenance (MAINTENANCE_MODE=true), seules les routes appelées par /api/admin/... restent accessibles ; le préfixe /api/v1/admin/... est bloqué comme le reste de l'API (voir Structure et cycle d'une requête).
Routes
| Méthode et chemin | Middlewares propres | Rôle | Audit |
|---|---|---|---|
GET /api/v1/admin/dashboard |
aucun | Indicateurs globaux | non |
POST /api/v1/admin/circuit-breaker |
validate(circuitBreakerSchema) |
Pause ou reprise du contrat | CIRCUIT_BREAKER_PAUSE ou CIRCUIT_BREAKER_RESUME |
POST /api/v1/admin/treasury/withdraw |
validate(treasuryWithdrawalSchema) |
Reversement de la commission | TREASURY_WITHDRAWAL_MULTISIG |
PATCH /api/v1/admin/partners/:partnerId/status |
validate(approvePartnerSchema) |
Activation ou suspension d'un partenaire | PARTNER_APPROVED ou PARTNER_SUSPENDED |
GET /api/v1/admin/reports/export |
aucun | Rapport CSV ou « PDF » | EXPORT_REPORT_CSV ou EXPORT_REPORT_PDF |
GET /api/v1/admin/identity-verifications |
validate(listIdentityVerificationsSchema) |
File des pièces d'identité par statut | non |
GET /api/v1/admin/identity-verifications/:id |
validate(identityVerificationIdSchema) |
Consultation d'une pièce | IDENTITY_DOCUMENT_VIEWED |
POST /api/v1/admin/identity-verifications/:id/decision |
validate(identityDecisionSchema) |
Approbation ou refus | IDENTITY_VERIFICATION_DECISION |
GET /api/v1/admin/accounts |
validate(listStaffAccountsSchema) |
Liste des comptes Mairie et partenaires | non |
POST /api/v1/admin/accounts |
validate(createStaffAccountSchema) |
Création d'un compte ou attribution d'un rôle, invitation par email | STAFF_ACCOUNT_CREATED ou STAFF_ROLE_CHANGED |
PATCH /api/v1/admin/accounts/:id/role |
validate(updateStaffRoleSchema) |
Changement ou retrait du rôle | STAFF_ROLE_CHANGED ou STAFF_ROLE_REVOKED |
GET /api/v1/admin/audit-logs |
aucun | Journal d'audit | non |
GET /admin/dashboard
Combine les agrégats du module, ceux du tableau de bord analytique et l'état du réseau.
{
"success": true,
"message": "Tableau de bord central Mairie récupéré",
"data": {
"kpis": { "totalPrizePoolEth": 3.42, "ticketsSold": 342, "municipalityRevenueEth": 0.342, "registeredCitizens": 128, "activePartners": 2, "claimedRewards": 17 },
"demographics": { "target18_35Validated": true, "ageBreakdown": { "18-25": 61, "26-35": 44, "36-50": 15, "50+": 6 } },
"utmAcquisition": [ { "source": "direct", "conversions": 402 } ],
"network": { "mode": "onchain", "chainId": 31337, "isFallback": false, "rpcUrl": "http://127.0.0.1:8545", "isPaused": false, "contractAddress": "0xCf7Ed3AccA5a467e9e704C703E8D87F634fB0Fc9", "poapContractAddress": "0x5FC8d32690cc91D4c39d9d3abcBD16989F875707" },
"rounds": [ { "id": "019a7c41-...", "contractRoundId": 1, "status": "OPEN", "prizePool": 3.42 } ]
}
}| Indicateur | Calcul |
|---|---|
totalPrizePoolEth |
somme des prizePool de tous les tours en base, annulés compris |
municipalityRevenueEth |
10 % de cette somme, taux codé en dur et non lu dans le contrat |
activePartners |
nombre total de lignes Partner, suspendues comprises |
registeredCitizens |
comptes non supprimés, tous rôles confondus |
rounds |
toutes les colonnes de tous les tours, du plus récent au plus ancien |
La commission réellement disponible sur le contrat (accruedFees) n'est pas affichée ici. Elle est lisible sur la chaîne, voir Exploitation du contrat.
POST /admin/circuit-breaker
| Champ | Type | Règles |
|---|---|---|
paused |
booléen | obligatoire |
reason |
chaîne | facultatif, 5 caractères au moins, message Motif de sécurité obligatoire pour l'audit ; défaut Action manuelle de sécurité Mairie / DSI |
Mode onchain : le compte opérateur appelle pause() ou unpause() sur BlockLucky, ce qui exige le rôle PAUSER_ROLE. Pendant la pause, le contrat refuse achats et tirages : POST /lottery/buy renvoie 503 avec circuitBreaker: true, le keeper ne fait rien. Un compte opérateur sans le rôle produit 403 Le compte opérateur du backend n'a pas le rôle requis sur le Smart Contract.
Mode simulé : la clé Redis blockchain:circuit_breaker passe à true ou false et un hachage aléatoire est renvoyé.
{
"success": true,
"message": "Circuit Breaker ACTIVÉ : Smart Contract mis en pause d'urgence",
"data": { "paused": true, "txHash": "0x3c7e...b210", "timestamp": "2026-11-11T17:00:00.000Z", "reason": "Comportement anormal détecté sur les achats" }
}L'état courant est exposé par GET /health (network.isPaused) et GET /lottery/status. Le parcours complet est dans Disjoncteur.
POST /admin/treasury/withdraw
| Champ | Type | Règles |
|---|---|---|
multisigWallet |
chaîne | 0x + 40 hexadécimaux, message Adresse du portefeuille multi-signatures de la Mairie invalide |
amountEth |
nombre | strictement positif, 1 000 au plus |
note |
chaîne | facultatif, 500 caractères au plus |
Mode onchain, dans lotteryGateway.withdrawFees :
- si
multisigWalletdiffère de la trésorerie enregistrée dans le contrat, le compte opérateur appellesetTreasury(multisigWallet), qui exigeDEFAULT_ADMIN_ROLE. L'opérateur détient ce rôle en local (saufLOCAL_STRICT_ROLES=true), mais pas sur Sepolia, où il est réservé au multisig de la Mairie : il faut y saisir l'adresseMAIRIE_TREASURY_ADDRESSdéjà en place ; - il lit
accruedFees, puis appellewithdrawFees(), qui verse la totalité de la commission accumulée à la trésorerie.
Important
amountEth n'est pas utilisé pour le virement : c'est toujours la totalité de accruedFees qui part. La valeur demandée est seulement conservée dans l'audit (requestedAmountEth). L'adresse n'est pas contrôlée comme un portefeuille multi-signatures : toute adresse valide est acceptée et devient la nouvelle trésorerie du contrat. Sans commission, le contrat renvoie 409 Aucune commission à reverser à la trésorerie.
Mode simulé : aucun fonds ne bouge, la réponse contient un hachage aléatoire et reprend amountEth.
{
"success": true,
"message": "Retrait de trésorerie vers le portefeuille multi-signatures exécuté",
"data": { "transactionHash": "0x8e41...07cd", "amountEth": 0.004, "treasury": "0x70997970c51812dc3a010c7d01b50e0d17dc79c8" }
}En mode simulé, la réponse est { transactionHash, multisigWallet, amountEth, timestamp }.
PATCH /admin/partners/:partnerId/status
| Champ | Type | Règles |
|---|---|---|
isActive |
booléen | obligatoire |
Met à jour Partner.isActive. Une entreprise suspendue disparaît du catalogue avec toutes ses offres, mais ses codes déjà attribués restent valables en magasin et ses offres restent lisibles par GET /marketplace/rewards/:id. partnerId n'est pas validé : un identifiant inconnu renvoie 404 Ressource introuvable (erreur Prisma P2025). La réponse renvoie { "partner": {...} }.
Les entreprises sont actives dès leur création par POST /partners : cette route sert à suspendre puis éventuellement réactiver, pas à valider une demande en attente.
GET /admin/reports/export
| Paramètre | Règles |
|---|---|
format |
CSV (défaut) ou PDF, insensible à la casse ; toute autre valeur donne CSV |
Le rapport reprend les kpis du tableau de bord.
| Format | Contenu déposé sur S3 | Réponse |
|---|---|---|
CSV |
reports/etherbay-report-<horodatage>.csv, une ligne Metric,Value puis 7 lignes, cellules neutralisées contre l'injection de formules |
format, generatedAt, downloadUrl, content (texte CSV) |
PDF |
reports/etherbay-report-<horodatage>.pdf, qui contient en réalité un JSON indenté (titre, date, kpis, demographics) déclaré application/pdf |
format, generatedAt, downloadUrl, summary |
"Metric","Value"
"Date","2026-11-11T17:30:00.000Z"
"Total_Prize_Pool_ETH","3.42"
"Municipality_Revenue_ETH","0.342"
"Tickets_Sold","342"
"Registered_Citizens","128"
"Active_Partners","2"
"Claimed_Rewards","17"Attention
Aucun PDF n'est produit : le fichier .pdf ne s'ouvre pas dans un lecteur PDF. downloadUrl est construit comme AWS_S3_ENDPOINT/AWS_S3_BUCKET/clé, sans URL présignée : il ne fonctionne que si le bucket est public. Si le dépôt S3 échoue, l'erreur est seulement journalisée et l'URL renvoyée pointe vers un fichier absent.
Vérification des pièces d'identité
Ces trois routes servent la page Identités de l'espace Mairie. Elles traitent les demandes envoyées par POST /user/identity (voir Utilisateur).
GET /admin/identity-verifications
| Paramètre | Règles |
|---|---|
status |
PENDING (défaut), VERIFIED ou REJECTED |
page, limit |
pagination, limit de 1 à 100 |
La liste est triée par date d'envoi, la plus ancienne en premier. Chaque entrée contient id, userId, email, submittedAt et status, sans image. La réponse porte un objet meta de pagination.
GET /admin/identity-verifications/:id
L'identifiant doit être un UUID (400 Identifiant de demande invalide) ; une demande inconnue renvoie 404. La réponse, servie avec Cache-Control: no-store, contient le compte (email, walletAddress, accountCreatedAt), le statut, les dates, l'agent qui a statué, le motif d'un refus et les deux images front et back sous forme d'URL data: déchiffrées.
Les images ne sont renvoyées que pour une demande PENDING. Après une décision, front et back valent null, puisque les images ont été effacées. Chaque consultation est tracée dans l'audit, même sans image.
POST /admin/identity-verifications/:id/decision
| Champ | Type | Règles |
|---|---|---|
approved |
booléen | obligatoire |
note |
chaîne | 500 caractères au plus ; au moins 3 caractères obligatoires en cas de refus (Le motif du refus est obligatoire) |
| Cas | Réponse |
|---|---|
| Demande inconnue | 404 Demande de vérification d'identité introuvable |
| Demande de l'administrateur lui-même | 403 Vous ne pouvez pas statuer sur votre propre pièce d'identité. |
| Demande déjà traitée, y compris par un autre agent au même moment | 409 Cette demande a déjà été traitée. |
La décision est écrite dans une transaction qui ne s'applique qu'à une demande encore PENDING :
| Donnée | Approbation | Refus |
|---|---|---|
IdentityDocument.status |
VERIFIED |
REJECTED |
frontEncrypted, backEncrypted |
effacés | effacés |
reviewedAt, reviewerId |
date et agent | date et agent |
User.ageVerificationStatus |
VERIFIED |
REJECTED |
User.ageVerifiedAt |
date de la décision | null |
User.ageVerificationNote |
null |
motif |
L'administrateur reçoit { "decision": { "id", "userId", "status", "reviewedAt", "note" } }. Le citoyen reçoit l'email identity-decision, avec le motif en cas de refus ; il peut alors envoyer une nouvelle pièce. Un compte sans email n'est pas prévenu.
Comptes Mairie et partenaires
Ces trois routes servent la page /mairie/comptes du web (voir Pages et espaces). Elles remplacent la promotion d'un compte par une requête SQL : seul le tout premier compte ADMIN doit encore être créé en base (voir Comptes de démonstration et connexion).
GET /admin/accounts
| Paramètre | Règles |
|---|---|
role |
facultatif, ADMIN ou PARTNER ; sans valeur, les deux rôles sont listés |
search |
facultatif, 120 caractères au plus ; recherche sans tenir compte de la casse dans l'email, le prénom et le nom |
page, limit |
pagination, limit de 1 à 100 |
Les comptes supprimés sont exclus et la liste est triée du plus récent au plus ancien. Chaque compte contient id, email, role, firstName, lastName, isTwoFactorEnabled, createdAt et, pour un partenaire, partners, la liste de ses établissements (id, name, isActive). La réponse porte un objet meta de pagination.
POST /admin/accounts
Crée un compte Mairie ou partenaire, ou attribue ce rôle à un compte existant, puis envoie une invitation par email.
| Champ | Type | Règles |
|---|---|---|
email |
chaîne | email valide, passé en minuscules |
role |
énumération | ADMIN ou PARTNER |
firstName, lastName |
chaîne | facultatifs, 80 caractères au plus ; ne remplacent pas un prénom ou un nom déjà renseigné |
partnerId |
UUID | facultatif, établissement à rattacher ; refusé si role n'est pas PARTNER (Un établissement ne peut être rattaché qu'à un compte partenaire) |
| Situation | Effet | Audit | Réponse |
|---|---|---|---|
| Aucun compte pour cet email | Création du compte avec le rôle demandé, un portefeuille intégré chiffré et isEmailVerified = false, sans mot de passe ; rattachement de l'établissement s'il est fourni |
STAFF_ACCOUNT_CREATED |
201 Compte créé et invitation envoyée, created: true |
| Compte existant, autre rôle | Changement de rôle, rattachement de l'établissement, révocation de toutes ses sessions | STAFF_ROLE_CHANGED |
200 Rôle attribué et invitation envoyée, created: false |
La réponse contient { "account": {...}, "created": true }, avec le compte sous la même forme que dans la liste. L'invitation utilise le modèle staff-invitation.html : elle rappelle l'identifiant de connexion, l'établissement rattaché, la façon de se connecter (« Mot de passe oublié » sur le mobile, code email sur le web) et l'obligation d'activer la 2FA pour l'espace Mairie. Un échec de mise en file est seulement journalisé.
| Cas | Réponse |
|---|---|
| Compte supprimé | 409 Ce compte a été supprimé et ne peut pas recevoir de rôle. |
| Son propre compte | 403 Vous ne pouvez pas modifier votre propre rôle. |
| Rôle déjà attribué | 409 Ce compte a déjà le rôle Mairie. ou Partenaire. |
| Retrait du dernier compte ADMIN | 409 Impossible de retirer le dernier compte Mairie. |
| Établissement inconnu | 404 Établissement partenaire introuvable |
| Établissement déjà rattaché à un autre compte | 409 Cet établissement est déjà rattaché à un autre compte partenaire. |
PATCH /admin/accounts/:id/role
| Champ | Type | Règles |
|---|---|---|
id (chemin) |
UUID | 400 Identifiant de compte invalide sinon |
role |
énumération | USER, PARTNER ou ADMIN ; USER retire le rôle |
- Son propre compte est refusé :
403 Vous ne pouvez pas modifier votre propre rôle. - Un compte inconnu ou supprimé renvoie
404 Compte introuvable; un rôle identique409 Ce compte a déjà le rôle ...; le retrait du dernier ADMIN409 Impossible de retirer le dernier compte Mairie. - Le rôle est changé dans une transaction. Un compte qui quitte le rôle
PARTNERest détaché de ses établissements (ownerIdremis ànull), qui ne sont plus gérables que par la Mairie. - Toutes les sessions du compte sont révoquées : le nouveau rôle s'applique à la prochaine connexion.
- L'action est tracée :
STAFF_ROLE_REVOKEDpour un passage àUSER,STAFF_ROLE_CHANGEDsinon. Aucun email n'est envoyé.
La réponse vaut { "account": {...} }, avec le message Rôle retiré ou Rôle du compte mis à jour.
Journal d'audit
Chaque action sensible écrit une ligne Analytics avec eventType = ADMIN_AUDIT:<action>, userId de l'administrateur, utmSource = admin-dashboard et le détail en metadata. Les clients ne peuvent pas écrire de lignes avec ce préfixe (voir Analytique).
| Action | metadata |
|---|---|
CIRCUIT_BREAKER_PAUSE, CIRCUIT_BREAKER_RESUME |
paused, reason, txHash |
TREASURY_WITHDRAWAL_MULTISIG |
multisigWallet, amountEth (versé), requestedAmountEth, note, transactionHash |
PARTNER_APPROVED, PARTNER_SUSPENDED |
partnerId, partnerName, isActive |
EXPORT_REPORT_CSV, EXPORT_REPORT_PDF |
format, timestamp |
IDENTITY_DOCUMENT_VIEWED |
verificationId, userId, imagesShown |
IDENTITY_VERIFICATION_DECISION |
verificationId, userId, approved, status, note s'il est fourni |
STAFF_ACCOUNT_CREATED |
userId, email, role, partnerId et partnerName si un établissement est rattaché |
STAFF_ROLE_CHANGED |
userId, email, previousRole, role, partnerId et partnerName (création par POST) ou detachedPartnerIds (perte du rôle PARTNER) |
STAFF_ROLE_REVOKED |
userId, email, previousRole, role (USER), detachedPartnerIds pour un ancien partenaire |
L'audit est écrit après l'action : une action qui échoue n'est pas tracée, et un échec d'écriture de l'audit après une transaction réussie renvoie une erreur alors que l'action a eu lieu. Le tirage et l'annulation de tour (POST /lottery/draw, /refund) ne sont pas audités.
GET /admin/audit-logs
limit est lu dans la query, défaut 100, sans borne supérieure.
{
"success": true,
"message": "Journal des logs d'audit récupéré",
"data": {
"logs": [
{ "id": "019a7c80-...", "action": "CIRCUIT_BREAKER_PAUSE", "adminId": "019a7c01-...", "adminWallet": "0x4a1e...", "metadata": { "paused": true, "reason": "Comportement anormal détecté sur les achats", "txHash": "0x3c7e...b210" }, "timestamp": "2026-11-11T17:00:00.000Z" }
]
}
}adminWallet vaut Système si la ligne n'est rattachée à aucun compte.