@okrlinkhub/agent-factory 3.0.0 → 3.0.2

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.
Files changed (39) hide show
  1. package/dist/client/index.d.ts +42 -14
  2. package/dist/client/index.d.ts.map +1 -1
  3. package/dist/client/index.js +68 -2
  4. package/dist/client/index.js.map +1 -1
  5. package/dist/component/_generated/api.d.ts +2 -0
  6. package/dist/component/_generated/api.d.ts.map +1 -1
  7. package/dist/component/_generated/api.js.map +1 -1
  8. package/dist/component/_generated/component.d.ts +114 -0
  9. package/dist/component/_generated/component.d.ts.map +1 -1
  10. package/dist/component/lib.d.ts +2 -1
  11. package/dist/component/lib.d.ts.map +1 -1
  12. package/dist/component/lib.js +2 -1
  13. package/dist/component/lib.js.map +1 -1
  14. package/dist/component/messageTemplates.d.ts +37 -0
  15. package/dist/component/messageTemplates.d.ts.map +1 -0
  16. package/dist/component/messageTemplates.js +177 -0
  17. package/dist/component/messageTemplates.js.map +1 -0
  18. package/dist/component/pushing.d.ts +14 -14
  19. package/dist/component/queue.d.ts +33 -0
  20. package/dist/component/queue.d.ts.map +1 -1
  21. package/dist/component/queue.js +89 -2
  22. package/dist/component/queue.js.map +1 -1
  23. package/dist/component/scheduler.d.ts.map +1 -1
  24. package/dist/component/scheduler.js +37 -10
  25. package/dist/component/scheduler.js.map +1 -1
  26. package/dist/component/schema.d.ts +34 -8
  27. package/dist/component/schema.d.ts.map +1 -1
  28. package/dist/component/schema.js +14 -0
  29. package/dist/component/schema.js.map +1 -1
  30. package/package.json +1 -1
  31. package/src/client/index.ts +69 -2
  32. package/src/component/_generated/api.ts +2 -0
  33. package/src/component/_generated/component.ts +158 -0
  34. package/src/component/lib.test.ts +216 -1
  35. package/src/component/lib.ts +8 -0
  36. package/src/component/messageTemplates.ts +205 -0
  37. package/src/component/queue.ts +110 -2
  38. package/src/component/scheduler.ts +77 -14
  39. package/src/component/schema.ts +15 -0
@@ -583,6 +583,20 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
583
583
  },
584
584
  Name
585
585
  >;
586
+ createMessageTemplate: FunctionReference<
587
+ "mutation",
588
+ "internal",
589
+ {
590
+ actorUserId: string;
591
+ enabled?: boolean;
592
+ nowMs?: number;
593
+ tags: Array<string>;
594
+ text: string;
595
+ title: string;
596
+ },
597
+ string,
598
+ Name
599
+ >;
586
600
  createPairingCode: FunctionReference<
587
601
  "mutation",
588
602
  "internal",
@@ -740,6 +754,13 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
740
754
  { message: string; ok: boolean; status: number },
741
755
  Name
742
756
  >;
757
+ deleteMessageTemplate: FunctionReference<
758
+ "mutation",
759
+ "internal",
760
+ { templateId: string },
761
+ boolean,
762
+ Name
763
+ >;
743
764
  deletePushJob: FunctionReference<
744
765
  "mutation",
745
766
  "internal",
@@ -1253,6 +1274,25 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1253
1274
  { secretId: string; secretRef: string; version: number },
1254
1275
  Name
1255
1276
  >;
1277
+ listMessageTemplatesByCompany: FunctionReference<
1278
+ "query",
1279
+ "internal",
1280
+ { includeDisabled?: boolean; limit?: number },
1281
+ Array<{
1282
+ _id: string;
1283
+ createdAt: number;
1284
+ createdBy: string;
1285
+ enabled: boolean;
1286
+ tags: Array<string>;
1287
+ templateKey: string;
1288
+ text: string;
1289
+ title: string;
1290
+ updatedAt: number;
1291
+ updatedBy: string;
1292
+ usageCount: number;
1293
+ }>,
1294
+ Name
1295
+ >;
1256
1296
  listPushDispatchesByJob: FunctionReference<
1257
1297
  "query",
1258
1298
  "internal",
@@ -1632,6 +1672,29 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1632
1672
  },
1633
1673
  Name
1634
1674
  >;
1675
+ sendMessageTemplateToUserAgent: FunctionReference<
1676
+ "mutation",
1677
+ "internal",
1678
+ {
1679
+ agentKey: string;
1680
+ consumerUserId: string;
1681
+ metadata?: Record<string, string>;
1682
+ nowMs?: number;
1683
+ providerConfig?: {
1684
+ appName: string;
1685
+ image: string;
1686
+ kind: "fly" | "runpod" | "ecs";
1687
+ organizationSlug: string;
1688
+ region: string;
1689
+ volumeName: string;
1690
+ volumePath: string;
1691
+ volumeSizeGb: number;
1692
+ };
1693
+ templateId: string;
1694
+ },
1695
+ { messageId: string; usageCount: number },
1696
+ Name
1697
+ >;
1635
1698
  sendMessageToUserAgent: FunctionReference<
1636
1699
  "mutation",
1637
1700
  "internal",
@@ -1736,6 +1799,21 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1736
1799
  { enqueuedMessageId: string; runKey: string },
1737
1800
  Name
1738
1801
  >;
1802
+ updateMessageTemplate: FunctionReference<
1803
+ "mutation",
1804
+ "internal",
1805
+ {
1806
+ actorUserId: string;
1807
+ enabled?: boolean;
1808
+ nowMs?: number;
1809
+ tags?: Array<string>;
1810
+ templateId: string;
1811
+ text?: string;
1812
+ title?: string;
1813
+ },
1814
+ boolean,
1815
+ Name
1816
+ >;
1739
1817
  updatePushJob: FunctionReference<
1740
1818
  "mutation",
1741
1819
  "internal",
@@ -1817,6 +1895,63 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
1817
1895
  Name
1818
1896
  >;
1819
1897
  };
1898
+ messageTemplates: {
1899
+ createMessageTemplate: FunctionReference<
1900
+ "mutation",
1901
+ "internal",
1902
+ {
1903
+ actorUserId: string;
1904
+ enabled?: boolean;
1905
+ nowMs?: number;
1906
+ tags: Array<string>;
1907
+ text: string;
1908
+ title: string;
1909
+ },
1910
+ string,
1911
+ Name
1912
+ >;
1913
+ deleteMessageTemplate: FunctionReference<
1914
+ "mutation",
1915
+ "internal",
1916
+ { templateId: string },
1917
+ boolean,
1918
+ Name
1919
+ >;
1920
+ listMessageTemplatesByCompany: FunctionReference<
1921
+ "query",
1922
+ "internal",
1923
+ { includeDisabled?: boolean; limit?: number },
1924
+ Array<{
1925
+ _id: string;
1926
+ createdAt: number;
1927
+ createdBy: string;
1928
+ enabled: boolean;
1929
+ tags: Array<string>;
1930
+ templateKey: string;
1931
+ text: string;
1932
+ title: string;
1933
+ updatedAt: number;
1934
+ updatedBy: string;
1935
+ usageCount: number;
1936
+ }>,
1937
+ Name
1938
+ >;
1939
+ updateMessageTemplate: FunctionReference<
1940
+ "mutation",
1941
+ "internal",
1942
+ {
1943
+ actorUserId: string;
1944
+ enabled?: boolean;
1945
+ nowMs?: number;
1946
+ tags?: Array<string>;
1947
+ templateId: string;
1948
+ text?: string;
1949
+ title?: string;
1950
+ },
1951
+ boolean,
1952
+ Name
1953
+ >;
1954
+ };
1820
1955
  providers: {
1821
1956
  fly: {
1822
1957
  deleteFlyVolumeManual: FunctionReference<
@@ -2949,6 +3084,29 @@ export type ComponentApi<Name extends string | undefined = string | undefined> =
2949
3084
  { requeued: number; unlocked: number },
2950
3085
  Name
2951
3086
  >;
3087
+ sendMessageTemplateToUserAgent: FunctionReference<
3088
+ "mutation",
3089
+ "internal",
3090
+ {
3091
+ agentKey: string;
3092
+ consumerUserId: string;
3093
+ metadata?: Record<string, string>;
3094
+ nowMs?: number;
3095
+ providerConfig?: {
3096
+ appName: string;
3097
+ image: string;
3098
+ kind: "fly" | "runpod" | "ecs";
3099
+ organizationSlug: string;
3100
+ region: string;
3101
+ volumeName: string;
3102
+ volumePath: string;
3103
+ volumeSizeGb: number;
3104
+ };
3105
+ templateId: string;
3106
+ },
3107
+ { messageId: string; usageCount: number },
3108
+ Name
3109
+ >;
2952
3110
  sendMessageToUserAgent: FunctionReference<
2953
3111
  "mutation",
2954
3112
  "internal",
@@ -201,6 +201,131 @@ describe("component lib", () => {
201
201
  expect(claim?.payload.messageText).toBe("hello");
202
202
  });
203
203
 
204
+ test("message templates should normalize tags and auto-generate key", async () => {
205
+ const t = initConvexTest();
206
+
207
+ const templateId = await t.mutation(api.lib.createMessageTemplate, {
208
+ title: " Status Update ",
209
+ text: " Share your latest progress. ",
210
+ tags: [" Urgent ", "follow-up", "urgent", " Team "],
211
+ actorUserId: "user-admin-1",
212
+ });
213
+
214
+ expect(templateId).toBeDefined();
215
+
216
+ const templates = await t.query(api.lib.listMessageTemplatesByCompany, {
217
+ includeDisabled: true,
218
+ });
219
+
220
+ expect(templates).toHaveLength(1);
221
+ expect(templates[0]).toMatchObject({
222
+ templateKey: "status-update",
223
+ title: "Status Update",
224
+ text: "Share your latest progress.",
225
+ tags: ["follow-up", "team", "urgent"],
226
+ usageCount: 0,
227
+ enabled: true,
228
+ });
229
+ });
230
+
231
+ test("message templates should generate a unique key globally", async () => {
232
+ const t = initConvexTest();
233
+
234
+ const firstTemplateId = await t.mutation(api.lib.createMessageTemplate, {
235
+ title: "Daily check-in",
236
+ text: "Share your update.",
237
+ tags: [],
238
+ actorUserId: "user-admin-1",
239
+ });
240
+
241
+ const secondTemplateId = await t.mutation(api.lib.createMessageTemplate, {
242
+ title: "Daily check-in",
243
+ text: "Duplicate title",
244
+ tags: [],
245
+ actorUserId: "user-admin-2",
246
+ });
247
+
248
+ const templates = await t.query(api.lib.listMessageTemplatesByCompany, {
249
+ includeDisabled: true,
250
+ });
251
+
252
+ expect(firstTemplateId).toBeDefined();
253
+ expect(secondTemplateId).toBeDefined();
254
+ expect(templates.map((template) => template.templateKey).sort()).toEqual([
255
+ "daily-check-in",
256
+ "daily-check-in-2",
257
+ ]);
258
+ });
259
+
260
+ test("message templates should track usage and surface most used templates first", async () => {
261
+ const t = initConvexTest();
262
+ await t.mutation(api.queue.upsertAgentProfile, {
263
+ agentKey: "template-agent",
264
+ version: "1.0.0",
265
+ secretsRef: [],
266
+ enabled: true,
267
+ });
268
+ await t.mutation(api.lib.bindUserAgent, {
269
+ consumerUserId: "user-template-1",
270
+ agentKey: "template-agent",
271
+ source: "manual",
272
+ metadata: { companyId: "co-templates" },
273
+ });
274
+
275
+ const firstTemplateId = await t.mutation(api.lib.createMessageTemplate, {
276
+ title: "Weekly recap",
277
+ text: "Condividi il recap della settimana.",
278
+ tags: ["weekly", "recap"],
279
+ actorUserId: "user-admin-1",
280
+ });
281
+ const secondTemplateId = await t.mutation(api.lib.createMessageTemplate, {
282
+ title: "Quick nudge",
283
+ text: "Mandami un aggiornamento rapido.",
284
+ tags: ["follow-up"],
285
+ actorUserId: "user-admin-1",
286
+ });
287
+
288
+ await t.mutation((api.lib as any).sendMessageTemplateToUserAgent, {
289
+ consumerUserId: "user-template-1",
290
+ agentKey: "template-agent",
291
+ templateId: firstTemplateId,
292
+ });
293
+ await t.mutation((api.lib as any).sendMessageTemplateToUserAgent, {
294
+ consumerUserId: "user-template-1",
295
+ agentKey: "template-agent",
296
+ templateId: firstTemplateId,
297
+ });
298
+ await t.mutation((api.lib as any).sendMessageTemplateToUserAgent, {
299
+ consumerUserId: "user-template-1",
300
+ agentKey: "template-agent",
301
+ templateId: secondTemplateId,
302
+ });
303
+
304
+ const templates = await t.query(api.lib.listMessageTemplatesByCompany, {
305
+ includeDisabled: true,
306
+ });
307
+
308
+ expect(templates.map((template) => ({
309
+ title: template.title,
310
+ usageCount: template.usageCount,
311
+ }))).toEqual([
312
+ { title: "Weekly recap", usageCount: 2 },
313
+ { title: "Quick nudge", usageCount: 1 },
314
+ ]);
315
+
316
+ const queuedItems = await t.query(api.lib.listQueueItemsForUserAgent, {
317
+ consumerUserId: "user-template-1",
318
+ agentKey: "template-agent",
319
+ limit: 10,
320
+ });
321
+ expect(queuedItems).toHaveLength(3);
322
+ expect(queuedItems.map((item) => item.payload.messageText).sort()).toEqual([
323
+ "Condividi il recap della settimana.",
324
+ "Condividi il recap della settimana.",
325
+ "Mandami un aggiornamento rapido.",
326
+ ].sort());
327
+ });
328
+
204
329
  test("message runtime config should store telegram attachment retention", async () => {
205
330
  const t = initConvexTest();
206
331
  await t.mutation(api.lib.setMessageRuntimeConfig, {
@@ -1123,6 +1248,14 @@ describe("component lib", () => {
1123
1248
  const t = initConvexTest();
1124
1249
  const nowMs = Date.UTC(2026, 0, 1, 17, 0, 0);
1125
1250
  vi.setSystemTime(nowMs);
1251
+ let machineCreateBody:
1252
+ | {
1253
+ name?: string;
1254
+ config?: {
1255
+ env?: Record<string, string>;
1256
+ };
1257
+ }
1258
+ | null = null;
1126
1259
  const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
1127
1260
  const url = String(input);
1128
1261
  const method = init?.method ?? "GET";
@@ -1148,7 +1281,13 @@ describe("component lib", () => {
1148
1281
  });
1149
1282
  }
1150
1283
  if (url.endsWith(`/apps/${TEST_PROVIDER_CONFIG.appName}/machines`) && method === "POST") {
1151
- const body = JSON.parse(String(init?.body ?? "{}")) as { name?: string };
1284
+ const body = JSON.parse(String(init?.body ?? "{}")) as {
1285
+ name?: string;
1286
+ config?: {
1287
+ env?: Record<string, string>;
1288
+ };
1289
+ };
1290
+ machineCreateBody = body;
1152
1291
  return jsonResponse({
1153
1292
  id: "machine-new-worker",
1154
1293
  name: body.name,
@@ -1224,6 +1363,28 @@ describe("component lib", () => {
1224
1363
 
1225
1364
  expect(reconcile.spawned).toBe(1);
1226
1365
  expect(reconcile.activeWorkers).toBe(2);
1366
+ const machineCreateEnv = (
1367
+ machineCreateBody as
1368
+ | {
1369
+ config?: {
1370
+ env?: Record<string, string>;
1371
+ };
1372
+ }
1373
+ | null
1374
+ )?.config?.env;
1375
+ expect(machineCreateEnv?.OPENCLAW_CONVERSATION_ID).toBe("telegram:chat:spawn-b");
1376
+ expect(machineCreateEnv?.OPENCLAW_AGENT_KEY).toBe("support-agent");
1377
+
1378
+ const workers = await t.query((internal.queue as any).listWorkersForScheduler, {});
1379
+ const spawnedWorker = workers.find(
1380
+ (row: { workerId: string }) => row.workerId === `afw-${nowMs}-0`,
1381
+ );
1382
+ expect(spawnedWorker?.assignment).toEqual({
1383
+ conversationId: "telegram:chat:spawn-b",
1384
+ agentKey: "support-agent",
1385
+ leaseId: `spawn:afw-${nowMs}-0`,
1386
+ assignedAt: nowMs,
1387
+ });
1227
1388
  });
1228
1389
 
1229
1390
  test("scheduler should forward OpenClaw bridge env to spawned machines", async () => {
@@ -1498,6 +1659,60 @@ describe("component lib", () => {
1498
1659
  });
1499
1660
  });
1500
1661
 
1662
+ test("preassigned workers should claim only their assigned conversation on first claim", async () => {
1663
+ const t = initConvexTest();
1664
+ const nowMs = Date.UTC(2026, 0, 1, 17, 35, 0);
1665
+ vi.setSystemTime(nowMs);
1666
+ await t.mutation(api.queue.upsertAgentProfile, {
1667
+ agentKey: "support-agent",
1668
+ version: "1.0.0",
1669
+ secretsRef: [],
1670
+ enabled: true,
1671
+ });
1672
+
1673
+ await t.mutation(api.queue.enqueueMessage, {
1674
+ conversationId: "telegram:chat:preassign-a",
1675
+ agentKey: "support-agent",
1676
+ payload: {
1677
+ provider: "telegram",
1678
+ providerUserId: "u-preassign-a",
1679
+ messageText: "first",
1680
+ },
1681
+ nowMs,
1682
+ });
1683
+ const messageB = await t.mutation(api.queue.enqueueMessage, {
1684
+ conversationId: "telegram:chat:preassign-b",
1685
+ agentKey: "support-agent",
1686
+ payload: {
1687
+ provider: "telegram",
1688
+ providerUserId: "u-preassign-b",
1689
+ messageText: "second",
1690
+ },
1691
+ nowMs: nowMs + 1,
1692
+ });
1693
+
1694
+ await t.mutation(internal.queue.upsertWorkerState, {
1695
+ workerId: "worker-preassigned-1",
1696
+ provider: "fly",
1697
+ status: "active",
1698
+ load: 0,
1699
+ nowMs,
1700
+ assignment: {
1701
+ conversationId: "telegram:chat:preassign-b",
1702
+ agentKey: "support-agent",
1703
+ leaseId: "spawn:worker-preassigned-1",
1704
+ assignedAt: nowMs,
1705
+ },
1706
+ });
1707
+
1708
+ const claim = await t.mutation(api.lib.claim, {
1709
+ workerId: "worker-preassigned-1",
1710
+ nowMs: nowMs + 10,
1711
+ });
1712
+ expect(claim?.messageId).toBe(messageB);
1713
+ expect(claim?.conversationId).toBe("telegram:chat:preassign-b");
1714
+ });
1715
+
1501
1716
  test("exclusive ownership should block another worker and let the owner reclaim", async () => {
1502
1717
  const t = initConvexTest();
1503
1718
  const nowMs = Date.UTC(2026, 0, 1, 17, 40, 0);
@@ -23,6 +23,7 @@ export {
23
23
  listQueueItemsForUserAgent,
24
24
  getConversationViewForUserAgent,
25
25
  sendMessageToUserAgent,
26
+ sendMessageTemplateToUserAgent,
26
27
  listSnapshotsForConversation,
27
28
  listSnapshotsForUserAgent,
28
29
  getLatestSnapshotForUserAgent,
@@ -59,6 +60,13 @@ export {
59
60
  getWebhookReadiness,
60
61
  } from "./identity.js";
61
62
 
63
+ export {
64
+ createMessageTemplate,
65
+ updateMessageTemplate,
66
+ deleteMessageTemplate,
67
+ listMessageTemplatesByCompany,
68
+ } from "./messageTemplates.js";
69
+
62
70
  export {
63
71
  createPushTemplate,
64
72
  updatePushTemplate,
@@ -0,0 +1,205 @@
1
+ import { v } from "convex/values";
2
+ import { mutation, query } from "./_generated/server.js";
3
+ import type { MutationCtx } from "./_generated/server.js";
4
+
5
+ const messageTemplateViewValidator = v.object({
6
+ _id: v.id("messageTemplates"),
7
+ templateKey: v.string(),
8
+ title: v.string(),
9
+ text: v.string(),
10
+ tags: v.array(v.string()),
11
+ usageCount: v.number(),
12
+ enabled: v.boolean(),
13
+ createdBy: v.string(),
14
+ updatedBy: v.string(),
15
+ createdAt: v.number(),
16
+ updatedAt: v.number(),
17
+ });
18
+
19
+ function normalizeTemplateKey(value: string) {
20
+ return value.trim().toLowerCase();
21
+ }
22
+
23
+ function buildTemplateKeyBase(title: string) {
24
+ const normalized = title
25
+ .trim()
26
+ .toLowerCase()
27
+ .replace(/[^a-z0-9]+/g, "-")
28
+ .replace(/^-+|-+$/g, "");
29
+ return normalized || "message-template";
30
+ }
31
+
32
+ function normalizeTitle(value: string) {
33
+ return value.trim();
34
+ }
35
+
36
+ function normalizeText(value: string) {
37
+ return value.trim();
38
+ }
39
+
40
+ function normalizeTags(tags: Array<string>) {
41
+ return Array.from(
42
+ new Set(
43
+ tags
44
+ .map((tag) => tag.trim().toLowerCase())
45
+ .filter((tag) => tag.length > 0),
46
+ ),
47
+ ).sort((left, right) => left.localeCompare(right));
48
+ }
49
+
50
+ function validateMessageTemplateFields(input: {
51
+ title?: string;
52
+ text?: string;
53
+ }) {
54
+ if (input.title !== undefined && input.title.length === 0) {
55
+ throw new Error("Template title is required");
56
+ }
57
+ if (input.text !== undefined && input.text.length === 0) {
58
+ throw new Error("Template text is required");
59
+ }
60
+ }
61
+
62
+ async function resolveUniqueTemplateKey(
63
+ ctx: MutationCtx,
64
+ title: string,
65
+ currentTemplateId?: string,
66
+ ) {
67
+ const baseKey = buildTemplateKeyBase(title);
68
+ let candidate = baseKey;
69
+ let suffix = 2;
70
+
71
+ while (true) {
72
+ const existing = await ctx.db
73
+ .query("messageTemplates")
74
+ .withIndex("by_templateKey", (q) => q.eq("templateKey", candidate))
75
+ .unique();
76
+ if (!existing || String(existing._id) === currentTemplateId) {
77
+ return candidate;
78
+ }
79
+ candidate = `${baseKey}-${suffix}`;
80
+ suffix += 1;
81
+ }
82
+ }
83
+
84
+ export const createMessageTemplate = mutation({
85
+ args: {
86
+ title: v.string(),
87
+ text: v.string(),
88
+ tags: v.array(v.string()),
89
+ enabled: v.optional(v.boolean()),
90
+ actorUserId: v.string(),
91
+ nowMs: v.optional(v.number()),
92
+ },
93
+ returns: v.id("messageTemplates"),
94
+ handler: async (ctx, args) => {
95
+ const title = normalizeTitle(args.title);
96
+ const text = normalizeText(args.text);
97
+ const tags = normalizeTags(args.tags);
98
+ validateMessageTemplateFields({ title, text });
99
+ const templateKey = normalizeTemplateKey(await resolveUniqueTemplateKey(ctx, title));
100
+
101
+ const nowMs = args.nowMs ?? Date.now();
102
+ return await ctx.db.insert("messageTemplates", {
103
+ templateKey,
104
+ title,
105
+ text,
106
+ tags,
107
+ usageCount: 0,
108
+ enabled: args.enabled ?? true,
109
+ createdBy: args.actorUserId,
110
+ updatedBy: args.actorUserId,
111
+ createdAt: nowMs,
112
+ updatedAt: nowMs,
113
+ });
114
+ },
115
+ });
116
+
117
+ export const updateMessageTemplate = mutation({
118
+ args: {
119
+ templateId: v.id("messageTemplates"),
120
+ title: v.optional(v.string()),
121
+ text: v.optional(v.string()),
122
+ tags: v.optional(v.array(v.string())),
123
+ enabled: v.optional(v.boolean()),
124
+ actorUserId: v.string(),
125
+ nowMs: v.optional(v.number()),
126
+ },
127
+ returns: v.boolean(),
128
+ handler: async (ctx, args) => {
129
+ const template = await ctx.db.get(args.templateId);
130
+ if (!template) return false;
131
+
132
+ const title = args.title !== undefined ? normalizeTitle(args.title) : template.title;
133
+ const text = args.text !== undefined ? normalizeText(args.text) : template.text;
134
+ const tags = args.tags !== undefined ? normalizeTags(args.tags) : template.tags;
135
+ validateMessageTemplateFields({ title, text });
136
+ const templateKey =
137
+ title !== template.title
138
+ ? normalizeTemplateKey(await resolveUniqueTemplateKey(ctx, title, String(template._id)))
139
+ : template.templateKey;
140
+
141
+ const nowMs = args.nowMs ?? Date.now();
142
+ await ctx.db.patch(template._id, {
143
+ templateKey,
144
+ title,
145
+ text,
146
+ tags,
147
+ enabled: args.enabled ?? template.enabled,
148
+ updatedBy: args.actorUserId,
149
+ updatedAt: nowMs,
150
+ });
151
+ return true;
152
+ },
153
+ });
154
+
155
+ export const deleteMessageTemplate = mutation({
156
+ args: {
157
+ templateId: v.id("messageTemplates"),
158
+ },
159
+ returns: v.boolean(),
160
+ handler: async (ctx, args) => {
161
+ const template = await ctx.db.get(args.templateId);
162
+ if (!template) return false;
163
+ await ctx.db.delete(template._id);
164
+ return true;
165
+ },
166
+ });
167
+
168
+ export const listMessageTemplatesByCompany = query({
169
+ args: {
170
+ includeDisabled: v.optional(v.boolean()),
171
+ limit: v.optional(v.number()),
172
+ },
173
+ returns: v.array(messageTemplateViewValidator),
174
+ handler: async (ctx, args) => {
175
+ const includeDisabled = args.includeDisabled ?? false;
176
+ const limit = Math.max(1, Math.min(args.limit ?? 100, 200));
177
+
178
+ const rows = includeDisabled
179
+ ? await ctx.db.query("messageTemplates").take(limit)
180
+ : await ctx.db
181
+ .query("messageTemplates")
182
+ .withIndex("by_enabled", (q) => q.eq("enabled", true))
183
+ .take(limit);
184
+
185
+ rows.sort((left, right) => {
186
+ if (right.usageCount !== left.usageCount) {
187
+ return right.usageCount - left.usageCount;
188
+ }
189
+ return right.updatedAt - left.updatedAt;
190
+ });
191
+ return rows.map((row) => ({
192
+ _id: row._id,
193
+ templateKey: row.templateKey,
194
+ title: row.title,
195
+ text: row.text,
196
+ tags: row.tags,
197
+ usageCount: row.usageCount,
198
+ enabled: row.enabled,
199
+ createdBy: row.createdBy,
200
+ updatedBy: row.updatedBy,
201
+ createdAt: row.createdAt,
202
+ updatedAt: row.updatedAt,
203
+ }));
204
+ },
205
+ });