discovery-media-player 0.1.128 → 0.1.130
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/docs/HOST-CONTRACT.md +33 -8
- package/docs/README.md +6 -1
- package/package.json +7 -4
- package/server/presentations.js +16 -9
- package/server/routes-agent.js +29 -2
- package/server/shares.js +31 -5
package/docs/HOST-CONTRACT.md
CHANGED
|
@@ -76,12 +76,31 @@ The three `presence*` fields report what the host has **observed**, not what it
|
|
|
76
76
|
| `presenceDurcissement` | `actif` (a hardened call came back), `degrade` (migration 0018 is missing), `inconnu` (nothing attempted in this process — **not** a green light, and process-local: another instance may have seen otherwise) |
|
|
77
77
|
| `presenceFusion` | `actif` (a heartbeat used the fused contract — one round trip instead of two), `degrade` (migration 0019 is missing: heartbeats cost 3 round trips instead of 2, nothing breaks), `inconnu` (no heartbeat served in this process). Same three states, same trap, same reading rule as the row above |
|
|
78
78
|
|
|
79
|
-
⚠️ **Before you upgrade, do not read `presenceDurcissement`.**
|
|
80
|
-
instance where nothing is running
|
|
81
|
-
is there*. A pre-flight check built on
|
|
82
|
-
migration is then discovered at the first presentation, i.e. at the
|
|
83
|
-
`GET /api/doc?contract=1&schema=1` and read **`schema.durcissementBase`**
|
|
84
|
-
database, so
|
|
79
|
+
⚠️ **Before you upgrade, do not read `presenceDurcissement` or `presenceFusion`.** They are *reports
|
|
80
|
+
of execution*: on an instance where nothing is running they say `inconnu`, which means *nobody
|
|
81
|
+
looked* — not *the migration is there*. A pre-flight check built on one of them silently passes on
|
|
82
|
+
every idle host, and the missing migration is then discovered at the first presentation, i.e. at the
|
|
83
|
+
worst moment. Ask `GET /api/doc?contract=1&schema=1` and read **`schema.durcissementBase`** and
|
|
84
|
+
**`schema.fusionBase`** instead: they ask the database, so they answer a global fact.
|
|
85
|
+
|
|
86
|
+
⚠️ **And on a serverless host, `inconnu` is not the exception — it is the normal answer, forever.**
|
|
87
|
+
This paragraph used to say *"on an instance where nothing is running"*, which reads as a description
|
|
88
|
+
of an **idle** deployment. Field data from the second host corrected it: a real presentation ran on
|
|
89
|
+
their instance, with a participant, on the very day both fields read `inconnu`. Nothing was idle —
|
|
90
|
+
the presentation had simply ended, and the short-lived process answering `/api/doc` was never the one
|
|
91
|
+
that served a heartbeat. On a platform where each request may be a fresh process, that is the
|
|
92
|
+
**structural** case, not an edge case: a host serving presentations daily can read `inconnu` every
|
|
93
|
+
single time you ask.
|
|
94
|
+
|
|
95
|
+
So the two fields answer *"did this process, right now, see it work?"* — useful to confirm a fix on a
|
|
96
|
+
long-lived process, worthless as an inventory anywhere else. The durable signals live in `schema`:
|
|
97
|
+
`fusionBase` and `durcissementBase` for the migrations, and `schema.presence.avecJeton` crossed with
|
|
98
|
+
`presentationsActives` for actual traffic — those are read from the database and survive the process
|
|
99
|
+
that answers.
|
|
100
|
+
|
|
101
|
+
⚠️ **A corollary worth keeping:** *"our instances are idle"* and *"our instances are lightly used"*
|
|
102
|
+
are different claims, and only the second was true here. The distinction matters because a defect
|
|
103
|
+
that needs traffic to appear had real opportunities the whole time it was assumed to have none.
|
|
85
104
|
|
|
86
105
|
| `durcissementBase` | meaning |
|
|
87
106
|
|---|---|
|
|
@@ -96,14 +115,20 @@ when the long one is missing — i.e. exactly on the host that is behind and owe
|
|
|
96
115
|
|
|
97
116
|
| `fusionBase` | meaning |
|
|
98
117
|
|---|---|
|
|
99
|
-
| `applique` | `0019` is in the database — a presence heartbeat costs **2
|
|
100
|
-
| `absente` | `0019` is missing: **nothing breaks**, a heartbeat costs **3
|
|
118
|
+
| `applique` | `0019` is in the database — a presence heartbeat costs **2**† database round trips (**20**† ops/s for 250 attendees) |
|
|
119
|
+
| `absente` | `0019` is missing: **nothing breaks**, a heartbeat costs **3**† round trips (**30**† ops/s for 250 attendees). Applying it needs no redeploy — the player picks it up within a minute |
|
|
101
120
|
| `indetermine` | the question could not be asked — neither a yes nor a no |
|
|
102
121
|
|
|
103
122
|
⚠️ Unlike `0018`, a missing `0019` is a **cost**, not a risk: read it when you are sizing an
|
|
104
123
|
instance, not when you are deciding whether it is safe to run. A host missing it is also logged once
|
|
105
124
|
an hour, with the exact figures, so an idle instance still finds out.
|
|
106
125
|
|
|
126
|
+
† **Recomputed from the code on every CI run** by `charge/coutParGeste.test.js`, which measures both
|
|
127
|
+
regimes — the fallback still lives in the code, so the *without-`0019`* figure is a measurement, not
|
|
128
|
+
a number remembered from an older release. The build fails when this document and the bench disagree.
|
|
129
|
+
⚠️ **A number without † in this repository's documentation is hand-written: it was true once, and
|
|
130
|
+
nothing has checked it since.**
|
|
131
|
+
|
|
107
132
|
The probe writes nothing, for **two independent reasons**: `p_page = null` on a slug that does not
|
|
108
133
|
exist leaves through `0019`'s *introuvable* branch before the insert, and `p_anon_cap = 0` already
|
|
109
134
|
left through the previous contract's *capped* branch. Two reasons rather than one, because a
|
package/docs/README.md
CHANGED
|
@@ -17,7 +17,8 @@ no document assumes you have read the others.
|
|
|
17
17
|
|---|---|
|
|
18
18
|
| [`CONFIGURATION.md`](CONFIGURATION.md) | Every environment variable. An instance is described entirely by its environment — there is no configuration file, on purpose. |
|
|
19
19
|
| [`MIGRATIONS.md`](MIGRATIONS.md) | What happens to a database **already in service** when the player expects a newer schema. (French.) |
|
|
20
|
-
| [`
|
|
20
|
+
| [`VERIFYING-RELEASES.md`](VERIFYING-RELEASES.md) | how to check, yourself, that the package or image you pulled is the one this repository built — the commands, the expected output, and which signing identity to expect. |
|
|
21
|
+
| [`RETENTION.md`](RETENTION.md) | The declared perimeter of data retention: every personal-data column has a written policy, and CI enforces that the list is complete. Also an export of the package: `require.resolve("discovery-media-player/retention")`. |
|
|
21
22
|
|
|
22
23
|
## You are contributing, or publishing a version
|
|
23
24
|
|
|
@@ -26,6 +27,9 @@ no document assumes you have read the others.
|
|
|
26
27
|
| [`../CONTRIBUTING.md`](../CONTRIBUTING.md) | how to run the benches, what review looks for, and the one rule: a behaviour worth keeping is worth a test that fails without it. |
|
|
27
28
|
| [`../AGENTS.md`](../AGENTS.md) | the conventions that are not obvious from the file tree — which ones a guard enforces, and which ones only review does. |
|
|
28
29
|
| [`RELEASING.md`](RELEASING.md) | the release train, freezing the candidate SHA, the read-only preflight to run **before** the tag, and what to do when a tag lands on the wrong commit. |
|
|
30
|
+
| [`DEPENDENCIES.md`](DEPENDENCIES.md) | what the project depends on and why so little of it: the bar a new dependency has to clear, how updates arrive, the two upgrades deliberately held back, and the vulnerability thresholds a change has to clear to merge. |
|
|
31
|
+
| [`SECURITY-PRACTICES.md`](SECURITY-PRACTICES.md) | the two standing policies: how secrets are handled (there are no long-lived ones, on purpose) and what happens when a security tool reports something. |
|
|
32
|
+
| [`../MAINTAINERS.md`](../MAINTAINERS.md) | who can merge, who can publish, who reads a vulnerability report — and what one maintainer means for the answer. |
|
|
29
33
|
|
|
30
34
|
## You are evaluating the project
|
|
31
35
|
|
|
@@ -35,6 +39,7 @@ tracked version by version in the [CHANGELOG](../CHANGELOG.md).
|
|
|
35
39
|
|
|
36
40
|
| Document | What it is |
|
|
37
41
|
|---|---|
|
|
42
|
+
| [`THREAT-MODEL.md`](THREAT-MODEL.md) | what an attacker would go after, what stands in the way, and — the part most threat models omit — what is deliberately left standing. |
|
|
38
43
|
| [`AUDIT-2026-08-14-RAPPORT.md`](AUDIT-2026-08-14-RAPPORT.md) | First external audit, on `0.1.17`. Historical. (French.) |
|
|
39
44
|
| [`AUDIT-2026-08-14-SUIVI.md`](AUDIT-2026-08-14-SUIVI.md) | The follow-up ledger: done, decided-but-not-done, and rejected-with-reason. Historical. (French.) |
|
|
40
45
|
| [`AUDIT-2026-08-15-SECONDE-PASSE.md`](AUDIT-2026-08-15-SECONDE-PASSE.md) | Second pass, on `0.1.26` — including what the first follow-up had marked too optimistically. Historical. (French.) |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "discovery-media-player",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.130",
|
|
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",
|
|
@@ -68,8 +68,8 @@
|
|
|
68
68
|
"build": "node -e \"require('fs').existsSync('build/bundle.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && node build/bundle.mjs && tsc -p tsconfig.build.json && node -e \"import('./build/bundle.mjs').then(m=>m.marquerDistEsm())\"",
|
|
69
69
|
"test": "node -e \"require('fs').existsSync('server/__tests__')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run",
|
|
70
70
|
"test:watch": "vitest",
|
|
71
|
-
"lint": "node -e \"require('fs').existsSync('eslint.config.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && eslint bin context server src build",
|
|
72
|
-
"lint:fix": "eslint bin context server src build --fix",
|
|
71
|
+
"lint": "node -e \"require('fs').existsSync('eslint.config.mjs')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && eslint bin context server src build tools charge --max-warnings 0",
|
|
72
|
+
"lint:fix": "eslint bin context server src build tools charge --fix",
|
|
73
73
|
"typecheck": "node -e \"require('fs').existsSync('tsconfig.json')||(console.error('Ce script ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && tsc --noEmit",
|
|
74
74
|
"prepublishOnly": "npm run build && npm test",
|
|
75
75
|
"test:e2e": "node -e \"require('fs').existsSync('vitest.e2e.config.mjs')||(console.error('Ce banc ne vit pas dans le paquet publié - le champ scripts annonce plus que le tarball ne contient. Clonez le depot puis npm ci : https://github.com/Juli1artha/discovery-media-player'),process.exit(1))\" && vitest run --config vitest.e2e.config.mjs",
|
|
@@ -83,13 +83,16 @@
|
|
|
83
83
|
"devDependencies": {
|
|
84
84
|
"@eslint/js": "^10.0.1",
|
|
85
85
|
"axe-core": "^4.13.0",
|
|
86
|
+
"dockerfile-ast": "0.7.1",
|
|
86
87
|
"esbuild": "^0.28.2",
|
|
87
88
|
"eslint": "^10.8.1",
|
|
89
|
+
"fast-check": "4.9.0",
|
|
88
90
|
"jsdom": "^30.0.1",
|
|
89
91
|
"playwright-core": "^1.62.1",
|
|
90
92
|
"typescript": "^5.5.4",
|
|
91
93
|
"typescript-eslint": "^8.46.4",
|
|
92
|
-
"vitest": "^4.1.10"
|
|
94
|
+
"vitest": "^4.1.10",
|
|
95
|
+
"yaml": "2.9.0"
|
|
93
96
|
},
|
|
94
97
|
"dependencies": {
|
|
95
98
|
"pdfjs-dist": "6.2.108"
|
package/server/presentations.js
CHANGED
|
@@ -828,7 +828,10 @@ function signatureAbsente(erreur) {
|
|
|
828
828
|
if (!erreur) return false;
|
|
829
829
|
const code = erreur.details && (erreur.details.code || (erreur.details.error && erreur.details.error.code));
|
|
830
830
|
if (code === "PGRST202") return true;
|
|
831
|
-
|
|
831
|
+
// ⚠️ PAS DE `erreur &&` ICI : la garde de la première ligne l'a déjà tranché. Le garder ne
|
|
832
|
+
// protégeait de rien et APPRENAIT AU LECTEUR QUE `erreur` PEUT ÊTRE NULLE À CET ENDROIT — ce qui
|
|
833
|
+
// est faux. Un test qui ne peut pas échouer ne coûte pas un cycle, il coûte une lecture.
|
|
834
|
+
const texte = String(erreur.message || "");
|
|
832
835
|
return texte.includes("PGRST202") || /Could not find the function/i.test(texte);
|
|
833
836
|
}
|
|
834
837
|
|
|
@@ -1138,12 +1141,17 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1138
1141
|
presentateurEcrit = presentateurEcrit
|
|
1139
1142
|
|| (controlHash != null && relu.control_hash != null && controlHash === relu.control_hash);
|
|
1140
1143
|
}
|
|
1141
|
-
|
|
1144
|
+
// ⚠️ ON RELIT À CHAQUE TOUR — ET C'EST DÉJÀ CE QUI SE PASSAIT. La lecture était gardée par un
|
|
1145
|
+
// `if (cur === null)` qui ne pouvait JAMAIS être faux : les deux seuls chemins qui rebouclent
|
|
1146
|
+
// (un 409 à la création, une écriture battue en vol) remettaient `cur` à null juste avant. La
|
|
1147
|
+
// condition décrivait une intention — « ne relis que si tu n'as pas d'état frais » — que le code
|
|
1148
|
+
// ne pouvait pas contredire. Signalée par le scan (#78), et il a raison : une branche morte
|
|
1149
|
+
// laisse croire qu'il existe un cas où l'on repart avec un état déjà lu. Il n'y en a pas, et il
|
|
1150
|
+
// ne DOIT pas y en avoir : rejouer sur un état périmé est précisément ce que la serrure
|
|
1151
|
+
// `last_seen` interdit.
|
|
1142
1152
|
for (let essai = 0; essai < 4; essai += 1) {
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
cur = (Array.isArray(rows) && rows[0]) || false;
|
|
1146
|
-
}
|
|
1153
|
+
const rows = await PLAYER.db.request(`doc_presentation_attendees?slug=eq.${enc(slug)}&attendee_key=eq.${enc(String(key))}&select=*&limit=1`);
|
|
1154
|
+
let cur = (Array.isArray(rows) && rows[0]) || false;
|
|
1147
1155
|
if (!cur) {
|
|
1148
1156
|
const now = Date.now();
|
|
1149
1157
|
const row = { slug: String(slug), attendee_key: String(key).slice(0, 200), name: (name || "").slice(0, 120) || null, email: lc(email) || null, avatar: (avatar || "").slice(0, 600) || null, is_member: !!isMember, is_presenter: presentateurEcrit, first_seen: new Date(now).toISOString(), last_seen: new Date(now).toISOString(), total_ms: 0, pages: [pageEcrite] };
|
|
@@ -1159,8 +1167,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1159
1167
|
// Journalisé comme bénin : deux onglets qui arrivent ensemble sont une information, pas
|
|
1160
1168
|
// une panne — et la garde des écritures muettes exige que tout rattrapage parle.
|
|
1161
1169
|
try { PLAYER.errors.capture(new Error("présence déjà ouverte (second onglet) : " + String(slug)), { route: "present-attend", benin: true }); } catch { /* jamais bloquant */ }
|
|
1162
|
-
|
|
1163
|
-
continue;
|
|
1170
|
+
continue; // le tour suivant relit : la ligne que l'autre onglet vient de créer
|
|
1164
1171
|
}
|
|
1165
1172
|
}
|
|
1166
1173
|
// ⚠️ UN `last_seen` ACCEPTÉ EST STRICTEMENT CROISSANT — sans ça, la serrure est aveugle dans
|
|
@@ -1184,7 +1191,7 @@ async function recordAttendance(slug, participant, { presentation = null, ipHash
|
|
|
1184
1191
|
is_member: !!isMember, is_presenter: presentateurEcrit },
|
|
1185
1192
|
);
|
|
1186
1193
|
if (ecrit) return { ok: true };
|
|
1187
|
-
|
|
1194
|
+
// battu en vol : le tour suivant relit l'état frais — c'est la lecture en tête de boucle
|
|
1188
1195
|
}
|
|
1189
1196
|
// Quatre tours perdus : un battement de présence se reperd sans conséquence — le suivant arrive
|
|
1190
1197
|
// dans quelques secondes. Refuser net plutôt qu'écraser ce que les gagnants viennent d'écrire.
|
package/server/routes-agent.js
CHANGED
|
@@ -106,8 +106,35 @@ async function traiter(req, res, body, _slug) {
|
|
|
106
106
|
if (body.action === "bot-rate") { // satisfaction (1-5 étoiles) posée depuis le bloc central du viewer
|
|
107
107
|
const sess = await docbot.getSession(String(body.sessionId || ""));
|
|
108
108
|
if (!sess || sess.share_slug !== share.slug) return jp(400, { ok: false, error: "session" });
|
|
109
|
-
|
|
110
|
-
|
|
109
|
+
// ⚠️ CE REFUS ÉTAIT INATTEIGNABLE, ET C'EST LA MESURE QUI EN PAYAIT LE PRIX.
|
|
110
|
+
//
|
|
111
|
+
// const note = Math.max(1, Math.min(5, Number(body.rating) || 0));
|
|
112
|
+
// if (!note) return jp(400, { ok: false, error: "rating" });
|
|
113
|
+
//
|
|
114
|
+
// `Math.max(1, …)` posait un PLANCHER à 1 : `note` valait toujours au moins 1, donc
|
|
115
|
+
// `!note` n'était jamais vrai et la ligne suivante ne s'exécutait jamais. Une notation
|
|
116
|
+
// SANS note — champ absent, `null`, `"abc"`, un double envoi du client — n'était pas
|
|
117
|
+
// refusée : elle enregistrait **1 étoile**, la pire du barème. La satisfaction mesurée
|
|
118
|
+
// baissait donc d'elle-même à chaque appel malformé, et rien dans le chiffre ne
|
|
119
|
+
// permettait de le voir.
|
|
120
|
+
//
|
|
121
|
+
// C'est la règle que ce dépôt applique déjà au temps de lecture, une couche plus haut :
|
|
122
|
+
// « une métrique qui dit le contraire est la première que les gens cessent de croire ».
|
|
123
|
+
// Une note absente n'est pas une mauvaise note, c'est une absence — et elle se refuse.
|
|
124
|
+
//
|
|
125
|
+
// ⚠️ TROUVÉ EN CHERCHANT À EXERCER LA BRANCHE, pas en lisant le code. Le refus était
|
|
126
|
+
// écrit, donc il avait l'air tenu ; il a fallu écrire le test qui l'atteint pour
|
|
127
|
+
// constater qu'aucune entrée n'y arrivait.
|
|
128
|
+
//
|
|
129
|
+
// ⚠️ `Math.round` PARCE QUE LA COLONNE EST `smallint`. `3.7` traversait la garde intact
|
|
130
|
+
// et partait tel quel vers PostgREST : l'arrondi se décidait en aval, hors de vue. Le
|
|
131
|
+
// plafond à 5 reste, lui — un clic sur une sixième étoile est un défaut d'interface,
|
|
132
|
+
// pas une tentative, et le ramener à 5 est ce que l'appelant voulait dire.
|
|
133
|
+
//
|
|
134
|
+
// `!(note >= 1)` plutôt que `!note` : la forme rend faux pour `NaN` sans s'en remettre
|
|
135
|
+
// au `|| 0` qui le précède, donc elle survit à un changement de la ligne d'au-dessus.
|
|
136
|
+
const note = Math.min(5, Math.round(Number(body.rating) || 0));
|
|
137
|
+
if (!(note >= 1)) return jp(400, { ok: false, error: "rating" });
|
|
111
138
|
const cmt = String(body.comment || "").trim().slice(0, 500); // mot facultatif (2e temps du bloc)
|
|
112
139
|
await PLAYER.db.request("doc_bot_sessions?id=eq." + encodeURIComponent(String(body.sessionId)), { method: "PATCH", headers: { Prefer: "return=minimal" }, body: cmt ? { rating: note, rating_comment: cmt } : { rating: note } });
|
|
113
140
|
return jp(200, { ok: true });
|
package/server/shares.js
CHANGED
|
@@ -139,12 +139,27 @@ async function logView(share, { event, page, maxPage, seconds, sessionId, ua })
|
|
|
139
139
|
* d'administration) : sans restriction, un commercial verrait à qui d'autre le document a été
|
|
140
140
|
* envoyé — donc les prospects de ses collègues.
|
|
141
141
|
*/
|
|
142
|
+
// ⚠️ UNE SEULE DÉFINITION DE LA FENÊTRE D'ANALYTIQUE, PARCE QU'ELLE ÉTAIT DANS UNE FONCTION SUR DEUX.
|
|
143
|
+
// `overview()` bornait sa lecture à 24 mois glissants ; `listSharesForDoc`, quarante lignes plus haut,
|
|
144
|
+
// lisait TOUT l'historique d'un document. Deux lectures des mêmes tables d'événements, deux règles —
|
|
145
|
+
// et la seconde n'était écrite nulle part : elle se déduisait d'une absence.
|
|
146
|
+
//
|
|
147
|
+
// ⚠️ ET LE BORNAGE EST TEMPOREL, PAS EN NOMBRE DE LIGNES — la mesure l'impose. PostgREST plafonne à
|
|
148
|
+
// 1 000 lignes ici (constaté par un incident, cf. le commentaire d'`overview()` plus bas) : un
|
|
149
|
+
// `limit` inférieur mordrait DÉJÀ sur notre pire document (662 lignes), et un `limit` supérieur
|
|
150
|
+
// serait silencieusement ramené à 1 000 — donc un drapeau « tronqué » calculé sur la longueur
|
|
151
|
+
// MENTIRAIT. C'est `selectAll`, qui pagine par `Range`, qui met à l'abri du plafond ; la fenêtre,
|
|
152
|
+
// elle, borne le volume. Le patron « borné-ordonné-parlant » s'applique donc par sa borne TEMPORELLE.
|
|
153
|
+
const FENETRE_ANALYTIQUE_MOIS = 24;
|
|
154
|
+
const depuisFenetre = () =>
|
|
155
|
+
new Date(Date.now() - FENETRE_ANALYTIQUE_MOIS * 30 * 24 * 60 * 60 * 1000).toISOString();
|
|
156
|
+
|
|
142
157
|
async function listSharesForDoc(docId, owner) {
|
|
143
158
|
const id = enc(String(docId || ""));
|
|
144
159
|
const filtreOwner = owner ? `&created_by=eq.${enc(low(owner))}` : "";
|
|
145
160
|
const [shares, views] = await Promise.all([
|
|
146
161
|
PLAYER.db.request(`commercial_doc_shares?doc_id=eq.${id}&is_test=not.is.true${filtreOwner}&select=*&order=created_at.desc`),
|
|
147
|
-
PLAYER.db.selectAll(`commercial_doc_views?doc_id=eq.${id}&select=slug,event,page,max_page,seconds,session_id,at&order=at.asc`),
|
|
162
|
+
PLAYER.db.selectAll(`commercial_doc_views?doc_id=eq.${id}&select=slug,event,page,max_page,seconds,session_id,at&at=gte.${enc(depuisFenetre())}&order=at.asc`),
|
|
148
163
|
]);
|
|
149
164
|
const shareList = Array.isArray(shares) ? shares : [];
|
|
150
165
|
const viewList = Array.isArray(views) ? views : [];
|
|
@@ -160,7 +175,12 @@ async function listSharesForDoc(docId, owner) {
|
|
|
160
175
|
if (mp > s.maxPage) s.maxPage = mp;
|
|
161
176
|
s.seconds = Math.max(s.seconds, Number(v.seconds) || 0);
|
|
162
177
|
if (v.session_id) s.sessions.add(v.session_id);
|
|
163
|
-
s.lastAt = v.at
|
|
178
|
+
// ⚠️ UN MAXIMUM, PAS « LA DERNIÈRE LIGNE GAGNE ». `s.lastAt = v.at` n'était juste que TANT QUE la
|
|
179
|
+
// requête triait par `at.asc` — un couplage caché entre l'agrégation et l'ORDER BY, à trente
|
|
180
|
+
// lignes de distance. Quiconque aurait inversé le tri (pour garder le récent en cas de coupe)
|
|
181
|
+
// aurait transformé « dernière activité » en « première activité », sans qu'un seul test ne
|
|
182
|
+
// bouge. L'agrégation ne dépend plus de l'ordre : elle le calcule.
|
|
183
|
+
if (!s.lastAt || String(v.at) > String(s.lastAt)) s.lastAt = v.at;
|
|
164
184
|
}
|
|
165
185
|
const enriched = shareList.map((sh) => {
|
|
166
186
|
const a = bySlug.get(sh.slug) || { opens: 0, maxPage: 0, seconds: 0, sessions: new Set(), lastAt: null };
|
|
@@ -186,7 +206,11 @@ async function listSharesForDoc(docId, owner) {
|
|
|
186
206
|
maxPage: enriched.reduce((m, x) => Math.max(m, x.maxPage), 0),
|
|
187
207
|
readers: reached.length, // sessions distinctes ayant tourné au moins une page
|
|
188
208
|
};
|
|
189
|
-
|
|
209
|
+
// ⚠️ « PARLANT » : la réponse DIT ce qu'elle couvre. Une analytique bornée qui ne l'annonce pas
|
|
210
|
+
// est indiscernable d'une analytique complète — le lecteur y voit des chiffres définitifs. Le champ
|
|
211
|
+
// est présent même quand la fenêtre ne coupe rien (la purge à 13 mois arrive avant), pour que
|
|
212
|
+
// l'appelant n'ait jamais à déduire la couverture de l'ABSENCE d'un drapeau.
|
|
213
|
+
return { shares: enriched, total, funnel, fenetreMois: FENETRE_ANALYTIQUE_MOIS };
|
|
190
214
|
}
|
|
191
215
|
|
|
192
216
|
// Vue d'ensemble (tous documents) : stats agrégées par doc_id, pour les badges de la grille + le « top ».
|
|
@@ -197,7 +221,7 @@ async function overview() {
|
|
|
197
221
|
// Borne glissante généreuse (24 mois) : ces tables d'événements grossissent sans fin ; sans filtre, le
|
|
198
222
|
// scan intégral se dégrade avec le temps. 24 mois couvre tout l'historique utile pour la vue d'ensemble
|
|
199
223
|
// (opens / lecteurs / dernière activité) sans changer les chiffres actuels. Filtre servi par l'index sur `at`.
|
|
200
|
-
const since =
|
|
224
|
+
const since = depuisFenetre();
|
|
201
225
|
const [views, internal] = await Promise.all([
|
|
202
226
|
// PAGINÉ : au-delà de 1 000 lignes, PostgREST tronquait en silence — et comme le tri
|
|
203
227
|
// est ascendant, c'est le RÉCENT qui disparaissait. Les consultations des trois dernières
|
|
@@ -237,7 +261,9 @@ async function overview() {
|
|
|
237
261
|
if (v.event === "open") a.opens++;
|
|
238
262
|
if (v.session_id) a.readers.add(v.session_id);
|
|
239
263
|
a.maxPage = Math.max(a.maxPage, Number(v.page) || 0, Number(v.max_page) || 0);
|
|
240
|
-
|
|
264
|
+
// Même couplage caché que dans `listSharesForDoc`, et corrigé de la même façon : « dernière
|
|
265
|
+
// activité » se calcule, elle ne se déduit pas du tri de la requête.
|
|
266
|
+
if (!a.lastAt || String(v.at) > String(a.lastAt)) a.lastAt = v.at;
|
|
241
267
|
}
|
|
242
268
|
const intByDoc = new Map();
|
|
243
269
|
for (const s of Array.isArray(internal) ? internal : []) {
|