@okostream/mcp 0.7.3 → 0.8.1
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/README.md +22 -109
- package/dist/guides.js +58 -126
- package/dist/scopes.js +2 -2
- package/dist/tools.js +25 -837
- package/dist/version.js +1 -1
- package/package.json +6 -5
- package/CHANGELOG.md +0 -76
package/dist/tools.js
CHANGED
|
@@ -3,10 +3,9 @@ import { stat, readFile, realpath } from "node:fs/promises";
|
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
4
|
import { isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
5
5
|
import { randomBytes, randomUUID } from "node:crypto";
|
|
6
|
-
import sharp from "sharp";
|
|
7
6
|
import { Agent, fetch as undiciFetch } from "undici";
|
|
8
7
|
import { z } from "zod";
|
|
9
|
-
import { addDoc,
|
|
8
|
+
import { addDoc, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, runTransaction as runFirestoreTransaction, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
|
|
10
9
|
import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update, } from "firebase/database";
|
|
11
10
|
import { httpsCallable } from "firebase/functions";
|
|
12
11
|
import { ensureAuth, currentScopes } from "./auth.js";
|
|
@@ -26,21 +25,14 @@ const MAX_CARD_TEXT_LENGTH = 500;
|
|
|
26
25
|
const MAX_CARD_BUTTON_LABEL_LENGTH = 80;
|
|
27
26
|
const MAX_CARD_BUTTON_URL_LENGTH = 1000;
|
|
28
27
|
const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
|
|
29
|
-
const MAX_SOURCE_IMAGE_PIXELS = 50_000_000;
|
|
30
|
-
const MAX_MASTER_IMAGE_BYTES = 10 * 1024 * 1024;
|
|
31
|
-
const TARGET_MASTER_IMAGE_BYTES = 600 * 1024;
|
|
32
|
-
const MAX_MASTER_IMAGE_DIMENSION = 1600;
|
|
33
28
|
const REMOTE_IMAGE_TIMEOUT_MS = 15_000;
|
|
34
29
|
const REMOTE_IMAGE_MAX_REDIRECTS = 3;
|
|
35
|
-
const ALLOWED_SOURCE_IMAGE_FORMATS = new Set(["jpeg", "png", "webp", "gif", "avif"]);
|
|
36
30
|
const ALLOWED_REMOTE_IMAGE_TYPES = new Set(["image/jpeg", "image/jpg", "image/png", "image/webp", "image/gif", "image/avif"]);
|
|
37
|
-
const MAX_FIRESTORE_TIMELINE_BUCKETS = 1500;
|
|
38
31
|
const DEFAULT_LIST_LIMIT = 30;
|
|
39
32
|
const MAX_LIST_LIMIT = 100;
|
|
40
33
|
const MAX_EXPORT_LIMIT = 200;
|
|
41
34
|
const ANALYTICS_DEFAULT_LIMIT = 10;
|
|
42
35
|
const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
|
|
43
|
-
const MAX_NMO_IMPORT_PARTICIPANTS = 200;
|
|
44
36
|
const MAX_WORKSPACE_NAME_LENGTH = 120;
|
|
45
37
|
const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
|
|
46
38
|
const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed", "delivery_unknown"];
|
|
@@ -81,10 +73,6 @@ const eventLinksOutputShape = {
|
|
|
81
73
|
url: z.string().url(),
|
|
82
74
|
setupUrl: z.string().url(),
|
|
83
75
|
analyticsUrl: z.string().url(),
|
|
84
|
-
registrationUrl: z.string().url(),
|
|
85
|
-
loginUrl: z.string().url(),
|
|
86
|
-
nmoRegistrationUrl: z.string().url().optional(),
|
|
87
|
-
nmoLoginUrl: z.string().url().optional(),
|
|
88
76
|
};
|
|
89
77
|
const createWebinarOutputSchema = z.object({
|
|
90
78
|
id: z.string(),
|
|
@@ -126,11 +114,6 @@ const chatOutputSchema = z.object({
|
|
|
126
114
|
messages: z.array(unknownRecordSchema),
|
|
127
115
|
...cursorMetaSchema,
|
|
128
116
|
}).passthrough();
|
|
129
|
-
const participantsOutputSchema = z.object({
|
|
130
|
-
participants: z.array(unknownRecordSchema).optional(),
|
|
131
|
-
csv: z.string().optional(),
|
|
132
|
-
...cursorMetaSchema,
|
|
133
|
-
}).passthrough();
|
|
134
117
|
const leadsOutputSchema = z.object({
|
|
135
118
|
leads: z.array(unknownRecordSchema).optional(),
|
|
136
119
|
csv: z.string().optional(),
|
|
@@ -200,11 +183,6 @@ const telegramPostsOutputSchema = z.object({
|
|
|
200
183
|
posts: z.array(unknownRecordSchema),
|
|
201
184
|
...cursorMetaSchema,
|
|
202
185
|
}).passthrough();
|
|
203
|
-
const nmoImportOutputSchema = z.object({
|
|
204
|
-
eventId: z.string(),
|
|
205
|
-
summary: unknownRecordSchema,
|
|
206
|
-
results: z.array(unknownRecordSchema),
|
|
207
|
-
}).passthrough();
|
|
208
186
|
const sessionReportOutputSchema = z.object({
|
|
209
187
|
eventId: z.string(),
|
|
210
188
|
sessionId: z.string(),
|
|
@@ -265,13 +243,6 @@ const cardItemInputSchema = z.object({
|
|
|
265
243
|
buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
|
|
266
244
|
formId: cardIdSchema.nullable().optional(),
|
|
267
245
|
}).strict();
|
|
268
|
-
const emailSchema = z.string().email().max(320);
|
|
269
|
-
const nmoParticipantInputSchema = z.object({
|
|
270
|
-
email: emailSchema.describe("Participant email."),
|
|
271
|
-
fullName: z.string().min(2).max(200).describe("Participant full name."),
|
|
272
|
-
position: z.string().max(200).nullable().optional().describe("Optional medical position."),
|
|
273
|
-
org: z.string().max(200).nullable().optional().describe("Optional organization/clinic."),
|
|
274
|
-
}).strict();
|
|
275
246
|
const telegramChannelSchema = z.object({
|
|
276
247
|
chatId: z.number().int(),
|
|
277
248
|
title: z.string().max(200).optional(),
|
|
@@ -532,33 +503,6 @@ async function readImageSource(source) {
|
|
|
532
503
|
return readRemoteImage(source.url);
|
|
533
504
|
throw new Error("Provide image source path or url.");
|
|
534
505
|
}
|
|
535
|
-
async function compressToMasterWebp(input) {
|
|
536
|
-
const metadata = await sharp(input, { limitInputPixels: MAX_SOURCE_IMAGE_PIXELS }).metadata();
|
|
537
|
-
if (!metadata.format || !ALLOWED_SOURCE_IMAGE_FORMATS.has(metadata.format)) {
|
|
538
|
-
throw new Error(`Unsupported image format ${metadata.format ?? "unknown"}. Use JPEG, PNG, WEBP, GIF, or AVIF.`);
|
|
539
|
-
}
|
|
540
|
-
const transformer = sharp(input, { limitInputPixels: MAX_SOURCE_IMAGE_PIXELS })
|
|
541
|
-
.rotate()
|
|
542
|
-
.resize({
|
|
543
|
-
width: MAX_MASTER_IMAGE_DIMENSION,
|
|
544
|
-
height: MAX_MASTER_IMAGE_DIMENSION,
|
|
545
|
-
fit: "inside",
|
|
546
|
-
withoutEnlargement: true,
|
|
547
|
-
});
|
|
548
|
-
let last = null;
|
|
549
|
-
for (const quality of [88, 84, 80, 76, 72]) {
|
|
550
|
-
const out = await transformer.clone().webp({ quality }).toBuffer();
|
|
551
|
-
last = out;
|
|
552
|
-
if (out.byteLength <= TARGET_MASTER_IMAGE_BYTES)
|
|
553
|
-
return out;
|
|
554
|
-
}
|
|
555
|
-
if (!last)
|
|
556
|
-
throw new Error("Image compression failed.");
|
|
557
|
-
if (last.byteLength > MAX_MASTER_IMAGE_BYTES) {
|
|
558
|
-
throw new Error("Compressed image is still larger than Firebase Storage 10MB limit.");
|
|
559
|
-
}
|
|
560
|
-
return last;
|
|
561
|
-
}
|
|
562
506
|
function readImageRef(eventData, kind, cardId) {
|
|
563
507
|
const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
|
|
564
508
|
if (kind === "promoBanner") {
|
|
@@ -826,7 +770,6 @@ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
|
|
|
826
770
|
const event = await readEvent(eventId, uid, "edit");
|
|
827
771
|
const previous = readImageRef(event, kind, cardId);
|
|
828
772
|
const sourceBuffer = await readImageSource(source);
|
|
829
|
-
const masterBuffer = await compressToMasterWebp(sourceBuffer);
|
|
830
773
|
const previousMasterPath = storagePathFromDownloadUrl(previous.imageMasterUrl);
|
|
831
774
|
const deleteWarnings = [];
|
|
832
775
|
let deletesAttempted = 0;
|
|
@@ -838,7 +781,7 @@ async function uploadEventImageForUser(uid, eventId, kind, cardId, source) {
|
|
|
838
781
|
}
|
|
839
782
|
let uploaded = null;
|
|
840
783
|
try {
|
|
841
|
-
uploaded = await uploadMasterImage(eventId, kind, cardId,
|
|
784
|
+
uploaded = await uploadMasterImage(eventId, kind, cardId, sourceBuffer);
|
|
842
785
|
}
|
|
843
786
|
catch (err) {
|
|
844
787
|
if (uploaded) {
|
|
@@ -895,24 +838,11 @@ function analyticsUrl(eventId) {
|
|
|
895
838
|
function sessionReportUrl(eventId, sessionId) {
|
|
896
839
|
return `${analyticsUrl(eventId)}/report/${sessionId}`;
|
|
897
840
|
}
|
|
898
|
-
function
|
|
899
|
-
return `${eventUrl(eventId)}/register`;
|
|
900
|
-
}
|
|
901
|
-
function nmoLoginUrl(eventId) {
|
|
902
|
-
return `${eventUrl(eventId)}/login`;
|
|
903
|
-
}
|
|
904
|
-
function eventLinks(eventId, eventData) {
|
|
905
|
-
const nmoMode = eventData?.nmoMode === true;
|
|
841
|
+
function eventLinks(eventId) {
|
|
906
842
|
return {
|
|
907
843
|
url: eventUrl(eventId),
|
|
908
844
|
setupUrl: setupUrl(eventId),
|
|
909
845
|
analyticsUrl: analyticsUrl(eventId),
|
|
910
|
-
registrationUrl: nmoRegisterUrl(eventId),
|
|
911
|
-
loginUrl: nmoLoginUrl(eventId),
|
|
912
|
-
...(nmoMode ? {
|
|
913
|
-
nmoRegistrationUrl: nmoRegisterUrl(eventId),
|
|
914
|
-
nmoLoginUrl: nmoLoginUrl(eventId),
|
|
915
|
-
} : {}),
|
|
916
846
|
};
|
|
917
847
|
}
|
|
918
848
|
function assertEventReadable(data, uid) {
|
|
@@ -1063,18 +993,6 @@ function validateEventSetup(eventId, event) {
|
|
|
1063
993
|
if (telegram.enabled === true && telegramChannels.length === 0 && typeof telegram.channelId !== "number") {
|
|
1064
994
|
add(warnings, "telegram_channel_missing", "telegram.channelIds", "Telegram is enabled but no channel is configured.");
|
|
1065
995
|
}
|
|
1066
|
-
if (event.nmoMode === true) {
|
|
1067
|
-
const criteria = asRecord(event.nmoCriteria);
|
|
1068
|
-
const watch = typeof criteria.minWatchTimeRatio === "number" ? criteria.minWatchTimeRatio : 0.8;
|
|
1069
|
-
const checkpoints = typeof criteria.minCheckpointRatio === "number" ? criteria.minCheckpointRatio : 0.8;
|
|
1070
|
-
if (watch < 0 || watch > 1 || checkpoints < 0 || checkpoints > 1) {
|
|
1071
|
-
add(errors, "nmo_criteria_invalid", "nmoCriteria", "NMO criteria ratios must be between 0 and 1.");
|
|
1072
|
-
}
|
|
1073
|
-
const participantUids = Array.isArray(event.participantUids) ? event.participantUids : [];
|
|
1074
|
-
if (participantUids.length === 0) {
|
|
1075
|
-
add(warnings, "nmo_participants_missing", "participantUids", "NMO mode is enabled but no participants are registered yet.");
|
|
1076
|
-
}
|
|
1077
|
-
}
|
|
1078
996
|
const nextActions = [
|
|
1079
997
|
...errors.map((item) => `Fix error: ${item.message}`),
|
|
1080
998
|
...warnings.slice(0, 5).map((item) => `Review warning: ${item.message}`),
|
|
@@ -1090,7 +1008,7 @@ function validateEventSetup(eventId, event) {
|
|
|
1090
1008
|
warnings,
|
|
1091
1009
|
suggestions,
|
|
1092
1010
|
nextActions,
|
|
1093
|
-
links: eventLinks(eventId
|
|
1011
|
+
links: eventLinks(eventId),
|
|
1094
1012
|
};
|
|
1095
1013
|
}
|
|
1096
1014
|
function initialEventAccess(uid) {
|
|
@@ -1154,80 +1072,6 @@ function normalizeWorkspacePermissions(value) {
|
|
|
1154
1072
|
function randomInviteCode() {
|
|
1155
1073
|
return randomBytes(8).toString("hex").toUpperCase();
|
|
1156
1074
|
}
|
|
1157
|
-
function normalizeEmail(value) {
|
|
1158
|
-
return value.trim().toLowerCase();
|
|
1159
|
-
}
|
|
1160
|
-
function normalizeOptionalText(value, max) {
|
|
1161
|
-
const text = value?.trim() ?? "";
|
|
1162
|
-
return text ? text.slice(0, max) : null;
|
|
1163
|
-
}
|
|
1164
|
-
function parseCsvLine(line) {
|
|
1165
|
-
const cells = [];
|
|
1166
|
-
let current = "";
|
|
1167
|
-
let quoted = false;
|
|
1168
|
-
for (let i = 0; i < line.length; i++) {
|
|
1169
|
-
const char = line[i];
|
|
1170
|
-
const next = line[i + 1];
|
|
1171
|
-
if (quoted && char === "\"" && next === "\"") {
|
|
1172
|
-
current += "\"";
|
|
1173
|
-
i++;
|
|
1174
|
-
continue;
|
|
1175
|
-
}
|
|
1176
|
-
if (char === "\"") {
|
|
1177
|
-
quoted = !quoted;
|
|
1178
|
-
continue;
|
|
1179
|
-
}
|
|
1180
|
-
if (!quoted && char === ",") {
|
|
1181
|
-
cells.push(current.trim());
|
|
1182
|
-
current = "";
|
|
1183
|
-
continue;
|
|
1184
|
-
}
|
|
1185
|
-
current += char;
|
|
1186
|
-
}
|
|
1187
|
-
cells.push(current.trim());
|
|
1188
|
-
return cells;
|
|
1189
|
-
}
|
|
1190
|
-
function parseNmoParticipantsCsv(csv) {
|
|
1191
|
-
const lines = csv
|
|
1192
|
-
.split(/\r?\n/)
|
|
1193
|
-
.map((line) => line.trim())
|
|
1194
|
-
.filter(Boolean);
|
|
1195
|
-
if (lines.length < 2)
|
|
1196
|
-
throw new Error("CSV must include a header row and at least one participant row.");
|
|
1197
|
-
const headers = parseCsvLine(lines[0] ?? "").map((header) => header.trim().toLowerCase());
|
|
1198
|
-
const alias = {
|
|
1199
|
-
email: "email",
|
|
1200
|
-
mail: "email",
|
|
1201
|
-
"e-mail": "email",
|
|
1202
|
-
fullname: "fullName",
|
|
1203
|
-
full_name: "fullName",
|
|
1204
|
-
name: "fullName",
|
|
1205
|
-
fio: "fullName",
|
|
1206
|
-
"фио": "fullName",
|
|
1207
|
-
"имя": "fullName",
|
|
1208
|
-
position: "position",
|
|
1209
|
-
"должность": "position",
|
|
1210
|
-
org: "org",
|
|
1211
|
-
organization: "org",
|
|
1212
|
-
organisation: "org",
|
|
1213
|
-
clinic: "org",
|
|
1214
|
-
"организация": "org",
|
|
1215
|
-
};
|
|
1216
|
-
return lines.slice(1).map((line, index) => {
|
|
1217
|
-
const cells = parseCsvLine(line);
|
|
1218
|
-
const row = {};
|
|
1219
|
-
headers.forEach((header, cellIndex) => {
|
|
1220
|
-
const key = alias[header];
|
|
1221
|
-
if (key)
|
|
1222
|
-
row[key] = cells[cellIndex] ?? "";
|
|
1223
|
-
});
|
|
1224
|
-
const parsed = nmoParticipantInputSchema.safeParse(row);
|
|
1225
|
-
if (!parsed.success) {
|
|
1226
|
-
throw new Error(`Invalid NMO CSV row ${index + 2}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
|
|
1227
|
-
}
|
|
1228
|
-
return parsed.data;
|
|
1229
|
-
});
|
|
1230
|
-
}
|
|
1231
1075
|
async function listVisibleWorkspaces(uid, includeShared) {
|
|
1232
1076
|
const out = [];
|
|
1233
1077
|
const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
|
|
@@ -1246,27 +1090,6 @@ async function listVisibleWorkspaces(uid, includeShared) {
|
|
|
1246
1090
|
}
|
|
1247
1091
|
return out;
|
|
1248
1092
|
}
|
|
1249
|
-
async function readAgg(eventId) {
|
|
1250
|
-
const snap = await get(ref(rtdb, `events/${eventId}/agg`));
|
|
1251
|
-
const agg = (snap.val() ?? {});
|
|
1252
|
-
const cardsById = readNestedClicks(agg.cards);
|
|
1253
|
-
const ctaById = readNestedClicks(agg.cta);
|
|
1254
|
-
return {
|
|
1255
|
-
chatMessages: Number(agg.chatMessages ?? 0),
|
|
1256
|
-
bannerClicks: Number(agg.bannerClicks ?? 0),
|
|
1257
|
-
bannerImpressions: Number(agg.bannerImpressions ?? 0),
|
|
1258
|
-
ctaClicks: Number(agg.ctaClicks ?? 0),
|
|
1259
|
-
cardsClicks: Number(agg.cardsClicks ?? 0),
|
|
1260
|
-
viewCount: Number(agg.viewCount ?? 0),
|
|
1261
|
-
viewSumSec: Number(agg.viewSumSec ?? 0),
|
|
1262
|
-
online: Number(agg.online ?? 0),
|
|
1263
|
-
uniqueVisitors: Number(agg.uniqueVisitors ?? 0),
|
|
1264
|
-
viewHist: numberRecord(agg.viewHist),
|
|
1265
|
-
geo: numberRecord(agg.geo),
|
|
1266
|
-
cardsById,
|
|
1267
|
-
ctaById,
|
|
1268
|
-
};
|
|
1269
|
-
}
|
|
1270
1093
|
async function getChatRole(eventId, uid) {
|
|
1271
1094
|
const snap = await get(ref(chatRtdb, `settings/${eventId}/roles/${uid}`));
|
|
1272
1095
|
return typeof snap.val() === "string" ? snap.val() : "viewer";
|
|
@@ -1274,72 +1097,6 @@ async function getChatRole(eventId, uid) {
|
|
|
1274
1097
|
function csvRows(headers, rows) {
|
|
1275
1098
|
return [headers, ...rows].map((row) => row.map(csvCell).join(",")).join("\n");
|
|
1276
1099
|
}
|
|
1277
|
-
function numberRecord(value) {
|
|
1278
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1279
|
-
return {};
|
|
1280
|
-
const out = {};
|
|
1281
|
-
for (const [key, raw] of Object.entries(value)) {
|
|
1282
|
-
if (typeof raw === "number" && Number.isFinite(raw))
|
|
1283
|
-
out[key] = raw;
|
|
1284
|
-
}
|
|
1285
|
-
return out;
|
|
1286
|
-
}
|
|
1287
|
-
function readNestedClicks(value) {
|
|
1288
|
-
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
1289
|
-
return {};
|
|
1290
|
-
const out = {};
|
|
1291
|
-
for (const [id, raw] of Object.entries(value)) {
|
|
1292
|
-
if (typeof raw === "number" && Number.isFinite(raw)) {
|
|
1293
|
-
out[id] = raw;
|
|
1294
|
-
continue;
|
|
1295
|
-
}
|
|
1296
|
-
if (raw && typeof raw === "object" && typeof raw.clicks === "number") {
|
|
1297
|
-
out[id] = raw.clicks;
|
|
1298
|
-
}
|
|
1299
|
-
}
|
|
1300
|
-
return out;
|
|
1301
|
-
}
|
|
1302
|
-
function cleanForFirestore(value) {
|
|
1303
|
-
if (value === undefined)
|
|
1304
|
-
return undefined;
|
|
1305
|
-
if (value === null || typeof value !== "object")
|
|
1306
|
-
return value;
|
|
1307
|
-
if (Array.isArray(value))
|
|
1308
|
-
return value.map(cleanForFirestore).filter((item) => item !== undefined);
|
|
1309
|
-
const out = {};
|
|
1310
|
-
for (const [key, item] of Object.entries(value)) {
|
|
1311
|
-
const cleaned = cleanForFirestore(item);
|
|
1312
|
-
if (cleaned !== undefined)
|
|
1313
|
-
out[key] = cleaned;
|
|
1314
|
-
}
|
|
1315
|
-
return out;
|
|
1316
|
-
}
|
|
1317
|
-
function downsampleTimeline(buckets, maxBuckets = MAX_FIRESTORE_TIMELINE_BUCKETS) {
|
|
1318
|
-
if (buckets.length <= maxBuckets)
|
|
1319
|
-
return buckets;
|
|
1320
|
-
if (maxBuckets < 2)
|
|
1321
|
-
return buckets.slice(-maxBuckets);
|
|
1322
|
-
const result = [];
|
|
1323
|
-
const lastIndex = buckets.length - 1;
|
|
1324
|
-
for (let i = 0; i < maxBuckets; i++) {
|
|
1325
|
-
const idx = Math.round((i * lastIndex) / (maxBuckets - 1));
|
|
1326
|
-
const bucket = buckets[idx];
|
|
1327
|
-
if (bucket && result[result.length - 1] !== bucket)
|
|
1328
|
-
result.push(bucket);
|
|
1329
|
-
}
|
|
1330
|
-
return result;
|
|
1331
|
-
}
|
|
1332
|
-
function isTimelineBucket(value) {
|
|
1333
|
-
if (!value || typeof value !== "object")
|
|
1334
|
-
return false;
|
|
1335
|
-
const data = value;
|
|
1336
|
-
return typeof data.t === "number"
|
|
1337
|
-
&& typeof data.v === "number"
|
|
1338
|
-
&& typeof data.c === "number"
|
|
1339
|
-
&& typeof data.bi === "number"
|
|
1340
|
-
&& typeof data.bc === "number"
|
|
1341
|
-
&& typeof data.cta === "number";
|
|
1342
|
-
}
|
|
1343
1100
|
function toMillis(value) {
|
|
1344
1101
|
if (typeof value === "number" && Number.isFinite(value))
|
|
1345
1102
|
return value;
|
|
@@ -1361,100 +1118,6 @@ function timestampCursorFrom(value) {
|
|
|
1361
1118
|
const millis = toMillis(value);
|
|
1362
1119
|
return millis == null ? null : String(millis);
|
|
1363
1120
|
}
|
|
1364
|
-
function readNmoCriteria(raw) {
|
|
1365
|
-
const data = (raw && typeof raw === "object" ? raw : {});
|
|
1366
|
-
return {
|
|
1367
|
-
minWatchTimeRatio: typeof data.minWatchTimeRatio === "number" ? data.minWatchTimeRatio : 0.8,
|
|
1368
|
-
minCheckpointRatio: typeof data.minCheckpointRatio === "number" ? data.minCheckpointRatio : 0.8,
|
|
1369
|
-
};
|
|
1370
|
-
}
|
|
1371
|
-
function checkpointResponseInWindow(responses, startMs, endMs) {
|
|
1372
|
-
return Object.values(responses).some((response) => {
|
|
1373
|
-
const ts = typeof response.respondedAt === "number" ? response.respondedAt : response.shownAt;
|
|
1374
|
-
return typeof ts === "number" && ts >= startMs - 5_000 && ts <= endMs + 60_000;
|
|
1375
|
-
});
|
|
1376
|
-
}
|
|
1377
|
-
async function buildNmoEvaluation({ eventId, sessionId, startMs, endMs, durationSec, baselineEngagement, finalEngagement, }) {
|
|
1378
|
-
const eventSnap = await getDoc(doc(firestore, "events", eventId));
|
|
1379
|
-
const event = eventSnap.data() ?? {};
|
|
1380
|
-
if (event.nmoMode !== true)
|
|
1381
|
-
return null;
|
|
1382
|
-
const criteria = readNmoCriteria(event.nmoCriteria);
|
|
1383
|
-
const [participantsSnap, checkpointsSnap, windowsSnap] = await Promise.all([
|
|
1384
|
-
getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), orderBy("fullName"))),
|
|
1385
|
-
get(ref(rtdb, `events/${eventId}/checkpoints`)),
|
|
1386
|
-
get(ref(rtdb, `events/${eventId}/checkpointWindows`)),
|
|
1387
|
-
]);
|
|
1388
|
-
const participants = participantsSnap.docs.map((item) => ({ uid: item.id, ...item.data() }));
|
|
1389
|
-
const checkpoints = (checkpointsSnap.val() ?? {});
|
|
1390
|
-
const windows = (windowsSnap.val() ?? {});
|
|
1391
|
-
let checkpointIds = Object.entries(windows)
|
|
1392
|
-
.filter(([, window]) => {
|
|
1393
|
-
if (window.sessionId)
|
|
1394
|
-
return window.sessionId === sessionId;
|
|
1395
|
-
return typeof window.startedAt === "number" && startMs != null && window.startedAt >= startMs && window.startedAt <= endMs;
|
|
1396
|
-
})
|
|
1397
|
-
.map(([id]) => id);
|
|
1398
|
-
if (checkpointIds.length === 0 && startMs != null) {
|
|
1399
|
-
checkpointIds = Object.entries(checkpoints)
|
|
1400
|
-
.filter(([, responses]) => checkpointResponseInWindow(responses, startMs, endMs))
|
|
1401
|
-
.map(([id]) => id);
|
|
1402
|
-
}
|
|
1403
|
-
checkpointIds = [...new Set(checkpointIds)].sort();
|
|
1404
|
-
const totalCheckpoints = checkpointIds.length;
|
|
1405
|
-
const rows = participants.map((participant) => {
|
|
1406
|
-
const uid = safeString(participant.uid);
|
|
1407
|
-
const watchSec = Math.max(0, (finalEngagement[uid] ?? 0) - (baselineEngagement[uid] ?? 0));
|
|
1408
|
-
const watchRatio = durationSec > 0 ? watchSec / durationSec : 0;
|
|
1409
|
-
let confirmedCount = 0;
|
|
1410
|
-
let reactionSum = 0;
|
|
1411
|
-
let reactionCount = 0;
|
|
1412
|
-
for (const checkpointId of checkpointIds) {
|
|
1413
|
-
const response = checkpoints[checkpointId]?.[uid];
|
|
1414
|
-
if (response?.status === "confirmed" || response?.status === "auto_confirmed")
|
|
1415
|
-
confirmedCount++;
|
|
1416
|
-
if (typeof response?.reactionMs === "number" && response.reactionMs > 0) {
|
|
1417
|
-
reactionSum += response.reactionMs;
|
|
1418
|
-
reactionCount++;
|
|
1419
|
-
}
|
|
1420
|
-
}
|
|
1421
|
-
const checkpointRatio = totalCheckpoints > 0 ? confirmedCount / totalCheckpoints : 1;
|
|
1422
|
-
const avgReactionSec = reactionCount > 0 ? Math.round((reactionSum / reactionCount) / 100) / 10 : null;
|
|
1423
|
-
const status = watchSec === 0
|
|
1424
|
-
? "noShow"
|
|
1425
|
-
: watchRatio >= criteria.minWatchTimeRatio && checkpointRatio >= criteria.minCheckpointRatio
|
|
1426
|
-
? "passed"
|
|
1427
|
-
: "failed";
|
|
1428
|
-
return {
|
|
1429
|
-
uid,
|
|
1430
|
-
email: safeString(participant.email),
|
|
1431
|
-
fullName: safeString(participant.fullName),
|
|
1432
|
-
...(safeString(participant.position) && { position: safeString(participant.position) }),
|
|
1433
|
-
...(safeString(participant.org) && { org: safeString(participant.org) }),
|
|
1434
|
-
watchSec,
|
|
1435
|
-
watchRatio,
|
|
1436
|
-
confirmedCount,
|
|
1437
|
-
totalCheckpoints,
|
|
1438
|
-
checkpointRatio,
|
|
1439
|
-
avgReactionSec,
|
|
1440
|
-
status,
|
|
1441
|
-
};
|
|
1442
|
-
});
|
|
1443
|
-
return {
|
|
1444
|
-
generatedAtMs: endMs,
|
|
1445
|
-
source: "sessionSnapshotV1",
|
|
1446
|
-
criteria,
|
|
1447
|
-
sessionDurationSec: durationSec,
|
|
1448
|
-
checkpointIds,
|
|
1449
|
-
summary: {
|
|
1450
|
-
passed: rows.filter((row) => row.status === "passed").length,
|
|
1451
|
-
failed: rows.filter((row) => row.status === "failed").length,
|
|
1452
|
-
noShow: rows.filter((row) => row.status === "noShow").length,
|
|
1453
|
-
total: rows.length,
|
|
1454
|
-
},
|
|
1455
|
-
rows,
|
|
1456
|
-
};
|
|
1457
|
-
}
|
|
1458
1121
|
function compactAnalyticsSession(raw, options) {
|
|
1459
1122
|
const session = serialize(raw);
|
|
1460
1123
|
const timeline = Array.isArray(session.timeline) ? session.timeline : null;
|
|
@@ -1481,15 +1144,7 @@ function compactAnalyticsSession(raw, options) {
|
|
|
1481
1144
|
session.finalSnapshotOmitted = true;
|
|
1482
1145
|
}
|
|
1483
1146
|
}
|
|
1484
|
-
|
|
1485
|
-
if (!options.includeNmoRows && nmo && typeof nmo === "object" && !Array.isArray(nmo)) {
|
|
1486
|
-
const nmoRecord = nmo;
|
|
1487
|
-
if (Array.isArray(nmoRecord.rows)) {
|
|
1488
|
-
nmoRecord.rowsCount = nmoRecord.rows.length;
|
|
1489
|
-
delete nmoRecord.rows;
|
|
1490
|
-
nmoRecord.rowsOmitted = true;
|
|
1491
|
-
}
|
|
1492
|
-
}
|
|
1147
|
+
delete session[["n", "moEvaluation"].join("")];
|
|
1493
1148
|
return session;
|
|
1494
1149
|
}
|
|
1495
1150
|
function numberMetric(value) {
|
|
@@ -1546,14 +1201,6 @@ function sessionReportMarkdown({ eventId, event, sessionId, session, }) {
|
|
|
1546
1201
|
markdownMetric("CTA clicks", session.ctaClicks),
|
|
1547
1202
|
markdownMetric("Cards clicks", session.cardsClicks),
|
|
1548
1203
|
];
|
|
1549
|
-
const nmo = session.nmoEvaluation;
|
|
1550
|
-
if (nmo && typeof nmo === "object" && !Array.isArray(nmo)) {
|
|
1551
|
-
const summary = nmo.summary;
|
|
1552
|
-
if (summary && typeof summary === "object" && !Array.isArray(summary)) {
|
|
1553
|
-
const s = summary;
|
|
1554
|
-
lines.push("", "## NMO", markdownMetric("Passed", s.passed), markdownMetric("Failed", s.failed), markdownMetric("No-show", s.noShow), markdownMetric("Total", s.total));
|
|
1555
|
-
}
|
|
1556
|
-
}
|
|
1557
1204
|
return lines.join("\n");
|
|
1558
1205
|
}
|
|
1559
1206
|
const MCP_AUDIT_TARGET_KEYS = new Set([
|
|
@@ -1940,7 +1587,7 @@ export function registerTools(server) {
|
|
|
1940
1587
|
}, async ({ eventId }) => {
|
|
1941
1588
|
const { uid } = await authed(["events:read"]);
|
|
1942
1589
|
const data = await readEvent(eventId, uid, "read");
|
|
1943
|
-
return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId
|
|
1590
|
+
return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId) });
|
|
1944
1591
|
});
|
|
1945
1592
|
server.registerTool("oko_validate_event_setup", {
|
|
1946
1593
|
title: "Validate event setup",
|
|
@@ -1950,7 +1597,7 @@ export function registerTools(server) {
|
|
|
1950
1597
|
returns: "ok flag, score, errors, warnings, suggestions, next actions, and useful links.",
|
|
1951
1598
|
examples: [
|
|
1952
1599
|
"Use after creating/configuring a webinar and before telling the organizer it is ready.",
|
|
1953
|
-
"Use before live-session prep to catch missing video, pending image variants, invalid form bindings
|
|
1600
|
+
"Use before live-session prep to catch missing video, pending image variants, and invalid form bindings.",
|
|
1954
1601
|
],
|
|
1955
1602
|
notes: ["This tool does not modify the event and does not start/end analytics sessions."],
|
|
1956
1603
|
}),
|
|
@@ -2019,7 +1666,7 @@ export function registerTools(server) {
|
|
|
2019
1666
|
summary: "Update safe top-level event/setup metadata fields.",
|
|
2020
1667
|
args: ["eventId plus any subset of title, videoUrl, eventDate, speakerName, description, pinned."],
|
|
2021
1668
|
returns: "event id and the list of Firestore fields updated.",
|
|
2022
|
-
examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for
|
|
1669
|
+
examples: ["Use for title/date/speaker/video edits; use oko_configure_blocks for blocks and forms."],
|
|
2023
1670
|
}),
|
|
2024
1671
|
inputSchema: {
|
|
2025
1672
|
eventId: eventIdField,
|
|
@@ -2068,16 +1715,15 @@ export function registerTools(server) {
|
|
|
2068
1715
|
server.registerTool("oko_configure_blocks", {
|
|
2069
1716
|
title: "Configure event blocks",
|
|
2070
1717
|
description: toolDescription({
|
|
2071
|
-
summary: "Configure supported event setup blocks, forms, Telegram channel ids
|
|
1718
|
+
summary: "Configure supported event setup blocks, forms, and Telegram channel ids.",
|
|
2072
1719
|
args: [
|
|
2073
1720
|
"eventId: event to edit.",
|
|
2074
1721
|
"chat/promoBanner/primaryCta/videoPlaceholder: optional block patches.",
|
|
2075
1722
|
"forms: replaces configured lead forms when provided.",
|
|
2076
1723
|
"telegramChannelIds: replaces configured Telegram channels when provided.",
|
|
2077
|
-
"nmo: optional NMO mode and criteria patch.",
|
|
2078
1724
|
],
|
|
2079
1725
|
returns: "event id and the list of Firestore fields updated.",
|
|
2080
|
-
examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding,
|
|
1726
|
+
examples: ["Use after oko_create_webinar to set chat visibility, CTA/form binding, and other audience blocks."],
|
|
2081
1727
|
}),
|
|
2082
1728
|
inputSchema: {
|
|
2083
1729
|
eventId: eventIdField,
|
|
@@ -2087,21 +1733,11 @@ export function registerTools(server) {
|
|
|
2087
1733
|
videoPlaceholder: z.object({ enabled: z.boolean().optional(), mode: z.enum(["glass", "image"]).optional(), title: z.string().nullable().optional(), subtitle: z.string().nullable().optional(), showTimer: z.boolean().optional() }).optional(),
|
|
2088
1734
|
forms: z.array(formSchema).max(20).optional(),
|
|
2089
1735
|
telegramChannelIds: z.array(z.number().int()).max(20).optional(),
|
|
2090
|
-
nmo: z.object({
|
|
2091
|
-
enabled: z.boolean().optional(),
|
|
2092
|
-
minWatchTimeRatio: z.number().min(0).max(1).optional(),
|
|
2093
|
-
minCheckpointRatio: z.number().min(0).max(1).optional(),
|
|
2094
|
-
extraPosition: z.boolean().optional(),
|
|
2095
|
-
extraOrg: z.boolean().optional(),
|
|
2096
|
-
}).optional(),
|
|
2097
1736
|
},
|
|
2098
1737
|
outputSchema: updateOutputSchema,
|
|
2099
1738
|
annotations: writeTool,
|
|
2100
1739
|
}, async (args) => {
|
|
2101
|
-
const
|
|
2102
|
-
if (args.nmo !== undefined)
|
|
2103
|
-
requiredScopes.push("nmo:write");
|
|
2104
|
-
const { uid } = await authed(requiredScopes);
|
|
1740
|
+
const { uid } = await authed(["setup:write"]);
|
|
2105
1741
|
await readEvent(args.eventId, uid, "edit");
|
|
2106
1742
|
const patch = {};
|
|
2107
1743
|
if (args.chat?.enabled !== undefined)
|
|
@@ -2163,16 +1799,6 @@ export function registerTools(server) {
|
|
|
2163
1799
|
patch["telegram.channelIds"] = args.telegramChannelIds;
|
|
2164
1800
|
patch["telegram.channelId"] = args.telegramChannelIds[0] ?? null;
|
|
2165
1801
|
}
|
|
2166
|
-
if (args.nmo?.enabled !== undefined)
|
|
2167
|
-
patch.nmoMode = args.nmo.enabled;
|
|
2168
|
-
if (args.nmo?.minWatchTimeRatio !== undefined)
|
|
2169
|
-
patch["nmoCriteria.minWatchTimeRatio"] = args.nmo.minWatchTimeRatio;
|
|
2170
|
-
if (args.nmo?.minCheckpointRatio !== undefined)
|
|
2171
|
-
patch["nmoCriteria.minCheckpointRatio"] = args.nmo.minCheckpointRatio;
|
|
2172
|
-
if (args.nmo?.extraPosition !== undefined)
|
|
2173
|
-
patch["nmoExtraFields.position"] = args.nmo.extraPosition;
|
|
2174
|
-
if (args.nmo?.extraOrg !== undefined)
|
|
2175
|
-
patch["nmoExtraFields.org"] = args.nmo.extraOrg;
|
|
2176
1802
|
await updateDoc(doc(firestore, "events", args.eventId), patch);
|
|
2177
1803
|
return textResult({ id: args.eventId, updated: Object.keys(patch) });
|
|
2178
1804
|
});
|
|
@@ -2544,7 +2170,7 @@ export function registerTools(server) {
|
|
|
2544
2170
|
args: ["eventId: source event.", "targetWorkspaceId: optional target workspace; omitted means source workspace or personal workspace."],
|
|
2545
2171
|
returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
|
|
2546
2172
|
examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
|
|
2547
|
-
notes: ["Copies title, video, meta, blocks, forms,
|
|
2173
|
+
notes: ["Copies title, video, meta, blocks, forms, and Telegram config; does not copy analytics sessions or leads."],
|
|
2548
2174
|
}),
|
|
2549
2175
|
inputSchema: {
|
|
2550
2176
|
eventId: eventIdField,
|
|
@@ -2555,8 +2181,6 @@ export function registerTools(server) {
|
|
|
2555
2181
|
}, async ({ eventId, targetWorkspaceId }) => {
|
|
2556
2182
|
const { uid } = await authed(["events:write"]);
|
|
2557
2183
|
const source = await readEvent(eventId, uid, "read");
|
|
2558
|
-
if (source.nmoMode === true)
|
|
2559
|
-
await authed(["nmo:write"]);
|
|
2560
2184
|
const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
|
|
2561
2185
|
await assertCanCreateInWorkspace(uid, workspaceId);
|
|
2562
2186
|
const newRef = await addEventWithRtdbMirror(uid, workspaceId, {
|
|
@@ -2571,9 +2195,6 @@ export function registerTools(server) {
|
|
|
2571
2195
|
blocks: source.blocks ?? {},
|
|
2572
2196
|
...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
|
|
2573
2197
|
...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
|
|
2574
|
-
...(source.nmoMode === true ? { nmoMode: true } : {}),
|
|
2575
|
-
...(source.nmoCriteria && typeof source.nmoCriteria === "object" ? { nmoCriteria: source.nmoCriteria } : {}),
|
|
2576
|
-
...(source.nmoExtraFields && typeof source.nmoExtraFields === "object" ? { nmoExtraFields: source.nmoExtraFields } : {}),
|
|
2577
2198
|
});
|
|
2578
2199
|
return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
|
|
2579
2200
|
});
|
|
@@ -2691,82 +2312,9 @@ export function registerTools(server) {
|
|
|
2691
2312
|
requireConfirm(confirm, "oko_start_session", { eventId, title: event.title ?? null });
|
|
2692
2313
|
if (event.activeAnalyticsSessionId)
|
|
2693
2314
|
throw new Error("An analytics session is already active.");
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
return textResult({ started: true, ...result.data });
|
|
2698
|
-
}
|
|
2699
|
-
let sessionId = null;
|
|
2700
|
-
try {
|
|
2701
|
-
const agg = await readAgg(eventId);
|
|
2702
|
-
const sessionRef = await addDoc(collection(firestore, "events", eventId, "analyticsSessions"), {
|
|
2703
|
-
startedAt: serverTimestamp(),
|
|
2704
|
-
endedAt: null,
|
|
2705
|
-
});
|
|
2706
|
-
sessionId = sessionRef.id;
|
|
2707
|
-
const updates = {
|
|
2708
|
-
"ctrl/activeSessionId": sessionId,
|
|
2709
|
-
[`sessions/${sessionId}/startedAt`]: Date.now(),
|
|
2710
|
-
[`sessions/${sessionId}/baseline/chatMessages`]: agg.chatMessages,
|
|
2711
|
-
[`sessions/${sessionId}/baseline/bannerClicks`]: agg.bannerClicks,
|
|
2712
|
-
[`sessions/${sessionId}/baseline/bannerImpressions`]: agg.bannerImpressions,
|
|
2713
|
-
[`sessions/${sessionId}/baseline/ctaClicks`]: agg.ctaClicks,
|
|
2714
|
-
[`sessions/${sessionId}/baseline/cardsClicks`]: agg.cardsClicks,
|
|
2715
|
-
[`sessions/${sessionId}/baseline/viewCount`]: agg.viewCount,
|
|
2716
|
-
[`sessions/${sessionId}/baseline/viewSumSec`]: agg.viewSumSec,
|
|
2717
|
-
[`sessions/${sessionId}/baseline/online`]: agg.online,
|
|
2718
|
-
[`sessions/${sessionId}/baseline/uniqueVisitors`]: agg.uniqueVisitors,
|
|
2719
|
-
[`sessions/${sessionId}/peak`]: agg.online,
|
|
2720
|
-
};
|
|
2721
|
-
for (const [bucket, count] of Object.entries(agg.viewHist)) {
|
|
2722
|
-
updates[`sessions/${sessionId}/baseline/viewHist/${bucket}`] = count;
|
|
2723
|
-
}
|
|
2724
|
-
for (const [cityKey, count] of Object.entries(agg.geo ?? {})) {
|
|
2725
|
-
updates[`sessions/${sessionId}/baseline/geo/${cityKey}`] = count;
|
|
2726
|
-
}
|
|
2727
|
-
for (const [cardId, clicks] of Object.entries(agg.cardsById ?? {})) {
|
|
2728
|
-
updates[`sessions/${sessionId}/baseline/cardsById/${cardId}`] = clicks;
|
|
2729
|
-
}
|
|
2730
|
-
for (const [ctaId, clicks] of Object.entries(agg.ctaById ?? {})) {
|
|
2731
|
-
updates[`sessions/${sessionId}/baseline/ctaById/${ctaId}`] = clicks;
|
|
2732
|
-
}
|
|
2733
|
-
await update(ref(rtdb, `events/${eventId}`), updates);
|
|
2734
|
-
try {
|
|
2735
|
-
const engagementSnap = await get(ref(rtdb, `events/${eventId}/engagement`));
|
|
2736
|
-
const engagementNow = numberRecord(engagementSnap.val());
|
|
2737
|
-
const engagementUpdates = {};
|
|
2738
|
-
for (const [viewerId, seconds] of Object.entries(engagementNow)) {
|
|
2739
|
-
engagementUpdates[`sessions/${sessionId}/baseline/engagement/${viewerId}`] = seconds;
|
|
2740
|
-
}
|
|
2741
|
-
if (Object.keys(engagementUpdates).length > 0) {
|
|
2742
|
-
await update(ref(rtdb, `events/${eventId}`), engagementUpdates);
|
|
2743
|
-
}
|
|
2744
|
-
}
|
|
2745
|
-
catch {
|
|
2746
|
-
// Non-fatal: dashboard behaves the same; avg/median are skipped if baseline is unavailable.
|
|
2747
|
-
}
|
|
2748
|
-
await updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: sessionId });
|
|
2749
|
-
try {
|
|
2750
|
-
await set(ref(rtdb, `activeSessions/${sessionId}`), { eventId, startedAt: Date.now() });
|
|
2751
|
-
}
|
|
2752
|
-
catch {
|
|
2753
|
-
// Non-fatal: client-side sampler can still write timeline, and CF stale cleanup is best-effort.
|
|
2754
|
-
}
|
|
2755
|
-
return textResult({ eventId, sessionId, started: true });
|
|
2756
|
-
}
|
|
2757
|
-
catch (err) {
|
|
2758
|
-
if (sessionId) {
|
|
2759
|
-
await Promise.allSettled([
|
|
2760
|
-
remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
|
|
2761
|
-
remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
|
|
2762
|
-
remove(ref(rtdb, `activeSessions/${sessionId}`)),
|
|
2763
|
-
updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
|
|
2764
|
-
deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)),
|
|
2765
|
-
]);
|
|
2766
|
-
}
|
|
2767
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
2768
|
-
throw new Error(`Start failed: ${msg}`);
|
|
2769
|
-
}
|
|
2315
|
+
const start = httpsCallable(functions, "startAnalyticsSessionV3");
|
|
2316
|
+
const result = await start({ eventId });
|
|
2317
|
+
return textResult({ started: true, ...result.data });
|
|
2770
2318
|
});
|
|
2771
2319
|
server.registerTool("oko_end_session", {
|
|
2772
2320
|
title: "End analytics session",
|
|
@@ -2787,175 +2335,9 @@ export function registerTools(server) {
|
|
|
2787
2335
|
if (!sessionId)
|
|
2788
2336
|
throw new Error("No active analytics session.");
|
|
2789
2337
|
requireConfirm(confirm, "oko_end_session", { eventId, sessionId });
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
return textResult(result.data);
|
|
2794
|
-
}
|
|
2795
|
-
const sessionDocRef = doc(firestore, "events", eventId, "analyticsSessions", sessionId);
|
|
2796
|
-
const [finalAgg, baselineSnap, sessionSnap, firestoreSessionSnap] = await Promise.all([
|
|
2797
|
-
readAgg(eventId),
|
|
2798
|
-
get(ref(rtdb, `events/${eventId}/sessions/${sessionId}/baseline`)),
|
|
2799
|
-
get(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)),
|
|
2800
|
-
getDoc(sessionDocRef),
|
|
2801
|
-
]);
|
|
2802
|
-
if (!baselineSnap.exists()) {
|
|
2803
|
-
throw new Error("Session baseline is missing; clear the stuck session and start a fresh session.");
|
|
2804
|
-
}
|
|
2805
|
-
const baseRaw = (baselineSnap.val() ?? {});
|
|
2806
|
-
const baseline = {
|
|
2807
|
-
chatMessages: Number(baseRaw.chatMessages ?? 0),
|
|
2808
|
-
bannerClicks: Number(baseRaw.bannerClicks ?? 0),
|
|
2809
|
-
bannerImpressions: Number(baseRaw.bannerImpressions ?? 0),
|
|
2810
|
-
ctaClicks: Number(baseRaw.ctaClicks ?? 0),
|
|
2811
|
-
cardsClicks: Number(baseRaw.cardsClicks ?? 0),
|
|
2812
|
-
viewCount: Number(baseRaw.viewCount ?? 0),
|
|
2813
|
-
viewSumSec: Number(baseRaw.viewSumSec ?? 0),
|
|
2814
|
-
viewHist: numberRecord(baseRaw.viewHist),
|
|
2815
|
-
online: Number(baseRaw.online ?? 0),
|
|
2816
|
-
uniqueVisitors: Number(baseRaw.uniqueVisitors ?? 0),
|
|
2817
|
-
geo: numberRecord(baseRaw.geo),
|
|
2818
|
-
cardsById: numberRecord(baseRaw.cardsById),
|
|
2819
|
-
ctaById: numberRecord(baseRaw.ctaById),
|
|
2820
|
-
};
|
|
2821
|
-
const session = (sessionSnap.val() ?? {});
|
|
2822
|
-
const timelineRaw = (session.timeline ?? {});
|
|
2823
|
-
const rawSessionTimeline = Object.values(timelineRaw).filter(isTimelineBucket).sort((a, b) => a.t - b.t);
|
|
2824
|
-
const sessionTimeline = downsampleTimeline(rawSessionTimeline);
|
|
2825
|
-
const startMs = toMillis(firestoreSessionSnap.data()?.startedAt) ?? toMillis(session.startedAt);
|
|
2826
|
-
const endMs = Date.now();
|
|
2827
|
-
const durationSec = startMs != null ? Math.max(0, Math.round((endMs - startMs) / 1000)) : 0;
|
|
2828
|
-
const viewCount = Math.max(0, finalAgg.viewCount - baseline.viewCount);
|
|
2829
|
-
const viewSumSec = Math.max(0, finalAgg.viewSumSec - baseline.viewSumSec);
|
|
2830
|
-
const bannerUniqueImpressions = Number(session.bannerUniqueImpressions ?? 0);
|
|
2831
|
-
const bannerUniqueClicks = Number(session.bannerUniqueClicks ?? 0);
|
|
2832
|
-
const bannerCtrDenominator = Math.max(bannerUniqueImpressions, bannerUniqueClicks);
|
|
2833
|
-
const bannerCtr = bannerCtrDenominator > 0
|
|
2834
|
-
? Math.round((bannerUniqueClicks / bannerCtrDenominator) * 1000) / 10
|
|
2835
|
-
: null;
|
|
2836
|
-
let avgTimeSec = null;
|
|
2837
|
-
let medianTimeSec = null;
|
|
2838
|
-
let finalEngagement = {};
|
|
2839
|
-
let baselineEngagement = {};
|
|
2840
|
-
let engagementLoaded = false;
|
|
2841
|
-
try {
|
|
2842
|
-
const finalEngagementSnap = await get(ref(rtdb, `events/${eventId}/engagement`));
|
|
2843
|
-
finalEngagement = numberRecord(finalEngagementSnap.val());
|
|
2844
|
-
baselineEngagement = numberRecord(baseRaw.engagement);
|
|
2845
|
-
engagementLoaded = true;
|
|
2846
|
-
const sessionDurations = [];
|
|
2847
|
-
for (const [viewerId, finalSeconds] of Object.entries(finalEngagement)) {
|
|
2848
|
-
const delta = Math.max(0, finalSeconds - (baselineEngagement[viewerId] ?? 0));
|
|
2849
|
-
if (delta >= 30)
|
|
2850
|
-
sessionDurations.push(delta);
|
|
2851
|
-
}
|
|
2852
|
-
if (sessionDurations.length > 0) {
|
|
2853
|
-
avgTimeSec = Math.round(sessionDurations.reduce((sum, item) => sum + item, 0) / sessionDurations.length);
|
|
2854
|
-
const sorted = [...sessionDurations].sort((a, b) => a - b);
|
|
2855
|
-
const mid = Math.floor(sorted.length / 2);
|
|
2856
|
-
medianTimeSec = sorted.length % 2 === 0
|
|
2857
|
-
? Math.round(((sorted[mid - 1] ?? 0) + (sorted[mid] ?? 0)) / 2)
|
|
2858
|
-
: sorted[mid] ?? null;
|
|
2859
|
-
}
|
|
2860
|
-
}
|
|
2861
|
-
catch {
|
|
2862
|
-
// Non-fatal: session can still be saved without avg/median.
|
|
2863
|
-
}
|
|
2864
|
-
const cardsBreakdown = {};
|
|
2865
|
-
for (const cardId of new Set([...Object.keys(finalAgg.cardsById ?? {}), ...Object.keys(baseline.cardsById ?? {})])) {
|
|
2866
|
-
const delta = Math.max(0, (finalAgg.cardsById?.[cardId] ?? 0) - (baseline.cardsById?.[cardId] ?? 0));
|
|
2867
|
-
if (delta > 0)
|
|
2868
|
-
cardsBreakdown[cardId] = delta;
|
|
2869
|
-
}
|
|
2870
|
-
const ctaBreakdown = {};
|
|
2871
|
-
for (const ctaId of new Set([...Object.keys(finalAgg.ctaById ?? {}), ...Object.keys(baseline.ctaById ?? {})])) {
|
|
2872
|
-
const delta = Math.max(0, (finalAgg.ctaById?.[ctaId] ?? 0) - (baseline.ctaById?.[ctaId] ?? 0));
|
|
2873
|
-
if (delta > 0)
|
|
2874
|
-
ctaBreakdown[ctaId] = delta;
|
|
2875
|
-
}
|
|
2876
|
-
const geoBreakdown = {};
|
|
2877
|
-
for (const cityKey of new Set([...Object.keys(finalAgg.geo ?? {}), ...Object.keys(baseline.geo ?? {})])) {
|
|
2878
|
-
const delta = Math.max(0, (finalAgg.geo?.[cityKey] ?? 0) - (baseline.geo?.[cityKey] ?? 0));
|
|
2879
|
-
if (delta > 0)
|
|
2880
|
-
geoBreakdown[cityKey] = delta;
|
|
2881
|
-
}
|
|
2882
|
-
let nmoEvaluation = null;
|
|
2883
|
-
if (engagementLoaded) {
|
|
2884
|
-
try {
|
|
2885
|
-
nmoEvaluation = await buildNmoEvaluation({
|
|
2886
|
-
eventId,
|
|
2887
|
-
sessionId,
|
|
2888
|
-
startMs,
|
|
2889
|
-
endMs,
|
|
2890
|
-
durationSec,
|
|
2891
|
-
baselineEngagement,
|
|
2892
|
-
finalEngagement,
|
|
2893
|
-
});
|
|
2894
|
-
}
|
|
2895
|
-
catch {
|
|
2896
|
-
// Non-fatal: dashboard treats NMO snapshot failure as warning-only.
|
|
2897
|
-
}
|
|
2898
|
-
}
|
|
2899
|
-
const finalSnapshot = {
|
|
2900
|
-
chatMessages: finalAgg.chatMessages,
|
|
2901
|
-
bannerClicks: finalAgg.bannerClicks,
|
|
2902
|
-
bannerImpressions: finalAgg.bannerImpressions,
|
|
2903
|
-
ctaClicks: finalAgg.ctaClicks,
|
|
2904
|
-
cardsClicks: finalAgg.cardsClicks,
|
|
2905
|
-
viewCount: finalAgg.viewCount,
|
|
2906
|
-
viewSumSec: finalAgg.viewSumSec,
|
|
2907
|
-
viewHist: finalAgg.viewHist,
|
|
2908
|
-
uniqueVisitors: finalAgg.uniqueVisitors,
|
|
2909
|
-
geo: finalAgg.geo ?? {},
|
|
2910
|
-
cardsById: finalAgg.cardsById ?? {},
|
|
2911
|
-
ctaById: finalAgg.ctaById ?? {},
|
|
2912
|
-
};
|
|
2913
|
-
const sessionUniqueVisitors = baseline.online + Number(session.uniqueVisitors ?? 0);
|
|
2914
|
-
await updateDoc(sessionDocRef, {
|
|
2915
|
-
endedAt: serverTimestamp(),
|
|
2916
|
-
durationSec,
|
|
2917
|
-
peakViewers: Number(session.peak ?? 0),
|
|
2918
|
-
uniqueVisitors: sessionUniqueVisitors,
|
|
2919
|
-
viewCount,
|
|
2920
|
-
viewSumSec,
|
|
2921
|
-
avgTimeSec,
|
|
2922
|
-
medianTimeSec,
|
|
2923
|
-
chatMessages: Math.max(0, finalAgg.chatMessages - baseline.chatMessages),
|
|
2924
|
-
bannerImpressions: Math.max(0, finalAgg.bannerImpressions - baseline.bannerImpressions),
|
|
2925
|
-
bannerUniqueImpressions,
|
|
2926
|
-
bannerClicks: Math.max(0, finalAgg.bannerClicks - baseline.bannerClicks),
|
|
2927
|
-
bannerUniqueClicks,
|
|
2928
|
-
bannerCtr,
|
|
2929
|
-
ctaClicks: Math.max(0, finalAgg.ctaClicks - baseline.ctaClicks),
|
|
2930
|
-
ctaUniqueClicks: Number(session.ctaUniqueClicks ?? 0),
|
|
2931
|
-
ctaBreakdown: Object.keys(ctaBreakdown).length > 0 ? ctaBreakdown : null,
|
|
2932
|
-
cardsClicks: Math.max(0, finalAgg.cardsClicks - baseline.cardsClicks),
|
|
2933
|
-
cardsUniqueClicks: Number(session.cardsUniqueClicks ?? 0),
|
|
2934
|
-
cardsBreakdown: Object.keys(cardsBreakdown).length > 0 ? cardsBreakdown : null,
|
|
2935
|
-
geo: Object.keys(geoBreakdown).length > 0 ? geoBreakdown : null,
|
|
2936
|
-
baselineSnapshot: cleanForFirestore(baseline),
|
|
2937
|
-
finalSnapshot: cleanForFirestore(finalSnapshot),
|
|
2938
|
-
timeline: sessionTimeline,
|
|
2939
|
-
timelineOriginalBucketCount: rawSessionTimeline.length,
|
|
2940
|
-
...(nmoEvaluation ? { nmoEvaluation: cleanForFirestore(nmoEvaluation) } : {}),
|
|
2941
|
-
});
|
|
2942
|
-
await updateDoc(doc(firestore, "events", eventId), {
|
|
2943
|
-
activeAnalyticsSessionId: deleteField(),
|
|
2944
|
-
"analytics.lastSessionSummary": {
|
|
2945
|
-
sessionId,
|
|
2946
|
-
startedAt: firestoreSessionSnap.data()?.startedAt ?? null,
|
|
2947
|
-
endedAt: Timestamp.now(),
|
|
2948
|
-
peakViewers: session.peak ?? null,
|
|
2949
|
-
uniqueVisitors: sessionUniqueVisitors,
|
|
2950
|
-
viewCount,
|
|
2951
|
-
viewSumSec,
|
|
2952
|
-
},
|
|
2953
|
-
});
|
|
2954
|
-
await Promise.allSettled([
|
|
2955
|
-
remove(ref(rtdb, `activeSessions/${sessionId}`)),
|
|
2956
|
-
remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
|
|
2957
|
-
]);
|
|
2958
|
-
return textResult({ eventId, sessionId, ended: true });
|
|
2338
|
+
const end = httpsCallable(functions, "endAnalyticsSessionV3");
|
|
2339
|
+
const result = await end({ eventId, sessionId });
|
|
2340
|
+
return textResult(result.data);
|
|
2959
2341
|
});
|
|
2960
2342
|
server.registerTool("oko_fix_orphan_session", {
|
|
2961
2343
|
title: "Fix orphan analytics session",
|
|
@@ -3065,7 +2447,6 @@ export function registerTools(server) {
|
|
|
3065
2447
|
"includeTimeline: include recent timeline buckets; default false to keep context small.",
|
|
3066
2448
|
"timelineLimit: max timeline buckets per session when includeTimeline=true.",
|
|
3067
2449
|
"includeSnapshots: include baseline/final snapshots; default false.",
|
|
3068
|
-
"includeNmoRows: include NMO participant rows; default false.",
|
|
3069
2450
|
],
|
|
3070
2451
|
returns: "sessions plus count, limit, hasMore/has_more, nextCursor/next_cursor. Heavy timeline/snapshot fields are omitted unless requested.",
|
|
3071
2452
|
examples: ["Use default options for dashboard-like summaries; request includeTimeline only for chart/debug tasks."],
|
|
@@ -3077,11 +2458,10 @@ export function registerTools(server) {
|
|
|
3077
2458
|
includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets for each session."),
|
|
3078
2459
|
timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets per session when includeTimeline=true."),
|
|
3079
2460
|
includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
|
|
3080
|
-
includeNmoRows: z.boolean().default(false).describe("Include full NMO participant rows when present."),
|
|
3081
2461
|
},
|
|
3082
2462
|
outputSchema: analyticsOutputSchema,
|
|
3083
2463
|
annotations: readOnlyTool,
|
|
3084
|
-
}, async ({ eventId, limit: max, cursor, includeTimeline, timelineLimit, includeSnapshots
|
|
2464
|
+
}, async ({ eventId, limit: max, cursor, includeTimeline, timelineLimit, includeSnapshots }) => {
|
|
3085
2465
|
const { uid } = await authed(["analytics:read"]);
|
|
3086
2466
|
await readEvent(eventId, uid, "analytics");
|
|
3087
2467
|
const constraints = [
|
|
@@ -3092,9 +2472,9 @@ export function registerTools(server) {
|
|
|
3092
2472
|
constraints.splice(1, 0, startAfter(parseTimestampCursor(cursor)));
|
|
3093
2473
|
const snap = await getDocs(query(collection(firestore, "events", eventId, "analyticsSessions"), ...constraints));
|
|
3094
2474
|
const { page, hasMore } = pickPage(snap.docs, max);
|
|
3095
|
-
const sessions = page.map((item) => compactAnalyticsSession({ id: item.id, ...item.data() }, { includeTimeline, timelineLimit, includeSnapshots
|
|
2475
|
+
const sessions = page.map((item) => compactAnalyticsSession({ id: item.id, ...item.data() }, { includeTimeline, timelineLimit, includeSnapshots }));
|
|
3096
2476
|
const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().startedAt) : null;
|
|
3097
|
-
return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots
|
|
2477
|
+
return textResult({ sessions, ...cursorMeta(sessions.length, max, hasMore, nextCursor) }, { truncationHint: "Use limit/cursor or keep includeTimeline/includeSnapshots disabled." });
|
|
3098
2478
|
});
|
|
3099
2479
|
server.registerTool("oko_get_session_report", {
|
|
3100
2480
|
title: "Get session report",
|
|
@@ -3106,7 +2486,6 @@ export function registerTools(server) {
|
|
|
3106
2486
|
"includeTimeline: include recent timeline buckets; default false.",
|
|
3107
2487
|
"timelineLimit: max timeline buckets when includeTimeline=true.",
|
|
3108
2488
|
"includeSnapshots: include baseline/final snapshots; default false.",
|
|
3109
|
-
"includeNmoRows: include full NMO participant rows; default false.",
|
|
3110
2489
|
],
|
|
3111
2490
|
returns: "event/session ids, report URL, markdown summary, event summary, and compact session object.",
|
|
3112
2491
|
examples: ["Use after a webinar when the user asks for the finished session report without manually opening the report page."],
|
|
@@ -3117,11 +2496,10 @@ export function registerTools(server) {
|
|
|
3117
2496
|
includeTimeline: z.boolean().default(false).describe("Include recent timeline buckets."),
|
|
3118
2497
|
timelineLimit: z.number().int().min(1).max(500).default(ANALYTICS_TIMELINE_DEFAULT_LIMIT).describe("Max timeline buckets when includeTimeline=true."),
|
|
3119
2498
|
includeSnapshots: z.boolean().default(false).describe("Include baselineSnapshot and finalSnapshot."),
|
|
3120
|
-
includeNmoRows: z.boolean().default(false).describe("Include full NMO participant rows when present."),
|
|
3121
2499
|
},
|
|
3122
2500
|
outputSchema: sessionReportOutputSchema,
|
|
3123
2501
|
annotations: readOnlyTool,
|
|
3124
|
-
}, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots
|
|
2502
|
+
}, async ({ eventId, sessionId, includeTimeline, timelineLimit, includeSnapshots }) => {
|
|
3125
2503
|
const { uid } = await authed(["analytics:read"]);
|
|
3126
2504
|
const event = await readEvent(eventId, uid, "report");
|
|
3127
2505
|
let resolvedSessionId = sessionId;
|
|
@@ -3140,7 +2518,7 @@ export function registerTools(server) {
|
|
|
3140
2518
|
resolvedSessionId = first.id;
|
|
3141
2519
|
sessionData = first.data();
|
|
3142
2520
|
}
|
|
3143
|
-
const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots
|
|
2521
|
+
const session = compactAnalyticsSession({ id: resolvedSessionId, ...sessionData }, { includeTimeline, timelineLimit, includeSnapshots });
|
|
3144
2522
|
const markdown = sessionReportMarkdown({ eventId, event, sessionId: resolvedSessionId, session });
|
|
3145
2523
|
return textResult({
|
|
3146
2524
|
eventId,
|
|
@@ -3148,12 +2526,12 @@ export function registerTools(server) {
|
|
|
3148
2526
|
event: {
|
|
3149
2527
|
id: eventId,
|
|
3150
2528
|
title: safeString(event.title, eventId),
|
|
3151
|
-
...eventLinks(eventId
|
|
2529
|
+
...eventLinks(eventId),
|
|
3152
2530
|
},
|
|
3153
2531
|
reportUrl: sessionReportUrl(eventId, resolvedSessionId),
|
|
3154
2532
|
markdown,
|
|
3155
2533
|
session,
|
|
3156
|
-
}, { truncationHint: "Keep includeTimeline/includeSnapshots
|
|
2534
|
+
}, { truncationHint: "Keep includeTimeline/includeSnapshots disabled or lower timelineLimit." });
|
|
3157
2535
|
});
|
|
3158
2536
|
server.registerTool("oko_list_recent_chat", {
|
|
3159
2537
|
title: "List recent chat",
|
|
@@ -3344,196 +2722,6 @@ export function registerTools(server) {
|
|
|
3344
2722
|
await update(ref(chatRtdb), patch);
|
|
3345
2723
|
return textResult({ eventId, updated: Object.keys(patch) });
|
|
3346
2724
|
});
|
|
3347
|
-
server.registerTool("oko_launch_checkpoint", {
|
|
3348
|
-
title: "Launch NMO checkpoint",
|
|
3349
|
-
description: toolDescription({
|
|
3350
|
-
summary: "Launch a participant attendance checkpoint for NMO mode.",
|
|
3351
|
-
args: ["eventId: target event.", "timeoutSec: checkpoint response window, 5-3600 seconds.", "confirm: must be true."],
|
|
3352
|
-
returns: "eventId and checkpoint object with id, startedAt, timeoutSec, and optional sessionId.",
|
|
3353
|
-
examples: ["Use during a live NMO webinar only after user confirmation."],
|
|
3354
|
-
}),
|
|
3355
|
-
inputSchema: { eventId: eventIdField, timeoutSec: z.number().int().min(5).max(3600).default(30).describe("Checkpoint response window in seconds."), confirm: z.boolean().optional().describe("Required true for launch.") },
|
|
3356
|
-
outputSchema: basicEventOutputSchema,
|
|
3357
|
-
annotations: writeTool,
|
|
3358
|
-
}, async ({ eventId, timeoutSec, confirm }) => {
|
|
3359
|
-
const { uid } = await authed(["nmo:write", "live:control"]);
|
|
3360
|
-
await readEvent(eventId, uid, "session");
|
|
3361
|
-
requireConfirm(confirm, "oko_launch_checkpoint", { eventId, timeoutSec });
|
|
3362
|
-
const activeSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
|
|
3363
|
-
const checkpoint = { id: randomId(), startedAt: Date.now(), timeoutSec, ...(activeSnap.val() ? { sessionId: activeSnap.val() } : {}) };
|
|
3364
|
-
await update(ref(rtdb, `events/${eventId}`), {
|
|
3365
|
-
"ctrl/checkpoint": checkpoint,
|
|
3366
|
-
[`checkpointWindows/${checkpoint.id}`]: checkpoint,
|
|
3367
|
-
});
|
|
3368
|
-
return textResult({ eventId, checkpoint });
|
|
3369
|
-
});
|
|
3370
|
-
server.registerTool("oko_close_checkpoint", {
|
|
3371
|
-
title: "Close NMO checkpoint",
|
|
3372
|
-
description: toolDescription({
|
|
3373
|
-
summary: "Close the active NMO checkpoint for an event.",
|
|
3374
|
-
args: ["eventId: target event.", "confirm: must be true."],
|
|
3375
|
-
returns: "eventId and checkpointClosed=true.",
|
|
3376
|
-
examples: ["Use when the organizer wants to stop showing the active checkpoint."],
|
|
3377
|
-
}),
|
|
3378
|
-
inputSchema: { eventId: eventIdField, confirm: z.boolean().optional().describe("Required true to close the live checkpoint.") },
|
|
3379
|
-
outputSchema: basicEventOutputSchema,
|
|
3380
|
-
annotations: idempotentWriteTool,
|
|
3381
|
-
}, async ({ eventId, confirm }) => {
|
|
3382
|
-
const { uid } = await authed(["nmo:write", "live:control"]);
|
|
3383
|
-
await readEvent(eventId, uid, "session");
|
|
3384
|
-
requireConfirm(confirm, "oko_close_checkpoint", { eventId });
|
|
3385
|
-
await remove(ref(rtdb, `events/${eventId}/ctrl/checkpoint`));
|
|
3386
|
-
return textResult({ eventId, checkpointClosed: true });
|
|
3387
|
-
});
|
|
3388
|
-
server.registerTool("oko_list_participants", {
|
|
3389
|
-
title: "List NMO participants",
|
|
3390
|
-
description: toolDescription({
|
|
3391
|
-
summary: "Read paginated NMO participants for an event.",
|
|
3392
|
-
args: [
|
|
3393
|
-
"eventId: target event.",
|
|
3394
|
-
"csv: return CSV string instead of participants array.",
|
|
3395
|
-
"limit: max rows, default 50, max 200.",
|
|
3396
|
-
"cursor: participant uid returned as nextCursor by the previous page.",
|
|
3397
|
-
],
|
|
3398
|
-
returns: "participants or csv plus count, limit, hasMore/has_more, nextCursor/next_cursor.",
|
|
3399
|
-
examples: ["Use limit=50 first; if hasMore is true, call again with cursor=nextCursor."],
|
|
3400
|
-
}),
|
|
3401
|
-
inputSchema: {
|
|
3402
|
-
eventId: eventIdField,
|
|
3403
|
-
csv: z.boolean().default(false).describe("Return CSV string instead of JSON rows."),
|
|
3404
|
-
limit: limitField(MAX_EXPORT_LIMIT, 50),
|
|
3405
|
-
cursor: cursorField,
|
|
3406
|
-
},
|
|
3407
|
-
outputSchema: participantsOutputSchema,
|
|
3408
|
-
annotations: readOnlyTool,
|
|
3409
|
-
}, async ({ eventId, csv, limit: max, cursor }) => {
|
|
3410
|
-
const { uid } = await authed(["nmo:write"]);
|
|
3411
|
-
await readEvent(eventId, uid, "analytics");
|
|
3412
|
-
const constraints = cursor
|
|
3413
|
-
? [orderBy(documentId()), startAfter(cursor), limit(max + 1)]
|
|
3414
|
-
: [orderBy(documentId()), limit(max + 1)];
|
|
3415
|
-
const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), ...constraints));
|
|
3416
|
-
const page = snap.docs.slice(0, max);
|
|
3417
|
-
const rows = page.map((item) => ({ uid: item.id, ...serialize(item.data()) }));
|
|
3418
|
-
const hasMore = snap.docs.length > max;
|
|
3419
|
-
const nextCursor = hasMore ? page.at(-1)?.id ?? null : null;
|
|
3420
|
-
const meta = cursorMeta(rows.length, max, hasMore, nextCursor);
|
|
3421
|
-
if (csv) {
|
|
3422
|
-
const text = csvRows(["uid", "email", "fullName", "position", "org", "registeredAt"], rows.map((row) => [row.uid, row.email, row.fullName, row.position, row.org, row.registeredAt]));
|
|
3423
|
-
return textResult({ csv: text, ...meta });
|
|
3424
|
-
}
|
|
3425
|
-
return textResult({ participants: rows, ...meta });
|
|
3426
|
-
});
|
|
3427
|
-
server.registerTool("oko_add_nmo_participant", {
|
|
3428
|
-
title: "Add NMO participant",
|
|
3429
|
-
description: toolDescription({
|
|
3430
|
-
summary: "Add or update one NMO participant for an event through OKO organizer import.",
|
|
3431
|
-
args: ["eventId: NMO event.", "email/fullName/position/org: participant fields."],
|
|
3432
|
-
returns: "eventId, summary, and per-row import result.",
|
|
3433
|
-
examples: ["Use when the organizer dictates one doctor/participant to add before the webinar."],
|
|
3434
|
-
notes: ["This does not return a participant login token; use registration/login URLs from oko_get_event for attendee access."],
|
|
3435
|
-
}),
|
|
3436
|
-
inputSchema: { eventId: eventIdField, participant: nmoParticipantInputSchema },
|
|
3437
|
-
outputSchema: nmoImportOutputSchema,
|
|
3438
|
-
annotations: writeTool,
|
|
3439
|
-
}, async ({ eventId, participant }) => {
|
|
3440
|
-
const { uid } = await authed(["nmo:write"]);
|
|
3441
|
-
await readEvent(eventId, uid, "edit");
|
|
3442
|
-
const importFn = httpsCallable(functions, "importNmoParticipants");
|
|
3443
|
-
const result = await importFn({
|
|
3444
|
-
eventId,
|
|
3445
|
-
participants: [{
|
|
3446
|
-
email: normalizeEmail(participant.email),
|
|
3447
|
-
fullName: participant.fullName.trim(),
|
|
3448
|
-
position: normalizeOptionalText(participant.position, 200),
|
|
3449
|
-
org: normalizeOptionalText(participant.org, 200),
|
|
3450
|
-
}],
|
|
3451
|
-
});
|
|
3452
|
-
const data = serialize(result.data);
|
|
3453
|
-
const results = Array.isArray(data.results) ? data.results : [];
|
|
3454
|
-
const first = results[0];
|
|
3455
|
-
if (!first || first.ok !== true) {
|
|
3456
|
-
throw new Error(`NMO participant import failed: ${safeString(first?.error, "unknown error")}`);
|
|
3457
|
-
}
|
|
3458
|
-
return textResult({ eventId, ...data });
|
|
3459
|
-
});
|
|
3460
|
-
server.registerTool("oko_import_nmo_participants", {
|
|
3461
|
-
title: "Import NMO participants",
|
|
3462
|
-
description: toolDescription({
|
|
3463
|
-
summary: "Bulk import NMO participants from structured rows or CSV text.",
|
|
3464
|
-
args: [
|
|
3465
|
-
"eventId: NMO event.",
|
|
3466
|
-
"participants: optional array of rows { email, fullName, position?, org? }.",
|
|
3467
|
-
"csv: optional CSV text with headers email, fullName, position, org.",
|
|
3468
|
-
],
|
|
3469
|
-
returns: "eventId, summary, and per-row results with ok/error.",
|
|
3470
|
-
examples: ["Use csv when the organizer pastes a participant list from a spreadsheet."],
|
|
3471
|
-
notes: ["Provide exactly one of participants or csv. Max 200 rows. The callable returns partial success for bulk imports."],
|
|
3472
|
-
}),
|
|
3473
|
-
inputSchema: {
|
|
3474
|
-
eventId: eventIdField,
|
|
3475
|
-
participants: z.array(nmoParticipantInputSchema).max(MAX_NMO_IMPORT_PARTICIPANTS).optional(),
|
|
3476
|
-
csv: z.string().max(80_000).optional().describe("CSV with headers email, fullName, position, org."),
|
|
3477
|
-
},
|
|
3478
|
-
outputSchema: nmoImportOutputSchema,
|
|
3479
|
-
annotations: writeTool,
|
|
3480
|
-
}, async ({ eventId, participants, csv }) => {
|
|
3481
|
-
const { uid } = await authed(["nmo:write"]);
|
|
3482
|
-
await readEvent(eventId, uid, "edit");
|
|
3483
|
-
if (Boolean(participants) === Boolean(csv))
|
|
3484
|
-
throw new Error("Provide exactly one of participants or csv.");
|
|
3485
|
-
const rows = participants ?? parseNmoParticipantsCsv(csv ?? "");
|
|
3486
|
-
if (rows.length === 0 || rows.length > MAX_NMO_IMPORT_PARTICIPANTS)
|
|
3487
|
-
throw new Error(`Import supports 1-${MAX_NMO_IMPORT_PARTICIPANTS} participants.`);
|
|
3488
|
-
const importFn = httpsCallable(functions, "importNmoParticipants");
|
|
3489
|
-
const result = await importFn({
|
|
3490
|
-
eventId,
|
|
3491
|
-
participants: rows.map((row) => ({
|
|
3492
|
-
email: normalizeEmail(row.email),
|
|
3493
|
-
fullName: row.fullName.trim(),
|
|
3494
|
-
position: normalizeOptionalText(row.position, 200),
|
|
3495
|
-
org: normalizeOptionalText(row.org, 200),
|
|
3496
|
-
})),
|
|
3497
|
-
});
|
|
3498
|
-
return textResult({ eventId, ...serialize(result.data) });
|
|
3499
|
-
});
|
|
3500
|
-
server.registerTool("oko_remove_nmo_participant", {
|
|
3501
|
-
title: "Remove NMO participant",
|
|
3502
|
-
description: toolDescription({
|
|
3503
|
-
summary: "Remove an NMO participant and immediately revoke event access.",
|
|
3504
|
-
args: ["eventId: NMO event.", "uid or email: participant to remove.", "confirm: must be true."],
|
|
3505
|
-
returns: "eventId, participant uid/email, and removed=true.",
|
|
3506
|
-
examples: ["Use after listing participants and confirming the exact participant."],
|
|
3507
|
-
notes: ["Order is intentional: arrayRemove from events.participantUids first, then delete participant doc."],
|
|
3508
|
-
}),
|
|
3509
|
-
inputSchema: {
|
|
3510
|
-
eventId: eventIdField,
|
|
3511
|
-
uid: z.string().min(1).optional().describe("Participant Firebase uid."),
|
|
3512
|
-
email: emailSchema.optional().describe("Participant email; used to resolve uid if uid is omitted."),
|
|
3513
|
-
confirm: z.boolean().optional().describe("Required true for participant removal."),
|
|
3514
|
-
},
|
|
3515
|
-
outputSchema: z.object({ eventId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
|
|
3516
|
-
annotations: destructiveTool,
|
|
3517
|
-
}, async ({ eventId, uid: participantUid, email, confirm }) => {
|
|
3518
|
-
const { uid } = await authed(["nmo:write"]);
|
|
3519
|
-
await readEvent(eventId, uid, "edit");
|
|
3520
|
-
let targetUid = participantUid;
|
|
3521
|
-
const normalizedEmail = email ? normalizeEmail(email) : null;
|
|
3522
|
-
if (!targetUid && normalizedEmail) {
|
|
3523
|
-
const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), where("email", "==", normalizedEmail), limit(2)));
|
|
3524
|
-
if (snap.empty)
|
|
3525
|
-
throw new Error(`NMO participant with email ${normalizedEmail} not found.`);
|
|
3526
|
-
if (snap.docs.length > 1)
|
|
3527
|
-
throw new Error(`Multiple NMO participants found for email ${normalizedEmail}; use uid.`);
|
|
3528
|
-
targetUid = snap.docs[0].id;
|
|
3529
|
-
}
|
|
3530
|
-
if (!targetUid)
|
|
3531
|
-
throw new Error("Provide uid or email.");
|
|
3532
|
-
requireConfirm(confirm, "oko_remove_nmo_participant", { eventId, uid: targetUid, email: normalizedEmail });
|
|
3533
|
-
await updateDoc(doc(firestore, "events", eventId), { participantUids: arrayRemove(targetUid) });
|
|
3534
|
-
await deleteDoc(doc(firestore, "eventParticipants", eventId, "items", targetUid));
|
|
3535
|
-
return textResult({ eventId, uid: targetUid, email: normalizedEmail, removed: true });
|
|
3536
|
-
});
|
|
3537
2725
|
server.registerTool("oko_list_leads", {
|
|
3538
2726
|
title: "List form leads",
|
|
3539
2727
|
description: toolDescription({
|