Le pré-vol de quota et la suppression du blob sont livrés. Le contrôle autoritaire à Create ne peut pas exister tel qu'il était écrit : manager-app crée la ligne en annonçant sizeBytes puis téléverse, donc il n'y a aucun blob à interroger à cet instant. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
26 KiB
Médias & stockage — Plan
Contexte : décisions prises le 2026-08-07, en amont de la migration Postgres. Ce chantier est un prérequis à la release de migration — il touche des données déjà écrites (URL, tailles), contrairement au RAG (
rag-pgvector-integration-plan.md) qui est purement additif et peut suivre.
Pourquoi maintenant
La règle qui a servi à trancher :
Ce qui touche aux données déjà écrites se fait pendant la migration. Ce qui est additif attend.
Les URL absolues Firebase sont dispersées dans Resource.Url, ImageSource, VideoSource et le contenu JSONB des sections. Les reprendre plus tard = migrer les mêmes données deux fois. Une table ContentEmbedding vide, elle, s'ajoute quand on veut.
1. Compression des images côté client
État actuel : aucune compression
Le chemin d'upload réel (resources_screen.dart) pousse les octets bruts du fichier choisi :
UploadTask uploadTask = ref.putData(platformFile.bytes!, metadata);
Une photo 4K de 8 Mo part entière. Le redimensionnement + watermark qui existe dans ResourceController.Upload (ImageHelper.ResizeAndAddWatermark) est du code mort : il vit dans l'endpoint legacy base64 que manager-app n'appelle plus depuis la bascule Firebase.
ImageHelpers'appuie surSystem.Drawing.Common, qui ne fonctionne plus sur Linux depuis .NET 6 sans libgdiplus — et l'image finale estaspnet:8.0Linux. Ce code lèverait probablement une exception s'il était appelé. Si un traitement serveur redevenait nécessaire un jour, passer par ImageSharp ou SkiaSharp, pasSystem.Drawing.
Décision : compression navigateur avant upload
Le fichier ne transite plus par le backend (navigateur → Firebase en direct), donc le redimensionnement serveur est structurellement impossible sans réintroduire un proxy. Ça se fait côté client.
Paramètres retenus : 2560 px sur le grand côté, JPEG qualité 82.
Le raisonnement : les visiteurs peuvent zoomer sur un détail d'œuvre ou lire un cartel. Or pour du zoom, c'est la résolution qui compte, pas le facteur de qualité — une image 2560 px à q80 se regarde bien mieux agrandie qu'une 1920 px à q95, où l'on ne fait qu'agrandir du flou proprement compressé. D'où : dimension généreuse, qualité légèrement rognée.
| Avant | Après | |
|---|---|---|
| Photo 4K typique | 8 Mo | ~600-700 Ko |
| Gain | ~12× sur le stockage et sur chaque consultation visiteur |
Implémentation
manager-app est un back-office web : flutter_image_compress est hors jeu (natif uniquement). Deux voies :
Package image (pur Dart, multiplateforme) — retenu par défaut :
final decoded = img.decodeImage(platformFile.bytes!);
final resized = img.copyResize(decoded!, width: 2560, maintainAspect: true);
final bytes = Uint8List.fromList(img.encodeJpg(resized, quality: 82));
Canvas natif (createImageBitmap + OffscreenCanvas + toBlob) — accéléré matériellement, ne fige pas l'UI. Mais code web-only, donc imports conditionnels puisque manager-app builde aussi macOS/Windows. À basculer si le figement de l'UI sur une 4K devient gênant.
Le format retenu est JPEG, pas WebP. Le canvas sait encoder du WebP nativement (~30 % de moins à qualité égale), mais c'est 30 % sur un problème déjà réglé à 12× — pas de quoi complexifier la chaîne aujourd'hui. À reconsidérer seulement si le poids redevient un sujet, et en n'oubliant pas alors la table
Content-Type → extensionde visitapp.
Règles
- Convertir les PNG photo en JPEG. Garder le PNG uniquement s'il y a de la transparence (logos, pictos). Un PNG de photo pèse 5 à 10× son équivalent JPEG — gros gain souvent oublié.
- Sauter la compression sous ~300 Ko. Inutile de ré-encoder.
- Ne pas conserver d'original haute définition. Le musée a ses fichiers sources sur ses machines ; le CMS est un canal de diffusion, pas un service d'archivage.
Vidéo
Le transcodage navigateur n'est pas réaliste (ffmpeg.wasm trop lourd et trop lent). Plafond de taille à l'upload + orienter vers YouTube/Vimeo — SectionVideo supporte déjà les deux, l'échappatoire existe.
2. Watermark — flag d'instance
Aujourd'hui codé en dur sur un id d'instance, avec un TODO qui traîne :
bool isFort = instanceId == "633ee379d9405f32f166f047"; // TODO add watermark in configuration and model
Décision : colonne booléenne sur Instance, basculée manuellement en base. Pas d'UI, pas de support, pas de client qui s'en sert de travers. Si la demande vient, ajouter l'écran plus tard est trivial.
Deux conséquences :
- Le flag doit remonter jusqu'au client. Le watermark s'appliquant désormais dans le navigateur au moment de l'upload, il ne suffit pas qu'il existe en base : il doit être exposé dans la config d'instance que manager-app lit au démarrage.
- Un flag invisible s'oublie. Le nommer explicitement et le documenter dans la fiche d'instance — dans six mois personne ne saura qu'il existe.
Le watermark est appliqué à l'upload, donc cuit dans le fichier stocké : il protège en aval quel que soit le canal.
3. IStorageService + StoragePath
Le problème à corriger
Resource.Url stocke une URL de téléchargement Firebase absolue, à jeton. Le client Flutter la génère après l'upload et la renvoie au backend. Conséquences :
- c'est le client qui décide où vivent les fichiers
- changer de fournisseur imposerait de réécrire chaque URL en base, y compris celles noyées dans les champs JSONB des sections
Décision
Une colonne StoragePath (clé relative) devient la source de vérité, l'URL absolue étant résolue à la lecture derrière une interface :
public interface IStorageService
{
string GetPublicUrl(string storagePath);
Task<long> GetSizeAsync(string storagePath);
Task DeleteAsync(string storagePath);
}
Une seule implémentation pour l'instant : Firebase. C'est ce qui transforme une migration future de « réécrire toutes les URL » en « changer une implémentation et recopier les blobs ».
Le chemin est déjà déterministe
storage.ref().child('pictures/${instanceId}/${Path.basename(newResource.id)}')
pictures/{instanceId}/{resourceId}, sans extension. Donc inutile de parser les URL à jeton pour quoi que ce soit : la clé se reconstruit depuis la ligne Resource. Ça simplifie le backfill (§4).
Anomalie du flux actuel
La ligne Resource est créée avant l'upload, et l'URL écrite après coup par le client. Si l'upload échoue ou que l'onglet se ferme, il reste une ligne sans URL et sans fichier — l'orphelin dans l'autre sens. À traiter avec le même inventaire.
4. Quota de stockage
État actuel — relevé dans le code le 2026-09-07
⚠️ Cette section était en retard sur le code. Les points 1 et 3 sont livrés, et le point 2 tel qu'il était écrit ne peut pas fonctionner. Corrigé ci-dessous.
| # | Correction prévue | État réel |
|---|---|---|
| 1 | Endpoint de pré-vol | ✅ livré — ExceedsStorageQuota (ResourceController.cs:966) résout quota d'instance puis quota de plan, et garde les deux chemins (:246 multipart, :330 JSON) |
| 2 | Contrôle autoritaire à Create, taille lue depuis Firebase |
⛔ infaisable tel quel — voir ci-dessous |
| 3 | Delete doit supprimer le blob |
✅ codé — blob avant ligne, 502 qui interrompt plutôt que de créer un orphelin (:581). Mais inerte : Firebase:StorageBucket vaut "", donc IsConfigured est faux, DeleteAsync renvoie NotConfigured, et l'appelant ne bloque que sur Failed. Verrou = la carte kanban « Poser Firebase:StorageBucket » |
Pourquoi le point 2 ne peut pas être un contrôle synchrone
manager-app crée la ligne en annonçant sizeBytes, puis téléverse le blob. À l'instant
du Create, le blob n'existe pas encore : il n'y a rien à interroger dans Firebase. Le
commentaire du code le dit déjà.
Le trou reste réel — Create croit le client, donc un appelant qui envoie 0 passe sous le
quota définitivement — mais le correctif est une réconciliation asynchrone, pas une
vérification à l'écriture :
- un job Hangfire récurrent (il y en a déjà huit, Startup.cs:359-401) qui repasse sur les blobs, lit la taille réelle et corrige
SizeBytes, en signalant les lignes où le déclaré divergeait ; - la logique de sondage existe déjà dans le backfill (:912) : il s'agit de la rendre récurrente et de ne plus la limiter à
SizeBytes == 0; - il faut ajouter une lecture de taille à
IResourceBlobService, qui n'expose aujourd'hui queDeleteAsync(ResourceBlobService.cs:32).
Et le garde-fou déclaratif, qui n'existe pas du tout
Il n'y a aucun storage.rules dans le repo. Comme manager-app téléverse directement vers
Firebase, une règle request.resource.size < N est le seul contrôle qu'un client ne puisse pas
contourner — un endpoint de pré-vol l'est par construction. Ça ne plafonne pas le total, mais
ça bloque le fichier de 4 Go.
Un plafond de taille par bucket n'existe pas — vérifié le 2026-09-07
Ni chez Google, ni chez OVH. À ne pas rechercher une seconde fois :
- GCS / Firebase Storage : stockage illimité au niveau bucket, seule limite 5 TiB par objet (doc). Le plafond de 1 To concerne les Rapid Buckets zonaux.
- OVH Object Storage S3 : 100 buckets par projet, 48 TiB par objet, nombre d'objets « unlimited », aucun quota de taille (limitations).
- L'ancienne interface Swift d'OVH avait un
X-Container-Meta-Quota-Bytes— c'est Swift, pas S3, et c'est l'offre legacy. Ne s'applique pas.
Et même s'il existait, ce serait la mauvaise granularité : le quota est par instance, alors que tous les clients partagent un bucket préfixé (§ « Un bucket global, préfixé par instance »). Le quota vit en base, il ne peut pas vivre dans le stockage.
5. Backfill des tailles existantes
Les Resource actuelles ont SizeBytes = 0 : sans reprise, tous les clients démarrent à 0 octet consommé et le quota ne veut rien dire.
À faire pendant la migration Postgres, qui touche déjà chaque ligne Resource.
État mesuré en base le 2026-08-09 (45 ressources, instance du Fort)
| Colonne | Lignes renseignées |
|---|---|
StoragePath |
0 — Create depuis le 2026-08-10 ; les 45 lignes existantes restent à backfiller |
SizeBytes > 0 |
0 — le quota de stockage vaut donc 0 pour tout le monde ; renseignée à Create depuis le 2026-08-10 |
FileName |
0 |
⚠️ Le périmètre n'est pas « chaque ligne Resource ». Sur 45 lignes :
- 37 ont un blob Firebase au chemin attendu → seules celles-là sont concernées ;
- 7 sont des types URL (
ImageUrl,VideoUrl,JSONUrl— Wikipedia, YouTube,agenda.php,swagger.json) : elles pointent hors du bucket et n'ont aucun blob. Leur donner unStoragePathserait faux, et les compter dans le quota le serait aussi. À exclure explicitement ; - 1 est de type fichier (
Video, « Exemple webm ») mais sans aucune URL : une ligne sans fichier, l'inverse d'un blob orphelin. C'est le premier cas que l'inventaire doit remonter.
Deux moitiés, dont une gratuite
Le chemin étant déterministe pour les types fichier, StoragePath se backfille en SQL pur, sans lire le bucket :
UPDATE "Resources" SET "StoragePath" = 'pictures/' || "InstanceId" || '/' || "Id"
WHERE "Type" NOT IN (2, 3, 7) -- ImageUrl, VideoUrl, JSONUrl : pas de blob
AND "Url" IS NOT NULL; -- écarte les lignes sans fichier
Seul SizeBytes exige de lister Firebase. Découper ainsi permet de faire la moitié utile immédiatement.
Prérequis à ne pas inverser :✅ Fait le 2026-08-10 —StoragePathetSizeBytesdoivent d'abord être écrits àCreate.ResourceController.Createécrit désormaisStoragePath = pictures/{instanceId}/{resourceId}etSizeBytes, en excluant les types URL (2, 3, 7). Côtémanager-app,resources_screen.dartrenseignesizeBytesavant l'appel de création (la taille est connue avant l'upload) : elle n'arrivait jusque-là qu'auUpdatepost-upload. Le backfill peut donc se faire une seule fois.⚠️ Reste non autoritaire : la taille vient du client. Le contrôle relu depuis Firebase est le point 2 du §4, pas encore fait.
1. Lister le bucket Firebase une fois (paginé) → map { objectPath: sizeBytes }
2. Pour chaque Resource : reconstruire la clé `pictures/{instanceId}/{id}`, chercher dans la map
3. Trouvé → SizeBytes = taille réelle, StoragePath = clé
Pas trouvé → journaliser (référence morte : ligne sans fichier)
4. Objets du bucket sans Resource correspondante → journaliser (blob orphelin)
Lister le bucket une seule fois plutôt qu'un appel de métadonnées par ressource : quelques appels au lieu de plusieurs milliers.
Le bonus : l'étape 4 donne l'inventaire des blobs orphelins, qui existent forcément puisque Delete n'a jamais supprimé les fichiers. Tu récupères probablement du stockage réel avant même de mettre les quotas en service.
Ne pas purger automatiquement. Le script rapporte, il ne supprime pas. Un défaut de rapprochement supprimerait des fichiers vivants. On regarde la liste, on valide, on purge ensuite.
Bascule pour les clients existants
Une fois les tailles réelles connues, certains clients seront peut-être déjà au-dessus du quota de leur plan. Ne pas les bloquer du jour au lendemain : calculer, regarder les chiffres, puis décider — quota sur les seuls nouveaux uploads, ou dépassement toléré avec alerte. À trancher sur les vrais chiffres.
6. Fournisseur de stockage — décision et déclencheur
Situation
Bucket en Europe — vérifié le 2026-09-07 : gs://mymuseum-3b97f.appspot.com est en EU, multi-region. Or les quotas gratuits Firebase Storage ne s'appliquent qu'aux régions us-central1, us-west1 et us-east1. Il n'y a donc aucun quota gratuit : la facturation court depuis le premier octet.
📏 Le volume réel, mesuré le 2026-09-07 : 1,43 Go
gcloud storage du -s gs://mymuseum-3b97f.appspot.com # 1433427569 octets
Ce chiffre était attendu du backfill (§5) alors que le bucket le connaissait depuis le début. Le backfill reste nécessaire pour la comptabilité par client et l'inventaire des orphelins, mais plus pour dimensionner un stockage ou arbitrer un fournisseur.
Conséquence directe : les médias tiennent dans le bucket de sauvegarde déjà créé
(gs://unov-myinfomate-backups, préfixe media/), et aucun second fournisseur n'est
nécessaire. Voir manager-service/ManagerService/Deployment/backup/README.md.
🔍 Inventaire des orphelins — première moitié faite le 2026-09-07
Obtenu en comparant les 2408 objets du bucket aux instances réelles, sans backfill. ~27 objets ne peuvent appartenir à aucun client :
| Quoi | Objets | Pourquoi c'est un orphelin |
|---|---|---|
pictures/63514fd67ed8c735aaa4b8f1/ |
19 | L'id finit par f1 ; l'instance MyInfoMate est …f2. Un caractère d'écart — instance supprimée ou faute de frappe historique |
pictures/3181820d61fb46639d234dc7/ |
2 | C'est MNAHA, qui existe en base de dev mais dans aucune instance de la prod Mongo |
| Racine du bucket | 6 | giphy.gif, All 24 Cybertruck Accessories Revealed!.mp4, file_example_{AVI,MOV,WEBM}, video_2023-12-13_14-49-12.mp4 — des fichiers de test |
Les 6 fichiers de la racine sont le cas le plus net : ils ne suivent pas le schéma
pictures/{instanceId}/{id}, donc aucun code actuel ne peut les produire ni les désigner.
Ils sont invisibles au quota et au StoragePath, et facturés depuis 2023.
Répartition des 2402 objets restants, par instance :
| Instance | Objets |
|---|---|
Fort Saint Héribert (633ee379…) |
1204 |
VisitNamur (65c5e576…) |
711 |
MDLF (65ccc672…) |
430 |
MyInfoMate démo (63514fd6…f2) |
36 |
Fort Saint Héribert porte la moitié du bucket — cohérent avec l'instance la plus ancienne, et avec le fait qu'elle n'a plus rien modifié depuis avril (voir STATUS.md §4).
⚠️ À confirmer avant toute suppression : croiser ces 27 objets avec la table Resources. Le
raisonnement ci-dessus repose sur les préfixes, pas sur les références.
✅ Versioning activé le 2026-09-07 — avant la clé, comme il fallait
Le bucket n'avait ni versioning ni aucune règle de cycle de vie. Les deux sont posés :
gcloud storage buckets update gs://mymuseum-3b97f.appspot.com --versioning
# + lifecycle : {"condition":{"daysSinceNoncurrentTime":30},"action":{"type":"Delete"}}
L'ordre importait. Tant que Firebase:StorageBucket est vide, Delete ne supprime aucun blob
et le risque est nul. Le jour où cette clé est renseignée, la suppression devient réelle — un
mauvais préfixe ou un bug emporterait les médias d'un client sans retour. Le filet est
désormais antérieur à ce basculement.
La purge des versions non courantes à 30 jours évite que les écrasements s'accumulent sans fin.
Et le bucket portait déjà une soft_delete_policy de 7 jours depuis mars 2024 — seconde couche.
Tarifs Blaze (bucket legacy *.appspot.com, hors quotas gratuits) :
| Poste | Prix |
|---|---|
| Stockage | 0,026 $/Go/mois |
| Bande passante sortante | 0,12 $/Go |
| Ops upload | 0,05 $/10K |
| Ops download | 0,004 $/10K |
Pourquoi la facture reste faible aujourd'hui
Le canal actuel est la tablette kiosk : elle télécharge le contenu une fois et sert hors ligne. L'egress est donc proportionnel au nombre d'installations, pas au nombre de visiteurs. Le stockage seul ne fait jamais une grosse facture (50 Go ≈ 1,30 $/mois), et l'ingress est gratuit — un client qui déverse toutes ses données ne coûte quasiment rien.
Ce qui change avec le mobile
mymuseum-visitapp sur les téléphones des visiteurs rend l'egress proportionnel à la fréquentation.
Comportement confirmé : visitapp ne pré-télécharge pas le contenu, et ne doit pas le faire. Le chargement est à la demande — un visiteur qui consulte trois POI coûte trois POI. C'est le bon défaut et il faut le préserver.
L'exception, c'est la visite hors ligne : un visiteur peut télécharger explicitement une visite complète, pour les lieux sans réseau (le Fort Saint Héribert et ses murs épais). C'est là — et pratiquement là seulement — que vit la facture d'egress.
La visite hors ligne est le poste de coût
Ordre de grandeur pour une visite type de 100 images + audio :
| Sans compression | Avec compression (2560 / q82) | |
|---|---|---|
| Poids du paquet | ~1 Go | ~150 Mo |
| Coût par téléchargement (0,12 $/Go) | ~0,12 $ | ~0,018 $ |
| 900 téléchargements/mois (30/jour) | ~108 $/mois | ~16 $/mois |
C'est le chiffre qui justifie la compression à lui seul. Le reste de l'app, en chargement à la demande, pèse une fraction de ça.
Mécanisme actuel — downloadConfiguration.dart
configurationExport(configurationId, language)→ExportConfigurationDTO(sections + toutes les ressources)- Création d'un dossier local
{localPath}/{configurationId} - Filtre incrémental, puis téléchargement séquentiel des ressources retenues
- Insertion des métadonnées dans le SQLite local
Le coût d'egress est déjà maîtrisé — il existe bien un filtre incrémental :
var resourcesToDownload = exportConfigurationDTO.resources!.where((resource) =>
resource.type != ResourceType.ImageUrl && ... &&
resource.url != null &&
!fileList.any((fileL) => fileL.uri.pathSegments.last.contains(resource.id!)));
Une ressource dont l'id apparaît déjà dans un nom de fichier local est sautée. Un second téléchargement de la même visite ne re-télécharge donc rien.
Le vrai problème n'est pas le coût, c'est la fraîcheur
Le filtre teste la présence du fichier, pas sa version. Or lorsqu'un client remplace une image dans le CMS, Update conserve l'id de la ressource et ne change que l'Url.
Conséquence : une ressource modifiée n'est jamais re-téléchargée. Le fichier local existe, l'id correspond, elle est sautée. Le visiteur qui avait déjà téléchargé la visite garde l'ancienne image indéfiniment. Les ajouts fonctionnent, les mises à jour non.
C'est un bug de correction, pas d'optimisation — et il est invisible côté client, qui voit son contenu à jour dans le CMS et sur le web.
Correctif : comparer une version, pas une présence. Resource.DateUpdate existe déjà sur l'entité — le stocker dans le SQLite local à côté du chemin, et re-télécharger quand la valeur distante est plus récente. Alternative plus robuste : un hash de contenu, qui évite de re-télécharger quand seul un champ de métadonnée a bougé.
Le hors ligne est plus cassé que ça. Le
switchdu backend qui collecte les ressources internes des sections (images d'articles, audios, quiz, cartes, sliders) est entièrement commenté dansConfigurationController.Export, et le bloc symétrique côté visitapp aussi. Une visite « téléchargée » ne contient aujourd'hui que des métadonnées et quelques vignettes. Analyse complète et plan de remise en état :offline-visit-plan.md.
Trois autres constats dans le même fichier
- La purge des fichiers obsolètes est désactivée. La liste
resourceToDeleteest calculée puis ledeleteSync()est commenté (// Preserve call to firebase // TODO uncomment if needed), etcleanLocalResourcesest commenté aussi. Le stockage sur le device grossit indéfiniment. Pas un coût d'egress, mais un problème sur un téléphone de visiteur. - Seuls les types
ArticleetQuizsont embarqués (TODO explicite dans le code pour Game, Menu, Map, PDF, Video, Slider, Web, Weather, Agenda). La visite hors ligne est donc partielle aujourd'hui — à savoir avant de la vendre comme argument. - L'extension est déduite du
Content-Typevia une table de correspondance qui renvoie"unknown"en cas d'absence. Or elle contientaudio/mp3, qui n'est pas un type MIME standard — le vrai MIME d'un MP3 estaudio/mpeg, absent de la table. Les audios téléchargés atterrissent donc probablement déjà en.unknown. À vérifier sur un device, c'est du contenu hors ligne cassé sans bruit. (Accessoirement,image/webpn'y est pas non plus — sans conséquence tant qu'on reste en JPEG, à corriger le jour où on changerait de format.)
Piste
Servir un paquet pré-assemblé (archive générée au save) plutôt que N requêtes individuelles : l'egress est identique, mais le nombre d'opérations de download facturées chute et c'est nettement plus robuste sur une connexion instable — typiquement à l'entrée d'un fort.
Décision : rester sur Firebase, garder l'abstraction
L'abstraction (StoragePath + IStorageService) est la préparation. Elle réduit une migration future à : changer d'implémentation, recopier les blobs, aucun changement de schéma. Migrer le fournisseur maintenant n'apporte rien de plus et ajoute un chantier pendant la migration Postgres.
Le levier immédiat sur le coût, c'est la compression (12×), pas le fournisseur.
Si migration un jour : OVH Object Storage, avec ses conditions
Ce que ça implique concrètement
- S3-compatible →
AWSSDK.S3pointé sur l'endpoint OVH - L'upload direct navigateur passe par des URL présignées : le backend génère une URL PUT temporaire, le client uploade dessus. Même principe, autre plomberie. Plus la config CORS du bucket.
- Firebase donne des URL de téléchargement permanentes à jeton, stockables en base. En S3 : soit objets publics (aucun contrôle d'accès), soit URL présignées qui expirent — donc générées à la lecture.
StoragePathrend ce basculement indolore. - Les push notifications restent chez Firebase de toute façon.
Un bucket global, préfixé par instance — pas un bucket par client. C'est déjà le modèle actuel (pictures/{instanceId}/{id}). Un bucket par client impose d'en provisionner un à chaque onboarding, gérer des quotas de nombre de buckets et plus d'IAM, pour une isolation déjà obtenue par le préfixe et une comptabilité de quota qui vit en base. À réserver à un client qui exigerait une séparation physique — improbable avec des musées.
Durabilité : la sauvegarde devient obligatoire. OVH a un précédent — l'incendie de SBG2 (Strasbourg, 2021) a détruit les données de clients sans sauvegarde ailleurs. Google Cloud Storage annonce 11 neufs de durabilité et n'a pas cet historique. Partir chez OVH impose donc une sauvegarde des médias dans une autre région, ce qui mange une partie de l'économie sur l'egress et ajoute de l'ops.
Déclencheur de réouverture
Quand la facture mensuelle Firebase dépasse ~30 €. En dessous, ça ne vaut pas le temps passé. Mettre une alerte de budget GCP dès maintenant — c'est cinq minutes et c'est la vraie protection contre la surprise.
Ordre d'implémentation
| # | Tâche | Quand |
|---|---|---|
| 1 | Alerte de budget GCP | tout de suite, 5 min |
| 2 | Compression client 2560 px / q82 + PNG→JPEG | avant la release |
| 3 | Colonne StoragePath + IStorageService (impl. Firebase) |
migration |
| 4 | SizeBytes renseigné à Create, lu depuis Firebase |
migration |
| 5 | Backfill tailles + StoragePath + inventaire orphelins |
migration |
| 6 | Flag watermark sur Instance + exposition dans la config |
migration |
| 7 | Pré-vol quota + contrôle autoritaire + Delete du blob |
avant les uploads documentaires |
| 8 | Remise en état de la visite hors ligne → offline-visit-plan.md |
bugs — dès que possible |
Liens
rag-pgvector-integration-plan.md— formats de documents, extraction, ingestion (dépend de §3 et §4 de ce doc)myinfomate-ai-persona-analysis.md— vision d'ensemble