discovery-media-player 0.1.0
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/CONTRAT.md +515 -0
- package/LICENSE +661 -0
- package/LICENSE-MIT +21 -0
- package/README.md +152 -0
- package/bin/__tests__/serve.test.js +84 -0
- package/bin/serve.js +115 -0
- package/context/__tests__/storage.test.js +99 -0
- package/context/standalone.js +224 -0
- package/context/storage.js +230 -0
- package/package.json +72 -0
- package/server/brands.js +44 -0
- package/server/browser.generated.js +7 -0
- package/server/handler.js +2657 -0
- package/server/presentations.js +319 -0
- package/server/shared.generated.js +93 -0
- package/server/shares.js +275 -0
- package/src/__tests__/bridge.test.ts +108 -0
- package/src/__tests__/chat.test.ts +138 -0
- package/src/__tests__/live.test.ts +211 -0
- package/src/__tests__/presentation-content.test.ts +132 -0
- package/src/__tests__/presentation-state.test.ts +81 -0
- package/src/__tests__/tracking.test.ts +217 -0
- package/src/__tests__/viewer.test.ts +133 -0
- package/src/bridge.ts +141 -0
- package/src/chat.ts +103 -0
- package/src/index.ts +14 -0
- package/src/live.ts +225 -0
- package/src/presentation-content.ts +109 -0
- package/src/presentation-state.ts +93 -0
- package/src/tracking.ts +250 -0
- package/src/viewer.ts +109 -0
- package/supabase/init.sql +242 -0
package/server/shares.js
ADDED
|
@@ -0,0 +1,275 @@
|
|
|
1
|
+
// GED commerciale : liens de partage tracés (un par destinataire) + agrégation des consultations.
|
|
2
|
+
// Tables service-role only (cf. migration v12321) → tout passe par le service role ici.
|
|
3
|
+
const crypto = require("crypto");
|
|
4
|
+
// Tout ce qui vient de l'hôte passe par le contexte injecté — base, email, marque. C'est ce qui
|
|
5
|
+
// permettra à ce fichier de partir dans le dépôt du player sans emporter le studio avec lui.
|
|
6
|
+
// ⚠️ Le contexte est REÇU, pas construit. Ce module ne doit pas savoir d'où il vient : c'est ce
|
|
7
|
+
// qui lui permettra de partir dans le dépôt du player sans emporter le studio avec lui.
|
|
8
|
+
let PLAYER = null;
|
|
9
|
+
function init(ctx) { PLAYER = ctx; }
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
const enc = encodeURIComponent;
|
|
13
|
+
const low = (s) => String(s || "").trim().toLowerCase();
|
|
14
|
+
function newSlug() { return crypto.randomBytes(9).toString("base64url"); } // ~12 chars URL-safe
|
|
15
|
+
|
|
16
|
+
// Crée un lien de partage (un par destinataire). Dénormalise titre/URL/nom pour résilience (le doc vit dans
|
|
17
|
+
// un snapshot). Renvoie le slug.
|
|
18
|
+
async function createShare({ docId, docTitle, fileUrl, fileName, recipientEmail, recipientName, createdBy, bot, botScript, guided, profileId, allowDownload, isTest, videoLayout, logo, logoDark, brandKey}) {
|
|
19
|
+
if (!docId || !fileUrl) throw Object.assign(new Error("doc invalide"), { statusCode: 400 });
|
|
20
|
+
const slug = newSlug();
|
|
21
|
+
const row = {
|
|
22
|
+
slug, doc_id: String(docId), doc_title: docTitle || null, file_url: String(fileUrl), file_name: fileName || null,
|
|
23
|
+
recipient_email: low(recipientEmail) || null, recipient_name: (recipientName || "").trim() || null, created_by: low(createdBy) || null,
|
|
24
|
+
bot_enabled: !!bot, bot_script: (botScript || "").trim() ? String(botScript).slice(0, 2000) : null, bot_guided: guided !== false,
|
|
25
|
+
bot_profile_id: (profileId || "").trim() ? String(profileId).slice(0, 40) : null,
|
|
26
|
+
allow_download: allowDownload !== false, // défaut : autorisé (rétro-compatible)
|
|
27
|
+
video_layout: ["side-r", "side-l", "split-m"].includes(videoLayout) ? videoLayout : null, // présentateur vidéo « en grand »
|
|
28
|
+
is_test: !!isTest, // « répétition générale » : lien de test exclu des stats/notifications
|
|
29
|
+
// Logo de marque (promoteur) du loader — null = logo 3D Discovery global.
|
|
30
|
+
// Référence de marque (registre) : préférée au logo recopié — un logo rectifié se propage
|
|
31
|
+
// alors aux liens DÉJÀ ENVOYÉS. Le logo direct reste accepté (flux des propositions).
|
|
32
|
+
brand_key: (brandKey || "").trim() || null,
|
|
33
|
+
brand_logo: (logo || "").trim() ? String(logo).trim().slice(0, 500) : null,
|
|
34
|
+
brand_dark: !!logoDark, // fond sombre du loader (logo clair/blanc)
|
|
35
|
+
};
|
|
36
|
+
await PLAYER.db.request("commercial_doc_shares", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
|
|
37
|
+
return { slug };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Re-partage depuis la visionneuse publique (forward) : crée un lien ENFANT tracé pour un nouveau
|
|
41
|
+
// destinataire, rattaché au lien parent (parent_slug) → chaîne de diffusion. created_by = celui qui forwarde.
|
|
42
|
+
async function createReshare(parentSlug, { email, name }) {
|
|
43
|
+
const parent = await getShareBySlug(parentSlug);
|
|
44
|
+
if (!parent) throw Object.assign(new Error("lien introuvable"), { statusCode: 404 });
|
|
45
|
+
const slug = newSlug();
|
|
46
|
+
const row = {
|
|
47
|
+
slug, doc_id: parent.doc_id, doc_title: parent.doc_title, file_url: parent.file_url, file_name: parent.file_name,
|
|
48
|
+
recipient_email: low(email) || null, recipient_name: (name || "").trim() || null,
|
|
49
|
+
created_by: parent.recipient_email || parent.created_by || null, parent_slug: parent.slug,
|
|
50
|
+
};
|
|
51
|
+
await PLAYER.db.request("commercial_doc_shares", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
|
|
52
|
+
return { slug, docTitle: parent.doc_title };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async function getShareBySlug(slug) {
|
|
56
|
+
const rows = await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}&revoked=eq.false&select=*&limit=1`);
|
|
57
|
+
return Array.isArray(rows) && rows[0] ? rows[0] : null;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Journalise un événement de consultation (ouverture / page vue / battement). Best-effort.
|
|
61
|
+
async function logView(share, { event, page, maxPage, seconds, sessionId, ua }) {
|
|
62
|
+
const num = (v) => (Number.isFinite(+v) ? Math.trunc(+v) : null);
|
|
63
|
+
const row = {
|
|
64
|
+
slug: share.slug, doc_id: share.doc_id, recipient_email: share.recipient_email,
|
|
65
|
+
event: String(event || "open").slice(0, 16), page: num(page), max_page: num(maxPage), seconds: num(seconds),
|
|
66
|
+
session_id: String(sessionId || "").slice(0, 64) || null, ua: String(ua || "").slice(0, 300) || null,
|
|
67
|
+
};
|
|
68
|
+
await PLAYER.db.request("commercial_doc_views", { method: "POST", headers: { Prefer: "return=minimal" }, body: [row] });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Liste des liens d'un document + stats par lien + agrégat global. Agrégation en mémoire (volume modéré).
|
|
72
|
+
/**
|
|
73
|
+
* Liens d'un document.
|
|
74
|
+
*
|
|
75
|
+
* `owner` restreint la liste aux liens créés par cette personne. C'est ce qui permet à un hôte de
|
|
76
|
+
* distinguer « lister MES liens » (acte commercial ordinaire) de « lister tous les liens » (acte
|
|
77
|
+
* d'administration) : sans restriction, un commercial verrait à qui d'autre le document a été
|
|
78
|
+
* envoyé — donc les prospects de ses collègues.
|
|
79
|
+
*/
|
|
80
|
+
async function listSharesForDoc(docId, owner) {
|
|
81
|
+
const id = enc(String(docId || ""));
|
|
82
|
+
const filtreOwner = owner ? `&created_by=eq.${enc(low(owner))}` : "";
|
|
83
|
+
const [shares, views] = await Promise.all([
|
|
84
|
+
PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&is_test=not.is.true${filtreOwner}&select=*&order=created_at.desc`),
|
|
85
|
+
PLAYER.db.selectAll(`commercial_doc_views?doc_id=eq.${id}&select=slug,event,page,max_page,seconds,session_id,at&order=at.asc`),
|
|
86
|
+
]);
|
|
87
|
+
const shareList = Array.isArray(shares) ? shares : [];
|
|
88
|
+
const viewList = Array.isArray(views) ? views : [];
|
|
89
|
+
const bySlug = {};
|
|
90
|
+
for (const v of viewList) {
|
|
91
|
+
const s = (bySlug[v.slug] = bySlug[v.slug] || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null });
|
|
92
|
+
if (v.event === "open") s.opens++;
|
|
93
|
+
const mp = Math.max(Number(v.page) || 0, Number(v.max_page) || 0);
|
|
94
|
+
if (mp > s.maxPage) s.maxPage = mp;
|
|
95
|
+
s.seconds = Math.max(s.seconds, Number(v.seconds) || 0);
|
|
96
|
+
if (v.session_id) s.sessions.add(v.session_id);
|
|
97
|
+
s.lastAt = v.at;
|
|
98
|
+
}
|
|
99
|
+
const enriched = shareList.map((sh) => {
|
|
100
|
+
const a = bySlug[sh.slug] || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null };
|
|
101
|
+
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 };
|
|
102
|
+
});
|
|
103
|
+
// Entonnoir de lecture : page max atteinte PAR SESSION → combien de lecteurs ont atteint AU MOINS la page p.
|
|
104
|
+
const sessMax = {};
|
|
105
|
+
for (const v of viewList) {
|
|
106
|
+
const sid = v.session_id || v.slug;
|
|
107
|
+
const mp = Math.max(Number(v.page) || 0, Number(v.max_page) || 0);
|
|
108
|
+
if (mp > 0) sessMax[sid] = Math.max(sessMax[sid] || 0, mp);
|
|
109
|
+
}
|
|
110
|
+
const reached = Object.values(sessMax);
|
|
111
|
+
const maxReached = reached.reduce((m, x) => Math.max(m, x), 0);
|
|
112
|
+
const funnel = [];
|
|
113
|
+
for (let p = 1; p <= maxReached; p++) funnel.push(reached.filter((x) => x >= p).length);
|
|
114
|
+
|
|
115
|
+
const total = {
|
|
116
|
+
shares: shareList.length,
|
|
117
|
+
opened: enriched.filter((x) => x.opens > 0).length,
|
|
118
|
+
opens: enriched.reduce((s, x) => s + x.opens, 0),
|
|
119
|
+
maxPage: enriched.reduce((m, x) => Math.max(m, x.maxPage), 0),
|
|
120
|
+
readers: reached.length, // sessions distinctes ayant tourné au moins une page
|
|
121
|
+
};
|
|
122
|
+
return { shares: enriched, total, funnel };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Vue d'ensemble (tous documents) : stats agrégées par doc_id, pour les badges de la grille + le « top ».
|
|
126
|
+
// Sépare bien les OUVERTURES CLIENT (liens tracés = commercial_doc_views) des CONSULTATIONS INTERNES
|
|
127
|
+
// (équipe 3D Discovery = commercial_doc_internal_sessions) → la liste peut afficher « vu par l'équipe »
|
|
128
|
+
// vs « ouvert par le client » sans mélanger la métrique commerciale.
|
|
129
|
+
async function overview() {
|
|
130
|
+
// Borne glissante généreuse (24 mois) : ces tables d'événements grossissent sans fin ; sans filtre, le
|
|
131
|
+
// scan intégral se dégrade avec le temps. 24 mois couvre tout l'historique utile pour la vue d'ensemble
|
|
132
|
+
// (opens / lecteurs / dernière activité) sans changer les chiffres actuels. Filtre servi par l'index sur `at`.
|
|
133
|
+
const since = new Date(Date.now() - 24 * 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
134
|
+
const [views, internal] = await Promise.all([
|
|
135
|
+
// PAGINÉ : au-delà de 1 000 lignes, PostgREST tronquait en silence — et comme le tri
|
|
136
|
+
// est ascendant, c'est le RÉCENT qui disparaissait. Les consultations des trois dernières
|
|
137
|
+
// semaines étaient invisibles du tableau de bord (0 ouverture affichée sur des plans lus).
|
|
138
|
+
PLAYER.db.selectAll(`commercial_doc_views?select=doc_id,event,session_id,page,max_page,at&at=gte.${since}&order=at.asc`),
|
|
139
|
+
PLAYER.db.selectAll(`commercial_doc_internal_sessions?select=doc_id,user_email,last_at&last_at=gte.${since}&order=last_at.asc`).catch(() => []),
|
|
140
|
+
]);
|
|
141
|
+
const list = Array.isArray(views) ? views : [];
|
|
142
|
+
const byDoc = {};
|
|
143
|
+
for (const v of list) {
|
|
144
|
+
const id = v.doc_id || "";
|
|
145
|
+
if (!id) continue;
|
|
146
|
+
const a = (byDoc[id] = byDoc[id] || { opens: 0, readers: new Set(), maxPage: 0, lastAt: null });
|
|
147
|
+
if (v.event === "open") a.opens++;
|
|
148
|
+
if (v.session_id) a.readers.add(v.session_id);
|
|
149
|
+
a.maxPage = Math.max(a.maxPage, Number(v.page) || 0, Number(v.max_page) || 0);
|
|
150
|
+
a.lastAt = v.at;
|
|
151
|
+
}
|
|
152
|
+
const intByDoc = {};
|
|
153
|
+
for (const s of Array.isArray(internal) ? internal : []) {
|
|
154
|
+
const id = s.doc_id || "";
|
|
155
|
+
if (!id) continue;
|
|
156
|
+
const b = (intByDoc[id] = intByDoc[id] || { opens: 0, users: new Set(), lastAt: null });
|
|
157
|
+
b.opens++;
|
|
158
|
+
if (s.user_email) b.users.add(String(s.user_email).toLowerCase());
|
|
159
|
+
b.lastAt = s.last_at;
|
|
160
|
+
}
|
|
161
|
+
const out = {};
|
|
162
|
+
for (const id of new Set([...Object.keys(byDoc), ...Object.keys(intByDoc)])) {
|
|
163
|
+
const a = byDoc[id] || { opens: 0, readers: new Set(), maxPage: 0, lastAt: null };
|
|
164
|
+
const b = intByDoc[id] || { opens: 0, users: new Set(), lastAt: null };
|
|
165
|
+
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 };
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Parse minimal d'un User-Agent → appareil / OS / navigateur (sans dépendance).
|
|
171
|
+
function parseUa(ua) {
|
|
172
|
+
const s = String(ua || "");
|
|
173
|
+
let device = /Mobile|iPhone|Android.*Mobile/.test(s) ? "Mobile" : /iPad|Tablet/.test(s) ? "Tablette" : "Ordinateur";
|
|
174
|
+
let os = /Windows/.test(s) ? "Windows" : /iPhone|iPad|iOS/.test(s) ? "iOS" : /Mac OS X|Macintosh/.test(s) ? "macOS" : /Android/.test(s) ? "Android" : /Linux/.test(s) ? "Linux" : "—";
|
|
175
|
+
let browser = /Edg\//.test(s) ? "Edge" : /OPR\/|Opera/.test(s) ? "Opera" : /Chrome\//.test(s) ? "Chrome" : /Firefox\//.test(s) ? "Firefox" : /Safari\//.test(s) ? "Safari" : "—";
|
|
176
|
+
return { device, os, browser };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Upsert d'une session de consultation (résumé envoyé périodiquement par la visionneuse). Stocke le temps
|
|
180
|
+
// PAR page (cumulatif côté client → on remplace), totaux, appareil. Conserve started_at (insert) via merge.
|
|
181
|
+
async function upsertSession(share, p, { ip, ua }) {
|
|
182
|
+
const num = (v) => (Number.isFinite(+v) ? Math.trunc(+v) : null);
|
|
183
|
+
const sessionId = String(p.sessionId || "").slice(0, 64);
|
|
184
|
+
if (!sessionId) return;
|
|
185
|
+
const pagesTime = (p.pagesTime && typeof p.pagesTime === "object") ? p.pagesTime : {};
|
|
186
|
+
const { device, os, browser } = parseUa(ua);
|
|
187
|
+
const row = {
|
|
188
|
+
session_id: sessionId, slug: share.slug, doc_id: share.doc_id, recipient_email: share.recipient_email,
|
|
189
|
+
num_pages: num(p.numPages), max_page: num(p.maxPage), total_seconds: num(p.totalSeconds) || 0, pages_time: pagesTime,
|
|
190
|
+
ua: String(ua || "").slice(0, 300), ip: String(ip || "").slice(0, 60), device, os, browser, last_at: new Date().toISOString(),
|
|
191
|
+
};
|
|
192
|
+
// started_at non touché par l'upsert (default à l'insert ; merge ne l'écrase pas car absent du body).
|
|
193
|
+
await PLAYER.db.request("commercial_doc_sessions?on_conflict=session_id", { method: "POST", headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, body: [row] });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Sessions de consultation d'un document (détail riche par session) + nom du destinataire (jointure share).
|
|
197
|
+
async function listSessionsForDoc(docId) {
|
|
198
|
+
const id = enc(String(docId || ""));
|
|
199
|
+
const [sessions, shares] = await Promise.all([
|
|
200
|
+
PLAYER.db.request(`commercial_doc_sessions?doc_id=eq.${id}&select=*&order=last_at.desc&limit=500`),
|
|
201
|
+
PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&is_test=not.is.true&select=slug,recipient_email,recipient_name`),
|
|
202
|
+
]);
|
|
203
|
+
const nameBySlug = {};
|
|
204
|
+
for (const sh of (Array.isArray(shares) ? shares : [])) nameBySlug[sh.slug] = sh.recipient_name || null;
|
|
205
|
+
return (Array.isArray(sessions) ? sessions : []).map((s) => ({ ...s, recipient_name: nameBySlug[s.slug] || null }));
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Envoi AUTO du re-partage via 3D Discovery (Resend). Contenu 100% templé (pas de texte libre → anti-spam),
|
|
209
|
+
// attribué au recommandeur (destinataire du lien parent), reply-to vers lui. Best-effort.
|
|
210
|
+
async function sendReshareEmail({ parent, childSlug, origin, toEmail, toName }) {
|
|
211
|
+
const e = (s) => String(s == null ? "" : s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
212
|
+
const url = `${origin.replace(/\/$/, "")}/doc/${childSlug}`;
|
|
213
|
+
const forwarder = parent.recipient_name || parent.recipient_email || "Un contact 3D Discovery";
|
|
214
|
+
const title = parent.doc_title || parent.file_name || "Document";
|
|
215
|
+
let logoUrl = ""; try { logoUrl = await PLAYER.branding.logo(); } catch { /* sans logo */ }
|
|
216
|
+
const hello = toName && toName.trim() ? `Bonjour ${e(toName.trim())},` : "Bonjour,";
|
|
217
|
+
const html = `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;max-width:520px;margin:0 auto;color:#1c1c1c">
|
|
218
|
+
${logoUrl ? `<div style="text-align:center;padding:8px 0 18px"><img src="${e(logoUrl)}" alt="3D Discovery" style="height:34px"></div>` : `<div style="text-align:center;font-weight:800;font-size:18px;padding:8px 0 18px">3D Discovery</div>`}
|
|
219
|
+
<p style="font-size:15px">${hello}</p>
|
|
220
|
+
<p style="font-size:15px"><b>${e(forwarder)}</b> vous recommande ce document :</p>
|
|
221
|
+
<div style="border:1px solid #e7e3db;border-radius:12px;padding:16px 18px;margin:14px 0">
|
|
222
|
+
<div style="font-weight:700;font-size:16px;margin-bottom:12px">${e(title)}</div>
|
|
223
|
+
<a href="${e(url)}" style="display:inline-block;background:#111;color:#fff;text-decoration:none;font-weight:600;padding:11px 18px;border-radius:9px">Ouvrir le document</a>
|
|
224
|
+
</div>
|
|
225
|
+
<p style="font-size:13px;color:#777">Vous pouvez répondre directement à cet email pour échanger avec ${e(forwarder)}.</p>
|
|
226
|
+
<p style="font-size:11px;color:#999;margin-top:22px">Propulsé par 3D Discovery — visualisation 3D & visites immersives.</p>
|
|
227
|
+
</div>`;
|
|
228
|
+
return PLAYER.mail.send({ to: toEmail, subject: `${forwarder} vous recommande : ${title}`, html, replyTo: parent.recipient_email || undefined });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function revokeShare(slug) {
|
|
232
|
+
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { revoked: true } });
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// Soft wall : (dé)verrouille l'accès d'un lien /doc — require_auth=true → connexion visiteur exigée.
|
|
236
|
+
async function setShareAuth(slug, requireAuth) {
|
|
237
|
+
await PLAYER.db.request(`commercial_doc_shares?slug=eq.${enc(String(slug || ""))}`, { method: "PATCH", headers: { Prefer: "return=minimal" }, body: { require_auth: !!requireAuth } });
|
|
238
|
+
return { ok: true };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// — Consultations INTERNES (équipe), table dédiée → n'affecte PAS les stats prospects. —
|
|
242
|
+
// Upsert d'une session interne (depuis l'aperçu interne de la visionneuse).
|
|
243
|
+
async function upsertInternalSession(p, { ip, ua }) {
|
|
244
|
+
const num = (v) => (Number.isFinite(+v) ? Math.trunc(+v) : null);
|
|
245
|
+
const sessionId = String(p.sessionId || "").slice(0, 64);
|
|
246
|
+
if (!sessionId || !p.docId) return;
|
|
247
|
+
const { device, os, browser } = parseUa(ua);
|
|
248
|
+
const row = {
|
|
249
|
+
session_id: sessionId, doc_id: String(p.docId), user_email: low(p.userEmail) || null, user_name: (p.userName || "").trim() || null,
|
|
250
|
+
num_pages: num(p.numPages), max_page: num(p.maxPage), total_seconds: num(p.totalSeconds) || 0,
|
|
251
|
+
pages_time: (p.pagesTime && typeof p.pagesTime === "object") ? p.pagesTime : {},
|
|
252
|
+
ua: String(ua || "").slice(0, 300), ip: String(ip || "").slice(0, 60), device, os, browser, last_at: new Date().toISOString(),
|
|
253
|
+
};
|
|
254
|
+
await PLAYER.db.request("commercial_doc_internal_sessions?on_conflict=session_id", { method: "POST", headers: { Prefer: "resolution=merge-duplicates,return=minimal" }, body: [row] });
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Agrégat interne d'un document : nb de consultations (sessions), lecteurs distincts, détail par membre.
|
|
258
|
+
async function internalStatsForDoc(docId) {
|
|
259
|
+
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`);
|
|
260
|
+
const list = Array.isArray(rows) ? rows : [];
|
|
261
|
+
const byUser = {};
|
|
262
|
+
for (const r of list) {
|
|
263
|
+
const k = low(r.user_email) || (r.user_name || "?");
|
|
264
|
+
const u = (byUser[k] = byUser[k] || { email: r.user_email || null, name: r.user_name || null, opens: 0, maxPage: 0, seconds: 0, lastAt: null });
|
|
265
|
+
u.opens++;
|
|
266
|
+
u.maxPage = Math.max(u.maxPage, Number(r.max_page) || 0);
|
|
267
|
+
u.seconds += Number(r.total_seconds) || 0;
|
|
268
|
+
if (!u.lastAt || r.last_at > u.lastAt) u.lastAt = r.last_at;
|
|
269
|
+
if (!u.name && r.user_name) u.name = r.user_name;
|
|
270
|
+
}
|
|
271
|
+
const users = Object.values(byUser).sort((a, b) => (b.lastAt || "").localeCompare(a.lastAt || ""));
|
|
272
|
+
return { opens: list.length, readers: users.length, lastAt: list[0]?.last_at || null, users };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
module.exports = { init, createShare, createReshare, sendReshareEmail, getShareBySlug, logView, upsertSession, listSharesForDoc, listSessionsForDoc, revokeShare, setShareAuth, overview, upsertInternalSession, internalStatsForDoc };
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
toWire,
|
|
4
|
+
parsePlayerMessage,
|
|
5
|
+
parseHostMessage,
|
|
6
|
+
sendToHost,
|
|
7
|
+
sendToPlayer,
|
|
8
|
+
onPlayerMessage,
|
|
9
|
+
type PlayerMessage,
|
|
10
|
+
} from "../bridge";
|
|
11
|
+
|
|
12
|
+
describe("bridge — format sur le fil", () => {
|
|
13
|
+
it("préfixe le type et ne transporte le slug que s'il existe", () => {
|
|
14
|
+
expect(toWire({ type: "close" })).toEqual({ type: "3dd-doc-close" });
|
|
15
|
+
expect(toWire({ type: "present-invite", slug: "aZ9_-xyz" })).toEqual({
|
|
16
|
+
type: "3dd-doc-present-invite",
|
|
17
|
+
slug: "aZ9_-xyz",
|
|
18
|
+
});
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Le fil doit rester compatible avec les onglets déjà ouverts : renommer ces chaînes est un
|
|
22
|
+
// changement de contrat, pas un détail cosmétique.
|
|
23
|
+
it("conserve les noms historiques attendus par l'app déployée", () => {
|
|
24
|
+
const wire = (["close", "share", "present-left", "embed-ready"] as const).map(
|
|
25
|
+
(type) => toWire({ type } as PlayerMessage).type,
|
|
26
|
+
);
|
|
27
|
+
expect(wire).toEqual([
|
|
28
|
+
"3dd-doc-close",
|
|
29
|
+
"3dd-doc-share",
|
|
30
|
+
"3dd-doc-present-left",
|
|
31
|
+
"3dd-doc-embed-ready",
|
|
32
|
+
]);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("fait l'aller-retour sans perte", () => {
|
|
36
|
+
const msg: PlayerMessage = { type: "present-switch", slug: "abc123" };
|
|
37
|
+
expect(parsePlayerMessage(toWire(msg))).toEqual(msg);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
describe("bridge — lecture défensive", () => {
|
|
42
|
+
it("ignore ce qui n'est pas un message du player", () => {
|
|
43
|
+
for (const junk of [null, undefined, 0, "close", [], {}, { type: 42 }, { type: "close" }]) {
|
|
44
|
+
expect(parsePlayerMessage(junk)).toBeNull();
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("ignore un type inconnu, même correctement préfixé", () => {
|
|
49
|
+
expect(parsePlayerMessage({ type: "3dd-doc-drop-database" })).toBeNull();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
// Le slug traverse la frontière puis sert de clé d'API : on le borne ici, une fois.
|
|
53
|
+
it("rejette un slug absent, vide, trop long ou hors charset", () => {
|
|
54
|
+
const bad = ["", "a".repeat(65), "abc/../def", "abc def", "<script>", "abc?x=1"];
|
|
55
|
+
for (const slug of bad) {
|
|
56
|
+
expect(parsePlayerMessage({ type: "3dd-doc-present-invite", slug })).toBeNull();
|
|
57
|
+
}
|
|
58
|
+
expect(parsePlayerMessage({ type: "3dd-doc-present-invite" })).toBeNull();
|
|
59
|
+
expect(parsePlayerMessage({ type: "3dd-doc-present-invite", slug: 12 })).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("accepte le format réel des slugs (base64url, 12 caractères)", () => {
|
|
63
|
+
expect(parsePlayerMessage({ type: "3dd-doc-present-invite", slug: "Ab3-_xYz9012" })).toEqual({
|
|
64
|
+
type: "present-invite",
|
|
65
|
+
slug: "Ab3-_xYz9012",
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("ne confond pas les deux sens de circulation", () => {
|
|
70
|
+
expect(parseHostMessage({ type: "3dd-doc-close" })).toBeNull();
|
|
71
|
+
expect(parsePlayerMessage({ type: "3dd-doc-handover-done" })).toBeNull();
|
|
72
|
+
expect(parseHostMessage({ type: "3dd-doc-handover-done" })).toEqual({ type: "handover-done" });
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("bridge — runtime", () => {
|
|
77
|
+
it("poste au parent et n'explose pas si le parent est injoignable", () => {
|
|
78
|
+
const postMessage = vi.fn();
|
|
79
|
+
sendToHost({ type: "close" }, { postMessage } as unknown as Window);
|
|
80
|
+
expect(postMessage).toHaveBeenCalledWith({ type: "3dd-doc-close" }, "*");
|
|
81
|
+
|
|
82
|
+
const dead = { get contentWindow(): never { throw new Error("détruite"); } };
|
|
83
|
+
expect(() => sendToPlayer(dead as unknown as HTMLIFrameElement, { type: "handover-done" })).not.toThrow();
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("s'abonne, filtre le bruit, et se désabonne", () => {
|
|
87
|
+
const listeners: Array<(e: MessageEvent) => void> = [];
|
|
88
|
+
const win = {
|
|
89
|
+
addEventListener: (_: string, h: (e: MessageEvent) => void) => listeners.push(h),
|
|
90
|
+
removeEventListener: (_: string, h: (e: MessageEvent) => void) => {
|
|
91
|
+
const i = listeners.indexOf(h);
|
|
92
|
+
if (i >= 0) listeners.splice(i, 1);
|
|
93
|
+
},
|
|
94
|
+
} as unknown as Window;
|
|
95
|
+
|
|
96
|
+
const seen: PlayerMessage[] = [];
|
|
97
|
+
const off = onPlayerMessage((m) => seen.push(m), win);
|
|
98
|
+
const fire = (data: unknown) => listeners.forEach((h) => h({ data } as MessageEvent));
|
|
99
|
+
|
|
100
|
+
fire({ type: "3dd-doc-share" });
|
|
101
|
+
fire({ type: "un-autre-widget", payload: "bruit" });
|
|
102
|
+
expect(seen).toEqual([{ type: "share" }]);
|
|
103
|
+
|
|
104
|
+
off();
|
|
105
|
+
fire({ type: "3dd-doc-close" });
|
|
106
|
+
expect(seen).toHaveLength(1);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
//
|
|
3
|
+
// Harnais DOM. Le rendu des messages produit une CHAÎNE : on peut donc l'inspecter à l'œil nu.
|
|
4
|
+
// Mais la seule question qui compte n'est pas « que contient la chaîne », c'est « qu'en fait le
|
|
5
|
+
// navigateur ». Un `<script>` dans le texte est inoffensif ; un `<script>` créé dans l'arbre
|
|
6
|
+
// ne l'est pas. On insère donc pour de vrai et on interroge le DOM obtenu.
|
|
7
|
+
import { describe, it, expect, beforeEach } from "vitest";
|
|
8
|
+
import { renderMessage, renderReactions, renderAttachment, renderActions, messageClassName, type ChatMessageFull } from "../chat";
|
|
9
|
+
|
|
10
|
+
const ME = { email: "moi@b.fr", name: "Moi" };
|
|
11
|
+
const AUTRE = { author_email: "autre@b.fr", author_name: "Léa" };
|
|
12
|
+
|
|
13
|
+
/** Insère le fragment comme le fait la visionneuse, et rend l'élément réel. */
|
|
14
|
+
function mount(html: string): HTMLElement {
|
|
15
|
+
const host = document.createElement("div");
|
|
16
|
+
host.innerHTML = html;
|
|
17
|
+
document.body.appendChild(host);
|
|
18
|
+
return host;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
beforeEach(() => { document.body.innerHTML = ""; });
|
|
22
|
+
|
|
23
|
+
describe("chat — ce que le navigateur fabrique vraiment", () => {
|
|
24
|
+
it("un corps hostile ne crée aucun élément exécutable", () => {
|
|
25
|
+
const msg: ChatMessageFull = { ...AUTRE, body: '<img src=x onerror="alert(1)"><script>alert(2)</script>' };
|
|
26
|
+
const el = mount(renderMessage(msg, { me: ME }));
|
|
27
|
+
|
|
28
|
+
expect(el.querySelectorAll("script")).toHaveLength(0);
|
|
29
|
+
expect(el.querySelectorAll("img[onerror]")).toHaveLength(0);
|
|
30
|
+
expect(el.querySelector("[onerror]")).toBeNull();
|
|
31
|
+
// Le texte, lui, doit rester lisible — on neutralise, on ne supprime pas.
|
|
32
|
+
expect(el.textContent).toContain("onerror");
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("un NOM d'auteur hostile ne s'échappe pas de son élément", () => {
|
|
36
|
+
const el = mount(renderMessage({ author_name: '</b><script>alert(1)</script><b>' } as ChatMessageFull, { me: ME }));
|
|
37
|
+
expect(el.querySelectorAll("script")).toHaveLength(0);
|
|
38
|
+
expect(el.querySelector(".who b")?.textContent).toContain("<script>");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("une CITATION hostile est neutralisée elle aussi", () => {
|
|
42
|
+
const el = mount(renderMessage(
|
|
43
|
+
{ ...AUTRE, reply_to: 1, reply_name: '<img src=x onerror=1>', reply_text: '"><script>alert(1)</script>' } as ChatMessageFull,
|
|
44
|
+
{ me: ME },
|
|
45
|
+
));
|
|
46
|
+
expect(el.querySelectorAll("script, img[onerror]")).toHaveLength(0);
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it("une PIÈCE JOINTE hostile ne peut ni casser l'attribut ni injecter un attribut", () => {
|
|
50
|
+
const el = mount(renderAttachment({ kind: "image", url: 'x" onerror="alert(1)' }));
|
|
51
|
+
const img = el.querySelector("img");
|
|
52
|
+
expect(img?.getAttribute("onerror")).toBeNull();
|
|
53
|
+
expect(img?.getAttribute("src")).toBe('x" onerror="alert(1)');
|
|
54
|
+
|
|
55
|
+
const pdf = mount(renderAttachment({ kind: "pdf", url: "https://ok.fr/a.pdf", name: '<script>x</script>' }));
|
|
56
|
+
expect(pdf.querySelectorAll("script")).toHaveLength(0);
|
|
57
|
+
expect(pdf.querySelector(".cm-pdflabel")?.textContent).toBe("<script>x</script>");
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it("un EMOJI de réaction hostile est neutralisé", () => {
|
|
61
|
+
const el = mount(renderReactions({ reactions: { '"><script>alert(1)</script>': ["a@b.fr"] } } as ChatMessageFull, ME));
|
|
62
|
+
expect(el.querySelectorAll("script")).toHaveLength(0);
|
|
63
|
+
expect(el.querySelector(".re-chip")).not.toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("les liens sortants ne fuitent pas le référent et ne prennent pas la main sur l'onglet", () => {
|
|
67
|
+
const el = mount(renderMessage({ ...AUTRE, body: "voir https://exemple.fr/a" }, { me: ME }));
|
|
68
|
+
const link = el.querySelector("a.cm-link") as HTMLAnchorElement;
|
|
69
|
+
expect(link.getAttribute("href")).toBe("https://exemple.fr/a");
|
|
70
|
+
expect(link.getAttribute("rel")).toBe("noreferrer");
|
|
71
|
+
expect(link.getAttribute("target")).toBe("_blank");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it("un lien à schéma dangereux n'est jamais fabriqué", () => {
|
|
75
|
+
for (const hostile of ["javascript:alert(1)", "data:text/html,<b>x</b>"]) {
|
|
76
|
+
const el = mount(renderMessage({ ...AUTRE, body: hostile }, { me: ME }));
|
|
77
|
+
expect(el.querySelectorAll("a.cm-link"), hostile).toHaveLength(0);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe("chat — contenu et droits", () => {
|
|
83
|
+
it("affiche l'auteur, son texte, sa mention de modification", () => {
|
|
84
|
+
const el = mount(renderMessage({ ...AUTRE, body: "bonjour", edited: true }, { me: ME }));
|
|
85
|
+
expect(el.querySelector(".who b")?.textContent).toBe("Léa");
|
|
86
|
+
expect(el.querySelector(".txt")?.textContent).toContain("bonjour");
|
|
87
|
+
expect(el.querySelector(".cm-ed")?.textContent).toContain("modifié");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("le badge présentateur n'apparaît que s'il vient du serveur", () => {
|
|
91
|
+
expect(mount(renderMessage({ ...AUTRE, is_presenter: true }, { me: ME })).querySelector(".tag")).not.toBeNull();
|
|
92
|
+
expect(mount(renderMessage({ ...AUTRE }, { me: ME })).querySelector(".tag")).toBeNull();
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
// Un message supprimé est supprimé, pas masqué : rien de son contenu ne doit rester dans l'arbre.
|
|
96
|
+
it("un message supprimé ne laisse filtrer ni corps, ni citation, ni pièce jointe", () => {
|
|
97
|
+
const el = mount(renderMessage(
|
|
98
|
+
{ ...AUTRE, deleted: true, body: "secret", reply_text: "secret aussi", attachment: { kind: "image", url: "https://x/y.png" } } as ChatMessageFull,
|
|
99
|
+
{ me: ME },
|
|
100
|
+
));
|
|
101
|
+
expect(el.textContent).toBe("Message supprimé");
|
|
102
|
+
expect(el.querySelector("img")).toBeNull();
|
|
103
|
+
expect(el.innerHTML).not.toContain("secret");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it("« Modifier » n'existe que sur mes messages", () => {
|
|
107
|
+
const mien = mount(renderActions({ author_email: "moi@b.fr" } as ChatMessageFull, { me: ME }));
|
|
108
|
+
const autre = mount(renderActions(AUTRE as ChatMessageFull, { me: ME }));
|
|
109
|
+
expect(mien.querySelector(".cm-edit")).not.toBeNull();
|
|
110
|
+
expect(autre.querySelector(".cm-edit")).toBeNull();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("« Supprimer » existe sur les miens, et sur tous pour le présentateur", () => {
|
|
114
|
+
const q = (html: string) => mount(html).querySelector(".cm-del-btn");
|
|
115
|
+
expect(q(renderActions(AUTRE as ChatMessageFull, { me: ME }))).toBeNull();
|
|
116
|
+
expect(q(renderActions({ author_email: "moi@b.fr" } as ChatMessageFull, { me: ME }))).not.toBeNull();
|
|
117
|
+
expect(q(renderActions(AUTRE as ChatMessageFull, { me: { ...ME, role: "presenter" } }))).not.toBeNull();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it("marque mes réactions comme miennes", () => {
|
|
121
|
+
const el = mount(renderReactions({ reactions: { "👍": ["moi@b.fr", "x@y.fr"], "🎉": ["x@y.fr"] } } as ChatMessageFull, ME));
|
|
122
|
+
const chips = [...el.querySelectorAll(".re-chip")];
|
|
123
|
+
expect(chips).toHaveLength(2);
|
|
124
|
+
expect(chips[0].classList.contains("on")).toBe(true);
|
|
125
|
+
expect(chips[0].textContent).toBe("👍 2");
|
|
126
|
+
expect(chips[1].classList.contains("on")).toBe(false);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it("ignore une réaction sans réacteur", () => {
|
|
130
|
+
expect(renderReactions({ reactions: { "👍": [] } } as ChatMessageFull, ME)).toBe("");
|
|
131
|
+
expect(renderReactions(null, ME)).toBe("");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("dérive les classes d'état du conteneur", () => {
|
|
135
|
+
expect(messageClassName({ author_email: "moi@b.fr" } as ChatMessageFull, ME, false)).toBe("cm me");
|
|
136
|
+
expect(messageClassName({ ...AUTRE, deleted: true } as ChatMessageFull, ME, true)).toBe("cm isdel mentioned");
|
|
137
|
+
});
|
|
138
|
+
});
|