> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cel-eleague.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Regles metier verifiees

# Règles métier vérifiées

Cette page ne liste que les règles **explicitement observées dans le code vérifié**.\
Les éléments à confirmer sont regroupés dans la section finale.

## Match reports (demande de report)

Sources: `convex/competition/match_postponement_mutations.ts`.

* `match_reports.status` vérifiés: `PENDING`, `ACCEPTED`, `COUNTER_PROPOSED`, `CANCELLED`, `REJECTED`.
* `status` peut être absent sur des données legacy.
* Avant mutération, les rows legacy sans statut conforme sont filtrées via `hasLegacyMatchReportStatus`.

`requestPostponement`:

* Seuls les membres staff du club demandeur (`MANAGER`, `CO_MANAGER`, `COACH`) peuvent créer.
* Matchs admissibles: `scheduled` ou `provisional`.
* Blocage si une demande `PENDING` ou `COUNTER_PROPOSED` existe déjà sur le même match.
* Blocage si le match a déjà un report `ACCEPTED`.
* Transition vers `PENDING`.

`acceptPostponement`:

* Réservé à l’adversaire du demandeur.
* Sources valides: `PENDING` et `COUNTER_PROPOSED`.
* En cas de `COUNTER_PROPOSED`, seul le demandeur initial peut accepter.
* Transition vers `ACCEPTED`, puis `matches.scheduledAt` et `matches.status = scheduled`.

`counterProposePostponement`:

* Réservé à l’équipe adverse, pas à l’initiateur.
* Source stricte `PENDING`.
* Transition vers `COUNTER_PROPOSED` avec `counterProposedDate`.

`cancelPostponement`:

* Sources: `PENDING`, `COUNTER_PROPOSED`.
* Exécutable par le club initiateur.
* Transition vers `CANCELLED`.

`rejectPostponement`:

* Réservé `ADMIN`.
* Sources: `PENDING`, `COUNTER_PROPOSED`.
* Transition vers `REJECTED`.

`adminDirectPostponement`:

* Réservé `ADMIN`.
* Match admissible si `scheduled` ou `provisional`.
* Aucune création si un report `PENDING`, `COUNTER_PROPOSED` ou `ACCEPTED` existe déjà.
* Crée directement un report en `ACCEPTED`.

Quota de reports:

* `isReportQuotaConsumed` ne compte comme consommé que le statut `ACCEPTED`.
* `effectiveReportsTotal` inclut bonus premium via `PREMIUM_REPORTS_BONUS = 2`.

## Compositions d’avant-match

Source: `convex/competition/match_lineups.ts`.

* `LINEUP_DEADLINE_MS = 20 * 60 * 1000`.
* Statut `match_lineups.status`: `draft`, `submitted`.
* `match_lineups.status` peut être forcé en visibilité/validation par `ADMIN`/`MODERATOR` via `hasLineupOverridePrivileges`.
* Sans override:
  * match `scheduled`,
  * délai max 20 minutes depuis le coup d’envoi.
* `submit` exige min `7` joueurs (`minPlayers: 7`).
* Un draft ne peut pas écraser une version `submitted`.
* Positions détaillées autorisées: `GK`, `DG`, `DC`, `DD`, `MDC`, `MC`, `MOC`, `MG`, `MD`, `AG`, `AD`, `BU`.
* `saveDraft`/`submit`: `resolveManagedClubIdForMutation(..., includeCoach: true)`.
* Vues:
  * `ADMIN/MODERATOR` peuvent lire `allVersions`,
  * profils non-admin lisent la dernière version `submitted` uniquement.
* Actions auditées override: `SAVE_DRAFT_LINEUP_OVERRIDE`, `SUBMIT_LINEUP_OVERRIDE`.

## Gestion de compte

Sources: `convex/auth/guards.ts`, `convex/auth/accountStatus.ts`, `convex/social/users.ts`, `convex/admin/users.ts`.

* Par défaut, absence de `accountStatus` = `ACTIVE`.
* `assertAccountAccessAllowed` applique l’ordre:
  1. `DELETED` -> erreur `ACCOUNT_DELETED`;
  2. `DEACTIVATED` -> erreur `ACCOUNT_DEACTIVATED`;
  3. sanctions `APP_ACCESS` si présentes.
* `transitionAccountStatus` met à jour:
  * `users.accountStatus`,
  * `users.accountStatusUpdatedAt`,
  * `user_profiles.isActive` à `false` hors statut actif.
* Changement vers statut non-`ACTIVE` => invalidation session via `invalidateUserSessions`.
* Self-service `deleteOwnAccount`:
  * refusé si l’acteur gère un club actif,
  * écrit `DELETE_USER` (`SELF_SERVICE_DELETE`, `source=self-service`),
  * `selfDeletedAt`,
  * purge auth et anonymisation.
* Admin only:
  * `deleteUser`, `deactivateUser`, `reactivateUser`.
  * `self` impossible à supprimer, désactiver, réactiver par la logique API.
  * `reactivateUser` refusé si `selfDeletedAt` déjà présent.
* `setUserBanState` et certains patches utilisent `allowModerator: true`, mais sans marge sur rôle/utilisateur premium/`username`.

## Avatars et assets de profil

Sources: `convex/social/users.ts`, `convex/social/profile.ts`, `convex/lib/user_avatar.ts`, `convex/web.ts`.

* La photo de profil standard est enregistrée via `updateUserProfile.avatarUrl` dans `users.image`.
* Les utilisateurs non premium peuvent définir une photo de profil standard, limitée aux uploads `/uploads/profiles` en PNG, JPG ou WEBP statique, 5 Mo maximum.
* Les GIF et images animées sont refusés pour la photo standard (`upload_objects.isAnimated` et MIME type).
* Pour un utilisateur non premium, l’avatar public ne peut pas venir de `oauthImage` ni d’un miroir OAuth (`users.image === users.oauthImage`).
* Les assets créateur (`user_profiles.avatarUrl`, `bannerUrl`, `logoUrl`) restent gérés par `updatePremiumAssets` et nécessitent Premium.
* Les bannières, logos et indicateurs animés du profil public restent masqués pour les non-premium.

## Matrice Auth / Admin / Modérateur

Sources: `convex/admin/_shared.ts`, `convex/admin/users.ts`, `convex/social/_shared.ts`, `convex/competition/match_lineups.ts`, `convex/competition/match_postponement_mutations.ts`.

| Surface                                          | ADMIN                | MODERATOR                                        | USER                 |
| ------------------------------------------------ | -------------------- | ------------------------------------------------ | -------------------- |
| `requireAdminActor` par défaut                   | oui                  | non                                              | non                  |
| Surfaces admin avec `allowModerator: true`       | oui                  | oui                                              | non                  |
| Publication report refusé / adminDirect          | oui                  | non                                              | non                  |
| Publication `season_awards`                      | oui                  | non                                              | non                  |
| Publication `publishTotw`                        | oui                  | non                                              | non                  |
| Gestion de compte (delete/deactivate/reactivate) | oui                  | non                                              | non                  |
| Changement de rôle (`USER`/`ADMIN`/`MODERATOR`)  | oui                  | non                                              | non                  |
| Override lineup                                  | oui                  | oui                                              | non                  |
| Gestion de reports côté équipe                   | oui                  | oui                                              | non                  |
| Actions standard club/ligue/match                | selon logique métier | selon logique métier + surfaces `allowModerator` | selon logique métier |

## Awards, TOTS, TOTW

Sources: `convex/competition/season_awards.ts`, `convex/competition/totw.ts`.

`publishSeasonAwards`:

* Restriction `ADMIN`.
* `league.status` requis `COMPLETED` ou `ARCHIVED`.
* `mvpSelectionKey` requis et `bestGkSelectionKey` requis.
* `totsSelectionKey` optionnel.
* `replaceExisting` supporté.
* Notifications et emails activés par défaut.

TOTS (`season_awards`, `award_recipients`):

* `TOTS_FORMULA_VERSION = 'tots-v3'`.
* Limites vérifiées: individuel `70`, distinction `10`, collectif `20`.
* Bonus: `TOTS_TOTW_BONUS = 2` et `TOTS_MATCH_MVP_BONUS = 2`.
* Seuils: `TOTS_MIN_SEASON_MATCHES = 10`, `TOTS_MIN_POSITION_MATCHES = 15`.
* `TOTS_TOTW_MAX = 6`, `TOTS_MATCH_MVP_MAX = 4`.
* La régularité ne donne plus de points séparés; elle est portée par les seuils
  d'éligibilité saison/poste.

### Felicitations palmarès (B4)

Source métier: `convex/social/award_congrats.ts`.

* `congratulate({ awardId, entryId, reaction, actorUserId? })`
  * authentification requise (`requireActorUser`).
  * résout `awardId` et `entryId` via `id` métier (`by_external_id`).
  * vérifie que `entry.seasonAwardId === award.id`.
  * `reaction` strictement dans `MERITE | QUELLE_SAISON | FIER | RENDEZVOUS`.
  * idempotent si la même réaction est déjà posée par l’utilisateur pour l’entrée.
  * en cas de changement de réaction, remplacement atomique: l’ancienne est décrémentée, la nouvelle incrémentée, total inchangé.
* `uncongratulate({ awardId, entryId, actorUserId? })`
  * authentification requise (`requireActorUser`).
  * suppression de la réaction de l’utilisateur s’il en existe une.
  * décrémente le compteur `award_congratulation_counts`.
  * idempotent si aucune réaction n’existe.
* `getAwardCongratulations({ awardId, actorUserId? })`
  * résolution actor via `resolveActorUserId` (valeur `mine: null` si déconnecté).
  * retourne un objet indexé par `entryId`:
    `{ count, byReaction, mine }`.
  * bornage de lecture sur les entrées de l’award (`take(250)`).

Invariants B4:

* `award_congratulation_counts` est la source de vérité de lecture (pas de
  `collect().length` pour les compteurs).
* `by_entry_user` sert au calcul `mine`.
* `award_congratulations` contient au plus 1 ligne active par (`entryId`, `congratulatorUserId`).

TOTW:

* Réservé `ADMIN`.
* `publishTotw` exige preview et période résolue (`periodStartAt` / `periodEndAt` non nuls).
* Requiert au moins 11 joueurs dans la sélection.
* Républication: remplacement de l’entrée existante pour la même période.
* Version active en production: `TOTW_CALC_VERSION = 'totw-v3'`.

## Bannière d'annonces (feed de croissance)

Sources : `convex/social/announcements.ts` (`listBannerAnnouncements`, `upsertAnnouncementBySource`, `archiveAnnouncementBySource`, `BANNER_PRIORITY`, `BANNER_TTL_MS`), `web/src/components/announcements/AnnouncementTicker.tsx`.

### Eligibilité bannière

`AnnouncementTicker` affiche les annonces dont :

* `status = ACTIVE`,
* `placement` est `BANNER_GLOBAL` ou `ALL_GLOBAL`,
* la fenêtre temporelle `[startsAt, endsAt]` est valide à l'instant de lecture.

### Gouvernance du feed (`listBannerAnnouncements`)

Le feed applique deux étages, dans l'ordre :

1. **Étage ops** (`priority ≥ 50`) — triés par priorité décroissante, toujours placés en tête.
2. **Étage croissance** (`priority < 50`) — triés par récence décroissante, soumis à un plafond par source :

| `sourceType`                          | plafond croissance |
| ------------------------------------- | ------------------ |
| `ARTICLE`                             | 2                  |
| `TRANSFER`                            | 1                  |
| `TOTW`                                | 1                  |
| `SEASON_AWARD`                        | 1                  |
| `AWARD`                               | 1                  |
| `ADMIN`, `LEGACY`, `SYSTEM`, indéfini | illimité           |

**Plafond global** : 6 messages affichés au total (ops + croissance, par ordre ops-first).

### Émetteurs automatiques

Les annonces systèmes sont créées/mises à jour via `upsertAnnouncementBySource` (dédupliqué par la clé composite `(sourceType, sourceId)`) et retirées via `archiveAnnouncementBySource` (passage en `ARCHIVED`, idempotent). Leur déclencheur et leur archivage sont décrits ci-dessous.

| Source                             | `sourceType`    | `placement`     | priorité | TTL / `endsAt`                 | `href`                | Déclencheur                                                   | Archivage                  |
| ---------------------------------- | --------------- | --------------- | -------- | ------------------------------ | --------------------- | ------------------------------------------------------------- | -------------------------- |
| Forfait club (`FULL_COMPETITION`)  | `CLUB_FORFEIT`  | `BANNER_GLOBAL` | 80       | 14 j                           | —                     | `applyClubForfeit`                                            | `revertClubForfeitImpacts` |
| Forfait club (`POST_MERCATO_ONLY`) | `CLUB_FORFEIT`  | `BANNER_GLOBAL` | 60       | 14 j                           | —                     | `applyClubForfeit`                                            | `revertClubForfeitImpacts` |
| Sanction utilisateur               | `USER_SANCTION` | `BANNER_GLOBAL` | 55       | 7 j (ou `expiresAt` si fourni) | —                     | `createSanction` (chemin admin manuel, pas le chemin forfait) | `deleteSanction`           |
| TOTW                               | `TOTW`          | `ALL_GLOBAL`    | 20       | 7 j                            | `/totw`               | `publishTotw`                                                 | `deleteTotw`               |
| Palmarès                           | `SEASON_AWARD`  | `ALL_GLOBAL`    | 18       | 7 j                            | `/palmares/{awardId}` | `publishSeasonAwards`                                         | `unpublishSeasonAwards`    |
| Transfert accepté                  | `TRANSFER`      | `BANNER_GLOBAL` | 12       | 48 h                           | `/mercato`            | `respond` (acceptation transfert)                             | aucun (expiration via TTL) |
| Article publié                     | `ARTICLE`       | `BANNER_GLOBAL` | 10       | 48 h                           | `/journal/{slug}`     | `publish`                                                     | `unpublish` / `archive`    |

### Tracking clics

Chaque clic sur un CTA de la bannière est capturé via PostHog (`posthog.capture('banner_cta_click', { announcementId, tag, sourceType })`). Le lien de destination est augmenté du paramètre `from=banner` (ajout de `?from=banner` ou `&from=banner` selon la présence d'un `?` existant).

## Messagerie / notifications

Sources: `convex/social/recipient_groups.ts`, `convex/messaging/notifications.ts`, `convex/messaging/bus.ts`.

* Groupes résolus par code:
  * `ADMINS`: `ADMIN`
  * `MODERATORS`: `MODERATOR`
  * `STAFF`: `ADMIN`, `MODERATOR`
  * `USERS`: `USER`
  * `ALL_ACTIVE_USERS`: `USER`, `ADMIN`, `MODERATOR`
* `resolveRecipientGroupMembers` applique un filtre `CONTACT` via `hasRestrictionEffect`.
* Côté bus:
  * `MAX_MATCH_TARGETED_IN_APP_RECIPIENTS = 40`.
  * Les événements match ciblés doivent émettre exactement un plan in-app.
  * `recipientUserIds`/`managerUserIds` doivent correspondre strictement au ciblage.
* Sources qui explicitent `recipientUserIds` requis:
  * `messaging/match.scheduling.*`
  * `messaging/match.postponement.*`
  * `messaging/league.calendar.deleted`
* Les événements de match ciblent explicitement des utilisateurs (`users` / `userIds`), pas de groupe implicite.

## Sanctions (vérifié)

Sources: `convex/schema.ts`, `convex/admin/_shared.ts`, `convex/admin/users.ts`, `convex/lib/person_sanctions.ts`.

* `targetType` vérifiés: `PERSON`, `CLUB`.
* `effectScopes` vérifiés: `APP_ACCESS`, `SPORT_ELIGIBILITY`, `VISIBILITY`, `CONTACT`.
* États vérifiés: `ACTIVE`, `ENDED_MANUAL`, `ENDED_EXPIRED`, `ENDED_REPLACED`.
* Sources de fin: `MANUAL`, `AUTO_EXPIRED`, `REPLACED`.
* Typologies vérifiées (liste schéma):\
  `BAN_PLAYER`, `SUSPENSION`, `BAN_CLUB`, `PENDING_DISCIPLINARY`, `POINTS_DEDUCTION`, `FINE`, `FORFAIT`, `PERSON_BAN`, `PERSON_SUSPENSION`, `PERSON_DISCIPLINARY_REVIEW`, `COMPETITION_BAN`.

## Recherche (invariants)

* Index utilisés via logique métier: annonces, articles, audits, litiges, clubs, matchs, notifications, sanctions, transferts, profils utilisateurs, utilisateurs.

## Zones non vérifiées / à confirmer

* Les règles de livraison email (queues/retry/backoff précis) ne sont pas exhaustivement couvertes ici.
* Le détail complet de la visibilité utilisateur hors restriction `CONTACT` n’est pas reconstruit intégralement dans ce document métier.
