spécification “Studio + module minimal” (prête à exécuter)


Partie haute @Antonio 


Partie basse de la page @Faniry cf SUPABASE 

Très bien. Ci-dessous une spécification “Studio + module minimal” (prête à exécuter) pour :

  • Bouton “Ouvrir média” (signature lazy refresh)
  • Création automatique du stub ir.attachment(type='url')
  • Indexation Odoo Documents (documents.document + documents.folder)
  • Record rules (héritées du Case)
  • Contrat API du Media Gateway (signature / révocation / audit)

Je structure en tableaux “copiable”.

0) Hypothèses d’architecture (stables)

SujetChoix recommandé
Stockage des gros médiasExterne (Supabase Storage ou S3), jamais en ir.attachment(binary)
Vérité durablex_media_asset (provider + key + checksum + rétention + scope)
Vue navigableir.attachment(type='url') stub, URL signée TTL court
Rafraîchissement URLLazy refresh (au clic) via “Media Gateway”
Gouvernance & indexdocuments.folder (1 par Case) + documents.document pour indexer le stub
Attachement du stubir.attachment(res_model='x_media_asset', res_id=asset.id) (recommandé)

1) Modèles Odoo (champs) — version “Odoo Studio compatible”

1.1 x_medical_case (rappel des ajouts utiles)

ChampTypeRequisCommentaire
x_documents_folder_idM2O → documents.folderOuiFolder dédié Case (auto-créé)
x_care_team_idsM2M → res.partnerOuiBase des droits “need-to-know”
x_owner_user_idM2O → res.usersOuiResponsable interne
x_patient_idM2O → res.partnerOuiPatient

Si vous voulez une sécurité très propre : ajoutez aussi un M2M x_care_team_user_ids vers res.users (car les record rules sont plus simples avec res.users qu’avec res.partner).

1.2 x_media_asset (ajouts indispensables pour “stub + lazy refresh”)

ChampType OdooRequisValeurs / ExempleCommentaire
nameCharOuiCASE-2026-000123 – audio – 2026-01-02 16:15Nom “neutre” (pas PHI)
x_case_idM2O → x_medical_caseOuiPivot
x_patient_idM2O → res.partnerOuiRedondance utile
x_asset_typeSelectionOuiaudio/video/image/pdf/other
x_sourceSelectionOuiexternal / odoo_attachment
x_external_providerSelectionOui si externalsupabase/s3/azure/gcs/other
x_external_keyCharOui si externalcases/2026/000123/audio/call_...wavClé stable (pas URL)
x_mime_typeCharNonaudio/wav
x_size_bytesIntegerNon12345678
x_checksum_sha256CharNon...Audit intégrité
x_recorded_atDatetimeOuiCapture
x_retention_policySelectionOui30j/90j/1an/10ans
x_access_scopeSelectionOuipatient/team/expert/partnerGouvernance
x_documents_folder_idM2O → documents.folderOui= folder du CaseIndex Documents
x_stub_attachment_idM2O → ir.attachmentNonLe stub type=url
x_signed_url_last_atDatetimeNonDernier refresh
x_signed_url_ttl_sIntegerOui900TTL cible (s)
x_statusSelectionOuipending/ready/archived/revokedReco pour robustesse
x_call_idCharNonUUID FreeSWITCHCorrélation

1.3 x_expert_review_request (ajout recommandé pour partage)

ChampTypeRequisCommentaire
x_share_package_idM2O → x_share_packageNonIndustrialise partage externe
x_media_asset_idsM2M → x_media_assetOuiPièces lourdes partagées
x_consent_statusSelectionOuiTrace consentement

2) Automatisations — “Création Case” et “Création/MAJ média”

2.1 À la création d’un x_medical_case : créer le folder Documents

Méthode (Studio) : Automated Action sur x_medical_case (On Creation)

ÉlémentValeur
TriggerOn Creation
Modèlex_medical_case
Conditionnot record.x_documents_folder_id
Action“Execute Python Code” (si module) ou Server Action (si autorisé)

Pseudo-logic :

  • Créer documents.folder nommé CASE-YYYY-NNNNN – <alias>
  • Lier x_documents_folder_id

En Odoo Online, le code Python est limité. Si vous ne pouvez pas coder : faites une action manuelle + bouton “Créer dossier” sur le Case. Sinon, module minimal (recommandé).

2.2 À la création/MAJ d’un x_media_asset : créer le stub + index Documents

Déclencheur : quand x_status passe à ready (le fichier existe et est accessible).

ÉlémentValeur
TriggerOn Update
Modèlex_media_asset
Conditionrecord.x_source == 'external' and record.x_status == 'ready' and not record.x_stub_attachment_id
ActionCréer ir.attachment stub + documents.document

Stub ir.attachment (type url) :

Champ ir.attachmentValeur
namerecord.name
typeurl
urlhttps://placeholder.local/expired (ou vide)
res_modelx_media_asset
res_idrecord.id
mimetyperecord.x_mime_type (si dispo)

Index Documents documents.document :

Champ documents.documentValeur
namerecord.name
folder_idrecord.x_documents_folder_id
attachment_idstub_attachment.id
tag_idstags (Nature=Audio, Sensibilité=PHI_Haut, Rétention=90j, etc.)

Si vous ne créez pas documents.document, le stub reste visible en pièces jointes du record x_media_asset, mais vous perdez la puissance de Documents (tags, vues, workflows).

3) Bouton “Ouvrir média” (Lazy refresh) — design exact

3.1 UX cible

Sur la fiche x_media_asset :

  • Bouton primaire : Ouvrir média
  • Badge : x_status, x_access_scope, x_retention_policy
  • Alerte si x_status != ready

3.2 Fonction serveur (module minimal recommandé)

Nom : action_open_media()

Logique :

  1. Vérifier droits (Case/team/expert)
  2. Appeler Media Gateway → obtenir signed_url + expires_at
  3. Écrire stub_attachment.url = signed_url
  4. Mettre à jour x_signed_url_last_at = now()
  5. Retourner action Odoo ir.actions.act_url vers signed_url

4) Sécurité (Record Rules) — “hériter du Case”

4.1 Groupes (minimaux)

GroupeBut
group_synergia_care_teamIntervenants (lecture/écriture sur leurs Cases)
group_synergia_supervision_medSupervision médicale (lecture large + audit)
group_synergia_backoffice_opsBackoffice (accès limité, idéalement sans contenu médical)
group_synergia_expert_externalExperts externes (accès via share package/portail, pas via Documents)

4.2 Record rules (proposées)

A) x_medical_case

Règle Care Team : accès si user est owner ou assigné (via users)

ÉlémentValeur
Modèlex_medical_case
Groupgroup_synergia_care_team
Domaine (domain_force)`['

B) x_media_asset

Héritage strict par Case :

ÉlémentValeur
Modèlex_media_asset
Groupgroup_synergia_care_team
Domaine`['

C) documents.document

Limiter par folder du Case

  • Si votre documents.folder est proprement ACLé par équipe, vous pouvez vous appuyer dessus.
  • Sinon, ajoutez une règle de cohérence : document visible si folder lié à un Case accessible.

Dans la pratique Odoo Documents, la sécurité peut vite devenir subtile. Le plus robuste : folder Case + ACL strict + pas d’external sharing.

D) ir.attachment

Important : ne pas exposer les attachments au-delà du modèle parent.

  • En standard, Odoo attache la sécurité à res_model/res_id.
  • Donc : attacher le stub à x_media_asset (pas au Case) vous donne un contrôle plus granulaire.

5) “Share Package” minimal (pour l’expertise externe)

Même si vous ne le déployez pas tout de suite, voici le modèle minimal pour industrialiser.

5.1 x_share_package

ChampTypeCommentaire
nameCharSHARE-2026-000012
x_case_idM2OPivot
x_expert_review_request_idM2OSource
x_recipient_emailCharDestinataire
x_recipient_phoneCharOTP
x_stateSelectiondraft/sent/opened/revoked/expired
x_expires_atDatetimeExpiration globale
x_media_asset_idsM2MSélection d’assets
x_audit_logText/JsonJournal d’accès (résumé)

Principe : l’expert ne passe pas par Documents. Il passe par portail/OTP → gateway → URLs signées.

6) Contrat API “Media Gateway” (signature / révocation / audit)

6.1 Endpoints

A) Signer une URL (lecture)

POST /v1/media/sign

Request JSON :

{ "provider": "supabase", "bucket": "synergia-media", "key": "cases/2026/000123/audio/call_abc.wav", "method": "GET", "ttl_seconds": 900, "content_disposition": "inline", "request_context": { "odoo_db": "prod", "user_id": 123, "case_id": 456, "asset_id": 789, "reason": "open_media" } }

Response JSON :

{ "signed_url": "https://....", "expires_at": "2026-01-02T17:10:00Z", "provider": "supabase" }

B) Signer une URL (upload PUT) — si vous uploadez depuis un client

POST /v1/media/sign

  • method = "PUT"
  • Retourne une signed URL PUT (ou POST policy selon provider)

C) Révoquer / bloquer un asset (optionnel)

POST /v1/media/revoke

Request :

{ "provider": "supabase", "key": "cases/2026/000123/audio/call_abc.wav", "reason": "consent_revoked" }

Response :

{ "status": "ok" }

D) Audit (optionnel)

POST /v1/audit/event

Permet d’enregistrer : user, asset, action, timestamp, IP, etc.

7) Module minimal (structure) — si vous voulez “propre et durable”

7.1 Contenu du module

FichierRôle
models/x_media_asset.pyméthode action_open_media() + helpers
models/x_medical_case.pycréation folder (si pas Studio)
security/ir.model.access.csvACL
security/rules.xmlrecord rules
views/x_media_asset_views.xmlbouton + smart buttons
data/server_actions.xmlactions auto (stub + documents.document)
controllers/media_proxy.py (optionnel)si vous proxyfiez au lieu d’ouvrir URL

7.2 Décision : ouvrir URL signée directe vs proxy

  • Directe (recommandée) : Odoo renvoie act_url vers signed URL
  • Proxy : Odoo sert comme proxy → plus complexe, mais contrôle supplémentaire

8) Checklist “prête à implémenter” (ordre recommandé)

  1. Créer modèles + champs (Studio)
  2. Ajouter x_care_team_user_ids sur Case (si possible) et remplir automatiquement
  3. Ajouter record rules Case + Asset
  4. Automatisation : créer folder Documents au Case
  5. Automatisation : à x_media_asset.ready, créer stub ir.attachment(type=url) + documents.document
  6. Ajouter bouton “Ouvrir média” sur Asset (module minimal recommandé)
  7. Déployer Media Gateway (signature GET/PUT)
  8. Tests : accès Care Team / supervision / backoffice, et expiration URL

9) Ce que je vous propose comme “pack suivant” (si vous continuez)

Je peux vous fournir, dans le format que vous utilisez déjà (très structuré) :

  1. Table complète Studio (champs + vues + actions) prête à copier dans votre cahier des charges
  2. XML record rules + ACL (exact)
  3. Spéc technique Media Gateway (FastAPI ou Node) avec :
    • auth service-to-service (HMAC / JWT)
    • logs d’audit
    • politique TTL par scope (team/expert/partner)
    • option “watermark” pour PDF (si besoin)

Notre stockage externe cible est Supabase Storage @Faniry


Parfait — avec Supabase Storage comme cible, le pattern “x_media_asset vérité + ir.attachment(type='url') stub + URLs signées TTL court” est très naturel, à condition de ne jamais signer depuis Odoo (pas de clé Supabase côté Odoo), mais via un Media Gateway.

Ci-dessous : architecture + conventions de stockage + contrat API + implémentation de référence (FastAPI, et une variante Node si vous préférez).

1) Supabase Storage — réglages recommandés

1.1 Buckets

BucketVisibilitéUsage
synergia-mediaprivateaudio/vidéo/images/HD, preuves, etc.
synergia-docs (optionnel)privatePDF médicaux si vous ne les mettez pas dans Odoo

Règle : buckets private, accès uniquement via signed URLs.

1.2 Convention de chemins (keys)

Ne mettez pas de PHI dans les paths.

Exemple robuste :

cases/{year}/{case_uid}/audio/{call_uuid}.wav cases/{year}/{case_uid}/images/{asset_uuid}.jpg cases/{year}/{case_uid}/pdf/{asset_uuid}.pdf

Où :

  • case_uid = identifiant interne non parlant (ex: c_2f8a9c...)
  • call_uuid = UUID FreeSWITCH
  • asset_uuid = UUID généré à la création de x_media_asset

Dans Odoo, x_external_key = ce path.

2) Signature Supabase : ce que fait le Media Gateway

2.1 Lecture (GET) : signed URL

Supabase Storage fournit une URL signée (TTL en secondes). Le gateway renvoie :

  • signed_url
  • expires_at

2.2 Upload (PUT/POST) : signed upload URL (selon SDK)

Selon la version du SDK Supabase, vous avez :

  • Signed upload URL (préférable si vous uploadez depuis un client web/mobile)
  • ou upload côté backend (gateway) si vous centralisez

Dans votre cas VoIP/FreeSWITCH :

  • l’audio est généralement déposé par votre infra (FreeSWITCH/worker) → le gateway peut uploader directement (ou votre worker utilise la clé service-role en réseau privé).
  • la partie “sign upload” est surtout utile si des clients externes uploadent (ex: aidant via portail).

3) Objet Odoo : champs à utiliser avec Supabase

x_media_asset

ChampValeur Supabase
x_external_providersupabase
x_external_bucket (ajout conseillé)synergia-media
x_external_keycases/2026/<case_uid>/audio/<call_uuid>.wav
x_sourceexternal
x_statuspending → ready quand l’objet est présent
x_checksum_sha256à calculer côté worker (idéal)
x_stub_attachment_idir.attachment(type='url')

J’ajoute x_external_bucket : utile si vous multipliez les buckets.

4) Contrat API Media Gateway (spéc exact)

4.1 Auth

  • Appel Odoo → Gateway avec un HMAC ou JWT service-to-service.
  • Le gateway détient SUPABASE_URL + SERVICE_ROLE_KEY (jamais dans Odoo).

4.2 Endpoint “sign GET”

POST /v1/media/sign-get

Request

{ "bucket": "synergia-media", "key": "cases/2026/c_2f8a9c/audio/7b0d...-call.wav", "ttl_seconds": 900, "context": { "odoo_user_id": 123, "case_id": 456, "asset_id": 789, "reason": "open_media" } }

Response

{ "signed_url": "https://<project>.supabase.co/storage/v1/object/sign/....", "expires_at": "2026-01-02T18:45:00Z" }

4.3 Endpoint “check exists” (optionnel mais pratique)

POST /v1/media/exists

Request

{ "bucket": "synergia-media", "key": "cases/2026/.../call.wav" }

Response

{ "exists": true, "size_bytes": 12345678, "mime_type": "audio/wav" }

4.4 Endpoint “revoke” (optionnel)

Supabase signed URLs expirent ; la “révocation” forte se fait en pratique par :

  • suppression/rotation de l’objet,
  • ou changement de key (move) si vous voulez invalider immédiatement.
    Le gateway peut proposer :
    POST /v1/media/revoke → move vers un “quarantine/” + mise à jour Odoo.

5) Cycle exact côté Odoo (ce que vous implémentez)

A2 — “Si média entrant (audio)”

  1. Créer x_media_asset dès l’événement d’appel (même si fichier pas final)
    • x_status='pending'
    • x_external_bucket='synergia-media'
    • x_external_key=<path cible final>
  2. Quand l’audio est uploadé par votre worker FreeSWITCH :
    • update x_status='ready', x_size_bytes, x_mime_type, x_checksum_sha256
  3. Automated Action : si ready et pas de stub
    • créer ir.attachment(type='url') attaché à x_media_asset
    • créer documents.document dans le folder du Case (si vous indexez)
  4. Bouton “Ouvrir média”
    • Odoo appelle POST /v1/media/sign-get
    • met à jour stub.url = signed_url
    • ouvre l’URL

6) Implémentation de référence — FastAPI (gateway)

Code minimal (à adapter à votre auth et logs). Le point clé : service_role_key côté gateway uniquement.

# app.py import os from datetime import datetime, timezone, timedelta from fastapi import FastAPI, HTTPException, Header from pydantic import BaseModel, Field from supabase import create_client, Client SUPABASE_URL = os.environ["SUPABASE_URL"] SUPABASE_SERVICE_ROLE_KEY = os.environ["SUPABASE_SERVICE_ROLE_KEY"] sb: Client = create_client(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY) app = FastAPI(title="Synergia Media Gateway") # ---- Request/Response models class SignGetReq(BaseModel): bucket: str key: str ttl_seconds: int = Field(default=900, ge=60, le=3600) context: dict = Field(default_factory=dict) class SignGetResp(BaseModel): signed_url: str expires_at: str def require_internal_auth(x_synergia_sig: str | None): # TODO: vérifier HMAC/JWT, timestamp, anti-replay if not x_synergia_sig: raise HTTPException(status_code=401, detail="Missing signature") @app.post("/v1/media/sign-get", response_model=SignGetResp) def sign_get(req: SignGetReq, x_synergia_sig: str | None = Header(default=None)): require_internal_auth(x_synergia_sig) # Signed URL (download) res = sb.storage.from_(req.bucket).create_signed_url(req.key, req.ttl_seconds) if not res or not res.get("signedURL"): raise HTTPException(status_code=404, detail="Cannot sign URL (missing object or invalid key)") signed_url = res["signedURL"] expires_at = (datetime.now(timezone.utc) + timedelta(seconds=req.ttl_seconds)).isoformat() # TODO: audit log (req.context + signed_url hash + expires_at) return SignGetResp(signed_url=signed_url, expires_at=expires_at)

Variables d’environnement

  • SUPABASE_URL
  • SUPABASE_SERVICE_ROLE_KEY

Déploiement

  • idéalement dans un VPC / réseau privé
  • logs structurés (audit) : odoo_user_id, case_id, asset_id, reason, ip

7) Variante Node.js (si vous préférez)

import express from "express"; import { createClient } from "@supabase/supabase-js"; const app = express(); app.use(express.json()); const supabaseUrl = process.env.SUPABASE_URL; const serviceRoleKey = process.env.SUPABASE_SERVICE_ROLE_KEY; const sb = createClient(supabaseUrl, serviceRoleKey); function requireInternalAuth(req, res, next) { // TODO HMAC/JWT if (!req.header("x-synergia-sig")) return res.status(401).json({ error: "Missing signature" }); next(); } app.post("/v1/media/sign-get", requireInternalAuth, async (req, res) => { const { bucket, key, ttl_seconds = 900, context = {} } = req.body; const { data, error } = await sb.storage.from(bucket).createSignedUrl(key, ttl_seconds); if (error || !data?.signedUrl) return res.status(404).json({ error: "Cannot sign URL" }); const expiresAt = new Date(Date.now() + ttl_seconds * 1000).toISOString(); // TODO audit log res.json({ signed_url: data.signedUrl, expires_at: expiresAt }); }); app.listen(8080);

8) Détails importants (sinon vous aurez des incidents)

8.1 TTL et UX

  • TTL typique : 900s (15 min) pour lecture
  • Si l’utilisateur garde l’onglet ouvert : au prochain clic → regen

8.2 Pas d’URL signée stockée “comme vérité”

  • Dans Odoo, ir.attachment.url est une vue (éphémère)
  • La vérité : bucket + key

8.3 Auditabilité

  • Log côté gateway : who/what/when/why
  • Log côté Odoo : message_post sur le Case ou un x_case_event (“media_opened”, “share_created”)

8.4 “Révocation instantanée”

  • Supabase signed URL ne se “révoque” pas individuellement ; elle expire.
  • Pour couper immédiatement : move/delete l’objet, ou rotate le path (move) + update Odoo.

9) Ce que je vous propose maintenant (prochain livrable utile)

Je peux vous livrer en un seul bloc (copiable) :

  1. Tables Studio complètes (x_external_bucket, x_stub_attachment_id, actions, vues)
  2. Règles d’accès exactes (Case → Asset → Documents)
  3. Le Media Gateway prêt prod (FastAPI ou Node) avec :
    • auth HMAC (timestamp + nonce)
    • anti-replay
    • logs JSON
    • endpoint exists (HEAD)
    • endpoint move-to-quarantine (révocation forte)

Dites-moi simplement si vous voulez que je parte sur FastAPI (souvent le plus rapide à stabiliser) ou Node.js (si votre équipe est plus JS).

Annexes : est ce que FastAPI (souvent le plus rapide à stabiliser) ou Node.js

Avec un FreeSWITCH dans la chaîne, le choix FastAPI vs Node.js ne doit pas se faire “par FreeSWITCH”, mais par l’endroit où vous allez piloter les événements téléphonie et le niveau de concurrence I/O (ESL, webhooks, uploads, signatures, audit). FreeSWITCH s’intègre très bien avec les deux.

Ma recommandation pragmatique, pour Synergia et vos contraintes (MVP robuste, traçabilité, time-to-market, charge I/O, équipe déjà très React/Node) :

Recommandation

1) Si vous avez déjà (ou allez avoir) un “orchestrateur téléphonie” en Node (ESL + webhooks)

→ Choisissez Node.js pour le Media Gateway.

Pourquoi :

  • L’ESL (Event Socket Library) de FreeSWITCH est très fréquemment piloté en Node (event-driven, websockets, streaming I/O).
  • Vous aurez souvent un service Node “Voice Orchestrator” qui :
    • consomme les événements (answered/bridge/hangup/recording, etc.)
    • déclenche l’enregistrement, nommage, post-processing
    • pousse dans Odoo (JSON-RPC) et met à jour x_media_asset
  • Dans ce cas, mettre signature Supabase + audit + endpoints dans le même runtime (Node) réduit l’intégration, la latence et la surface d’erreur.

Architecture typique recommandée :

  • Service A (Node) : Voice Orchestrator (ESL, CDR-like events, workers)
  • Service B (Node) : Media Gateway (sign-get, exists, revoke/move quarantine)
  • Ou un seul service Node “Voice+Media” au MVP, puis split ensuite.

Décision rapide (grille simple)

CritèreSi c’est votre casChoix conseillé
Vous pilotez FreeSWITCH via ESL en Node (ou prévoyez de le faire)OuiNode.js
Votre équipe est majoritairement JS/TS (React/Node)OuiNode.js
Vous voulez un service “security/storage” isolé, très strict, typé, facile à auditerOuiFastAPI
Vous prévoyez beaucoup de workflows backend Python (IA, traitement audio, pipelines) dans le même serviceOuiFastAPI
MVP le plus rapide avec moins de friction d’intégrationGénéralement côté JS chez vousNode.js

Ma recommandation pour votre contexte concret


Vu vos échanges récents : vous êtes fortement React/Node, vous travaillez sur IVR/FreeSWITCH/WebRTC, vous parlez de contrôleurs Node.js ESL, et votre logique “events → Odoo → assets” est naturellement event-driven.

Donc : je recommande Node.js pour le Media Gateway, et je garderais FastAPI plutôt pour les traitements IA “lourds” (transcription, résumé, RAG, scoring) si vous en avez besoin.

Pattern cible “propre”

  • Node (Voice Orchestrator) : ESL + webhooks + state machine call/recording + push Odoo
  • Node (Media Gateway) : sign-get / exists / move-to-quarantine / audit-log
  • Odoo : vérité métier (x_medical_case, x_media_asset, x_expert_review_request)
  • Supabase Storage : objets privés

Point de vigilance (important)

Quel que soit le langage :

  • ne mettez jamais la service_role_key Supabase dans Odoo
  • mettez le gateway derrière auth service-to-service (HMAC/JWT + anti-replay)
  • loggez toute signature (qui/quoi/pourquoi) pour audit

Voici une version Node.js “prête prod MVP” du Media Gateway Supabase (Express), avec :

  • Auth HMAC (timestamp + nonce + anti-replay)
  • Endpoints : sign-get, exists, move-to-quarantine
  • Logs JSON (audit)
  • Payloads exacts à appeler depuis Odoo
  • Recommandations de déploiement et d’exploitation

1) Contrat d’auth HMAC (service-to-service)

1.1 En-têtes requis

  • x-synergia-ts : timestamp Unix (secondes)
  • x-synergia-nonce : UUID/nonce unique
  • x-synergia-sig : signature HMAC SHA-256
  • content-type: application/json

1.2 Canonical string signée

On signe exactement :

TS\nNONCE\nMETHOD\nPATH\nBODY_SHA256_HEX

  • TS = x-synergia-ts
  • NONCE = x-synergia-nonce
  • METHOD = POST
  • PATH = /v1/media/sign-get etc.
  • BODY_SHA256_HEX = sha256 du body JSON brut (string)

Signature :

  • sig = hex(hmac_sha256(SYNERGIA_HMAC_SECRET, canonical_string))

1.3 Anti-replay

  • Fenêtre de validité : ±120 secondes
  • Cache nonce : TTL 5 minutes (Redis recommandé ; fallback mémoire possible en MVP)

2) Endpoints

2.1 POST /v1/media/sign-get

Request

{ "bucket": "synergia-media", "key": "cases/2026/c_2f8a9c/audio/7b0d-call.wav", "ttl_seconds": 900, "content_disposition": "inline", "context": { "odoo_db": "prod", "odoo_user_id": 123, "case_id": 456, "asset_id": 789, "reason": "open_media" } }

Response

{ "signed_url": "https://<project>.supabase.co/storage/v1/object/sign/...", "expires_at": "2026-01-02T19:10:00.000Z" }

2.2 POST /v1/media/exists

Request

{ "bucket": "synergia-media", "key": "cases/2026/c_2f8a9c/audio/7b0d-call.wav", "context": { "asset_id": 789 } }

Response

{ "exists": true }

Note : Supabase Storage ne donne pas toujours un HEAD simple via SDK ; la méthode MVP la plus robuste est un download() et fermeture immédiate (coûte un peu). En prod, on peut passer par l’API REST Storage ou stocker size/mime côté worker lors de l’upload.

2.3 POST /v1/media/move-to-quarantine

But : “révocation forte” (coupure immédiate) en déplaçant l’objet vers un chemin de quarantaine.

Request

{ "bucket": "synergia-media", "from_key": "cases/2026/c_2f8a9c/audio/7b0d-call.wav", "to_key": "quarantine/2026/01/02/asset_789_7b0d-call.wav", "context": { "asset_id": 789, "reason": "consent_revoked" } }

Response

{ "status": "ok" }

3) Implémentation Express (code complet MVP)

3.1 package.json

{ "name": "synergia-media-gateway", "version": "1.0.0", "type": "module", "main": "server.js", "scripts": { "start": "node server.js" }, "dependencies": { "@supabase/supabase-js": "^2.49.1", "express": "^4.19.2", "pino": "^9.3.2", "pino-http": "^10.3.0" } }

3.2 server.js

import express from "express"; import crypto from "crypto"; import pino from "pino"; import pinoHttp from "pino-http"; import { createClient } from "@supabase/supabase-js"; const { PORT = "8080", SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SYNERGIA_HMAC_SECRET, NONCE_TTL_SECONDS = "300", MAX_SKEW_SECONDS = "120", } = process.env; if (!SUPABASE_URL || !SUPABASE_SERVICE_ROLE_KEY || !SYNERGIA_HMAC_SECRET) { throw new Error("Missing env: SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, SYNERGIA_HMAC_SECRET"); } const log = pino({ level: "info" }); const app = express(); // IMPORTANT: conserver le body brut pour le hash sha256 app.use(express.json({ verify: (req, res, buf) => { req.rawBody = buf.toString("utf8"); } })); app.use(pinoHttp({ logger: log })); const sb = createClient(SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, { auth: { persistSession: false, autoRefreshToken: false }, }); // // Nonce cache MVP (mémoire) // En prod : remplacer par Redis (recommandé). // const nonceCache = new Map(); // nonce -> expiresAtMs function cleanupNonces() { const now = Date.now(); for (const [nonce, exp] of nonceCache.entries()) { if (exp <= now) nonceCache.delete(nonce); } } setInterval(cleanupNonces, 30_000).unref(); function sha256Hex(s) { return crypto.createHash("sha256").update(s, "utf8").digest("hex"); } function hmacHex(secret, data) { return crypto.createHmac("sha256", secret).update(data, "utf8").digest("hex"); } function timingSafeEqualHex(a, b) { const aBuf = Buffer.from(a, "hex"); const bBuf = Buffer.from(b, "hex"); if (aBuf.length !== bBuf.length) return false; return crypto.timingSafeEqual(aBuf, bBuf); } function requireHmac(req, res, next) { const ts = req.header("x-synergia-ts"); const nonce = req.header("x-synergia-nonce"); const sig = req.header("x-synergia-sig"); if (!ts || !nonce || !sig) { return res.status(401).json({ error: "Missing auth headers" }); } const tsInt = parseInt(ts, 10); if (!Number.isFinite(tsInt)) { return res.status(401).json({ error: "Invalid timestamp" }); } const now = Math.floor(Date.now() / 1000); const maxSkew = parseInt(MAX_SKEW_SECONDS, 10); if (Math.abs(now - tsInt) > maxSkew) { return res.status(401).json({ error: "Timestamp skew too large" }); } // anti-replay const ttl = parseInt(NONCE_TTL_SECONDS, 10); const expMs = Date.now() + ttl * 1000; if (nonceCache.has(nonce)) { return res.status(401).json({ error: "Nonce replay detected" }); } nonceCache.set(nonce, expMs); const bodyRaw = req.rawBody ?? ""; const bodyHash = sha256Hex(bodyRaw); const canonical = `${ts}\n${nonce}\n${req.method}\n${req.path}\n${bodyHash}`; const expected = hmacHex(SYNERGIA_HMAC_SECRET, canonical); if (!timingSafeEqualHex(expected, sig)) { // purge nonce to avoid locking out on bad sig storms nonceCache.delete(nonce); return res.status(401).json({ error: "Bad signature" }); } req.authContext = { ts: tsInt, nonce }; next(); } function clampTtl(ttlSeconds) { const ttl = Math.max(60, Math.min(3600, Number(ttlSeconds || 900))); return Number.isFinite(ttl) ? ttl : 900; } function safeKey(key) { // MVP: validation minimaliste if (typeof key !== "string" || key.length < 3 || key.length > 1024) return false; if (key.includes("..")) return false; return true; } app.get("/health", (req, res) => res.json({ ok: true })); app.post("/v1/media/sign-get", requireHmac, async (req, res) => { const { bucket, key, ttl_seconds = 900, content_disposition = "inline", context = {} } = req.body || {}; if (typeof bucket !== "string" || !safeKey(key)) { return res.status(400).json({ error: "Invalid bucket/key" }); } const ttl = clampTtl(ttl_seconds); // Audit log (ne loggez pas d’infos PHI) req.log.info({ event: "sign_get", bucket, key_hash: sha256Hex(key), ttl, context, }); try { // Supabase JS v2: createSignedUrl const { data, error } = await sb.storage.from(bucket).createSignedUrl(key, ttl); if (error || !data?.signedUrl) { return res.status(404).json({ error: "Cannot sign URL" }); } // Option content-disposition : Supabase n’expose pas toujours ce paramètre // de manière uniforme. Si besoin, vous passerez par un proxy de download. const expiresAt = new Date(Date.now() + ttl * 1000).toISOString(); return res.json({ signed_url: data.signedUrl, expires_at: expiresAt }); } catch (e) { req.log.error({ event: "sign_get_error", err: String(e) }); return res.status(500).json({ error: "Internal error" }); } }); app.post("/v1/media/exists", requireHmac, async (req, res) => { const { bucket, key, context = {} } = req.body || {}; if (typeof bucket !== "string" || !safeKey(key)) { return res.status(400).json({ error: "Invalid bucket/key" }); } req.log.info({ event: "exists_check", bucket, key_hash: sha256Hex(key), context }); try { // MVP: tenter un download (peut être coûteux). // Alternative: utiliser storage API REST pour HEAD/metadata. const { data, error } = await sb.storage.from(bucket).download(key); if (error || !data) { return res.json({ exists: false }); } // Fermer/consommer rapidement // data est un Blob (Node 18+). On ne lit pas le contenu. return res.json({ exists: true }); } catch (e) { req.log.error({ event: "exists_error", err: String(e) }); return res.status(500).json({ error: "Internal error" }); } }); app.post("/v1/media/move-to-quarantine", requireHmac, async (req, res) => { const { bucket, from_key, to_key, context = {} } = req.body || {}; if (typeof bucket !== "string" || !safeKey(from_key) || !safeKey(to_key)) { return res.status(400).json({ error: "Invalid bucket/keys" }); } req.log.warn({ event: "move_to_quarantine", bucket, from_hash: sha256Hex(from_key), to_hash: sha256Hex(to_key), context }); try { const from = sb.storage.from(bucket); // copy then remove (move) : supabase storage API // Attention: selon versions, move() peut exister; sinon copy+remove. const { error: copyErr } = await from.copy(from_key, to_key); if (copyErr) return res.status(500).json({ error: "Copy failed" }); const { error: rmErr } = await from.remove([from_key]); if (rmErr) return res.status(500).json({ error: "Remove failed" }); return res.json({ status: "ok" }); } catch (e) { req.log.error({ event: "move_error", err: String(e) }); return res.status(500).json({ error: "Internal error" }); } }); app.listen(parseInt(PORT, 10), () => { log.info({ msg: "Synergia Media Gateway started", port: PORT }); });

4) Variables d’environnement (prod)

VariableDescription
SUPABASE_URLURL projet Supabase
SUPABASE_SERVICE_ROLE_KEYClé service_role (jamais dans Odoo)
SYNERGIA_HMAC_SECRETSecret HMAC (à stocker dans un vault)
PORT8080
NONCE_TTL_SECONDS300
MAX_SKEW_SECONDS120

5) Appel depuis Odoo (pseudo-spéc, côté module minimal)

5.1 Au clic “Ouvrir média”

  1. Construire body JSON : bucket, key, ttl_seconds, context
  2. Calculer body_raw (JSON string)
  3. body_hash = sha256(body_raw)
  4. canonical = ts + "\n" + nonce + "\n" + "POST" + "\n" + path + "\n" + body_hash
  5. sig = hmac_sha256(secret, canonical)
  6. POST vers gateway
  7. Mettre à jour ir.attachment.url = signed_url
  8. Retourner act_url vers signed_url

Si vous ne voulez pas implémenter HMAC côté Odoo (possible selon contraintes), vous pouvez passer par un petit “bridge” interne (n8n/worker) qui signe pour Odoo. Mais le plus propre est de signer côté module Odoo (secret stocké serveur) uniquement si votre Odoo est auto-hébergé et vous contrôlez le secret. Sur Odoo Online, évitez de stocker des secrets applicatifs.

6) Recommandation d’exploitation (très concrète)

6.1 Où déployer ce gateway

  • Même zone / VPC que FreeSWITCH et vos workers (latence faible)
  • Exposer publiquement uniquement si nécessaire (sinon privé)
  • Si exposé : TLS + WAF + rate limit

6.2 Logs / audit

Le gateway log :

  • event, bucket, key_hash, ttl, context (sans PHI)
  • À corréler avec Odoo (asset_id, case_id)

6.3 Politique TTL

  • team: 15 min
  • expert: 5–10 min
  • partner: 5 min + OTP/portail

Très clair : avec Odoo Online, la règle d’or est simple :

  • Odoo Online ne doit détenir aucun secret (ni Supabase service role, ni HMAC interne).
  • Donc Odoo Online ne doit pas signer (ni même appeler directement un endpoint qui nécessite une signature HMAC basée sur un secret stocké dans Odoo).

La bonne architecture est donc :

Recommandation cible (Odoo Online) : “Odoo → Redirector (Node) → Gateway (HMAC) → Supabase Signed URL”

Principe

  1. L’utilisateur clique “Ouvrir média” dans Odoo
  2. Odoo ouvre une URL vers votre service Node “Redirector” (public ou privé via VPN)
  3. Le Redirector :
    • vérifie un token one-time (sans secret côté Odoo)
    • journalise (audit)
    • appelle le Media Gateway (HMAC, secret côté Node)
    • récupère une signed URL Supabase
    • renvoie un 302 redirect vers la signed URL

Ainsi :

  • Odoo ne fait que “rediriger” (aucun secret).
  • Node fait la sécurité + la signature + l’audit.
  • Supabase reste private, accès via signed URLs.

1) Modifs minimales côté Odoo (Odoo Studio)

Ajoutez 2 champs sur x_media_asset :

ChampTypeRôle
x_open_tokenChartoken à usage unique (opaque)
x_open_token_expires_atDatetimeexpiration du token (ex: now+10 min)

Optionnel (utile audit) :

ChampTypeRôle
x_last_opened_atDatetimedernière ouverture
x_open_countIntegercompteur

Bouton “Ouvrir média”

Dans Studio, faites un bouton qui ouvre une URL externe construite comme :

https://media.synergia.tld/open?asset_id=<id>&token=<x_open_token>

(ou incluez db si vous en avez besoin, mais évitez toute donnée sensible)

Le bouton “URL”/“Ouvrir lien” est exactement ce que Odoo Online sait faire proprement, sans secrets.

2) Qui génère x_open_token ? (côté Node, pas côté Odoo)

Moment de génération

Quand l’asset passe à ready (ou quand le stub est créé).

Votre orchestrateur Node (celui qui reçoit les événements FreeSWITCH et pousse dans Odoo) fait :

  1. Génère un token aléatoire :
  • 32 bytes aléatoires, encodés base64url ou hex
  1. Stocke le hash du token côté Node (Redis recommandé) :
  • clé : open_token_hash -> {asset_id, expires_at, policy}
  1. Écrit dans Odoo (JSON-RPC) :
  • x_open_token = token_plain (oui, en clair dans Odoo, mais TTL court)
  • x_open_token_expires_at = now + 10 min

Pourquoi token en clair dans Odoo ?

Parce qu’Odoo doit pouvoir le mettre dans l’URL du bouton.

La sécurité vient de :

  • TTL court
  • usage unique
  • vérification côté Node
  • pas de secret exposé

3) Service Node “Redirector” : endpoint /open

Spéc

GET /open?asset_id=789&token=...

Comportement

  1. Vérifie :
  • token présent
  • TTL non dépassé (en se basant sur Redis côté Node)
  • token one-time (consommé dès usage)
  • optionnel : vérifier que l’asset est ready
  1. Appelle le Media Gateway (HMAC) :
  • sign-get(bucket, key, ttl=900)
  1. Renvoie 302 Location: <signed_url>

Effet UX

L’utilisateur clique dans Odoo → l’audio s’ouvre / se télécharge via URL signée, sans que Odoo ait signé quoi que ce soit.

4) Pourquoi c’est la meilleure approche pour Odoo Online

Avantages

  • Zéro secret dans Odoo Online (conforme à vos contraintes)
  • Vous gardez le HMAC + anti-replay sur le segment Node ↔ Gateway
  • Gouvernance : vous centralisez audit, quotas, règles TTL, “revocation forte”
  • Très simple côté Odoo (champs + bouton)

Points de vigilance (gérés par Node)

  • Token one-time consommé dès la première utilisation
  • TTL court (5–10 minutes)
  • Logs d’audit (asset_id, case_id, user si disponible, IP)
  • Rate limiting sur /open

5) Variante encore plus stricte (si vous voulez éviter token “en clair” dans Odoo)

C’est possible, mais plus complexe : Odoo ouvrirait une URL sans token, et le Redirector devrait authentifier l’utilisateur via Odoo (SSO/OAuth) ou via un “Odoo signed callback”. En Odoo Online, c’est généralement surdimensionné pour un MVP.

Pour un MVP “santé” robuste, le token one-time TTL court est le meilleur compromis.

6) Ce que je vous recommande de faire maintenant (ordre exact)

  1. Ajouter x_open_token, x_open_token_expires_at dans x_media_asset
  2. Créer le bouton “Ouvrir média” → URL https://media.../open?asset_id=...&token=...
  3. Dans votre orchestrateur Node (FreeSWITCH → Odoo) :
    • à asset.ready : générer token + écrire dans Odoo
  4. Déployer le Redirector /open (avec Redis)
  5. Conserver le Media Gateway HMAC pour signer Supabase

Si vous me confirmez le nom de domaine envisagé (ex: media.synergia.tld) et si vous avez déjà Redis disponible (oui/non), je vous fournis immédiatement :

  • le code Node complet du Redirector /open (Express) avec Redis + one-time token,
  • et la séquence JSON-RPC exacte côté orchestrateur pour écrire x_open_token dans Odoo Online.

ANNEXE : about SIP , about IVR classique pour FREESWITCH , et about FREESWITCH 

IVR pour freeSwitch 

quel est l'IVR open source fonctionnant le mieux avec freeswitch sachant que nous allons utiliser des services de STT et TTS et des services N8N ?  

Modèle d'habitat

Des habitats avec une empreinte minimale sur la planète et un impact positif maximal sur la communauté locale.

Jardin biologique

Apprenez à utiliser des méthodes de jardinage biologique pour cultiver des fruits et légumes frais.