discovery-media-player 0.1.60 → 0.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -65,6 +65,25 @@ it. `verdict` is then one of:
65
65
  | `partiel` | some expectations checked, none of them missing |
66
66
  | `complet` | all checked, all present |
67
67
  | `incomplet` | at least one is missing — `manquant` names the file and the sleeping feature |
68
+
69
+ Each `manquant` entry has **exactly this shape** — pin your parser to it, not to what a schema
70
+ probe "should" return:
71
+
72
+ ```json
73
+ { "migration": "supabase/migrations/0006-reactions-ordonnees.sql",
74
+ "fonction": "empêcher deux réactions simultanées de s'écraser" }
75
+ ```
76
+
77
+ ⚠️ This shape went **undocumented** for two releases, and the second host typed it from memory as
78
+ `{table, colonne, migration}` — their monitoring filter then discarded every real entry and would
79
+ have shown an empty table on a database that was actually missing something. **No mutation on
80
+ their side could catch it: their test built the card the same wrong way, so the bench validated
81
+ the assumption instead of the behaviour.** The only cure was reading data they had not fabricated.
82
+ If you consume this card, test your parser against the JSON above, not against a fixture you wrote.
83
+
84
+ ⚠️ **A card without a `schema` field is an alert, not a success**: it signals an instance older
85
+ than 0.1.58 — a version that cannot answer the question. (Rule contributed by the second host, for
86
+ exactly the monitoring case where "no data" would otherwise read as "all clear".)
68
87
  | `indetermine` | the database did not answer; this measurement did not happen |
69
88
 
70
89
  ⚠️ **`incomplet` wins over `partiel`**: a missing column is a positive fact and settles the verdict
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.60",
3
+ "version": "0.1.61",
4
4
  "description": "Self-hosted document viewer: per-recipient tracked links, reading analytics, live presentation. The core knows nothing about the application hosting it — everything it borrows arrives through an injected context.",
5
5
  "keywords": [
6
6
  "pdf-viewer",
package/server/handler.js CHANGED
@@ -2102,6 +2102,8 @@ ${LEGAL_CSS}
2102
2102
  .pbar-link{border:1px solid #e4e0d9;border-radius:8px;padding:6px 9px;font:inherit;font-size:12px;color:#555;width:240px;max-width:32vw;background:#f8f6f2}
2103
2103
  .pbar-btn{border:1px solid #e4e0d9;background:#f6f4ef;color:#1c1c1c;border-radius:8px;padding:7px 11px;font:inherit;font-size:12.5px;font-weight:600;cursor:pointer;white-space:nowrap}
2104
2104
  .pbar-btn:hover{background:#eceae5}
2105
+ .pbar-err{display:none;color:#b3261e;font-size:12px;font-weight:600;max-width:260px;line-height:1.25}
2106
+ .pbar-err.on{display:inline-block}
2105
2107
  #pbarEnd{color:#c0392b}
2106
2108
  /* Barre d'outils responsive : sous 860px, zoom/plein écran/Présenter/Partager/Télécharger passent dans le
2107
2109
  menu « ⋯ » ; le titre se tronque puis disparaît. */
@@ -2149,6 +2151,7 @@ ${LEGAL_CSS}
2149
2151
  <button class=pbar-btn id=pbarInvite>Inviter l'équipe</button>
2150
2152
  <button class=pbar-btn id=pbarHandover>Passer la main</button>
2151
2153
  <button class=pbar-btn id=pbarEnd>Terminer</button>
2154
+ <span class=pbar-err id=pbarErr role=alert></span>
2152
2155
  </div>` : ""}
2153
2156
  <div class=pop id=pop>
2154
2157
  <h4>Transmettre le document</h4>
@@ -2348,15 +2351,39 @@ ${LEGAL_CSS}
2348
2351
  if(!PRES) return Promise.resolve();
2349
2352
  var p=PRES, btn=document.getElementById('pbarEnd'), libelle=btn?btn.textContent:'';
2350
2353
  if(btn){ btn.disabled=true; btn.textContent='…'; }
2354
+ var errAvant=document.getElementById('pbarErr'); if(errAvant){ errAvant.classList.remove('on'); }
2351
2355
  return Player.live.fetchBorne('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},keepalive:true,
2352
2356
  body:JSON.stringify({action:'present-end',slug:p.slug,control:p.control})})
2353
2357
  .then(function(r){ if(!r||!r.ok) throw new Error('present-end'); })
2354
2358
  .then(function(){
2359
+ // RELIRE, PAS REUTILISER : un second aller-retour, indépendant de l'écriture. Réserve
2360
+ // posée par le second hôte, et elle est juste — relire la réponse du PATCH ferait ce que
2361
+ // faisait le cache négatif : la mesure confirmerait ce que l'écriture CROIT avoir fait.
2362
+ // C'est le seul moyen de rendre observable la classe entière des succès silencieux, y
2363
+ // compris contre un futur serveur qui répondrait ok sans avoir écrit.
2364
+ return Player.live.fetchBorne('/api/doc?present='+encodeURIComponent(p.slug)+'&state=1')
2365
+ .then(function(r){ return r&&r.ok?r.json():null; })
2366
+ .catch(function(){ return null; }); // relecture INDISPONIBLE n'est pas « encore active »
2367
+ })
2368
+ .then(function(d){
2369
+ if(d&&d.state&&d.state.active){
2370
+ // Le serveur a dit oui, la base dit encore active : fin non prise, ou reprise ailleurs.
2371
+ // Dans les deux cas, fermer l'interface mentirait au présentateur.
2372
+ if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; }
2373
+ var err2=document.getElementById('pbarErr');
2374
+ if(err2){ err2.textContent='La présentation est ENCORE ACTIVE après la fin — reprise ailleurs, ou fin non prise. Réessayez.'; err2.classList.add('on'); }
2375
+ return;
2376
+ }
2355
2377
  PRES=null; clearInterval(_hbIv); clearCtl(p.slug);
2356
2378
  var pb=document.getElementById('pbar'); if(pb)pb.style.display='none';
2357
2379
  try{ if(window.Live){ Live.sendState(); Live.disconnect(); } }catch(e){}
2358
2380
  }, function(){
2359
- if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; btn.title='La fin n’a pas été enregistrée — réessayez.'; }
2381
+ // L'échec d'une clôture ne prive de rien qu'on regarde : son succès ne produit rien,
2382
+ // donc son échec non plus. La première version le disait dans un TITLE — un tooltip que
2383
+ // personne ne survole. Le second hôte a eu des présentations « actives » trois jours.
2384
+ if(btn){ btn.disabled=false; btn.textContent=libelle||'Terminer'; }
2385
+ var err=document.getElementById('pbarErr');
2386
+ if(err){ err.textContent='La présentation est TOUJOURS ACTIVE — la fin n’a pas été enregistrée. Réessayez.'; err.classList.add('on'); }
2360
2387
  });
2361
2388
  }
2362
2389
  // ⚠️ LE SEUL ENDROIT OÙ « sendBeacon » A UN SENS : la page s'en va, aucune réponse ne pourra être
@@ -3107,7 +3134,14 @@ async function handler(req, res) {
3107
3134
  ? await touchPresentation(String(body.slug || ""), String(body.control || ""))
3108
3135
  : await endPresentation(String(body.slug || ""), String(body.control || ""));
3109
3136
  return jp(r.ok ? 200 : (r.status || 400), r);
3110
- } catch { return jp(500, { ok: false }); }
3137
+ } catch (erreur) {
3138
+ // ⚠️ CE CATCH A AVALÉ TROIS JOURS DE CLÔTURES IMPOSSIBLES SANS UNE TRACE. Chez le second
3139
+ // hôte, chaque « Terminer » échouait en 23502 (marqueur d'archive NOT NULL) — et ce 500
3140
+ // muet ne laissait RIEN, même pas une ligne dans le journal d'erreurs. Un journal que
3141
+ // personne ne lit vaut peu ; aucun journal ne vaut rien du tout.
3142
+ try { PLAYER.errors.capture(erreur instanceof Error ? erreur : new Error(String(erreur)), { route: "present-" + String(body.action || "").replace(/^present-/, "") }); } catch { /* jamais bloquant */ }
3143
+ return jp(500, { ok: false });
3144
+ }
3111
3145
  }
3112
3146
  // Assistant IA « présentateur » (bot) sur un lien tracé bot_enabled. PUBLIC (prospect anonyme) → rate-limit IP.
3113
3147
  // Synthèse vocale (ElevenLabs) : Léa lit ses messages. PUBLIC (audience anonyme), mais gated : la clé
@@ -24,8 +24,29 @@ const tokenMatches = (token, storedHash) => !!(token && storedHash && sameHash(s
24
24
  // owner = { id, email, name, avatar } (membre authentifié). La CLÉ de propriété est l'EMAIL (issu du JWT
25
25
  // vérifié) → permet reprise / liste / transfert, y compris vers un membre choisi par email.
26
26
  const lc = (s) => String(s || "").trim().toLowerCase();
27
+ /**
28
+ * ⚠️ LA PURGE NE DOIT PAS DÉPENDRE DE L'OUVERTURE D'UN PANNEAU. Elle ne vivait que dans
29
+ * `listActivePresentations` — c'est-à-dire qu'elle ne tournait que si quelqu'un ouvrait la liste.
30
+ * Le second hôte a eu QUATRE présentations bloquées « actives » pendant TROIS JOURS : personne
31
+ * n'avait ouvert le panneau, donc personne n'avait déclenché le seul mécanisme qui les fermait.
32
+ * Un échec de clôture ne prive de rien qu'on regarde — la purge doit donc s'accrocher à un geste
33
+ * qui arrive de lui-même. Démarrer une présentation en est un : ce sont les présentateurs qui
34
+ * créent les orphelines, et le suivant nettoie celles d'avant.
35
+ *
36
+ * Conditionnée à `active=eq.true&last_seen=lte.seuil` : une session qui vient de battre n'est pas
37
+ * touchée (même garde que la purge du panneau, chantier des écritures conditionnées).
38
+ */
39
+ async function purgerPerimees(now) {
40
+ const seuil = new Date(now - STALE_MS).toISOString();
41
+ await PLAYER.db.request(
42
+ `doc_presentations?active=eq.true&last_seen=lte.${enc(seuil)}`,
43
+ { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { active: false, updated_at: new Date(now).toISOString() } },
44
+ ).catch(() => { /* la purge est un service, jamais un préalable */ });
45
+ }
46
+
27
47
  async function createPresentation({ docId, fileUrl, fileName, docTitle, presenterName, owner }) {
28
48
  if (!fileUrl) throw Object.assign(new Error("doc invalide"), { statusCode: 400 });
49
+ await purgerPerimees(Date.now());
29
50
  const slug = newToken(9); // ~12 chars URL-safe
30
51
  const control = newToken(18); // secret pilotage
31
52
  const o = owner && typeof owner === "object" ? owner : {};
@@ -846,4 +867,5 @@ async function listPresentationsForDoc(docId, email, isAdmin, autoriseLarge) {
846
867
 
847
868
  module.exports = {
848
869
  reacteurDepuisJeton,
870
+ purgerPerimees,
849
871
  messagePublic, CHAMPS_PUBLICS, init, createPresentation, getPresentation, setPage, endPresentation, addMessage, listMessages, toggleReaction, editMessage, deleteMessage, setChatLock, createUploadUrl, reclaimPresentation, touchPresentation, listActivePresentations, handoverPresentation, endPresentationByOwner, recordAttendance, presentationStats, listPresentationsForDoc, switchPresentationDoc, setPresentationContent };