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 +1 -1
- package/server/handler.js +43 -7
- package/server/presentations.js +11 -7
- package/server/shares.js +53 -20
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
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;
|
|
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
|
package/server/presentations.js
CHANGED
|
@@ -171,7 +171,9 @@ const CHAMPS_PUBLICS = [
|
|
|
171
171
|
];
|
|
172
172
|
function messagePublic(row) {
|
|
173
173
|
if (!row || typeof row !== "object") return null;
|
|
174
|
-
|
|
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
|
-
|
|
325
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
150
|
+
if (mp > 0) sessMax.set(sid, Math.max(sessMax.get(sid) || 0, mp));
|
|
146
151
|
}
|
|
147
|
-
const reached =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
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
|
|
247
|
-
return (Array.isArray(sessions) ? sessions : []).map((s) => ({ ...s, recipient_name: nameBySlug
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|