discovery-media-player 0.1.30 → 0.1.32

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "discovery-media-player",
3
- "version": "0.1.30",
3
+ "version": "0.1.32",
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
@@ -234,7 +234,11 @@ const LIVE_PANEL = `<div class="chat hidden" id=chatPanel><div class=chat-grip i
234
234
  // JS partagé : présence + chat via Supabase Realtime. Live.connect(slug, me) / Live.disconnect().
235
235
  const LIVE_JS = `
236
236
  var Live=(function(){
237
- var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen={},msgEls={},msgData={},replyCtx=null,typers={},pdfCache={},_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
237
+ // ⚠️ LES DICTIONNAIRES SONT SANS PROTOTYPE, Y COMPRIS ICI. Leurs clés viennent de messages, de
238
+ // participants, d'URL — donc du dehors. typers est le cas vif : il est alimenté par 'typing',
239
+ // le SEUL événement qui croie encore son émetteur (cf. 0.1.30). Un objet nu retire la question
240
+ // entière au lieu de la traiter cas par cas. (audit P1-2)
241
+ var sb=null,ch=null,ME=null,SLUG=null,CONTROL=null,LOCKED=false,AUTHTOK=null,PRESENT=[],PRESNAME='',seen=Object.create(null),msgEls=Object.create(null),msgData=Object.create(null),replyCtx=null,typers=Object.create(null),pdfCache=Object.create(null),_tyT=0,_tyIv=0,_atIv=0,unread=0,autoOpened=false,_histDone=false,_phWired=false,_onMap=null,_onState=null,_peekT=0,MUTED=false;
238
242
  try{ MUTED=localStorage.getItem('3dd-present-mute')==='1'; }catch(e){}
239
243
  // Couper/rétablir les notifications du chat (cloche) : coupé = plus de ticker ni de pulse (badge silencieux gardé).
240
244
  function applyMute(){ var b=document.getElementById('chatMute'); if(b){b.classList.toggle('muted',MUTED);b.title=MUTED?'Réactiver les notifications du chat':'Couper les notifications du chat';} setBadge(); }
@@ -246,7 +250,9 @@ var Live=(function(){
246
250
  function onMap(fn){_onMap=fn;}
247
251
  // État de la présentation diffusé par le présentateur — même canal que la carte. Sert à se
248
252
  // passer de la lecture anonyme des tables : l'audience n'a plus besoin de lire la ligne.
249
- function sendState(p){try{if(ch)ch.send({type:'broadcast',event:'state',payload:p});}catch(e){}}
253
+ // Un SIGNAL, pas un état. L'audience relit depuis 0.1.19 et ignore déjà cette charge ; la laisser
254
+ // partir donnait l'illusion qu'elle sert, et invitait le prochain à s'en resservir.
255
+ function sendState(){try{if(ch)ch.send({type:'broadcast',event:'state',payload:{}});}catch(e){}}
250
256
  // CHAT EN DIFFUSION. Les messages arrivaient jusqu'ici par la lecture de TABLE en temps réel,
251
257
  // qui exige que cette table soit lisible publiquement — donc, avec la clé publiable, les
252
258
  // conversations de TOUTES les présentations, pas seulement la sienne. C'était le dernier
@@ -368,7 +374,7 @@ var Live=(function(){
368
374
  function onTyping(p){if(!p||p.id===MYID)return;typers[p.id]={name:p.name,t:Date.now()};renderTyping();}
369
375
  function renderTyping(){var el=document.getElementById('chatTyping');if(!el)return;var n=Date.now(),names=[];for(var k in typers){if(n-typers[k].t<4200)names.push(typers[k].name||'Quelqu\\'un');else delete typers[k];}el.textContent=names.length?(names.slice(0,2).join(', ')+(names.length>1?' écrivent…':' écrit…')):'';}
370
376
  function openPicker(btn,id){var p=document.getElementById('emojiPick');if(!p)return;p.__id=id;var r=btn.getBoundingClientRect();p.style.left=Math.max(8,Math.min(r.left-120,window.innerWidth-200))+'px';p.style.top=Math.max(8,r.top-44)+'px';p.classList.add('open');}
371
- function mentionCheck(){var t=document.getElementById('chatText'),pop=document.getElementById('mentionPop');if(!t||!pop)return;var pos=t.selectionStart||0,pre=t.value.slice(0,pos),mm=pre.match(/@([\\p{L}0-9_'.-]*)$/u);if(!mm){pop.classList.remove('open');return;}var q=(mm[1]||'').toLowerCase();var seenN={},uniq=[];(PRESENT||[]).forEach(function(p){if(!p.name)return;var k=p.name.toLowerCase();if(seenN[k]||(ME&&p.name===ME.name))return;if(q&&k.indexOf(q)<0)return;seenN[k]=1;uniq.push(p);});uniq=uniq.slice(0,6);if(!uniq.length){pop.classList.remove('open');return;}pop.__len=(mm[1]||'').length;pop.innerHTML=uniq.map(function(p,i){return '<button class="'+(i===0?'sel':'')+'" data-n="'+esc(p.name)+'"><span class=a>'+av(p.avatar,p.name)+'</span>'+esc(p.name)+'</button>';}).join('');pop.classList.add('open');}
377
+ function mentionCheck(){var t=document.getElementById('chatText'),pop=document.getElementById('mentionPop');if(!t||!pop)return;var pos=t.selectionStart||0,pre=t.value.slice(0,pos),mm=pre.match(/@([\\p{L}0-9_'.-]*)$/u);if(!mm){pop.classList.remove('open');return;}var q=(mm[1]||'').toLowerCase();var seenN=Object.create(null),uniq=[];(PRESENT||[]).forEach(function(p){if(!p.name)return;var k=p.name.toLowerCase();if(seenN[k]||(ME&&p.name===ME.name))return;if(q&&k.indexOf(q)<0)return;seenN[k]=1;uniq.push(p);});uniq=uniq.slice(0,6);if(!uniq.length){pop.classList.remove('open');return;}pop.__len=(mm[1]||'').length;pop.innerHTML=uniq.map(function(p,i){return '<button class="'+(i===0?'sel':'')+'" data-n="'+esc(p.name)+'"><span class=a>'+av(p.avatar,p.name)+'</span>'+esc(p.name)+'</button>';}).join('');pop.classList.add('open');}
372
378
  function pickMention(name){var t=document.getElementById('chatText'),pop=document.getElementById('mentionPop');if(!t)return;var pos=t.selectionStart||t.value.length,len=(pop&&pop.__len)||0,before=t.value.slice(0,pos-len-1),after=t.value.slice(pos),ins='@'+name+' ';t.value=before+ins+after;var np=before.length+ins.length;t.focus();try{t.setSelectionRange(np,np);}catch(e){}if(pop)pop.classList.remove('open');}
373
379
  function wire(){var s=document.getElementById('chatSend'),t=document.getElementById('chatText'),cb=document.getElementById('chatBtn'),cl=document.getElementById('chatClose'),pn=document.getElementById('chatPanel'),pb=document.getElementById('presBtn'),pp=document.getElementById('presList'),box=document.getElementById('chatMsgs'),pick=document.getElementById('emojiPick');
374
380
  if(s&&!s._w){s._w=1;s.addEventListener('click',send);
@@ -1975,9 +1981,36 @@ ${LEGAL_CSS}
1975
1981
  function showBar(slug){ var lk=document.getElementById('pbarLink'); if(lk) lk.value=location.origin+'/present/'+slug; var pb=document.getElementById('pbar'); if(pb) pb.style.display='flex'; }
1976
1982
  // Le présentateur DIFFUSE l'état qu'il vient de persister. La base reste la vérité (les
1977
1983
  // arrivants tardifs la relisent) ; la diffusion évite à l'audience de lire la table.
1978
- function diffuserEtat(extra){ if(!PRES||!window.Live)return; try{ Live.sendState(Object.assign({active:true,current_page:cur||1,file_url:CFG.present&&CFG.present.url||null,updated_at:new Date().toISOString()},extra||{})); }catch(e){} }
1979
- function pushPage(){ if(!PRES)return; clearTimeout(_pushT); _pushT=setTimeout(function(){ if(!PRES)return; diffuserEtat({current_page:cur||1}); fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:cur||1})}).catch(function(){}); },250); }
1980
- function endPresent(){ if(!PRES)return; diffuserEtat({active:false}); var p=PRES; PRES=null; clearInterval(_hbIv); clearCtl(p.slug); var pb=document.getElementById('pbar'); if(pb)pb.style.display='none'; try{ if(window.Live) Live.disconnect(); }catch(e){} try{ var b=JSON.stringify({action:'present-end',slug:p.slug,control:p.control}); if(navigator.sendBeacon){navigator.sendBeacon('/api/doc',new Blob([b],{type:'application/json'}));} else {fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:b,keepalive:true});} }catch(e){} }
1984
+ // ⚠️ ÉCRIRE, PUIS SIGNALER ET PAS L'INVERSE.
1985
+ //
1986
+ // Le signal partait AVANT l'écriture. L'audience relisait donc l'état pendant que la base
1987
+ // portait encore l'ancien, et aucun second signal n'était garanti : la page tournée se perdait
1988
+ // jusqu'au filet de resynchronisation. Le commentaire d'origine affirmait pourtant l'ordre
1989
+ // inverse — il décrivait l'intention, pas le code.
1990
+ //
1991
+ // Depuis 0.1.19 le signal ne sert qu'à dire « relis » : le retarder d'un aller-retour ne coûte
1992
+ // rien, alors que l'émettre trop tôt fait relire pour rien ET perdre le changement.
1993
+ // (audit P0-3)
1994
+ function diffuserEtat(){ if(!PRES||!window.Live)return; try{ Live.sendState(); }catch(e){} }
1995
+ function pushPage(){ if(!PRES)return; clearTimeout(_pushT); _pushT=setTimeout(function(){ if(!PRES)return;
1996
+ fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({action:'present-page',slug:PRES.slug,control:PRES.control,page:cur||1})})
1997
+ .then(function(r){ if(r&&r.ok)diffuserEtat(); })
1998
+ .catch(function(){});
1999
+ },250); }
2000
+ // ⚠️ TROIS GESTES, ET L'ORDRE ÉTAIT LE PIRE DES SIX. On signalait la fin, puis on COUPAIT LE
2001
+ // CANAL, puis on envoyait l'avis de fin. Le signal partait donc avant l'écriture (relecture sur
2002
+ // un état périmé), et la coupure avant l'envoi. L'audience pouvait ne jamais apprendre que la
2003
+ // présentation était terminée — jusqu'au filet, 25 s plus tard, ou jamais si elle s'était
2004
+ // rechargée entre-temps.
2005
+ //
2006
+ // sendBeacon ne s'attend pas, mais il rend la main une fois la requête MISE EN FILE : signaler
2007
+ // juste après, puis couper, respecte l'ordre autant que ce transport le permet.
2008
+ function endPresent(){ if(!PRES)return; var p=PRES; PRES=null; clearInterval(_hbIv); clearCtl(p.slug);
2009
+ var pb=document.getElementById('pbar'); if(pb)pb.style.display='none';
2010
+ try{ var b=JSON.stringify({action:'present-end',slug:p.slug,control:p.control});
2011
+ if(navigator.sendBeacon){navigator.sendBeacon('/api/doc',new Blob([b],{type:'application/json'}));}
2012
+ else {fetch('/api/doc',{method:'POST',headers:{'Content-Type':'application/json'},body:b,keepalive:true});} }catch(e){}
2013
+ try{ if(window.Live){ Live.sendState(); Live.disconnect(); } }catch(e){} }
1981
2014
  // Transfert : je passe la main → je cesse de piloter SANS clôturer la présentation (le nouvel owner reprendra).
1982
2015
  function stopPilotingLocally(){ if(!PRES)return; var s=PRES.slug; PRES=null; clearInterval(_hbIv); clearCtl(s); var pb=document.getElementById('pbar'); if(pb)pb.style.display='none'; try{ if(window.Live) Live.disconnect(); }catch(e){} }
1983
2016
  function liveConnect(slug,control){ try{ if(window.Live){ var P=CFG.present||{}; Live.connect(slug,{name:P.by||'Présentateur',email:P.email||'',avatar:P.av||'',role:'presenter',member:true},control); } }catch(e){} }
@@ -2008,7 +2041,10 @@ ${LEGAL_CSS}
2008
2041
  }).catch(function(){});
2009
2042
  }
2010
2043
  // Carte live : persiste le contenu (present-content, JWT) → l'audience bascule/suit via Realtime.
2011
- function presentContent(content){ if(!PRES)return; diffuserEtat({content:content||null}); var h={'Content-Type':'application/json'}; var tk=appToken(); if(tk) h['Authorization']='Bearer '+tk; fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-content',slug:PRES.slug,content:content})}).catch(function(){}); }
2044
+ function presentContent(content){ if(!PRES)return; var h={'Content-Type':'application/json'}; var tk=appToken(); if(tk) h['Authorization']='Bearer '+tk;
2045
+ fetch('/api/doc',{method:'POST',headers:h,body:JSON.stringify({action:'present-content',slug:PRES.slug,content:content})})
2046
+ .then(function(r){ if(r&&r.ok)diffuserEtat(); })
2047
+ .catch(function(){}); }
2012
2048
  function showMap(){ if(!PRES||!window.Map3DD)return; var wrap=document.getElementById('mapWrap'); if(wrap&&wrap.classList.contains('on')){ Map3DD.enter(null,true,presentContent); return; } var init=Player.presentation.initialMapContent(); presentContent(init); Map3DD.enter(init,true,presentContent); }
2013
2049
  function hideMap(){ if(!window.Map3DD)return; Map3DD.exit(); presentContent(null); }
2014
2050
  // Mode INTÉGRÉ : la page hôte délègue sa barre de titre à celle-ci. On lui dit
@@ -171,7 +171,9 @@ const CHAMPS_PUBLICS = [
171
171
  ];
172
172
  function messagePublic(row) {
173
173
  if (!row || typeof row !== "object") return null;
174
- const out = {};
174
+ // Les clés viennent d'une liste blanche interne, mais l'objet est nu quand même : la règle se
175
+ // relit sans avoir à vérifier d'où vient chaque clé.
176
+ const out = Object.create(null);
175
177
  for (const c of CHAMPS_PUBLICS) if (c in row) out[c] = row[c];
176
178
  return out;
177
179
  }
@@ -321,12 +323,14 @@ async function presentationStats(slug) {
321
323
  PLAYER.db.request(`doc_presentation_messages?slug=eq.${enc(slug)}&deleted=eq.false&select=author_email,author_name&limit=1000`),
322
324
  ]);
323
325
  const msgs = Array.isArray(msgRows) ? msgRows : [];
324
- const msgByKey = {};
325
- msgs.forEach((m) => { const k = lc(m.author_email) || ("name:" + (m.author_name || "")); msgByKey[k] = (msgByKey[k] || 0) + 1; });
326
+ // ⚠️ Une `Map` : la clé est l'e-mail ou le nom d'un participant, donc une donnée du dehors.
327
+ // Avec un objet, `msgByKey["__proto__"]` traverse le prototype au lieu de compter. (audit P1-2)
328
+ const msgByKey = new Map();
329
+ msgs.forEach((m) => { const k = lc(m.author_email) || ("name:" + (m.author_name || "")); msgByKey.set(k, (msgByKey.get(k) || 0) + 1); });
326
330
  const attendees = (Array.isArray(attRows) ? attRows : []).map((a) => {
327
331
  const k = lc(a.email) || ("name:" + (a.name || ""));
328
332
  const pages = Array.isArray(a.pages) ? a.pages : [];
329
- return { name: a.name, email: a.email, avatar: a.avatar, isMember: !!a.is_member, isPresenter: !!a.is_presenter, firstSeen: a.first_seen, lastSeen: a.last_seen, totalMs: Number(a.total_ms || 0), pages, pagesCount: pages.length, msgCount: msgByKey[k] || 0 };
333
+ return { name: a.name, email: a.email, avatar: a.avatar, isMember: !!a.is_member, isPresenter: !!a.is_presenter, firstSeen: a.first_seen, lastSeen: a.last_seen, totalMs: Number(a.total_ms || 0), pages, pagesCount: pages.length, msgCount: msgByKey.get(k) || 0 };
330
334
  });
331
335
  const viewers = attendees.filter((a) => !a.isPresenter);
332
336
  const start = new Date(pres.created_at || 0).getTime();
@@ -375,15 +379,15 @@ async function listPresentationsForDoc(docId) {
375
379
  const rows = await PLAYER.db.request(`doc_presentations?doc_id=eq.${enc(String(docId))}&select=slug,presenter_name,owner_name,current_page,active,created_at,updated_at&order=created_at.desc&limit=50`);
376
380
  const list = Array.isArray(rows) ? rows : [];
377
381
  // UNE requête groupée (in.(…)) au lieu d'une par présentation (N+1, jusqu'à 50) ; agrégation en mémoire.
378
- const counts = {};
382
+ const counts = new Map();
379
383
  if (list.length) {
380
384
  try {
381
385
  const slugs = list.map((p) => enc(p.slug)).join(",");
382
386
  const att = await PLAYER.db.request(`doc_presentation_attendees?slug=in.(${slugs})&is_presenter=eq.false&select=slug&limit=5000`);
383
- for (const a of Array.isArray(att) ? att : []) counts[a.slug] = (counts[a.slug] || 0) + 1;
387
+ for (const a of Array.isArray(att) ? att : []) counts.set(a.slug, (counts.get(a.slug) || 0) + 1);
384
388
  } catch { /* best-effort : compteurs à 0 */ }
385
389
  }
386
- return list.map((p) => ({ slug: p.slug, presenterName: p.presenter_name, ownerName: p.owner_name, currentPage: p.current_page || 1, active: !!p.active, createdAt: p.created_at, endedAt: p.active ? null : p.updated_at, attendees: counts[p.slug] || 0 }));
390
+ return list.map((p) => ({ slug: p.slug, presenterName: p.presenter_name, ownerName: p.owner_name, currentPage: p.current_page || 1, active: !!p.active, createdAt: p.created_at, endedAt: p.active ? null : p.updated_at, attendees: counts.get(p.slug) || 0 }));
387
391
  }
388
392
 
389
393
  module.exports = {
package/server/shares.js CHANGED
@@ -123,9 +123,13 @@ async function listSharesForDoc(docId, owner) {
123
123
  ]);
124
124
  const shareList = Array.isArray(shares) ? shares : [];
125
125
  const viewList = Array.isArray(views) ? views : [];
126
- const bySlug = {};
126
+ // Le slug est engendré par le serveur, donc celui-ci n'était pas atteignable — on le convertit
127
+ // quand même. Un agrégateur qui doit se justifier au cas par cas finit par se tromper de cas :
128
+ // la règle « toute clé venue d'une ligne va dans une Map » se relit sans réfléchir.
129
+ const bySlug = new Map();
127
130
  for (const v of viewList) {
128
- const s = (bySlug[v.slug] = bySlug[v.slug] || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null });
131
+ let s = bySlug.get(v.slug);
132
+ if (!s) { s = { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null }; bySlug.set(v.slug, s); }
129
133
  if (v.event === "open") s.opens++;
130
134
  const mp = Math.max(Number(v.page) || 0, Number(v.max_page) || 0);
131
135
  if (mp > s.maxPage) s.maxPage = mp;
@@ -134,17 +138,18 @@ async function listSharesForDoc(docId, owner) {
134
138
  s.lastAt = v.at;
135
139
  }
136
140
  const enriched = shareList.map((sh) => {
137
- const a = bySlug[sh.slug] || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null };
141
+ const a = bySlug.get(sh.slug) || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null };
138
142
  return { slug: sh.slug, parent_slug: sh.parent_slug || null, recipient_email: sh.recipient_email, recipient_name: sh.recipient_name, created_by: sh.created_by, created_at: sh.created_at, revoked: sh.revoked, opens: a.opens, sessions: a.sessions.size, maxPage: a.maxPage, seconds: a.seconds, lastAt: a.lastAt };
139
143
  });
140
144
  // Entonnoir de lecture : page max atteinte PAR SESSION → combien de lecteurs ont atteint AU MOINS la page p.
141
- const sessMax = {};
145
+ // ⚠️ UNE `Map`, PAS UN OBJET — la clé vient du dehors. Voir l'explication complète sur `byDoc`.
146
+ const sessMax = new Map();
142
147
  for (const v of viewList) {
143
148
  const sid = v.session_id || v.slug;
144
149
  const mp = Math.max(Number(v.page) || 0, Number(v.max_page) || 0);
145
- if (mp > 0) sessMax[sid] = Math.max(sessMax[sid] || 0, mp);
150
+ if (mp > 0) sessMax.set(sid, Math.max(sessMax.get(sid) || 0, mp));
146
151
  }
147
- const reached = Object.values(sessMax);
152
+ const reached = [...sessMax.values()];
148
153
  const maxReached = reached.reduce((m, x) => Math.max(m, x), 0);
149
154
  const funnel = [];
150
155
  for (let p = 1; p <= maxReached; p++) funnel.push(reached.filter((x) => x >= p).length);
@@ -176,29 +181,55 @@ async function overview() {
176
181
  PLAYER.db.selectAll(`commercial_doc_internal_sessions?select=doc_id,user_email,last_at&last_at=gte.${since}&order=last_at.asc`).catch(() => []),
177
182
  ]);
178
183
  const list = Array.isArray(views) ? views : [];
179
- const byDoc = {};
184
+ // ⚠️ POURQUOI DES `Map` DANS TOUT CE FICHIER, ET PAS DES OBJETS.
185
+ //
186
+ // Ces agrégateurs étaient des `{}` indexés par des identifiants, des e-mails, des sessions —
187
+ // tous venus du dehors. Une clé héritée y a une sémantique spéciale, et `X[k] = X[k] || {…}`
188
+ // suffit à tout casser :
189
+ //
190
+ // `byDoc["__proto__"]` ne rend pas `undefined`, il rend `Object.prototype` — qui est VRAI.
191
+ // Le `|| {…}` ne se déclenche donc pas, et `a` DEVIENT le prototype. Ensuite `a.opens++`
192
+ // écrit `Object.prototype.opens = NaN`, et `a.readers.add(…)` lève sur `undefined`.
193
+ //
194
+ // ⚠️ Reproduit avec une seule ligne : `TypeError` immédiate, ET la propriété reste sur le
195
+ // prototype POUR TOUT LE PROCESSUS. Sur une instance serverless tiède, la pollution survit aux
196
+ // requêtes suivantes : chaque objet du processus porte alors un `opens`, et n'importe quel
197
+ // `if (x.opens)` ailleurs devient faux. Une ligne de table pour empoisonner un processus.
198
+ //
199
+ // `user_email` est atteignable sans authentification tant que `PLAYER_INTERNAL_STRICT` n'est pas
200
+ // posé (cf. 0.1.22), donc ce n'est pas théorique.
201
+ //
202
+ // Une `Map` n'a pas de prototype à traverser : ses clés sont des données, pas des noms de
203
+ // propriétés. C'est la seule forme qui n'a rien à se rappeler. La garde statique, elle, filtrait
204
+ // sur des NOMS DE VARIABLES (`id`, `k`, `sid` en étaient absents) — une alarme, jamais une
205
+ // barrière. (audit P1-2)
206
+ const byDoc = new Map();
180
207
  for (const v of list) {
181
208
  const id = v.doc_id || "";
182
209
  if (!id) continue;
183
- const a = (byDoc[id] = byDoc[id] || { opens: 0, readers: new Set(), maxPage: 0, lastAt: null });
210
+ let a = byDoc.get(id);
211
+ if (!a) { a = { opens: 0, readers: new Set(), maxPage: 0, lastAt: null }; byDoc.set(id, a); }
184
212
  if (v.event === "open") a.opens++;
185
213
  if (v.session_id) a.readers.add(v.session_id);
186
214
  a.maxPage = Math.max(a.maxPage, Number(v.page) || 0, Number(v.max_page) || 0);
187
215
  a.lastAt = v.at;
188
216
  }
189
- const intByDoc = {};
217
+ const intByDoc = new Map();
190
218
  for (const s of Array.isArray(internal) ? internal : []) {
191
219
  const id = s.doc_id || "";
192
220
  if (!id) continue;
193
- const b = (intByDoc[id] = intByDoc[id] || { opens: 0, users: new Set(), lastAt: null });
221
+ let b = intByDoc.get(id);
222
+ if (!b) { b = { opens: 0, users: new Set(), lastAt: null }; intByDoc.set(id, b); }
194
223
  b.opens++;
195
224
  if (s.user_email) b.users.add(String(s.user_email).toLowerCase());
196
225
  b.lastAt = s.last_at;
197
226
  }
198
- const out = {};
199
- for (const id of new Set([...Object.keys(byDoc), ...Object.keys(intByDoc)])) {
200
- const a = byDoc[id] || { opens: 0, readers: new Set(), maxPage: 0, lastAt: null };
201
- const b = intByDoc[id] || { opens: 0, users: new Set(), lastAt: null };
227
+ // La sortie est rendue en JSON : un objet SANS prototype, pour qu'une clé héritée y reste une
228
+ // clé ordinaire jusqu'au bout de la chaîne.
229
+ const out = Object.create(null);
230
+ for (const id of new Set([...byDoc.keys(), ...intByDoc.keys()])) {
231
+ const a = byDoc.get(id) || { opens: 0, readers: new Set(), maxPage: 0, lastAt: null };
232
+ const b = intByDoc.get(id) || { opens: 0, users: new Set(), lastAt: null };
202
233
  out[id] = { opens: a.opens, readers: a.readers.size, maxPage: a.maxPage, lastAt: a.lastAt, internalOpens: b.opens, internalReaders: b.users.size, internalLastAt: b.lastAt };
203
234
  }
204
235
  return out;
@@ -242,9 +273,9 @@ async function listSessionsForDoc(docId) {
242
273
  PLAYER.db.request(`commercial_doc_sessions?doc_id=eq.${id}&select=*&order=last_at.desc&limit=500`),
243
274
  PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&is_test=not.is.true&select=slug,recipient_email,recipient_name`),
244
275
  ]);
245
- const nameBySlug = {};
246
- for (const sh of (Array.isArray(shares) ? shares : [])) nameBySlug[sh.slug] = sh.recipient_name || null;
247
- return (Array.isArray(sessions) ? sessions : []).map((s) => ({ ...s, recipient_name: nameBySlug[s.slug] || null }));
276
+ const nameBySlug = new Map();
277
+ for (const sh of (Array.isArray(shares) ? shares : [])) nameBySlug.set(sh.slug, sh.recipient_name || null);
278
+ return (Array.isArray(sessions) ? sessions : []).map((s) => ({ ...s, recipient_name: nameBySlug.get(s.slug) || null }));
248
279
  }
249
280
 
250
281
  // Envoi AUTO du re-partage via 3D Discovery (Resend). Contenu 100% templé (pas de texte libre → anti-spam),
@@ -347,17 +378,19 @@ async function upsertInternalSession(p, { ip, ua }) {
347
378
  async function internalStatsForDoc(docId) {
348
379
  const rows = await PLAYER.db.request(`commercial_doc_internal_sessions?doc_id=eq.${enc(String(docId || ""))}&select=user_email,user_name,max_page,total_seconds,last_at&order=last_at.desc&limit=500`);
349
380
  const list = Array.isArray(rows) ? rows : [];
350
- const byUser = {};
381
+ // ⚠️ La clé est un e-mail que l'appelant choisit — c'est le cas atteignable sans authentification.
382
+ const byUser = new Map();
351
383
  for (const r of list) {
352
384
  const k = low(r.user_email) || (r.user_name || "?");
353
- const u = (byUser[k] = byUser[k] || { email: r.user_email || null, name: r.user_name || null, opens: 0, maxPage: 0, seconds: 0, lastAt: null });
385
+ let u = byUser.get(k);
386
+ if (!u) { u = { email: r.user_email || null, name: r.user_name || null, opens: 0, maxPage: 0, seconds: 0, lastAt: null }; byUser.set(k, u); }
354
387
  u.opens++;
355
388
  u.maxPage = Math.max(u.maxPage, Number(r.max_page) || 0);
356
389
  u.seconds += Number(r.total_seconds) || 0;
357
390
  if (!u.lastAt || r.last_at > u.lastAt) u.lastAt = r.last_at;
358
391
  if (!u.name && r.user_name) u.name = r.user_name;
359
392
  }
360
- const users = Object.values(byUser).sort((a, b) => (b.lastAt || "").localeCompare(a.lastAt || ""));
393
+ const users = [...byUser.values()].sort((a, b) => (b.lastAt || "").localeCompare(a.lastAt || ""));
361
394
  return { opens: list.length, readers: users.length, lastAt: list[0]?.last_at || null, users };
362
395
  }
363
396