@okostream/mcp 0.1.1 → 0.2.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/dist/tools.js CHANGED
@@ -6,7 +6,7 @@ import { isAbsolute, join, resolve } from "node:path";
6
6
  import { randomUUID } from "node:crypto";
7
7
  import sharp from "sharp";
8
8
  import { z } from "zod";
9
- import { addDoc, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
9
+ import { addDoc, arrayRemove, collection, deleteDoc, deleteField, documentId, doc, getDoc, getDocs, limit, orderBy, query, serverTimestamp, setDoc, startAfter, Timestamp, updateDoc, where, } from "firebase/firestore";
10
10
  import { get, endBefore, limitToLast, orderByKey, push, query as dbQuery, ref, remove, runTransaction, serverTimestamp as rtdbServerTimestamp, set, update, } from "firebase/database";
11
11
  import { httpsCallable } from "firebase/functions";
12
12
  import { deleteObject, getDownloadURL, ref as storageRef, uploadBytes, } from "firebase/storage";
@@ -17,6 +17,13 @@ import { detectAndNormalize, extractIframeSrc } from "./videoUrl.js";
17
17
  const boolOrNull = z.boolean().nullable();
18
18
  const IMAGE_KINDS = ["promoBanner", "videoPlaceholder", "card"];
19
19
  const IMAGE_VARIANT_NAMES = ["thumb_320.webp", "card_800.webp", "hero_1600.webp"];
20
+ const CARD_IMAGE_RATIOS = ["auto", "1:1", "4:3", "3:2", "16:9", "2:3", "3:4"];
21
+ const CARD_ID_PATTERN = /^[A-Za-z0-9_-]{1,80}$/;
22
+ const MAX_CARD_ITEMS = 6;
23
+ const MAX_CARD_TITLE_LENGTH = 160;
24
+ const MAX_CARD_TEXT_LENGTH = 500;
25
+ const MAX_CARD_BUTTON_LABEL_LENGTH = 80;
26
+ const MAX_CARD_BUTTON_URL_LENGTH = 1000;
20
27
  const MAX_SOURCE_IMAGE_BYTES = 15 * 1024 * 1024;
21
28
  const MAX_SOURCE_IMAGE_PIXELS = 50_000_000;
22
29
  const MAX_MASTER_IMAGE_BYTES = 10 * 1024 * 1024;
@@ -32,6 +39,10 @@ const MAX_LIST_LIMIT = 100;
32
39
  const MAX_EXPORT_LIMIT = 200;
33
40
  const ANALYTICS_DEFAULT_LIMIT = 10;
34
41
  const ANALYTICS_TIMELINE_DEFAULT_LIMIT = 120;
42
+ const MAX_NMO_IMPORT_PARTICIPANTS = 200;
43
+ const MAX_WORKSPACE_NAME_LENGTH = 120;
44
+ const MAX_WORKSPACE_INVITE_CODE_LENGTH = 64;
45
+ const TELEGRAM_POST_STATUSES = ["draft", "scheduled", "sent", "failed"];
35
46
  const readOnlyTool = {
36
47
  readOnlyHint: true,
37
48
  destructiveHint: false,
@@ -133,6 +144,25 @@ const deleteImageOutputSchema = z.object({
133
144
  storageDeletesAttempted: z.number().int().nonnegative(),
134
145
  storageDeleteWarnings: z.array(z.string()),
135
146
  }).passthrough();
147
+ const workspaceAccessOutputSchema = z.object({
148
+ workspaceId: z.string(),
149
+ workspace: unknownRecordSchema,
150
+ members: z.array(unknownRecordSchema),
151
+ invites: z.array(unknownRecordSchema),
152
+ }).passthrough();
153
+ const telegramChannelsOutputSchema = z.object({
154
+ channels: z.array(unknownRecordSchema),
155
+ count: z.number().int().nonnegative(),
156
+ }).passthrough();
157
+ const telegramPostsOutputSchema = z.object({
158
+ posts: z.array(unknownRecordSchema),
159
+ ...cursorMetaSchema,
160
+ }).passthrough();
161
+ const nmoImportOutputSchema = z.object({
162
+ eventId: z.string(),
163
+ summary: unknownRecordSchema,
164
+ results: z.array(unknownRecordSchema),
165
+ }).passthrough();
136
166
  const formFieldSchema = z.object({
137
167
  id: z.string().min(1).optional(),
138
168
  label: z.string().max(120),
@@ -156,6 +186,44 @@ const imageSourceSchema = z.object({
156
186
  }).strict().refine((source) => Boolean(source.path) !== Boolean(source.url), {
157
187
  message: "Provide exactly one image source: path or url.",
158
188
  });
189
+ const cardIdSchema = z.string()
190
+ .regex(CARD_ID_PATTERN, "Use 1-80 characters: letters, numbers, underscore, or hyphen.")
191
+ .describe("Stable card id. Omit to generate one; keep it to preserve images and analytics continuity.");
192
+ const cardItemInputSchema = z.object({
193
+ id: cardIdSchema.optional(),
194
+ title: z.string().max(MAX_CARD_TITLE_LENGTH).nullable().optional(),
195
+ text: z.string().max(MAX_CARD_TEXT_LENGTH).nullable().optional(),
196
+ buttonLabel: z.string().max(MAX_CARD_BUTTON_LABEL_LENGTH).nullable().optional(),
197
+ buttonUrl: z.string().max(MAX_CARD_BUTTON_URL_LENGTH).nullable().optional(),
198
+ formId: cardIdSchema.nullable().optional(),
199
+ }).strict();
200
+ const emailSchema = z.string().email().max(320);
201
+ const nmoParticipantInputSchema = z.object({
202
+ email: emailSchema.describe("Participant email."),
203
+ fullName: z.string().min(2).max(200).describe("Participant full name."),
204
+ position: z.string().max(200).nullable().optional().describe("Optional medical position."),
205
+ org: z.string().max(200).nullable().optional().describe("Optional organization/clinic."),
206
+ }).strict();
207
+ const telegramChannelSchema = z.object({
208
+ chatId: z.number().int(),
209
+ title: z.string().max(200).optional(),
210
+ username: z.string().max(120).nullable().optional(),
211
+ }).strict();
212
+ const cardsConfigOutputSchema = z.object({
213
+ eventId: z.string(),
214
+ updated: z.array(z.string()),
215
+ cards: z.object({
216
+ enabled: z.boolean(),
217
+ visibility: z.enum(["always", "hidden"]),
218
+ cardImageRatio: z.enum(CARD_IMAGE_RATIOS),
219
+ items: z.array(unknownRecordSchema),
220
+ }).passthrough(),
221
+ generatedCardIds: z.array(z.string()),
222
+ removedCardIds: z.array(z.string()),
223
+ removedCardsWithImages: z.array(z.string()),
224
+ preservedImageCardIds: z.array(z.string()),
225
+ warnings: z.array(z.string()),
226
+ }).passthrough();
159
227
  const workspacePermissionsSchema = z.object({
160
228
  createEvents: z.boolean().optional(),
161
229
  editSetup: z.boolean().optional(),
@@ -167,6 +235,17 @@ const workspacePermissionsSchema = z.object({
167
235
  downloadReports: z.boolean().optional(),
168
236
  sendNotifications: z.boolean().optional(),
169
237
  }).strict().default({});
238
+ const WORKSPACE_PERMISSION_KEYS = [
239
+ "createEvents",
240
+ "editSetup",
241
+ "deleteEvents",
242
+ "controlLive",
243
+ "moderateChat",
244
+ "startSession",
245
+ "viewAnalytics",
246
+ "downloadReports",
247
+ "sendNotifications",
248
+ ];
170
249
  const eventIdField = z.string().min(1).describe("OKO event document id, for example KJsAku48821hB4PXTW0S.");
171
250
  const limitField = (max = MAX_LIST_LIMIT, defaultValue = DEFAULT_LIST_LIMIT) => z.number().int().min(1).max(max).default(defaultValue).describe(`Maximum items to return, 1-${max}.`);
172
251
  const cursorField = z.string().min(1).optional().describe("Cursor returned as nextCursor by the previous page.");
@@ -441,6 +520,112 @@ function cardItemsWithImage(eventData, cardId, patch) {
441
520
  throw new Error(`Card ${cardId} not found in blocks.cards.items.`);
442
521
  return next;
443
522
  }
523
+ function readImageVariants(value) {
524
+ if (!value || typeof value !== "object" || Array.isArray(value))
525
+ return null;
526
+ const data = value;
527
+ if (typeof data.thumbUrl !== "string" || typeof data.cardUrl !== "string" || typeof data.heroUrl !== "string") {
528
+ return null;
529
+ }
530
+ return {
531
+ thumbUrl: data.thumbUrl,
532
+ cardUrl: data.cardUrl,
533
+ heroUrl: data.heroUrl,
534
+ };
535
+ }
536
+ function readCardsConfig(eventData) {
537
+ const blocks = (eventData.blocks && typeof eventData.blocks === "object" ? eventData.blocks : {});
538
+ const raw = (blocks.cards && typeof blocks.cards === "object" ? blocks.cards : {});
539
+ const rawItems = Array.isArray(raw.items) ? raw.items : [];
540
+ const items = rawItems
541
+ .filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item))
542
+ .map((item) => {
543
+ const fallbackId = randomUUID();
544
+ return {
545
+ id: typeof item.id === "string" && CARD_ID_PATTERN.test(item.id) ? item.id : fallbackId,
546
+ title: safeString(item.title).trim() || null,
547
+ text: safeString(item.text).trim() || null,
548
+ buttonLabel: safeString(item.buttonLabel).trim() || null,
549
+ buttonUrl: safeString(item.buttonUrl).trim() || null,
550
+ imageUrl: safeString(item.imageUrl).trim() || null,
551
+ imageMasterUrl: safeString(item.imageMasterUrl).trim() || null,
552
+ imageVariants: readImageVariants(item.imageVariants),
553
+ formId: safeString(item.formId).trim() || null,
554
+ };
555
+ });
556
+ const ratio = safeString(raw.cardImageRatio);
557
+ return {
558
+ enabled: raw.enabled === true,
559
+ visibility: raw.visibility === "hidden" ? "hidden" : "always",
560
+ cardImageRatio: CARD_IMAGE_RATIOS.includes(ratio) ? ratio : "16:9",
561
+ items,
562
+ };
563
+ }
564
+ function normalizeNullableText(value) {
565
+ if (value == null)
566
+ return null;
567
+ return value.trim() || null;
568
+ }
569
+ function normalizeCardButtonUrl(value) {
570
+ const trimmed = normalizeNullableText(value);
571
+ if (!trimmed)
572
+ return null;
573
+ let url;
574
+ try {
575
+ url = new URL(trimmed);
576
+ }
577
+ catch {
578
+ throw new Error(`Invalid card buttonUrl "${trimmed}". Use http(s), mailto, tel, or set formId instead.`);
579
+ }
580
+ if (!["http:", "https:", "mailto:", "tel:"].includes(url.protocol)) {
581
+ throw new Error(`Unsupported card buttonUrl protocol "${url.protocol}". Use http(s), mailto, tel, or set formId instead.`);
582
+ }
583
+ return trimmed;
584
+ }
585
+ function eventFormIds(eventData) {
586
+ const rawForms = Array.isArray(eventData.forms) ? eventData.forms : [];
587
+ return new Set(rawForms
588
+ .map((form) => form && typeof form === "object" && !Array.isArray(form) ? safeString(form.id).trim() : "")
589
+ .filter(Boolean));
590
+ }
591
+ function cardHasImage(item) {
592
+ return Boolean(item.imageMasterUrl || item.imageUrl || item.imageVariants);
593
+ }
594
+ function buildCardsItems({ inputs, existing, formIds, }) {
595
+ const existingById = new Map(existing.map((item) => [item.id, item]));
596
+ const seen = new Set();
597
+ const generatedCardIds = [];
598
+ const preservedImageCardIds = [];
599
+ const items = inputs.map((input) => {
600
+ const id = input.id ?? randomUUID();
601
+ if (seen.has(id))
602
+ throw new Error(`Duplicate card id "${id}". Each card id must be unique.`);
603
+ seen.add(id);
604
+ if (!CARD_ID_PATTERN.test(id))
605
+ throw new Error(`Invalid card id "${id}". Use 1-80 characters: letters, numbers, underscore, or hyphen.`);
606
+ if (!input.id)
607
+ generatedCardIds.push(id);
608
+ const formId = normalizeNullableText(input.formId);
609
+ if (formId && !formIds.has(formId)) {
610
+ throw new Error(`Form ${formId} not found in event.forms. Create it with oko_configure_blocks forms first, or set formId:null.`);
611
+ }
612
+ const previous = existingById.get(id);
613
+ if (previous && cardHasImage(previous))
614
+ preservedImageCardIds.push(id);
615
+ return {
616
+ id,
617
+ title: normalizeNullableText(input.title),
618
+ text: normalizeNullableText(input.text),
619
+ buttonLabel: normalizeNullableText(input.buttonLabel),
620
+ buttonUrl: normalizeCardButtonUrl(input.buttonUrl),
621
+ imageUrl: previous?.imageUrl ?? null,
622
+ imageMasterUrl: previous?.imageMasterUrl ?? null,
623
+ imageVariants: previous?.imageVariants ?? null,
624
+ formId,
625
+ };
626
+ });
627
+ return { items, generatedCardIds, preservedImageCardIds };
628
+ }
444
629
  async function deleteStoragePaths(paths) {
445
630
  const warnings = [];
446
631
  for (const path of [...new Set(paths.filter(Boolean))]) {
@@ -494,6 +679,32 @@ function normalizeVideo(videoUrl) {
494
679
  function eventUrl(eventId) {
495
680
  return `https://okostream.app/event/${eventId}`;
496
681
  }
682
+ function setupUrl(eventId) {
683
+ return `https://okostream.app/setup/${eventId}`;
684
+ }
685
+ function analyticsUrl(eventId) {
686
+ return `https://okostream.app/analytics/${eventId}`;
687
+ }
688
+ function nmoRegisterUrl(eventId) {
689
+ return `${eventUrl(eventId)}/register`;
690
+ }
691
+ function nmoLoginUrl(eventId) {
692
+ return `${eventUrl(eventId)}/login`;
693
+ }
694
+ function eventLinks(eventId, eventData) {
695
+ const nmoMode = eventData?.nmoMode === true;
696
+ return {
697
+ url: eventUrl(eventId),
698
+ setupUrl: setupUrl(eventId),
699
+ analyticsUrl: analyticsUrl(eventId),
700
+ registrationUrl: nmoRegisterUrl(eventId),
701
+ loginUrl: nmoLoginUrl(eventId),
702
+ ...(nmoMode ? {
703
+ nmoRegistrationUrl: nmoRegisterUrl(eventId),
704
+ nmoLoginUrl: nmoLoginUrl(eventId),
705
+ } : {}),
706
+ };
707
+ }
497
708
  function assertEventReadable(data, uid) {
498
709
  const access = Array.isArray(data.accessibleBy) ? data.accessibleBy : [];
499
710
  const editors = Array.isArray(data.editors) ? data.editors : [];
@@ -556,6 +767,193 @@ async function buildWorkspaceAccess(uid, workspaceId) {
556
767
  }
557
768
  return { accessibleBy: [...accessibleBy], editors: [...editors] };
558
769
  }
770
+ async function buildMoveWorkspaceAccess(uid, targetWorkspaceId, originalOwnerId) {
771
+ const access = await buildWorkspaceAccess(uid, targetWorkspaceId);
772
+ const accessibleBy = new Set(access.accessibleBy);
773
+ const editors = new Set(access.editors);
774
+ if (originalOwnerId)
775
+ accessibleBy.add(originalOwnerId);
776
+ return { accessibleBy: [...accessibleBy], editors: [...editors] };
777
+ }
778
+ async function readWorkspaceForOwner(workspaceId, uid) {
779
+ const snap = await getDoc(doc(firestore, "workspaces", workspaceId));
780
+ if (!snap.exists())
781
+ throw new Error("Workspace not found.");
782
+ const data = snap.data();
783
+ if (data.ownerId !== uid)
784
+ throw new Error("Only the workspace owner can manage this workspace.");
785
+ return data;
786
+ }
787
+ function normalizeWorkspacePermissions(value) {
788
+ const out = {};
789
+ for (const key of WORKSPACE_PERMISSION_KEYS) {
790
+ if (value[key] === true)
791
+ out[key] = true;
792
+ else if (value[key] === false)
793
+ out[key] = false;
794
+ }
795
+ return out;
796
+ }
797
+ function randomInviteCode() {
798
+ return Math.random().toString(36).slice(2, 8).toUpperCase();
799
+ }
800
+ function normalizeEmail(value) {
801
+ return value.trim().toLowerCase();
802
+ }
803
+ function normalizeOptionalText(value, max) {
804
+ const text = value?.trim() ?? "";
805
+ return text ? text.slice(0, max) : null;
806
+ }
807
+ function parseCsvLine(line) {
808
+ const cells = [];
809
+ let current = "";
810
+ let quoted = false;
811
+ for (let i = 0; i < line.length; i++) {
812
+ const char = line[i];
813
+ const next = line[i + 1];
814
+ if (quoted && char === "\"" && next === "\"") {
815
+ current += "\"";
816
+ i++;
817
+ continue;
818
+ }
819
+ if (char === "\"") {
820
+ quoted = !quoted;
821
+ continue;
822
+ }
823
+ if (!quoted && char === ",") {
824
+ cells.push(current.trim());
825
+ current = "";
826
+ continue;
827
+ }
828
+ current += char;
829
+ }
830
+ cells.push(current.trim());
831
+ return cells;
832
+ }
833
+ function parseNmoParticipantsCsv(csv) {
834
+ const lines = csv
835
+ .split(/\r?\n/)
836
+ .map((line) => line.trim())
837
+ .filter(Boolean);
838
+ if (lines.length < 2)
839
+ throw new Error("CSV must include a header row and at least one participant row.");
840
+ const headers = parseCsvLine(lines[0] ?? "").map((header) => header.trim().toLowerCase());
841
+ const alias = {
842
+ email: "email",
843
+ mail: "email",
844
+ "e-mail": "email",
845
+ fullname: "fullName",
846
+ full_name: "fullName",
847
+ name: "fullName",
848
+ fio: "fullName",
849
+ "фио": "fullName",
850
+ "имя": "fullName",
851
+ position: "position",
852
+ "должность": "position",
853
+ org: "org",
854
+ organization: "org",
855
+ organisation: "org",
856
+ clinic: "org",
857
+ "организация": "org",
858
+ };
859
+ return lines.slice(1).map((line, index) => {
860
+ const cells = parseCsvLine(line);
861
+ const row = {};
862
+ headers.forEach((header, cellIndex) => {
863
+ const key = alias[header];
864
+ if (key)
865
+ row[key] = cells[cellIndex] ?? "";
866
+ });
867
+ const parsed = nmoParticipantInputSchema.safeParse(row);
868
+ if (!parsed.success) {
869
+ throw new Error(`Invalid NMO CSV row ${index + 2}: ${parsed.error.issues.map((issue) => issue.message).join("; ")}`);
870
+ }
871
+ return parsed.data;
872
+ });
873
+ }
874
+ async function readTelegramChannels(uid, includeWorkspaceChannels) {
875
+ const channelsById = new Map();
876
+ const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
877
+ const profileChannels = Array.isArray(profileSnap.data()?.telegramChannels) ? profileSnap.data()?.telegramChannels : [];
878
+ for (const raw of profileChannels) {
879
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
880
+ continue;
881
+ const ch = raw;
882
+ if (typeof ch.chatId !== "number")
883
+ continue;
884
+ channelsById.set(ch.chatId, { ...serialize(ch), source: "profile" });
885
+ }
886
+ if (includeWorkspaceChannels) {
887
+ const workspaces = await listVisibleWorkspaces(uid, true);
888
+ for (const workspace of workspaces) {
889
+ const workspaceId = safeString(workspace.id);
890
+ const workspaceName = safeString(workspace.name);
891
+ const channels = Array.isArray(workspace.telegramChannels) ? workspace.telegramChannels : [];
892
+ for (const raw of channels) {
893
+ if (!raw || typeof raw !== "object" || Array.isArray(raw))
894
+ continue;
895
+ const ch = raw;
896
+ if (typeof ch.chatId !== "number")
897
+ continue;
898
+ const prev = channelsById.get(ch.chatId) ?? {};
899
+ const visibleInWorkspaces = Array.isArray(prev.visibleInWorkspaces) ? prev.visibleInWorkspaces : [];
900
+ channelsById.set(ch.chatId, {
901
+ ...prev,
902
+ ...serialize(ch),
903
+ source: prev.source ? "profile+workspace" : "workspace",
904
+ visibleInWorkspaces: [...visibleInWorkspaces, { id: workspaceId, name: workspaceName }],
905
+ });
906
+ }
907
+ }
908
+ }
909
+ return [...channelsById.values()].sort((a, b) => safeString(a.title).localeCompare(safeString(b.title)));
910
+ }
911
+ async function listVisibleWorkspaces(uid, includeShared) {
912
+ const out = [];
913
+ const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
914
+ ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
915
+ if (includeShared) {
916
+ const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
917
+ const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
918
+ for (const workspaceId of sharedIds) {
919
+ if (out.some((row) => row.id === workspaceId))
920
+ continue;
921
+ const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
922
+ const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
923
+ if (wsSnap.exists())
924
+ out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
925
+ }
926
+ }
927
+ return out;
928
+ }
929
+ async function syncTelegramChannelToWorkspaces(uid, channel, workspaceIds) {
930
+ const updated = [];
931
+ if (workspaceIds.length === 0)
932
+ return updated;
933
+ const profileRef = doc(firestore, "userProfiles", uid);
934
+ const profileSnap = await getDoc(profileRef);
935
+ const channels = Array.isArray(profileSnap.data()?.telegramChannels) ? [...profileSnap.data().telegramChannels] : [];
936
+ const nextProfileChannels = channels.map((item) => {
937
+ if (item.chatId !== channel.chatId)
938
+ return item;
939
+ const current = Array.isArray(item.workspaceIds) ? item.workspaceIds.filter((id) => typeof id === "string") : [];
940
+ return { ...item, workspaceIds: [...new Set([...current, ...workspaceIds])] };
941
+ });
942
+ await setDoc(profileRef, { telegramChannels: nextProfileChannels }, { merge: true });
943
+ for (const workspaceId of workspaceIds) {
944
+ await readWorkspaceForOwner(workspaceId, uid);
945
+ const wsRef = doc(firestore, "workspaces", workspaceId);
946
+ const wsSnap = await getDoc(wsRef);
947
+ const wsChannels = Array.isArray(wsSnap.data()?.telegramChannels) ? wsSnap.data().telegramChannels : [];
948
+ if (!wsChannels.some((item) => item.chatId === channel.chatId)) {
949
+ await updateDoc(wsRef, {
950
+ telegramChannels: [...wsChannels, { chatId: channel.chatId, title: channel.title ?? "", username: channel.username ?? null }],
951
+ });
952
+ updated.push(workspaceId);
953
+ }
954
+ }
955
+ return updated;
956
+ }
559
957
  async function readAgg(eventId) {
560
958
  const snap = await get(ref(rtdb, `events/${eventId}/agg`));
561
959
  const agg = (snap.val() ?? {});
@@ -832,23 +1230,211 @@ export function registerTools(server) {
832
1230
  annotations: readOnlyTool,
833
1231
  }, async ({ includeShared }) => {
834
1232
  const { uid } = await authed(["workspace:read"]);
835
- const out = [];
836
- const ownedSnap = await getDocs(query(collection(firestore, "workspaces"), where("ownerId", "==", uid)));
837
- ownedSnap.forEach((item) => out.push({ id: item.id, ...serialize(item.data()), isOwner: true }));
838
- if (includeShared) {
839
- const profileSnap = await getDoc(doc(firestore, "userProfiles", uid));
840
- const sharedIds = (profileSnap.data()?.sharedWorkspaceIds ?? []);
841
- for (const workspaceId of sharedIds) {
842
- if (out.some((row) => typeof row === "object" && row !== null && row.id === workspaceId))
843
- continue;
844
- const wsSnap = await getDoc(doc(firestore, "workspaces", workspaceId));
845
- const shareSnap = await getDoc(doc(firestore, "workspaces", workspaceId, "shares", uid));
846
- if (wsSnap.exists())
847
- out.push({ id: workspaceId, ...serialize(wsSnap.data()), isOwner: false, permissions: serialize(shareSnap.data()?.permissions ?? {}) });
848
- }
849
- }
1233
+ const out = await listVisibleWorkspaces(uid, includeShared);
850
1234
  return textResult({ workspaces: out, count: out.length });
851
1235
  });
1236
+ server.registerTool("oko_create_workspace", {
1237
+ title: "Create workspace",
1238
+ description: toolDescription({
1239
+ summary: "Create a shared OKO workspace owned by the authenticated organizer.",
1240
+ args: ["name: workspace display name."],
1241
+ returns: "workspaceId, name, and created=true.",
1242
+ examples: ["Use when the organizer wants to create a new team/workspace for events."],
1243
+ }),
1244
+ inputSchema: { name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("Workspace name.") },
1245
+ outputSchema: z.object({ workspaceId: z.string(), created: z.boolean() }).passthrough(),
1246
+ annotations: writeTool,
1247
+ }, async ({ name }) => {
1248
+ const { uid } = await authed(["workspace:write"]);
1249
+ const workspaceRef = await addDoc(collection(firestore, "workspaces"), {
1250
+ name: name.trim(),
1251
+ ownerId: uid,
1252
+ personal: false,
1253
+ createdAt: serverTimestamp(),
1254
+ });
1255
+ return textResult({ workspaceId: workspaceRef.id, name: name.trim(), created: true });
1256
+ });
1257
+ server.registerTool("oko_update_workspace", {
1258
+ title: "Update workspace",
1259
+ description: toolDescription({
1260
+ summary: "Rename a workspace owned by the authenticated organizer.",
1261
+ args: ["workspaceId: workspace to update.", "name: new workspace display name."],
1262
+ returns: "workspaceId and updated fields.",
1263
+ examples: ["Use for workspace rename requests."],
1264
+ }),
1265
+ inputSchema: { workspaceId: z.string().min(1), name: z.string().min(1).max(MAX_WORKSPACE_NAME_LENGTH).describe("New workspace name.") },
1266
+ outputSchema: updateOutputSchema,
1267
+ annotations: idempotentWriteTool,
1268
+ }, async ({ workspaceId, name }) => {
1269
+ const { uid } = await authed(["workspace:write"]);
1270
+ await readWorkspaceForOwner(workspaceId, uid);
1271
+ await updateDoc(doc(firestore, "workspaces", workspaceId), { name: name.trim() });
1272
+ return textResult({ workspaceId, updated: ["name"] });
1273
+ });
1274
+ server.registerTool("oko_delete_workspace", {
1275
+ title: "Delete workspace",
1276
+ description: toolDescription({
1277
+ summary: "Delete an empty non-personal workspace owned by the authenticated organizer.",
1278
+ args: ["workspaceId: workspace to delete.", "confirm: must be true."],
1279
+ returns: "workspaceId and deleted=true.",
1280
+ examples: ["Use after listing workspace access and confirming the workspace is empty."],
1281
+ notes: ["Personal workspaces cannot be deleted. This tool refuses to delete workspaces that still contain events; move/delete events first."],
1282
+ }),
1283
+ inputSchema: { workspaceId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for workspace deletion.") },
1284
+ outputSchema: z.object({ workspaceId: z.string(), deleted: z.boolean() }).passthrough(),
1285
+ annotations: destructiveTool,
1286
+ }, async ({ workspaceId, confirm }) => {
1287
+ const { uid } = await authed(["workspace:write"]);
1288
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
1289
+ requireConfirm(confirm, "oko_delete_workspace", { workspaceId, name: workspace.name, personal: workspace.personal });
1290
+ if (workspace.personal === true)
1291
+ throw new Error("Personal workspace cannot be deleted.");
1292
+ const eventsSnap = await getDocs(query(collection(firestore, "events"), where("workspaceId", "==", workspaceId), limit(1)));
1293
+ if (!eventsSnap.empty)
1294
+ throw new Error("Workspace still has events. Move or delete its events before deleting the workspace.");
1295
+ await deleteDoc(doc(firestore, "workspaces", workspaceId));
1296
+ return textResult({ workspaceId, deleted: true });
1297
+ });
1298
+ server.registerTool("oko_list_workspace_access", {
1299
+ title: "List workspace access",
1300
+ description: toolDescription({
1301
+ summary: "List workspace members/shares and pending invites.",
1302
+ args: ["workspaceId: workspace owned by the authenticated organizer."],
1303
+ returns: "workspace document, members array, invites array.",
1304
+ examples: ["Use before changing permissions, removing a member, or revoking an invite."],
1305
+ }),
1306
+ inputSchema: { workspaceId: z.string().min(1) },
1307
+ outputSchema: workspaceAccessOutputSchema,
1308
+ annotations: readOnlyTool,
1309
+ }, async ({ workspaceId }) => {
1310
+ const { uid } = await authed(["workspace:read"]);
1311
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
1312
+ const [sharesSnap, invitesSnap] = await Promise.all([
1313
+ getDocs(collection(firestore, "workspaces", workspaceId, "shares")),
1314
+ getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId))),
1315
+ ]);
1316
+ const members = [];
1317
+ for (const share of sharesSnap.docs) {
1318
+ const profileSnap = await getDoc(doc(firestore, "userProfiles", share.id));
1319
+ members.push({
1320
+ uid: share.id,
1321
+ ...serialize(share.data()),
1322
+ profile: profileSnap.exists() ? serialize(profileSnap.data()) : null,
1323
+ });
1324
+ }
1325
+ const invites = invitesSnap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()) }));
1326
+ return textResult({ workspaceId, workspace: { id: workspaceId, ...serialize(workspace) }, members, invites });
1327
+ });
1328
+ server.registerTool("oko_update_workspace_member", {
1329
+ title: "Update workspace member",
1330
+ description: toolDescription({
1331
+ summary: "Create or update a workspace share/member permissions document.",
1332
+ args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "permissions: 9 workspace permission booleans.", "confirm: must be true."],
1333
+ returns: "workspaceId, uid, and updated=true.",
1334
+ examples: ["Use to change an existing member's permissions, or direct-add a known uid."],
1335
+ notes: ["Changing a share triggers OKO CF18 fan-out to event access, RTDB workspaceMembers, and chat roles."],
1336
+ }),
1337
+ inputSchema: {
1338
+ workspaceId: z.string().min(1),
1339
+ uid: z.string().min(1).describe("Member Firebase uid."),
1340
+ email: z.string().email().optional().describe("Optional member email cache."),
1341
+ comment: z.string().max(500).default(""),
1342
+ permissions: workspacePermissionsSchema,
1343
+ confirm: z.boolean().optional().describe("Required true for share updates."),
1344
+ },
1345
+ outputSchema: updateOutputSchema,
1346
+ annotations: writeTool,
1347
+ }, async (args) => {
1348
+ const { uid } = await authed(["workspace:write"]);
1349
+ const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
1350
+ requireConfirm(args.confirm, "oko_update_workspace_member", { workspaceId: args.workspaceId, workspaceName: workspace.name, targetUid: args.uid, permissions: args.permissions });
1351
+ await setDoc(doc(firestore, "workspaces", args.workspaceId, "shares", args.uid), {
1352
+ email: args.email ?? "",
1353
+ comment: args.comment.trim(),
1354
+ permissions: normalizeWorkspacePermissions(args.permissions),
1355
+ sharedAt: serverTimestamp(),
1356
+ sharedBy: uid,
1357
+ }, { merge: true });
1358
+ return textResult({ workspaceId: args.workspaceId, uid: args.uid, updated: true });
1359
+ });
1360
+ server.registerTool("oko_remove_workspace_member", {
1361
+ title: "Remove workspace member",
1362
+ description: toolDescription({
1363
+ summary: "Remove a member/share from a workspace.",
1364
+ args: ["workspaceId: workspace owned by the organizer.", "uid: member uid.", "confirm: must be true."],
1365
+ returns: "workspaceId, uid, removed=true.",
1366
+ examples: ["Use after listing workspace access and confirming the exact uid."],
1367
+ notes: ["Removing a share triggers OKO CF18 reverse fan-out."],
1368
+ }),
1369
+ inputSchema: { workspaceId: z.string().min(1), uid: z.string().min(1), confirm: z.boolean().optional().describe("Required true for member removal.") },
1370
+ outputSchema: z.object({ workspaceId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
1371
+ annotations: destructiveTool,
1372
+ }, async ({ workspaceId, uid: targetUid, confirm }) => {
1373
+ const { uid } = await authed(["workspace:write"]);
1374
+ const workspace = await readWorkspaceForOwner(workspaceId, uid);
1375
+ requireConfirm(confirm, "oko_remove_workspace_member", { workspaceId, workspaceName: workspace.name, targetUid });
1376
+ await deleteDoc(doc(firestore, "workspaces", workspaceId, "shares", targetUid));
1377
+ return textResult({ workspaceId, uid: targetUid, removed: true });
1378
+ });
1379
+ server.registerTool("oko_list_workspace_invites", {
1380
+ title: "List workspace invites",
1381
+ description: toolDescription({
1382
+ summary: "List workspace invite links for a workspace owned by the organizer.",
1383
+ args: ["workspaceId: workspace owned by the organizer."],
1384
+ returns: "invites array with code, claim status, permissions, and inviteUrl.",
1385
+ examples: ["Use before revoking or sharing an invite code."],
1386
+ }),
1387
+ inputSchema: { workspaceId: z.string().min(1) },
1388
+ outputSchema: z.object({ workspaceId: z.string(), invites: z.array(unknownRecordSchema), count: z.number().int().nonnegative() }).passthrough(),
1389
+ annotations: readOnlyTool,
1390
+ }, async ({ workspaceId }) => {
1391
+ const { uid } = await authed(["workspace:read"]);
1392
+ await readWorkspaceForOwner(workspaceId, uid);
1393
+ const snap = await getDocs(query(collection(firestore, "workspaceInvites"), where("workspaceId", "==", workspaceId)));
1394
+ const invites = snap.docs.map((invite) => ({ code: invite.id, ...serialize(invite.data()), inviteUrl: `https://okostream.app/invite/${invite.id}` }));
1395
+ return textResult({ workspaceId, invites, count: invites.length });
1396
+ });
1397
+ server.registerTool("oko_revoke_workspace_invite", {
1398
+ title: "Revoke workspace invite",
1399
+ description: toolDescription({
1400
+ summary: "Delete a pending workspace invite code.",
1401
+ args: ["code: invite code.", "confirm: must be true."],
1402
+ returns: "code and revoked=true.",
1403
+ examples: ["Use after confirming the exact invite code."],
1404
+ }),
1405
+ inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH), confirm: z.boolean().optional().describe("Required true for invite revocation.") },
1406
+ outputSchema: z.object({ code: z.string(), revoked: z.boolean() }).passthrough(),
1407
+ annotations: destructiveTool,
1408
+ }, async ({ code, confirm }) => {
1409
+ const { uid } = await authed(["workspace:write"]);
1410
+ const normalizedCode = code.trim().toUpperCase();
1411
+ const inviteSnap = await getDoc(doc(firestore, "workspaceInvites", normalizedCode));
1412
+ if (!inviteSnap.exists())
1413
+ throw new Error("Workspace invite not found.");
1414
+ const invite = inviteSnap.data();
1415
+ if (invite.createdBy !== uid)
1416
+ throw new Error("Only the invite creator/workspace owner can revoke this invite.");
1417
+ requireConfirm(confirm, "oko_revoke_workspace_invite", { code: normalizedCode, workspaceId: invite.workspaceId, claimed: invite.claimed });
1418
+ await deleteDoc(doc(firestore, "workspaceInvites", normalizedCode));
1419
+ return textResult({ code: normalizedCode, revoked: true });
1420
+ });
1421
+ server.registerTool("oko_claim_workspace_invite", {
1422
+ title: "Claim workspace invite",
1423
+ description: toolDescription({
1424
+ summary: "Accept a workspace invite code for the authenticated organizer.",
1425
+ args: ["code: invite code from /invite/{code}."],
1426
+ returns: "success plus workspace id/name returned by OKO CF19.",
1427
+ examples: ["Use when the organizer wants to join a workspace from an invite code."],
1428
+ }),
1429
+ inputSchema: { code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH) },
1430
+ outputSchema: unknownRecordSchema,
1431
+ annotations: writeTool,
1432
+ }, async ({ code }) => {
1433
+ await authed(["workspace:write"]);
1434
+ const claimInvite = httpsCallable(functions, "claimWorkspaceInvite");
1435
+ const result = await claimInvite({ code: code.trim().toUpperCase() });
1436
+ return textResult({ ...serialize(result.data) });
1437
+ });
852
1438
  server.registerTool("oko_list_my_events", {
853
1439
  title: "List my events",
854
1440
  description: toolDescription({
@@ -899,7 +1485,7 @@ export function registerTools(server) {
899
1485
  }, async ({ eventId }) => {
900
1486
  const { uid } = await authed(["events:read"]);
901
1487
  const data = await readEvent(eventId, uid, "read");
902
- return textResult({ id: eventId, ...serialize(data), url: eventUrl(eventId) });
1488
+ return textResult({ id: eventId, ...serialize(data), ...eventLinks(eventId, data) });
903
1489
  });
904
1490
  server.registerTool("oko_create_webinar", {
905
1491
  title: "Create webinar",
@@ -955,7 +1541,7 @@ export function registerTools(server) {
955
1541
  ownerId: uid,
956
1542
  workspaceId,
957
1543
  });
958
- return textResult({ id: docRef.id, url: eventUrl(docRef.id), workspaceId });
1544
+ return textResult({ id: docRef.id, ...eventLinks(docRef.id), workspaceId });
959
1545
  });
960
1546
  server.registerTool("oko_update_event", {
961
1547
  title: "Update event metadata",
@@ -1110,6 +1696,92 @@ export function registerTools(server) {
1110
1696
  await updateDoc(doc(firestore, "events", args.eventId), patch);
1111
1697
  return textResult({ id: args.eventId, updated: Object.keys(patch) });
1112
1698
  });
1699
+ server.registerTool("oko_configure_cards", {
1700
+ title: "Configure event cards",
1701
+ description: toolDescription({
1702
+ summary: "Configure the Cards setup block and replace its card list. Images are managed separately through oko_upload_image and are preserved by matching card id.",
1703
+ args: [
1704
+ "eventId: event to edit.",
1705
+ "enabled: optional Cards block enabled flag.",
1706
+ "visibility: optional default visibility, always or hidden.",
1707
+ "cardImageRatio: optional image ratio, one of auto, 1:1, 4:3, 3:2, 16:9, 2:3, 3:4.",
1708
+ "items: optional full replacement array, max 6 cards. Each item may include id, title, text, buttonLabel, buttonUrl, formId.",
1709
+ ],
1710
+ returns: "event id, updated Firestore fields, normalized cards config, generated card ids, preserved image ids, removed card ids, and warnings.",
1711
+ examples: [
1712
+ "Use after oko_create_webinar to create cards before uploading per-card images.",
1713
+ "Call oko_get_event first and pass the full desired items array when editing existing cards.",
1714
+ "Use formId instead of buttonUrl when a card button should open a lead form.",
1715
+ ],
1716
+ notes: [
1717
+ "This tool replaces blocks.cards.items when items is provided; omitted text fields become null.",
1718
+ "Existing card images are preserved only when the same card id remains in the replacement list.",
1719
+ "buttonUrl allows http(s), mailto, or tel URLs; javascript/data URLs are rejected.",
1720
+ "If a removed card had an image, Storage cleanup is not automatic; this matches the web Setup behavior.",
1721
+ ],
1722
+ }),
1723
+ inputSchema: {
1724
+ eventId: eventIdField,
1725
+ enabled: z.boolean().optional().describe("Whether the Cards block is enabled in setup."),
1726
+ visibility: z.enum(["always", "hidden"]).optional().describe("Default visibility when no live override exists."),
1727
+ cardImageRatio: z.enum(CARD_IMAGE_RATIOS).optional().describe("Image aspect ratio for all cards."),
1728
+ items: z.array(cardItemInputSchema).max(MAX_CARD_ITEMS).optional().describe("Full replacement list, max 6 cards. Omit to update only block settings."),
1729
+ },
1730
+ outputSchema: cardsConfigOutputSchema,
1731
+ annotations: writeTool,
1732
+ }, async (args) => {
1733
+ const { uid } = await authed(["setup:write"]);
1734
+ const event = await readEvent(args.eventId, uid, "edit");
1735
+ const currentCards = readCardsConfig(event);
1736
+ const patch = {};
1737
+ const finalCards = {
1738
+ enabled: args.enabled ?? currentCards.enabled,
1739
+ visibility: args.visibility ?? currentCards.visibility,
1740
+ cardImageRatio: args.cardImageRatio ?? currentCards.cardImageRatio,
1741
+ items: currentCards.items,
1742
+ };
1743
+ const generatedCardIds = [];
1744
+ const preservedImageCardIds = [];
1745
+ let removedCardIds = [];
1746
+ let removedCardsWithImages = [];
1747
+ if (args.enabled !== undefined)
1748
+ patch["blocks.cards.enabled"] = args.enabled;
1749
+ if (args.visibility !== undefined)
1750
+ patch["blocks.cards.visibility"] = args.visibility;
1751
+ if (args.cardImageRatio !== undefined)
1752
+ patch["blocks.cards.cardImageRatio"] = args.cardImageRatio;
1753
+ if (args.items !== undefined) {
1754
+ const built = buildCardsItems({
1755
+ inputs: args.items,
1756
+ existing: currentCards.items,
1757
+ formIds: eventFormIds(event),
1758
+ });
1759
+ finalCards.items = built.items;
1760
+ generatedCardIds.push(...built.generatedCardIds);
1761
+ preservedImageCardIds.push(...built.preservedImageCardIds);
1762
+ const nextIds = new Set(built.items.map((item) => item.id));
1763
+ const removed = currentCards.items.filter((item) => !nextIds.has(item.id));
1764
+ removedCardIds = removed.map((item) => item.id);
1765
+ removedCardsWithImages = removed.filter(cardHasImage).map((item) => item.id);
1766
+ patch["blocks.cards.items"] = built.items;
1767
+ }
1768
+ if (Object.keys(patch).length > 0) {
1769
+ await updateDoc(doc(firestore, "events", args.eventId), patch);
1770
+ }
1771
+ const warnings = removedCardsWithImages.length > 0
1772
+ ? [`Removed cards had images and their Storage files were not deleted: ${removedCardsWithImages.join(", ")}.`]
1773
+ : [];
1774
+ return textResult({
1775
+ eventId: args.eventId,
1776
+ updated: Object.keys(patch),
1777
+ cards: finalCards,
1778
+ generatedCardIds,
1779
+ removedCardIds,
1780
+ removedCardsWithImages,
1781
+ preservedImageCardIds,
1782
+ warnings,
1783
+ });
1784
+ });
1113
1785
  server.registerTool("oko_upload_image", {
1114
1786
  title: "Upload event image",
1115
1787
  description: toolDescription({
@@ -1286,6 +1958,71 @@ export function registerTools(server) {
1286
1958
  await deleteDoc(doc(firestore, "events", eventId));
1287
1959
  return textResult({ deleted: eventId });
1288
1960
  });
1961
+ server.registerTool("oko_duplicate_event", {
1962
+ title: "Duplicate event",
1963
+ description: toolDescription({
1964
+ summary: "Duplicate an event setup into the same or another workspace without analytics/session history.",
1965
+ args: ["eventId: source event.", "targetWorkspaceId: optional target workspace; omitted means source workspace or personal workspace."],
1966
+ returns: "new event id, public/setup/analytics URLs, source event id, and workspaceId.",
1967
+ examples: ["Use to create a reusable copy of a configured webinar before editing the new copy."],
1968
+ notes: ["Copies title, video, meta, blocks, forms, telegram config, NMO settings; does not copy analytics sessions/leads/participants."],
1969
+ }),
1970
+ inputSchema: {
1971
+ eventId: eventIdField,
1972
+ targetWorkspaceId: z.string().min(1).optional().describe("Target workspace id; omit to use the source workspace or personal workspace."),
1973
+ },
1974
+ outputSchema: createWebinarOutputSchema,
1975
+ annotations: writeTool,
1976
+ }, async ({ eventId, targetWorkspaceId }) => {
1977
+ const { uid } = await authed(["events:write"]);
1978
+ const source = await readEvent(eventId, uid, "read");
1979
+ const workspaceId = targetWorkspaceId ?? safeString(source.workspaceId, uid);
1980
+ const access = await buildWorkspaceAccess(uid, workspaceId);
1981
+ const newRef = await addDoc(collection(firestore, "events"), {
1982
+ ownerId: uid,
1983
+ workspaceId,
1984
+ accessibleBy: access.accessibleBy,
1985
+ editors: access.editors,
1986
+ createdAt: serverTimestamp(),
1987
+ title: safeString(source.title, "Untitled event"),
1988
+ pinned: false,
1989
+ video: source.video ?? { originalUrl: null, embedUrl: null },
1990
+ meta: source.meta ?? { eventDate: null, speakerName: null, description: null },
1991
+ blocks: source.blocks ?? {},
1992
+ ...(Array.isArray(source.forms) ? { forms: source.forms } : {}),
1993
+ ...(source.telegram && typeof source.telegram === "object" ? { telegram: source.telegram } : {}),
1994
+ ...(source.nmoMode === true ? { nmoMode: true } : {}),
1995
+ ...(source.nmoCriteria && typeof source.nmoCriteria === "object" ? { nmoCriteria: source.nmoCriteria } : {}),
1996
+ ...(source.nmoExtraFields && typeof source.nmoExtraFields === "object" ? { nmoExtraFields: source.nmoExtraFields } : {}),
1997
+ });
1998
+ await update(ref(rtdb, `events/${newRef.id}`), { ownerId: uid, workspaceId });
1999
+ return textResult({ id: newRef.id, sourceEventId: eventId, workspaceId, ...eventLinks(newRef.id) });
2000
+ });
2001
+ server.registerTool("oko_move_event", {
2002
+ title: "Move event",
2003
+ description: toolDescription({
2004
+ summary: "Move an event to another workspace and recompute accessibleBy/editors.",
2005
+ args: ["eventId: event to move.", "targetWorkspaceId: target workspace id."],
2006
+ returns: "eventId, targetWorkspaceId, updated Firestore fields, and RTDB mirror status.",
2007
+ examples: ["Use when an event belongs in another workspace. Call oko_list_workspaces first to choose the target."],
2008
+ notes: ["Reads target workspace shares from Firestore with permissions; does not use RTDB workspaceMembers."],
2009
+ }),
2010
+ inputSchema: { eventId: eventIdField, targetWorkspaceId: z.string().min(1).describe("Target workspace id.") },
2011
+ outputSchema: updateOutputSchema,
2012
+ annotations: writeTool,
2013
+ }, async ({ eventId, targetWorkspaceId }) => {
2014
+ const { uid } = await authed(["events:write", "workspace:write"]);
2015
+ const event = await readEvent(eventId, uid, "edit");
2016
+ const originalOwnerId = safeString(event.ownerId) || null;
2017
+ const access = await buildMoveWorkspaceAccess(uid, targetWorkspaceId, originalOwnerId);
2018
+ await updateDoc(doc(firestore, "events", eventId), {
2019
+ workspaceId: targetWorkspaceId,
2020
+ accessibleBy: access.accessibleBy,
2021
+ editors: access.editors,
2022
+ });
2023
+ await set(ref(rtdb, `events/${eventId}/workspaceId`), targetWorkspaceId);
2024
+ return textResult({ eventId, targetWorkspaceId, updated: ["workspaceId", "accessibleBy", "editors"], rtdbMirrorUpdated: true });
2025
+ });
1289
2026
  server.registerTool("oko_get_live_state", {
1290
2027
  title: "Get live state",
1291
2028
  description: toolDescription({
@@ -1629,6 +2366,103 @@ export function registerTools(server) {
1629
2366
  ]);
1630
2367
  return textResult({ eventId, sessionId, ended: true });
1631
2368
  });
2369
+ server.registerTool("oko_fix_orphan_session", {
2370
+ title: "Fix orphan analytics session",
2371
+ description: toolDescription({
2372
+ summary: "Clear stuck active-session pointers when an analytics session cannot be ended normally.",
2373
+ args: [
2374
+ "eventId: event with stuck session pointers.",
2375
+ "deleteRtdbSessionData: optional, also delete events/{eventId}/sessions/{sessionId}; default false.",
2376
+ "confirm: must be true.",
2377
+ ],
2378
+ returns: "eventId, cleared sessionId if found, and cleaned paths.",
2379
+ examples: ["Use when oko_end_session says the baseline is missing or dashboard shows a stuck/orphan session."],
2380
+ notes: ["This does not stop the video stream; OKO Start/End controls analytics sessions only."],
2381
+ }),
2382
+ inputSchema: {
2383
+ eventId: eventIdField,
2384
+ deleteRtdbSessionData: z.boolean().default(false).describe("Also delete RTDB session data for the stuck session."),
2385
+ confirm: z.boolean().optional().describe("Required true for clearing session pointers."),
2386
+ },
2387
+ outputSchema: basicEventOutputSchema,
2388
+ annotations: destructiveTool,
2389
+ }, async ({ eventId, deleteRtdbSessionData, confirm }) => {
2390
+ const { uid } = await authed(["analytics:write", "live:control"]);
2391
+ const event = await readEvent(eventId, uid, "edit");
2392
+ const ctrlSnap = await get(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`));
2393
+ const sessionId = safeString(event.activeAnalyticsSessionId) || safeString(ctrlSnap.val());
2394
+ requireConfirm(confirm, "oko_fix_orphan_session", { eventId, sessionId: sessionId || null, deleteRtdbSessionData });
2395
+ const cleanup = [
2396
+ updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }),
2397
+ remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)),
2398
+ ];
2399
+ if (sessionId)
2400
+ cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
2401
+ if (sessionId && deleteRtdbSessionData)
2402
+ cleanup.push(remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`)));
2403
+ const results = await Promise.allSettled(cleanup);
2404
+ const warnings = results
2405
+ .filter((result) => result.status === "rejected")
2406
+ .map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
2407
+ return textResult({
2408
+ eventId,
2409
+ sessionId: sessionId || null,
2410
+ fixed: warnings.length === 0,
2411
+ cleared: ["events.activeAnalyticsSessionId", "ctrl/activeSessionId", ...(sessionId ? [`activeSessions/${sessionId}`] : []), ...(sessionId && deleteRtdbSessionData ? [`events/${eventId}/sessions/${sessionId}`] : [])],
2412
+ warnings,
2413
+ });
2414
+ });
2415
+ server.registerTool("oko_delete_session", {
2416
+ title: "Delete analytics session",
2417
+ description: toolDescription({
2418
+ summary: "Delete an analytics session document and optionally its RTDB session data.",
2419
+ args: [
2420
+ "eventId: event id.",
2421
+ "sessionId: analytics session id.",
2422
+ "deleteRtdbData: best-effort delete of events/{eventId}/sessions/{sessionId}; default false.",
2423
+ "confirm: must be true.",
2424
+ ],
2425
+ returns: "eventId, sessionId, deleted=true, and cleaned paths.",
2426
+ examples: ["Use only after confirming the exact session id with the organizer."],
2427
+ notes: ["Destructive. If the session is active, active pointers are cleared first. RTDB session data cleanup is best-effort because rules may deny deleting historical session snapshots."],
2428
+ }),
2429
+ inputSchema: {
2430
+ eventId: eventIdField,
2431
+ sessionId: z.string().min(1).describe("analyticsSessions document id."),
2432
+ deleteRtdbData: z.boolean().default(false).describe("Best-effort delete RTDB session data for this session."),
2433
+ confirm: z.boolean().optional().describe("Required true for session deletion."),
2434
+ },
2435
+ outputSchema: basicEventOutputSchema,
2436
+ annotations: destructiveTool,
2437
+ }, async ({ eventId, sessionId, deleteRtdbData, confirm }) => {
2438
+ const { uid } = await authed(["analytics:write", "live:control"]);
2439
+ const event = await readEvent(eventId, uid, "edit");
2440
+ requireConfirm(confirm, "oko_delete_session", { eventId, sessionId, deleteRtdbData });
2441
+ const isActive = safeString(event.activeAnalyticsSessionId) === sessionId;
2442
+ const cleanup = [];
2443
+ if (isActive) {
2444
+ cleanup.push(updateDoc(doc(firestore, "events", eventId), { activeAnalyticsSessionId: deleteField() }));
2445
+ cleanup.push(remove(ref(rtdb, `events/${eventId}/ctrl/activeSessionId`)));
2446
+ cleanup.push(remove(ref(rtdb, `activeSessions/${sessionId}`)));
2447
+ }
2448
+ cleanup.push(deleteDoc(doc(firestore, "events", eventId, "analyticsSessions", sessionId)));
2449
+ const results = await Promise.allSettled(cleanup);
2450
+ const warnings = results
2451
+ .filter((result) => result.status === "rejected")
2452
+ .map((result) => result.reason instanceof Error ? result.reason.message : String(result.reason));
2453
+ if (warnings.length > 0)
2454
+ throw new Error(`Session delete partially failed: ${warnings.join("; ")}`);
2455
+ const rtdbCleanupWarnings = [];
2456
+ if (deleteRtdbData) {
2457
+ try {
2458
+ await remove(ref(rtdb, `events/${eventId}/sessions/${sessionId}`));
2459
+ }
2460
+ catch (err) {
2461
+ rtdbCleanupWarnings.push(err instanceof Error ? err.message : String(err));
2462
+ }
2463
+ }
2464
+ return textResult({ eventId, sessionId, deleted: true, wasActive: isActive, deleteRtdbData, rtdbCleanupWarnings });
2465
+ });
1632
2466
  server.registerTool("oko_get_analytics", {
1633
2467
  title: "Get analytics sessions",
1634
2468
  description: toolDescription({
@@ -1783,6 +2617,42 @@ export function registerTools(server) {
1783
2617
  await update(ref(chatRtdb), deletes);
1784
2618
  return textResult({ eventId, targetUid, banned: true, deletedMessages: Object.keys(deletes).length });
1785
2619
  });
2620
+ server.registerTool("oko_unmute_user", {
2621
+ title: "Unmute chat user",
2622
+ description: toolDescription({
2623
+ summary: "Remove a chat mute for a user.",
2624
+ args: ["eventId: target event.", "uid: Firebase uid to unmute.", "confirm: must be true."],
2625
+ returns: "eventId, targetUid, and unmuted=true.",
2626
+ examples: ["Use when a user was muted by mistake or the organizer wants to restore chat access."],
2627
+ }),
2628
+ inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unmute."), confirm: z.boolean().optional().describe("Required true for unmute.") },
2629
+ outputSchema: basicEventOutputSchema,
2630
+ annotations: idempotentWriteTool,
2631
+ }, async ({ eventId, uid: targetUid, confirm }) => {
2632
+ const actor = await authed(["chat:moderate"]);
2633
+ await readEvent(eventId, actor.uid, "edit");
2634
+ requireConfirm(confirm, "oko_unmute_user", { eventId, targetUid });
2635
+ await remove(ref(chatRtdb, `mutes/${eventId}/${targetUid}`));
2636
+ return textResult({ eventId, targetUid, unmuted: true });
2637
+ });
2638
+ server.registerTool("oko_unban_user", {
2639
+ title: "Unban chat user",
2640
+ description: toolDescription({
2641
+ summary: "Remove a chat ban for a user.",
2642
+ args: ["eventId: target event.", "uid: Firebase uid to unban.", "confirm: must be true."],
2643
+ returns: "eventId, targetUid, and unbanned=true.",
2644
+ examples: ["Use when a user was banned by mistake or the organizer wants to restore event chat access."],
2645
+ }),
2646
+ inputSchema: { eventId: eventIdField, uid: z.string().min(1).describe("Firebase uid to unban."), confirm: z.boolean().optional().describe("Required true for unban.") },
2647
+ outputSchema: basicEventOutputSchema,
2648
+ annotations: idempotentWriteTool,
2649
+ }, async ({ eventId, uid: targetUid, confirm }) => {
2650
+ const actor = await authed(["chat:moderate"]);
2651
+ await readEvent(eventId, actor.uid, "edit");
2652
+ requireConfirm(confirm, "oko_unban_user", { eventId, targetUid });
2653
+ await remove(ref(chatRtdb, `bans/${eventId}/${targetUid}`));
2654
+ return textResult({ eventId, targetUid, unbanned: true });
2655
+ });
1786
2656
  server.registerTool("oko_set_chat_settings", {
1787
2657
  title: "Set chat settings",
1788
2658
  description: toolDescription({
@@ -1888,6 +2758,116 @@ export function registerTools(server) {
1888
2758
  }
1889
2759
  return textResult({ participants: rows, ...meta });
1890
2760
  });
2761
+ server.registerTool("oko_add_nmo_participant", {
2762
+ title: "Add NMO participant",
2763
+ description: toolDescription({
2764
+ summary: "Add or update one NMO participant for an event through OKO organizer import.",
2765
+ args: ["eventId: NMO event.", "email/fullName/position/org: participant fields."],
2766
+ returns: "eventId, summary, and per-row import result.",
2767
+ examples: ["Use when the organizer dictates one doctor/participant to add before the webinar."],
2768
+ notes: ["This does not return a participant login token; use registration/login URLs from oko_get_event for attendee access."],
2769
+ }),
2770
+ inputSchema: { eventId: eventIdField, participant: nmoParticipantInputSchema },
2771
+ outputSchema: nmoImportOutputSchema,
2772
+ annotations: writeTool,
2773
+ }, async ({ eventId, participant }) => {
2774
+ const { uid } = await authed(["nmo:write"]);
2775
+ await readEvent(eventId, uid, "edit");
2776
+ const importFn = httpsCallable(functions, "importNmoParticipants");
2777
+ const result = await importFn({
2778
+ eventId,
2779
+ participants: [{
2780
+ email: normalizeEmail(participant.email),
2781
+ fullName: participant.fullName.trim(),
2782
+ position: normalizeOptionalText(participant.position, 200),
2783
+ org: normalizeOptionalText(participant.org, 200),
2784
+ }],
2785
+ });
2786
+ const data = serialize(result.data);
2787
+ const results = Array.isArray(data.results) ? data.results : [];
2788
+ const first = results[0];
2789
+ if (!first || first.ok !== true) {
2790
+ throw new Error(`NMO participant import failed: ${safeString(first?.error, "unknown error")}`);
2791
+ }
2792
+ return textResult({ eventId, ...data });
2793
+ });
2794
+ server.registerTool("oko_import_nmo_participants", {
2795
+ title: "Import NMO participants",
2796
+ description: toolDescription({
2797
+ summary: "Bulk import NMO participants from structured rows or CSV text.",
2798
+ args: [
2799
+ "eventId: NMO event.",
2800
+ "participants: optional array of rows { email, fullName, position?, org? }.",
2801
+ "csv: optional CSV text with headers email, fullName, position, org.",
2802
+ ],
2803
+ returns: "eventId, summary, and per-row results with ok/error.",
2804
+ examples: ["Use csv when the organizer pastes a participant list from a spreadsheet."],
2805
+ notes: ["Provide exactly one of participants or csv. Max 200 rows. The callable returns partial success for bulk imports."],
2806
+ }),
2807
+ inputSchema: {
2808
+ eventId: eventIdField,
2809
+ participants: z.array(nmoParticipantInputSchema).max(MAX_NMO_IMPORT_PARTICIPANTS).optional(),
2810
+ csv: z.string().max(80_000).optional().describe("CSV with headers email, fullName, position, org."),
2811
+ },
2812
+ outputSchema: nmoImportOutputSchema,
2813
+ annotations: writeTool,
2814
+ }, async ({ eventId, participants, csv }) => {
2815
+ const { uid } = await authed(["nmo:write"]);
2816
+ await readEvent(eventId, uid, "edit");
2817
+ if (Boolean(participants) === Boolean(csv))
2818
+ throw new Error("Provide exactly one of participants or csv.");
2819
+ const rows = participants ?? parseNmoParticipantsCsv(csv ?? "");
2820
+ if (rows.length === 0 || rows.length > MAX_NMO_IMPORT_PARTICIPANTS)
2821
+ throw new Error(`Import supports 1-${MAX_NMO_IMPORT_PARTICIPANTS} participants.`);
2822
+ const importFn = httpsCallable(functions, "importNmoParticipants");
2823
+ const result = await importFn({
2824
+ eventId,
2825
+ participants: rows.map((row) => ({
2826
+ email: normalizeEmail(row.email),
2827
+ fullName: row.fullName.trim(),
2828
+ position: normalizeOptionalText(row.position, 200),
2829
+ org: normalizeOptionalText(row.org, 200),
2830
+ })),
2831
+ });
2832
+ return textResult({ eventId, ...serialize(result.data) });
2833
+ });
2834
+ server.registerTool("oko_remove_nmo_participant", {
2835
+ title: "Remove NMO participant",
2836
+ description: toolDescription({
2837
+ summary: "Remove an NMO participant and immediately revoke event access.",
2838
+ args: ["eventId: NMO event.", "uid or email: participant to remove.", "confirm: must be true."],
2839
+ returns: "eventId, participant uid/email, and removed=true.",
2840
+ examples: ["Use after listing participants and confirming the exact participant."],
2841
+ notes: ["Order is intentional: arrayRemove from events.participantUids first, then delete participant doc."],
2842
+ }),
2843
+ inputSchema: {
2844
+ eventId: eventIdField,
2845
+ uid: z.string().min(1).optional().describe("Participant Firebase uid."),
2846
+ email: emailSchema.optional().describe("Participant email; used to resolve uid if uid is omitted."),
2847
+ confirm: z.boolean().optional().describe("Required true for participant removal."),
2848
+ },
2849
+ outputSchema: z.object({ eventId: z.string(), uid: z.string(), removed: z.boolean() }).passthrough(),
2850
+ annotations: destructiveTool,
2851
+ }, async ({ eventId, uid: participantUid, email, confirm }) => {
2852
+ const { uid } = await authed(["nmo:write"]);
2853
+ await readEvent(eventId, uid, "edit");
2854
+ let targetUid = participantUid;
2855
+ const normalizedEmail = email ? normalizeEmail(email) : null;
2856
+ if (!targetUid && normalizedEmail) {
2857
+ const snap = await getDocs(query(collection(firestore, "eventParticipants", eventId, "items"), where("email", "==", normalizedEmail), limit(2)));
2858
+ if (snap.empty)
2859
+ throw new Error(`NMO participant with email ${normalizedEmail} not found.`);
2860
+ if (snap.docs.length > 1)
2861
+ throw new Error(`Multiple NMO participants found for email ${normalizedEmail}; use uid.`);
2862
+ targetUid = snap.docs[0].id;
2863
+ }
2864
+ if (!targetUid)
2865
+ throw new Error("Provide uid or email.");
2866
+ requireConfirm(confirm, "oko_remove_nmo_participant", { eventId, uid: targetUid, email: normalizedEmail });
2867
+ await updateDoc(doc(firestore, "events", eventId), { participantUids: arrayRemove(targetUid) });
2868
+ await deleteDoc(doc(firestore, "eventParticipants", eventId, "items", targetUid));
2869
+ return textResult({ eventId, uid: targetUid, email: normalizedEmail, removed: true });
2870
+ });
1891
2871
  server.registerTool("oko_list_leads", {
1892
2872
  title: "List form leads",
1893
2873
  description: toolDescription({
@@ -1930,6 +2910,116 @@ export function registerTools(server) {
1930
2910
  }
1931
2911
  return textResult({ leads, ...meta });
1932
2912
  });
2913
+ server.registerTool("oko_list_telegram_channels", {
2914
+ title: "List Telegram channels",
2915
+ description: toolDescription({
2916
+ summary: "List Telegram channels available to the authenticated organizer.",
2917
+ args: ["includeWorkspaceChannels: include channels exposed through visible workspaces, default true."],
2918
+ returns: "channels with chatId, title, username, source, and visible workspace metadata.",
2919
+ examples: ["Use before oko_publish_telegram_post so the user can choose a channel by title instead of numeric id."],
2920
+ }),
2921
+ inputSchema: { includeWorkspaceChannels: z.boolean().default(true).describe("Include workspace-level channels visible to this organizer.") },
2922
+ outputSchema: telegramChannelsOutputSchema,
2923
+ annotations: readOnlyTool,
2924
+ }, async ({ includeWorkspaceChannels }) => {
2925
+ const { uid } = await authed(["telegram:write"]);
2926
+ const channels = await readTelegramChannels(uid, includeWorkspaceChannels);
2927
+ return textResult({ channels, count: channels.length });
2928
+ });
2929
+ server.registerTool("oko_claim_telegram_channel", {
2930
+ title: "Claim Telegram channel",
2931
+ description: toolDescription({
2932
+ summary: "Claim a pending Telegram channel using the code sent by OKO bot.",
2933
+ args: [
2934
+ "claimCode: code posted by the bot after it was added to a channel.",
2935
+ "workspaceIds: optional workspaces to expose this channel to after claim.",
2936
+ ],
2937
+ returns: "claimed channel and workspace ids updated.",
2938
+ examples: ["Add @OKOStreamBot to a Telegram channel, copy the claim code, then call this tool."],
2939
+ notes: ["Workspace sync requires the authenticated organizer to own each workspace."],
2940
+ }),
2941
+ inputSchema: {
2942
+ claimCode: z.string().min(4).max(32).describe("Telegram channel claim code."),
2943
+ workspaceIds: z.array(z.string().min(1)).max(20).default([]).describe("Optional workspace ids to attach the channel to."),
2944
+ },
2945
+ outputSchema: z.object({ channel: unknownRecordSchema, workspaceIdsUpdated: z.array(z.string()) }).passthrough(),
2946
+ annotations: writeTool,
2947
+ }, async ({ claimCode, workspaceIds }) => {
2948
+ const { uid } = await authed(["telegram:write"]);
2949
+ const claimChannel = httpsCallable(functions, "claimTelegramChannel");
2950
+ const result = await claimChannel({ code: claimCode.trim().toUpperCase() });
2951
+ const data = serialize(result.data);
2952
+ const channelRaw = data.channel && typeof data.channel === "object" ? data.channel : {};
2953
+ const parsed = telegramChannelSchema.safeParse(channelRaw);
2954
+ if (!parsed.success)
2955
+ throw new Error("OKO claimTelegramChannel returned an invalid channel payload.");
2956
+ const workspaceIdsUpdated = await syncTelegramChannelToWorkspaces(uid, parsed.data, workspaceIds);
2957
+ return textResult({ ...data, channel: parsed.data, workspaceIdsUpdated });
2958
+ });
2959
+ server.registerTool("oko_list_telegram_posts", {
2960
+ title: "List Telegram posts",
2961
+ description: toolDescription({
2962
+ summary: "List Telegram notification posts for an event.",
2963
+ args: [
2964
+ "eventId: event whose Telegram posts to list.",
2965
+ "status: optional client-side status filter.",
2966
+ "limit: max posts, default 30, max 100.",
2967
+ "cursor: createdAt cursor returned by previous page.",
2968
+ ],
2969
+ returns: "posts plus cursor pagination metadata.",
2970
+ examples: ["Use to review scheduled/sent/failed Telegram posts before canceling or cloning."],
2971
+ }),
2972
+ inputSchema: {
2973
+ eventId: eventIdField,
2974
+ status: z.enum(TELEGRAM_POST_STATUSES).optional().describe("Optional post status filter."),
2975
+ limit: limitField(),
2976
+ cursor: cursorField,
2977
+ },
2978
+ outputSchema: telegramPostsOutputSchema,
2979
+ annotations: readOnlyTool,
2980
+ }, async ({ eventId, status, limit: max, cursor }) => {
2981
+ const { uid } = await authed(["telegram:write"]);
2982
+ await readEvent(eventId, uid, "read");
2983
+ const constraints = [
2984
+ where("eventId", "==", eventId),
2985
+ orderBy("createdAt", "desc"),
2986
+ limit(max + 1),
2987
+ ];
2988
+ if (cursor)
2989
+ constraints.splice(2, 0, startAfter(parseTimestampCursor(cursor)));
2990
+ const snap = await getDocs(query(collection(firestore, "telegramPosts"), ...constraints));
2991
+ const { page, hasMore } = pickPage(snap.docs, max);
2992
+ let posts = page.map((item) => ({ id: item.id, ...serialize(item.data()) }));
2993
+ if (status)
2994
+ posts = posts.filter((post) => post.status === status);
2995
+ const nextCursor = hasMore ? timestampCursorFrom(page.at(-1)?.data().createdAt) : null;
2996
+ return textResult({ posts, ...cursorMeta(posts.length, max, hasMore, nextCursor) });
2997
+ });
2998
+ server.registerTool("oko_cancel_telegram_post", {
2999
+ title: "Cancel Telegram post",
3000
+ description: toolDescription({
3001
+ summary: "Delete a draft/scheduled/failed Telegram post record before it is sent.",
3002
+ args: ["postId: telegramPosts document id.", "confirm: must be true."],
3003
+ returns: "postId, eventId, status, and canceled=true.",
3004
+ examples: ["Use for scheduled posts that should not be sent."],
3005
+ notes: ["Already sent Telegram messages cannot be unsent through OKO; this tool refuses status=sent."],
3006
+ }),
3007
+ inputSchema: { postId: z.string().min(1), confirm: z.boolean().optional().describe("Required true for cancellation.") },
3008
+ outputSchema: z.object({ postId: z.string(), canceled: z.boolean() }).passthrough(),
3009
+ annotations: destructiveTool,
3010
+ }, async ({ postId, confirm }) => {
3011
+ await authed(["telegram:write"]);
3012
+ const postRef = doc(firestore, "telegramPosts", postId);
3013
+ const postSnap = await getDoc(postRef);
3014
+ if (!postSnap.exists())
3015
+ throw new Error("Telegram post not found.");
3016
+ const post = postSnap.data();
3017
+ if (post.status === "sent")
3018
+ throw new Error("Telegram post was already sent and cannot be canceled through OKO.");
3019
+ requireConfirm(confirm, "oko_cancel_telegram_post", { postId, eventId: post.eventId, status: post.status, caption: post.caption });
3020
+ await deleteDoc(postRef);
3021
+ return textResult({ postId, eventId: post.eventId, status: post.status, canceled: true });
3022
+ });
1933
3023
  server.registerTool("oko_publish_telegram_post", {
1934
3024
  title: "Publish Telegram post",
1935
3025
  description: toolDescription({
@@ -1992,45 +3082,41 @@ export function registerTools(server) {
1992
3082
  summary: "Create a workspace invite for a known OKO workspace.",
1993
3083
  args: [
1994
3084
  "workspaceId: workspace owned by the authenticated organizer.",
1995
- "code: invite code, 4-64 chars; stored uppercase.",
3085
+ "code: optional invite code, 4-64 chars; omitted means generated.",
1996
3086
  "email: optional intended recipient email.",
1997
3087
  "permissions: optional whitelist of workspace permission booleans.",
1998
3088
  "confirm: must be true.",
1999
3089
  ],
2000
- returns: "workspaceId, invite code, and created=true.",
3090
+ returns: "workspaceId, invite code, inviteUrl, and created=true.",
2001
3091
  examples: ["Use only after confirming the recipient and permission set with the workspace owner."],
2002
3092
  }),
2003
3093
  inputSchema: {
2004
3094
  workspaceId: z.string().min(1),
2005
- code: z.string().min(4).max(64),
3095
+ code: z.string().min(4).max(MAX_WORKSPACE_INVITE_CODE_LENGTH).optional(),
2006
3096
  email: z.string().email().optional(),
2007
3097
  comment: z.string().max(500).default(""),
2008
3098
  permissions: workspacePermissionsSchema,
2009
3099
  confirm: z.boolean().optional(),
2010
3100
  },
2011
- outputSchema: z.object({ workspaceId: z.string(), code: z.string(), created: z.boolean() }).passthrough(),
3101
+ outputSchema: z.object({ workspaceId: z.string(), code: z.string(), inviteUrl: z.string().url(), created: z.boolean() }).passthrough(),
2012
3102
  annotations: writeTool,
2013
3103
  }, async (args) => {
2014
3104
  const { uid } = await authed(["workspace:write"]);
2015
- const workspaceSnap = await getDoc(doc(firestore, "workspaces", args.workspaceId));
2016
- if (!workspaceSnap.exists())
2017
- throw new Error("Workspace not found.");
2018
- const workspace = workspaceSnap.data();
2019
- if (workspace.ownerId !== uid)
2020
- throw new Error("Only the workspace owner can create invites.");
3105
+ const workspace = await readWorkspaceForOwner(args.workspaceId, uid);
2021
3106
  requireConfirm(args.confirm, "oko_create_workspace_invite", { workspaceId: args.workspaceId, code: args.code, email: args.email });
2022
- const code = args.code.trim().toUpperCase();
3107
+ const code = (args.code?.trim() || randomInviteCode()).toUpperCase();
3108
+ const inviteUrl = `https://okostream.app/invite/${code}`;
2023
3109
  await setDoc(doc(firestore, "workspaceInvites", code), {
2024
3110
  workspaceId: args.workspaceId,
2025
3111
  workspaceName: safeString(workspace.name, "Workspace"),
2026
3112
  email: args.email ?? "",
2027
3113
  comment: args.comment,
2028
- permissions: args.permissions,
3114
+ permissions: normalizeWorkspacePermissions(args.permissions),
2029
3115
  createdBy: uid,
2030
3116
  createdAt: serverTimestamp(),
2031
3117
  claimed: false,
2032
3118
  claimedBy: null,
2033
3119
  });
2034
- return textResult({ workspaceId: args.workspaceId, code, created: true });
3120
+ return textResult({ workspaceId: args.workspaceId, code, inviteUrl, created: true });
2035
3121
  });
2036
3122
  }