@mgsoftwarebv/mcp-server-bridge 3.0.1 → 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.
package/dist/index.js CHANGED
@@ -1,15 +1,24 @@
1
1
  #!/usr/bin/env node
2
+ import { createJobDb } from '@refront/db/job-client';
3
+ import * as schema from '@refront/db/schema';
4
+ import { eq, and, desc, inArray, asc, ilike, or, sql } from 'drizzle-orm';
5
+ import { createHash } from 'crypto';
2
6
  import { Server } from '@modelcontextprotocol/sdk/server/index.js';
3
7
  import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
4
8
  import { ListToolsRequestSchema, ListResourcesRequestSchema, CallToolRequestSchema, ReadResourceRequestSchema } from '@modelcontextprotocol/sdk/types.js';
5
9
  import { Octokit } from '@octokit/rest';
6
- import { createHash } from 'crypto';
7
- import { eq, or, ilike, and, desc, asc, inArray, sql } from 'drizzle-orm';
8
- import { createJobDb } from '@refront/db/job-client';
9
- import * as schema from '@refront/db/schema';
10
10
  import { createStorageClient } from '@refront/storage';
11
+ import { ensureTipTapFormat } from '@refront/utils/tiptap';
11
12
 
12
- var _client = null;
13
+ var __defProp = Object.defineProperty;
14
+ var __getOwnPropNames = Object.getOwnPropertyNames;
15
+ var __esm = (fn, res) => function __init() {
16
+ return fn && (res = (0, fn[__getOwnPropNames(fn)[0]])(fn = 0)), res;
17
+ };
18
+ var __export = (target, all) => {
19
+ for (var name in all)
20
+ __defProp(target, name, { get: all[name], enumerable: true });
21
+ };
13
22
  function getClient() {
14
23
  if (!_client) {
15
24
  if (!process.env.DATABASE_PRIMARY_POOLER_URL) {
@@ -21,13 +30,6 @@ function getClient() {
21
30
  }
22
31
  return _client;
23
32
  }
24
- var db = new Proxy({}, {
25
- get(_target, prop) {
26
- const real = getClient().db;
27
- const value = Reflect.get(real, prop, real);
28
- return typeof value === "function" ? value.bind(real) : value;
29
- }
30
- });
31
33
  async function getAccessibleTeamIds(teamId) {
32
34
  const rows = await db.select({ id: schema.teams.id }).from(schema.teams).where(
33
35
  or(eq(schema.teams.id, teamId), eq(schema.teams.parentTeamId, teamId))
@@ -48,9 +50,7 @@ async function getAccessibleProjectIds(userId, teamId) {
48
50
  }
49
51
  async function getAccessibleCustomerIds(teamId) {
50
52
  const teamIds = await getAccessibleTeamIds(teamId);
51
- const ownCustomers = await db.select({ id: schema.customers.id }).from(schema.customers).where(
52
- teamIds.length === 1 ? eq(schema.customers.teamId, teamIds[0]) : sql`${schema.customers.teamId} = ANY(${teamIds}::uuid[])`
53
- );
53
+ const ownCustomers = await db.select({ id: schema.customers.id }).from(schema.customers).where(inArray(schema.customers.teamId, teamIds));
54
54
  const sharedCustomers = await db.select({ customerId: schema.customerSharedTeams.customerId }).from(schema.customerSharedTeams).where(eq(schema.customerSharedTeams.teamId, teamId));
55
55
  return [
56
56
  .../* @__PURE__ */ new Set([
@@ -59,6 +59,57 @@ async function getAccessibleCustomerIds(teamId) {
59
59
  ])
60
60
  ];
61
61
  }
62
+ async function getUserProviderTeams(userId) {
63
+ const rows = await db.select({
64
+ id: schema.teams.id,
65
+ name: schema.teams.name,
66
+ teamType: schema.teams.teamType
67
+ }).from(schema.usersOnTeam).leftJoin(schema.teams, eq(schema.usersOnTeam.teamId, schema.teams.id)).where(eq(schema.usersOnTeam.userId, userId));
68
+ const seen = /* @__PURE__ */ new Set();
69
+ const result = [];
70
+ for (const row of rows) {
71
+ if (!row.id || row.teamType === "customer" || seen.has(row.id)) continue;
72
+ seen.add(row.id);
73
+ result.push({ id: row.id, name: row.name });
74
+ }
75
+ return result;
76
+ }
77
+ async function isUserTeamMember(userId, teamId) {
78
+ const [row] = await db.select({ id: schema.usersOnTeam.id }).from(schema.usersOnTeam).where(
79
+ and(
80
+ eq(schema.usersOnTeam.userId, userId),
81
+ eq(schema.usersOnTeam.teamId, teamId)
82
+ )
83
+ ).limit(1);
84
+ return Boolean(row);
85
+ }
86
+ async function getUserAccessibleTeamIds(userId) {
87
+ const teams2 = await getUserProviderTeams(userId);
88
+ const all = /* @__PURE__ */ new Set();
89
+ for (const team of teams2) {
90
+ const ids = await getAccessibleTeamIds(team.id);
91
+ ids.forEach((id) => all.add(id));
92
+ }
93
+ return [...all];
94
+ }
95
+ async function getUserAccessibleProjectIds(userId) {
96
+ const teams2 = await getUserProviderTeams(userId);
97
+ const all = /* @__PURE__ */ new Set();
98
+ for (const team of teams2) {
99
+ const ids = await getAccessibleProjectIds(userId, team.id);
100
+ ids.forEach((id) => all.add(id));
101
+ }
102
+ return [...all];
103
+ }
104
+ async function getUserAccessibleCustomerIds(userId) {
105
+ const teams2 = await getUserProviderTeams(userId);
106
+ const all = /* @__PURE__ */ new Set();
107
+ for (const team of teams2) {
108
+ const ids = await getAccessibleCustomerIds(team.id);
109
+ ids.forEach((id) => all.add(id));
110
+ }
111
+ return [...all];
112
+ }
62
113
  async function resolveAiSessionId(prefix, teamIds) {
63
114
  if (teamIds.length === 0) return null;
64
115
  const rows = await db.select({ id: schema.aiSessions.id }).from(schema.aiSessions).where(
@@ -69,109 +120,29 @@ async function resolveAiSessionId(prefix, teamIds) {
69
120
  ).limit(1);
70
121
  return rows[0]?.id ?? null;
71
122
  }
72
- var _storage = null;
73
- function buildClient() {
74
- const endpoint = process.env.R2_ENDPOINT;
75
- const accessKeyId = process.env.R2_ACCESS_KEY_ID;
76
- const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
77
- if (!endpoint || !accessKeyId || !secretAccessKey) {
78
- throw new Error(
79
- "R2 storage is not configured. Set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY."
80
- );
81
- }
82
- return createStorageClient({
83
- endpoint,
84
- accessKeyId,
85
- secretAccessKey,
86
- publicDomain: process.env.R2_PUBLIC_DOMAIN || void 0,
87
- publicBuckets: [
88
- "vault",
89
- "avatars",
90
- "team-logos",
91
- "blog-images",
92
- "customer-assets"
93
- ]
94
- });
95
- }
96
- var storage = new Proxy({}, {
97
- get(_target, prop) {
98
- if (!_storage) _storage = buildClient();
99
- return Reflect.get(_storage, prop, _storage);
123
+ var _client, db;
124
+ var init_db = __esm({
125
+ "src/db.ts"() {
126
+ _client = null;
127
+ db = new Proxy({}, {
128
+ get(_target, prop) {
129
+ const real = getClient().db;
130
+ const value = Reflect.get(real, prop, real);
131
+ return typeof value === "function" ? value.bind(real) : value;
132
+ }
133
+ });
100
134
  }
101
135
  });
102
136
 
103
- // src/index.ts
104
- var args = process.argv.slice(2);
105
- function readArg(name) {
106
- const prefix = `--${name}=`;
107
- const hit = args.find((a) => a.startsWith(prefix));
108
- return hit ? hit.slice(prefix.length) : void 0;
109
- }
110
- var apiKey = readArg("api-key") ?? process.env.MG_TICKETS_API_KEY;
111
- var databaseUrl = readArg("database-url") ?? process.env.DATABASE_PRIMARY_POOLER_URL ?? process.env.DATABASE_URL;
112
- if (!apiKey) {
113
- console.error(
114
- "\u274C API key is required. Use --api-key=your_key or set MG_TICKETS_API_KEY environment variable"
115
- );
116
- process.exit(1);
117
- }
118
- if (!databaseUrl) {
119
- console.error(
120
- "\u274C Database URL is required. Use --database-url=postgresql://... or set DATABASE_PRIMARY_POOLER_URL (or DATABASE_URL) environment variable."
121
- );
122
- process.exit(1);
123
- }
124
- process.env.DATABASE_PRIMARY_POOLER_URL = databaseUrl;
125
- function asToolArgs(input) {
126
- return input ?? {};
127
- }
128
- function roundToNearest15Minutes(minutes) {
129
- if (minutes <= 0) return 0;
130
- return Math.round(minutes / 15) * 15;
131
- }
132
- function isImageFile(mimeType) {
133
- return mimeType.startsWith("image/") && ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"].includes(
134
- mimeType
135
- );
136
- }
137
- async function downloadImageAsBase64(storageKey) {
138
- try {
139
- let signedUrl;
140
- try {
141
- const { url } = await storage.createSignedUrl({
142
- bucket: "vault",
143
- path: storageKey,
144
- expiresIn: 3600
145
- });
146
- signedUrl = url;
147
- } catch (err) {
148
- console.error(`Failed to create signed URL for ${storageKey}:`, err);
149
- return null;
150
- }
151
- const response = await fetch(signedUrl);
152
- if (!response.ok) {
153
- console.error(
154
- `Failed to download file ${storageKey}: ${response.status}`
155
- );
156
- return null;
157
- }
158
- const arrayBuffer = await response.arrayBuffer();
159
- return Buffer.from(arrayBuffer).toString("base64");
160
- } catch (error) {
161
- console.error(`Error downloading image ${storageKey}:`, error);
162
- return null;
163
- }
164
- }
165
- function buildTicketAccessPredicate(teamIds, projectIds, customerIds) {
166
- const branches = [];
167
- if (teamIds.length > 0) branches.push(inArray(schema.tickets.teamId, teamIds));
168
- if (projectIds.length > 0)
169
- branches.push(inArray(schema.tickets.projectId, projectIds));
170
- if (customerIds.length > 0)
171
- branches.push(inArray(schema.tickets.customerId, customerIds));
172
- if (branches.length === 0) return sql`false`;
173
- if (branches.length === 1) return branches[0];
174
- return or(...branches);
137
+ // src/auth.ts
138
+ var auth_exports = {};
139
+ __export(auth_exports, {
140
+ authContext: () => authContext,
141
+ setAuthContext: () => setAuthContext,
142
+ validateApiKey: () => validateApiKey
143
+ });
144
+ function setAuthContext(ctx) {
145
+ authContext = ctx;
175
146
  }
176
147
  async function validateApiKey(key) {
177
148
  if (!key.startsWith("mid_") || key.length !== 68) {
@@ -206,128 +177,173 @@ async function validateApiKey(key) {
206
177
  return null;
207
178
  }
208
179
  }
209
- var authContext = null;
210
- async function getGithubTokenForProject(projectId, teamId) {
211
- try {
212
- const [repoData] = await db.select({
213
- repositoryFullName: schema.projectGithubRepositories.repositoryFullName
214
- }).from(schema.projectGithubRepositories).where(
215
- and(
216
- eq(schema.projectGithubRepositories.projectId, projectId),
217
- eq(schema.projectGithubRepositories.teamId, teamId)
218
- )
219
- ).limit(1);
220
- if (!repoData) {
221
- console.error(`No GitHub repository linked to project ${projectId}`);
222
- return null;
223
- }
224
- const [appData] = await db.select({ config: schema.apps.config }).from(schema.apps).where(
225
- and(eq(schema.apps.teamId, teamId), eq(schema.apps.appId, "github"))
226
- ).limit(1);
227
- const accessToken = appData?.config?.access_token;
228
- if (!appData || !accessToken) {
229
- console.error(`GitHub app not connected for team ${teamId}`);
230
- return null;
231
- }
232
- const repositoryFullName = repoData.repositoryFullName;
233
- const [owner, repo] = repositoryFullName.split("/");
234
- if (!owner || !repo) {
235
- console.error(`Invalid repository full name: ${repositoryFullName}`);
236
- return null;
237
- }
238
- return { token: accessToken, repositoryFullName, owner, repo };
239
- } catch (error) {
240
- console.error("Error getting GitHub token for project:", error);
241
- return null;
180
+ var authContext;
181
+ var init_auth = __esm({
182
+ "src/auth.ts"() {
183
+ init_db();
184
+ authContext = null;
242
185
  }
186
+ });
187
+
188
+ // src/index.ts
189
+ init_auth();
190
+
191
+ // src/resources.ts
192
+ init_auth();
193
+ init_db();
194
+
195
+ // src/types.ts
196
+ init_db();
197
+ function asToolArgs(input) {
198
+ return input ?? {};
243
199
  }
244
- async function transitionToNextPhase(sessionId, currentPhase) {
200
+ function roundToNearest15Minutes(minutes) {
201
+ if (minutes <= 0) return 0;
202
+ return Math.round(minutes / 15) * 15;
203
+ }
204
+ function buildTicketAccessPredicate(teamIds, projectIds, customerIds) {
205
+ const branches = [];
206
+ if (teamIds.length > 0) branches.push(inArray(schema.tickets.teamId, teamIds));
207
+ if (projectIds.length > 0)
208
+ branches.push(inArray(schema.tickets.projectId, projectIds));
209
+ if (customerIds.length > 0)
210
+ branches.push(inArray(schema.tickets.customerId, customerIds));
211
+ if (branches.length === 0) return sql`false`;
212
+ if (branches.length === 1) return branches[0];
213
+ return or(...branches);
214
+ }
215
+
216
+ // src/resources.ts
217
+ async function handleReadResource(uri) {
218
+ if (!authContext) {
219
+ return {
220
+ contents: [
221
+ {
222
+ uri,
223
+ mimeType: "text/plain",
224
+ text: "Error: Not authenticated. API key validation failed."
225
+ }
226
+ ]
227
+ };
228
+ }
229
+ const ctx = authContext;
230
+ console.error(`\u{1F4DA} Reading resource: ${uri}`);
245
231
  try {
246
- const now = /* @__PURE__ */ new Date();
247
- const phaseOrder = [
248
- "analysis",
249
- "bug_investigation",
250
- "development",
251
- "communication"
252
- ];
253
- const allPhases = await db.select().from(schema.aiTimeLogs).where(eq(schema.aiTimeLogs.aiSessionId, sessionId)).orderBy(asc(schema.aiTimeLogs.activityType));
254
- let currentPhaseType = currentPhase;
255
- if (!currentPhaseType) {
256
- const activePhase = allPhases.find((p) => p.status === "in_progress");
257
- currentPhaseType = activePhase?.activityType ?? void 0;
258
- }
259
- if (!currentPhaseType) {
260
- const analysisPhase = allPhases.find(
261
- (p) => p.activityType === "analysis"
262
- );
263
- if (analysisPhase && analysisPhase.status === "pending" && (analysisPhase.estimatedDurationSeconds ?? 0) > 0) {
264
- await db.update(schema.aiTimeLogs).set({ status: "in_progress", startedAt: now.toISOString() }).where(eq(schema.aiTimeLogs.id, analysisPhase.id));
265
- console.error("\u2705 Started analysis phase");
266
- }
267
- return;
268
- }
269
- const currentPhaseRecord = allPhases.find(
270
- (p) => p.activityType === currentPhaseType && p.status === "in_progress"
271
- );
272
- if (currentPhaseRecord) {
273
- const duration = Math.round(
274
- (now.getTime() - new Date(currentPhaseRecord.startedAt).getTime()) / 1e3
275
- );
276
- await db.update(schema.aiTimeLogs).set({
277
- status: "completed",
278
- endedAt: now.toISOString(),
279
- durationSeconds: duration
280
- }).where(eq(schema.aiTimeLogs.id, currentPhaseRecord.id));
281
- console.error(`\u2705 Completed phase: ${currentPhaseType} (${duration}s)`);
282
- }
283
- const currentIndex = phaseOrder.indexOf(currentPhaseType);
284
- if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) {
285
- console.error("No next phase to transition to");
286
- return;
287
- }
288
- for (let i = currentIndex + 1; i < phaseOrder.length; i++) {
289
- const nextPhaseType = phaseOrder[i];
290
- const nextPhase = allPhases.find(
291
- (p) => p.activityType === nextPhaseType
292
- );
293
- if (!nextPhase) continue;
294
- if ((nextPhase.estimatedDurationSeconds ?? 0) === 0) {
295
- await db.update(schema.aiTimeLogs).set({ status: "skipped" }).where(eq(schema.aiTimeLogs.id, nextPhase.id));
296
- console.error(
297
- `\u23ED\uFE0F Skipped phase: ${nextPhaseType} (0 minutes estimated)`
232
+ switch (uri) {
233
+ case "tickets://recent": {
234
+ const teamIds = await getUserAccessibleTeamIds(ctx.userId);
235
+ const projectIds = await getUserAccessibleProjectIds(ctx.userId);
236
+ const customerIds = await getUserAccessibleCustomerIds(ctx.userId);
237
+ const accessPredicate = buildTicketAccessPredicate(
238
+ teamIds,
239
+ projectIds,
240
+ customerIds
298
241
  );
299
- continue;
300
- }
301
- if (nextPhase.status === "pending") {
302
- await db.update(schema.aiTimeLogs).set({ status: "in_progress", startedAt: now.toISOString() }).where(eq(schema.aiTimeLogs.id, nextPhase.id));
303
- console.error(`\u2705 Started next phase: ${nextPhaseType}`);
304
- return;
305
- }
306
- }
307
- console.error("All remaining phases skipped or completed");
308
- } catch (error) {
309
- console.error("Error transitioning to next phase:", error);
310
- }
311
- }
312
- var server = new Server(
313
- {
314
- name: "mg-tickets-mcp-bridge",
315
- version: "3.0.0"
316
- },
317
- {
318
- capabilities: {
319
- tools: {},
320
- resources: {}
242
+ const rows = await db.select({
243
+ id: schema.tickets.id,
244
+ ticketNumber: schema.tickets.ticketNumber,
245
+ title: schema.tickets.title,
246
+ status: schema.tickets.status,
247
+ priority: schema.tickets.priority,
248
+ createdAt: schema.tickets.createdAt
249
+ }).from(schema.tickets).where(and(accessPredicate, eq(schema.tickets.isDeleted, false))).orderBy(desc(schema.tickets.createdAt)).limit(20);
250
+ return {
251
+ contents: [
252
+ {
253
+ uri,
254
+ mimeType: "application/json",
255
+ text: JSON.stringify(rows, null, 2)
256
+ }
257
+ ]
258
+ };
259
+ }
260
+ case "customers://all": {
261
+ const customerIds = await getUserAccessibleCustomerIds(ctx.userId);
262
+ if (customerIds.length === 0) {
263
+ return {
264
+ contents: [
265
+ { uri, mimeType: "application/json", text: JSON.stringify([], null, 2) }
266
+ ]
267
+ };
268
+ }
269
+ const rows = await db.select({
270
+ id: schema.customers.id,
271
+ name: schema.customers.name,
272
+ email: schema.customers.email,
273
+ website: schema.customers.website,
274
+ createdAt: schema.customers.createdAt
275
+ }).from(schema.customers).where(inArray(schema.customers.id, customerIds)).orderBy(asc(schema.customers.name)).limit(50);
276
+ return {
277
+ contents: [
278
+ { uri, mimeType: "application/json", text: JSON.stringify(rows, null, 2) }
279
+ ]
280
+ };
281
+ }
282
+ case "projects://active": {
283
+ const projectIds = await getUserAccessibleProjectIds(ctx.userId);
284
+ if (projectIds.length === 0) {
285
+ return {
286
+ contents: [
287
+ { uri, mimeType: "application/json", text: JSON.stringify([], null, 2) }
288
+ ]
289
+ };
290
+ }
291
+ const rows = await db.select({
292
+ id: schema.projects.id,
293
+ name: schema.projects.name,
294
+ description: schema.projects.description,
295
+ createdAt: schema.projects.createdAt,
296
+ customerId: schema.projects.customerId,
297
+ customerName: schema.customers.name
298
+ }).from(schema.projects).leftJoin(
299
+ schema.customers,
300
+ eq(schema.customers.id, schema.projects.customerId)
301
+ ).where(inArray(schema.projects.id, projectIds)).orderBy(asc(schema.projects.name)).limit(50);
302
+ return {
303
+ contents: [
304
+ { uri, mimeType: "application/json", text: JSON.stringify(rows, null, 2) }
305
+ ]
306
+ };
307
+ }
308
+ default:
309
+ throw new Error(`Unknown resource: ${uri}`);
321
310
  }
311
+ } catch (error) {
312
+ console.error("\u274C Resource read error:", error);
313
+ return {
314
+ contents: [
315
+ {
316
+ uri,
317
+ mimeType: "text/plain",
318
+ text: `Error reading ${uri}: ${error instanceof Error ? error.message : "Unknown error"}`
319
+ }
320
+ ]
321
+ };
322
322
  }
323
- );
323
+ }
324
+
325
+ // src/tools/definitions.ts
326
+ var teamIdProp = {
327
+ type: "string",
328
+ description: "Provider team ID. Optional when you belong to a single provider; required when you belong to several. Call get-teams to list the providers you can act on."
329
+ };
324
330
  var TOOLS = [
331
+ {
332
+ name: "get-teams",
333
+ description: "List the provider teams (workspaces) you can act on. Use a returned id as the `teamId` argument on other tools. Call this when a tool says you belong to multiple providers.",
334
+ inputSchema: {
335
+ type: "object",
336
+ properties: {},
337
+ required: []
338
+ }
339
+ },
325
340
  {
326
341
  name: "get-tickets",
327
342
  description: "Get tickets with optional filtering by status, priority, project, customer, or search query",
328
343
  inputSchema: {
329
344
  type: "object",
330
345
  properties: {
346
+ teamId: teamIdProp,
331
347
  status: {
332
348
  type: "string",
333
349
  enum: [
@@ -356,10 +372,11 @@ var TOOLS = [
356
372
  },
357
373
  {
358
374
  name: "get-ticket-by-id",
359
- description: "Get a specific ticket by its ID, including all attachments, comments, and images. Images from ticket attachments and comment attachments are automatically downloaded and returned as base64-encoded content that can be analyzed by AI.",
375
+ description: "Get a specific ticket by its ID, including comment text and a full attachment listing (with ids). Images from ticket and comment attachments are downloaded and returned inline as base64. For non-image attachments, call get-ticket-attachment with the listed id to get a download URL.",
360
376
  inputSchema: {
361
377
  type: "object",
362
378
  properties: {
379
+ teamId: teamIdProp,
363
380
  id: { type: "string", description: "Ticket ID" }
364
381
  },
365
382
  required: ["id"]
@@ -371,6 +388,7 @@ var TOOLS = [
371
388
  inputSchema: {
372
389
  type: "object",
373
390
  properties: {
391
+ teamId: teamIdProp,
374
392
  title: { type: "string", description: "Ticket title" },
375
393
  description: { type: "string" },
376
394
  status: {
@@ -408,12 +426,104 @@ var TOOLS = [
408
426
  required: ["title"]
409
427
  }
410
428
  },
429
+ {
430
+ name: "update-ticket",
431
+ description: "Update an existing ticket's fields (title, description, status, priority, type, project, customer, assignee, estimated hours). Only provided fields are changed. Changes are written to the ticket activity feed but do NOT send notifications. Set assigneeId to null to unassign; a provided assigneeId must be a member of the ticket's team. Common workflow: set status=in_progress when starting work; after merge/push set status=review and assigneeId to the requester (creator) id from get-ticket-by-id.",
432
+ inputSchema: {
433
+ type: "object",
434
+ properties: {
435
+ teamId: teamIdProp,
436
+ id: { type: "string", description: "Ticket ID" },
437
+ title: { type: "string" },
438
+ description: {
439
+ type: "string",
440
+ description: "Plain text or TipTap JSON; plain text is converted."
441
+ },
442
+ status: {
443
+ type: "string",
444
+ enum: [
445
+ "open",
446
+ "in_progress",
447
+ "review",
448
+ "resolved",
449
+ "closed",
450
+ "backlog"
451
+ ]
452
+ },
453
+ priority: {
454
+ type: "string",
455
+ enum: ["low", "medium", "high", "critical"]
456
+ },
457
+ type: {
458
+ type: "string",
459
+ enum: [
460
+ "task",
461
+ "bug",
462
+ "feature",
463
+ "support",
464
+ "question",
465
+ "improvement"
466
+ ]
467
+ },
468
+ projectId: { type: "string" },
469
+ customerId: { type: "string" },
470
+ assigneeId: {
471
+ type: ["string", "null"],
472
+ description: "User ID to assign, or null to unassign."
473
+ },
474
+ estimatedHours: { type: "number" }
475
+ },
476
+ required: ["id"]
477
+ }
478
+ },
479
+ {
480
+ name: "add-ticket-comment",
481
+ description: "Add a comment to a ticket. Content can be plain text or TipTap JSON. Set isInternal=true for an internal-only note. The comment appears in the ticket activity feed but does NOT send notifications or trigger @mention routing.",
482
+ inputSchema: {
483
+ type: "object",
484
+ properties: {
485
+ teamId: teamIdProp,
486
+ ticketId: { type: "string", description: "Ticket ID" },
487
+ content: { type: "string", description: "Comment body" },
488
+ isInternal: { type: "boolean", default: false }
489
+ },
490
+ required: ["ticketId", "content"]
491
+ }
492
+ },
493
+ {
494
+ name: "get-ticket-comments",
495
+ description: "Get all comments for a ticket, rendered as plain text with author, internal flag, and timestamp.",
496
+ inputSchema: {
497
+ type: "object",
498
+ properties: {
499
+ teamId: teamIdProp,
500
+ ticketId: { type: "string", description: "Ticket ID" }
501
+ },
502
+ required: ["ticketId"]
503
+ }
504
+ },
505
+ {
506
+ name: "get-ticket-attachment",
507
+ description: "Get a temporary signed download URL (valid 1 hour) for any ticket or comment attachment by its id. Works for any file type (PDF, docx, zip, images). Find attachment ids via get-ticket-by-id.",
508
+ inputSchema: {
509
+ type: "object",
510
+ properties: {
511
+ teamId: teamIdProp,
512
+ attachmentId: {
513
+ type: "string",
514
+ description: "Attachment ID (ticket or comment attachment)"
515
+ }
516
+ },
517
+ required: ["attachmentId"]
518
+ }
519
+ },
411
520
  {
412
521
  name: "get-customers",
413
522
  description: "Get customers with optional search",
414
523
  inputSchema: {
415
524
  type: "object",
416
525
  properties: {
526
+ teamId: teamIdProp,
417
527
  q: {
418
528
  type: "string",
419
529
  description: "Search query for customer name or email"
@@ -429,6 +539,7 @@ var TOOLS = [
429
539
  inputSchema: {
430
540
  type: "object",
431
541
  properties: {
542
+ teamId: teamIdProp,
432
543
  name: { type: "string", description: "Customer name" },
433
544
  email: { type: "string" },
434
545
  website: { type: "string" }
@@ -442,6 +553,7 @@ var TOOLS = [
442
553
  inputSchema: {
443
554
  type: "object",
444
555
  properties: {
556
+ teamId: teamIdProp,
445
557
  customerId: { type: "string", description: "Filter by customer ID" },
446
558
  q: { type: "string", description: "Search query for project name" },
447
559
  pageSize: { type: "number", default: 20, maximum: 100 }
@@ -455,6 +567,7 @@ var TOOLS = [
455
567
  inputSchema: {
456
568
  type: "object",
457
569
  properties: {
570
+ teamId: teamIdProp,
458
571
  name: { type: "string", description: "Project name" },
459
572
  description: { type: "string" },
460
573
  customerId: { type: "string" },
@@ -473,6 +586,7 @@ var TOOLS = [
473
586
  inputSchema: {
474
587
  type: "object",
475
588
  properties: {
589
+ teamId: teamIdProp,
476
590
  ticketId: { type: "string" },
477
591
  ticketUrl: { type: "string", description: "URL to the ticket" },
478
592
  cursorSessionId: {
@@ -499,6 +613,7 @@ var TOOLS = [
499
613
  inputSchema: {
500
614
  type: "object",
501
615
  properties: {
616
+ teamId: teamIdProp,
502
617
  aiSessionId: { type: "string" },
503
618
  originalPrompt: { type: "string" },
504
619
  aiResponse: { type: "string" },
@@ -543,6 +658,7 @@ var TOOLS = [
543
658
  inputSchema: {
544
659
  type: "object",
545
660
  properties: {
661
+ teamId: teamIdProp,
546
662
  aiSessionId: { type: "string" },
547
663
  includeTicketData: { type: "boolean", default: true },
548
664
  includeTodoProgress: { type: "boolean", default: true },
@@ -557,6 +673,7 @@ var TOOLS = [
557
673
  inputSchema: {
558
674
  type: "object",
559
675
  properties: {
676
+ teamId: teamIdProp,
560
677
  aiSessionId: { type: "string" },
561
678
  todos: {
562
679
  type: "array",
@@ -592,6 +709,7 @@ var TOOLS = [
592
709
  inputSchema: {
593
710
  type: "object",
594
711
  properties: {
712
+ teamId: teamIdProp,
595
713
  aiSessionId: { type: "string" },
596
714
  newTodos: {
597
715
  type: "array",
@@ -624,6 +742,7 @@ var TOOLS = [
624
742
  inputSchema: {
625
743
  type: "object",
626
744
  properties: {
745
+ teamId: teamIdProp,
627
746
  aiSessionId: { type: "string" },
628
747
  status: {
629
748
  type: "string",
@@ -641,6 +760,7 @@ var TOOLS = [
641
760
  inputSchema: {
642
761
  type: "object",
643
762
  properties: {
763
+ teamId: teamIdProp,
644
764
  aiSessionId: { type: "string" },
645
765
  includeFollowUps: { type: "boolean", default: true },
646
766
  includeTimeMetrics: { type: "boolean", default: true },
@@ -655,6 +775,7 @@ var TOOLS = [
655
775
  inputSchema: {
656
776
  type: "object",
657
777
  properties: {
778
+ teamId: teamIdProp,
658
779
  aiSessionId: { type: "string" },
659
780
  customerResponse: {
660
781
  type: "string",
@@ -675,6 +796,7 @@ var TOOLS = [
675
796
  inputSchema: {
676
797
  type: "object",
677
798
  properties: {
799
+ teamId: teamIdProp,
678
800
  aiSessionId: { type: "string" },
679
801
  workCompleted: {
680
802
  type: "array",
@@ -700,6 +822,7 @@ var TOOLS = [
700
822
  inputSchema: {
701
823
  type: "object",
702
824
  properties: {
825
+ teamId: teamIdProp,
703
826
  projectId: {
704
827
  type: "string",
705
828
  description: "Project ID (UUID) - Optional. Cursor AI should call get-projects first to try matching workspace name. If no clear match, omit this field."
@@ -734,6 +857,7 @@ var TOOLS = [
734
857
  inputSchema: {
735
858
  type: "object",
736
859
  properties: {
860
+ teamId: teamIdProp,
737
861
  projectId: { type: "string", description: "Project ID (UUID)" },
738
862
  filePath: {
739
863
  type: "string",
@@ -753,6 +877,7 @@ var TOOLS = [
753
877
  inputSchema: {
754
878
  type: "object",
755
879
  properties: {
880
+ teamId: teamIdProp,
756
881
  projectId: { type: "string", description: "Project ID (UUID)" },
757
882
  directoryPath: {
758
883
  type: "string",
@@ -787,548 +912,1075 @@ var RESOURCES = [
787
912
  mimeType: "application/json"
788
913
  }
789
914
  ];
790
- server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
791
- server.setRequestHandler(ListResourcesRequestSchema, async () => ({
792
- resources: RESOURCES
793
- }));
794
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
795
- if (!authContext) {
915
+
916
+ // src/tools/customers.ts
917
+ init_db();
918
+
919
+ // src/tools/team-resolution.ts
920
+ init_auth();
921
+ init_db();
922
+ function teamSelectionResponse(teams2) {
923
+ const list = teams2.map((t) => `- ${t.name ?? "(unnamed provider)"} (teamId: ${t.id})`).join("\n");
924
+ return {
925
+ content: [
926
+ {
927
+ type: "text",
928
+ text: `You belong to multiple providers, so this action is ambiguous. Re-call this tool with a \`teamId\` set to the intended provider.
929
+
930
+ Available providers:
931
+ ${list}
932
+
933
+ Ask the user which provider to use (or infer it from the conversation), then call the tool again with the chosen \`teamId\`.`
934
+ }
935
+ ]
936
+ };
937
+ }
938
+ function notAMemberResponse(teamId) {
939
+ return {
940
+ content: [
941
+ {
942
+ type: "text",
943
+ text: `Access denied: you are not a member of team ${teamId}. Call \`get-teams\` to list the providers you can act on.`
944
+ }
945
+ ]
946
+ };
947
+ }
948
+ async function resolveTeamId(requestedTeamId) {
949
+ const ctx = authContext;
950
+ if (requestedTeamId) {
951
+ const member = await isUserTeamMember(ctx.userId, requestedTeamId);
952
+ if (!member) {
953
+ return { ok: false, response: notAMemberResponse(requestedTeamId) };
954
+ }
955
+ return { ok: true, teamId: requestedTeamId };
956
+ }
957
+ const teams2 = await getUserProviderTeams(ctx.userId);
958
+ if (teams2.length === 0) {
959
+ return { ok: true, teamId: ctx.teamId };
960
+ }
961
+ if (teams2.length === 1) {
962
+ return { ok: true, teamId: teams2[0].id };
963
+ }
964
+ return { ok: false, response: teamSelectionResponse(teams2) };
965
+ }
966
+ async function resolveTeamScope(requestedTeamId) {
967
+ const ctx = authContext;
968
+ if (requestedTeamId) {
969
+ const member = await isUserTeamMember(ctx.userId, requestedTeamId);
970
+ if (!member) {
971
+ return { ok: false, response: notAMemberResponse(requestedTeamId) };
972
+ }
973
+ const [teamIds2, projectIds2, customerIds2] = await Promise.all([
974
+ getAccessibleTeamIds(requestedTeamId),
975
+ getAccessibleProjectIds(ctx.userId, requestedTeamId),
976
+ getAccessibleCustomerIds(requestedTeamId)
977
+ ]);
978
+ return { ok: true, teamIds: teamIds2, projectIds: projectIds2, customerIds: customerIds2 };
979
+ }
980
+ const [teamIds, projectIds, customerIds] = await Promise.all([
981
+ getUserAccessibleTeamIds(ctx.userId),
982
+ getUserAccessibleProjectIds(ctx.userId),
983
+ getUserAccessibleCustomerIds(ctx.userId)
984
+ ]);
985
+ return { ok: true, teamIds, projectIds, customerIds };
986
+ }
987
+
988
+ // src/tools/customers.ts
989
+ async function handleGetCustomers(input) {
990
+ const { q, pageSize = 20 } = input;
991
+ const resolved = await resolveTeamId(input.teamId);
992
+ if (!resolved.ok) return resolved.response;
993
+ const customerIds = await getAccessibleCustomerIds(resolved.teamId);
994
+ if (customerIds.length === 0) {
796
995
  return {
797
996
  content: [
798
997
  {
799
998
  type: "text",
800
- text: "Error: Not authenticated. API key validation failed."
999
+ text: "No customers found or no access to any customers."
801
1000
  }
802
1001
  ]
803
1002
  };
804
1003
  }
805
- const { name, arguments: args2 } = request.params;
806
- console.error(`\u{1F6E0}\uFE0F Executing tool: ${name} for team ${authContext.teamId}`);
1004
+ const filters = [inArray(schema.customers.id, customerIds)];
1005
+ if (q) {
1006
+ const pattern = `%${q}%`;
1007
+ filters.push(
1008
+ or(
1009
+ ilike(schema.customers.name, pattern),
1010
+ ilike(schema.customers.email, pattern)
1011
+ )
1012
+ );
1013
+ }
1014
+ const rows = await db.select({
1015
+ id: schema.customers.id,
1016
+ name: schema.customers.name,
1017
+ email: schema.customers.email,
1018
+ website: schema.customers.website,
1019
+ createdAt: schema.customers.createdAt
1020
+ }).from(schema.customers).where(and(...filters)).orderBy(asc(schema.customers.name)).limit(Math.min(pageSize, 100));
1021
+ return {
1022
+ content: [
1023
+ {
1024
+ type: "text",
1025
+ text: `Found ${rows.length} customers:
1026
+
1027
+ ${rows.map(
1028
+ (c) => `**${c.name}**
1029
+ ${c.email ? `Email: ${c.email}
1030
+ ` : ""}${c.website ? `Website: ${c.website}
1031
+ ` : ""}Created: ${new Date(c.createdAt).toLocaleDateString()}
1032
+ `
1033
+ ).join("\n") || "No customers found."}`
1034
+ }
1035
+ ]
1036
+ };
1037
+ }
1038
+ async function handleCreateCustomer(input) {
1039
+ const { name, email, website } = input;
1040
+ const resolved = await resolveTeamId(input.teamId);
1041
+ if (!resolved.ok) return resolved.response;
1042
+ await db.insert(schema.customers).values({
1043
+ teamId: resolved.teamId,
1044
+ name,
1045
+ email: email ?? "",
1046
+ website: website ?? null
1047
+ });
1048
+ return {
1049
+ content: [
1050
+ {
1051
+ type: "text",
1052
+ text: `\u2705 **Customer Created Successfully!**
1053
+
1054
+ Name: ${name}
1055
+ ${email ? `Email: ${email}
1056
+ ` : ""}${website ? `Website: ${website}
1057
+ ` : ""}`
1058
+ }
1059
+ ]
1060
+ };
1061
+ }
1062
+
1063
+ // src/tools/github.ts
1064
+ init_db();
1065
+ async function getGithubTokenForProject(projectId, teamIds) {
807
1066
  try {
808
- switch (name) {
809
- case "get-tickets":
810
- return await handleGetTickets(asToolArgs(args2));
811
- case "get-ticket-by-id":
812
- return await handleGetTicketById(asToolArgs(args2));
813
- case "create-ticket":
814
- return await handleCreateTicket(asToolArgs(args2));
815
- case "get-customers":
816
- return await handleGetCustomers(asToolArgs(args2));
817
- case "create-customer":
818
- return await handleCreateCustomer(asToolArgs(args2));
819
- case "get-projects":
820
- return await handleGetProjects(asToolArgs(args2));
821
- case "create-project":
822
- return await handleCreateProject(asToolArgs(args2));
823
- case "start-ai-session-smart":
824
- return await handleStartAiSession(
825
- asToolArgs(args2)
826
- );
827
- case "track-manual-follow-up":
828
- return await handleTrackManualFollowUp(
829
- asToolArgs(args2)
830
- );
831
- case "get-session-context":
832
- return await handleGetSessionContext(
833
- asToolArgs(args2)
834
- );
835
- case "sync-session-todos":
836
- return await handleSyncSessionTodos(
837
- asToolArgs(args2)
838
- );
839
- case "add-follow-up-todos":
840
- return await handleAddFollowUpTodos(
841
- asToolArgs(args2)
842
- );
843
- case "update-session-status":
844
- return await handleUpdateSessionStatus(
845
- asToolArgs(args2)
846
- );
847
- case "get-completion-context":
848
- return await handleGetCompletionContext(
849
- asToolArgs(args2)
850
- );
851
- case "save-customer-response":
852
- return await handleSaveCustomerResponse(
853
- asToolArgs(args2)
854
- );
855
- case "complete-ai-session":
856
- return await handleCompleteAiSession(
857
- asToolArgs(args2)
858
- );
859
- case "log-hours":
860
- return await handleLogHours(asToolArgs(args2));
861
- case "get-github-file":
862
- return await handleGetGithubFile(asToolArgs(args2));
863
- case "list-github-directory":
864
- return await handleListGithubDirectory(
865
- asToolArgs(args2)
866
- );
867
- default:
868
- throw new Error(`Unknown tool: ${name}`);
1067
+ if (teamIds.length === 0) return null;
1068
+ const [repoData] = await db.select({
1069
+ repositoryFullName: schema.projectGithubRepositories.repositoryFullName,
1070
+ teamId: schema.projectGithubRepositories.teamId
1071
+ }).from(schema.projectGithubRepositories).where(
1072
+ and(
1073
+ eq(schema.projectGithubRepositories.projectId, projectId),
1074
+ inArray(schema.projectGithubRepositories.teamId, teamIds)
1075
+ )
1076
+ ).limit(1);
1077
+ if (!repoData) {
1078
+ console.error(`No GitHub repository linked to project ${projectId}`);
1079
+ return null;
1080
+ }
1081
+ const teamId = repoData.teamId;
1082
+ const [appData] = await db.select({ config: schema.apps.config }).from(schema.apps).where(
1083
+ and(eq(schema.apps.teamId, teamId), eq(schema.apps.appId, "github"))
1084
+ ).limit(1);
1085
+ const accessToken = appData?.config?.access_token;
1086
+ if (!appData || !accessToken) {
1087
+ console.error(`GitHub app not connected for team ${teamId}`);
1088
+ return null;
869
1089
  }
1090
+ const repositoryFullName = repoData.repositoryFullName;
1091
+ const [owner, repo] = repositoryFullName.split("/");
1092
+ if (!owner || !repo) {
1093
+ console.error(`Invalid repository full name: ${repositoryFullName}`);
1094
+ return null;
1095
+ }
1096
+ return { token: accessToken, repositoryFullName, owner, repo };
870
1097
  } catch (error) {
871
- console.error("\u274C Tool execution error:", error);
872
- const message = error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error);
1098
+ console.error("Error getting GitHub token for project:", error);
1099
+ return null;
1100
+ }
1101
+ }
1102
+ async function handleGetGithubFile(input) {
1103
+ const { projectId, filePath, ref } = input;
1104
+ const scope = await resolveTeamScope(input.teamId);
1105
+ if (!scope.ok) return scope.response;
1106
+ const githubInfo = await getGithubTokenForProject(projectId, scope.teamIds);
1107
+ if (!githubInfo) {
1108
+ return {
1109
+ content: [
1110
+ { type: "text", text: "\u274C GitHub not configured for this project." }
1111
+ ]
1112
+ };
1113
+ }
1114
+ try {
1115
+ const octokit = new Octokit({ auth: githubInfo.token });
1116
+ console.error(
1117
+ `\u{1F4C4} Reading file: ${filePath} from ${githubInfo.repositoryFullName}`
1118
+ );
1119
+ const { data } = await octokit.rest.repos.getContent({
1120
+ owner: githubInfo.owner,
1121
+ repo: githubInfo.repo,
1122
+ path: filePath,
1123
+ ref
1124
+ });
1125
+ if (Array.isArray(data) || data.type !== "file") {
1126
+ return {
1127
+ content: [
1128
+ {
1129
+ type: "text",
1130
+ text: `\u274C "${filePath}" is not a file or contains multiple items.`
1131
+ }
1132
+ ]
1133
+ };
1134
+ }
1135
+ const content = Buffer.from(data.content, "base64").toString("utf-8");
1136
+ let responseText = `\u{1F4C4} **File: ${filePath}**
1137
+ `;
1138
+ responseText += `Repository: ${githubInfo.repositoryFullName}
1139
+ `;
1140
+ responseText += `Size: ${data.size} bytes
1141
+ `;
1142
+ responseText += `URL: ${data.html_url}
1143
+
1144
+ `;
1145
+ responseText += `**Content:**
1146
+ \`\`\`
1147
+ ${content}
1148
+ \`\`\``;
1149
+ return { content: [{ type: "text", text: responseText }] };
1150
+ } catch (error) {
1151
+ console.error("GitHub get file error:", error);
1152
+ const status = error?.status;
1153
+ if (status === 404) {
1154
+ return {
1155
+ content: [{ type: "text", text: `\u274C File not found: ${filePath}` }]
1156
+ };
1157
+ }
1158
+ const message = error instanceof Error ? error.message : "Unknown error";
1159
+ return {
1160
+ content: [
1161
+ { type: "text", text: `\u274C Failed to read file: ${message}` }
1162
+ ]
1163
+ };
1164
+ }
1165
+ }
1166
+ async function handleListGithubDirectory(input) {
1167
+ const { projectId, directoryPath, ref } = input;
1168
+ const scope = await resolveTeamScope(input.teamId);
1169
+ if (!scope.ok) return scope.response;
1170
+ const githubInfo = await getGithubTokenForProject(projectId, scope.teamIds);
1171
+ if (!githubInfo) {
1172
+ return {
1173
+ content: [
1174
+ { type: "text", text: "\u274C GitHub not configured for this project." }
1175
+ ]
1176
+ };
1177
+ }
1178
+ try {
1179
+ const octokit = new Octokit({ auth: githubInfo.token });
1180
+ const normalizedPath = !directoryPath || directoryPath === "/" ? "" : directoryPath;
1181
+ console.error(
1182
+ `\u{1F4C1} Listing directory: ${normalizedPath || "(root)"} in ${githubInfo.repositoryFullName}`
1183
+ );
1184
+ const { data } = await octokit.rest.repos.getContent({
1185
+ owner: githubInfo.owner,
1186
+ repo: githubInfo.repo,
1187
+ path: normalizedPath,
1188
+ ref
1189
+ });
1190
+ if (!Array.isArray(data)) {
1191
+ return {
1192
+ content: [
1193
+ {
1194
+ type: "text",
1195
+ text: `\u274C "${directoryPath}" is not a directory.`
1196
+ }
1197
+ ]
1198
+ };
1199
+ }
1200
+ let responseText = `\u{1F4C1} **Directory: ${directoryPath || "(root)"}**
1201
+ `;
1202
+ responseText += `Repository: ${githubInfo.repositoryFullName}
1203
+ `;
1204
+ responseText += `Items: ${data.length}
1205
+
1206
+ `;
1207
+ const directories = data.filter((item) => item.type === "dir");
1208
+ const files = data.filter((item) => item.type === "file");
1209
+ if (directories.length > 0) {
1210
+ responseText += `**\u{1F4C1} Directories (${directories.length}):**
1211
+ `;
1212
+ for (const dir of directories) responseText += ` - ${dir.name}/
1213
+ `;
1214
+ responseText += `
1215
+ `;
1216
+ }
1217
+ if (files.length > 0) {
1218
+ responseText += `**\u{1F4C4} Files (${files.length}):**
1219
+ `;
1220
+ for (const file of files)
1221
+ responseText += ` - ${file.name} (${file.size} bytes)
1222
+ `;
1223
+ }
1224
+ return { content: [{ type: "text", text: responseText }] };
1225
+ } catch (error) {
1226
+ console.error("GitHub list directory error:", error);
1227
+ const status = error?.status;
1228
+ if (status === 404) {
1229
+ return {
1230
+ content: [
1231
+ { type: "text", text: `\u274C Directory not found: ${directoryPath}` }
1232
+ ]
1233
+ };
1234
+ }
1235
+ const message = error instanceof Error ? error.message : "Unknown error";
1236
+ return {
1237
+ content: [
1238
+ { type: "text", text: `\u274C Failed to list directory: ${message}` }
1239
+ ]
1240
+ };
1241
+ }
1242
+ }
1243
+
1244
+ // src/tools/hours.ts
1245
+ init_auth();
1246
+ init_db();
1247
+ async function handleLogHours(input) {
1248
+ const ctx = authContext;
1249
+ const {
1250
+ projectId,
1251
+ ticketId,
1252
+ aiSessionId,
1253
+ workDescription,
1254
+ estimatedHours,
1255
+ chatContextSummary
1256
+ } = input;
1257
+ const scope = await resolveTeamScope(input.teamId);
1258
+ if (!scope.ok) return scope.response;
1259
+ let project = null;
1260
+ let ticket = null;
1261
+ let aiSession = null;
1262
+ if (projectId) {
1263
+ if (!scope.projectIds.includes(projectId)) {
1264
+ throw new Error(
1265
+ `Project not found or no access: ${projectId}. Please call get-projects first to find the correct project.`
1266
+ );
1267
+ }
1268
+ const [projectData] = await db.select({
1269
+ id: schema.projects.id,
1270
+ name: schema.projects.name,
1271
+ teamId: schema.projects.teamId
1272
+ }).from(schema.projects).where(eq(schema.projects.id, projectId)).limit(1);
1273
+ if (!projectData) throw new Error(`Project not found: ${projectId}.`);
1274
+ project = projectData;
1275
+ }
1276
+ if (ticketId) {
1277
+ const [ticketData] = await db.select({
1278
+ id: schema.tickets.id,
1279
+ title: schema.tickets.title,
1280
+ status: schema.tickets.status,
1281
+ teamId: schema.tickets.teamId,
1282
+ projectId: schema.tickets.projectId,
1283
+ customerId: schema.tickets.customerId
1284
+ }).from(schema.tickets).where(eq(schema.tickets.id, ticketId)).limit(1);
1285
+ if (!ticketData) {
1286
+ throw new Error(
1287
+ `Ticket not found: ${ticketId}. Please call get-tickets first to find the correct ticket.`
1288
+ );
1289
+ }
1290
+ let hasAccess = false;
1291
+ if (scope.teamIds.includes(ticketData.teamId)) hasAccess = true;
1292
+ if (!hasAccess && ticketData.projectId && scope.projectIds.includes(ticketData.projectId))
1293
+ hasAccess = true;
1294
+ if (!hasAccess && ticketData.customerId && scope.customerIds.includes(ticketData.customerId))
1295
+ hasAccess = true;
1296
+ if (!hasAccess) {
1297
+ throw new Error(
1298
+ `No access to ticket: ${ticketId}. Please call get-tickets first to find the correct ticket.`
1299
+ );
1300
+ }
1301
+ ticket = ticketData;
1302
+ }
1303
+ if (aiSessionId) {
1304
+ const [sessionData] = await db.select({
1305
+ id: schema.aiSessions.id,
1306
+ ticketId: schema.aiSessions.ticketId,
1307
+ status: schema.aiSessions.status
1308
+ }).from(schema.aiSessions).where(eq(schema.aiSessions.id, aiSessionId)).limit(1);
1309
+ if (!sessionData) throw new Error(`AI Session not found: ${aiSessionId}.`);
1310
+ aiSession = sessionData;
1311
+ }
1312
+ let insertTeamId = ticket?.teamId ?? project?.teamId ?? null;
1313
+ if (!insertTeamId) {
1314
+ const resolved = await resolveTeamId(input.teamId);
1315
+ if (!resolved.ok) return resolved.response;
1316
+ insertTeamId = resolved.teamId;
1317
+ }
1318
+ const durationSeconds = Math.round(estimatedHours * 3600);
1319
+ const now = /* @__PURE__ */ new Date();
1320
+ let agendaEntry = null;
1321
+ let wasUpdated = false;
1322
+ let consolidatedCount = 0;
1323
+ if (aiSession?.id || ticket?.id) {
1324
+ let existingEntries = [];
1325
+ if (aiSession?.id) {
1326
+ existingEntries = await db.select({
1327
+ id: schema.timesheetEvents.id,
1328
+ trackedDuration: schema.timesheetEvents.trackedDuration,
1329
+ projectId: schema.timesheetEvents.projectId,
1330
+ aiSessionId: schema.timesheetEvents.aiSessionId
1331
+ }).from(schema.timesheetEvents).where(
1332
+ and(
1333
+ eq(schema.timesheetEvents.status, "draft"),
1334
+ eq(schema.timesheetEvents.userId, ctx.userId),
1335
+ eq(schema.timesheetEvents.aiSessionId, aiSession.id)
1336
+ )
1337
+ ).orderBy(desc(schema.timesheetEvents.createdAt));
1338
+ } else if (ticket?.id) {
1339
+ const linkedEvents = await db.select({
1340
+ timesheetEventId: schema.timesheetEventTickets.timesheetEventId
1341
+ }).from(schema.timesheetEventTickets).where(eq(schema.timesheetEventTickets.ticketId, ticket.id));
1342
+ const eventIds = linkedEvents.map((e) => e.timesheetEventId);
1343
+ if (eventIds.length > 0) {
1344
+ existingEntries = await db.select({
1345
+ id: schema.timesheetEvents.id,
1346
+ trackedDuration: schema.timesheetEvents.trackedDuration,
1347
+ projectId: schema.timesheetEvents.projectId,
1348
+ aiSessionId: schema.timesheetEvents.aiSessionId
1349
+ }).from(schema.timesheetEvents).where(
1350
+ and(
1351
+ inArray(schema.timesheetEvents.id, eventIds),
1352
+ eq(schema.timesheetEvents.status, "draft"),
1353
+ eq(schema.timesheetEvents.userId, ctx.userId)
1354
+ )
1355
+ ).orderBy(desc(schema.timesheetEvents.createdAt));
1356
+ }
1357
+ }
1358
+ if (existingEntries.length > 0) {
1359
+ const existingEntry = existingEntries[0];
1360
+ if (existingEntries.length > 1) {
1361
+ const duplicateIds = existingEntries.slice(1).map((e) => e.id);
1362
+ await db.delete(schema.timesheetEvents).where(inArray(schema.timesheetEvents.id, duplicateIds));
1363
+ consolidatedCount = existingEntries.length - 1;
1364
+ }
1365
+ const newDuration = (existingEntry.trackedDuration ?? 0) + durationSeconds;
1366
+ const [updated] = await db.update(schema.timesheetEvents).set({
1367
+ trackedDuration: newDuration,
1368
+ endTime: now.toISOString(),
1369
+ title: workDescription,
1370
+ description: chatContextSummary ?? workDescription,
1371
+ projectId: project?.id ?? existingEntry.projectId
1372
+ }).where(eq(schema.timesheetEvents.id, existingEntry.id)).returning({
1373
+ id: schema.timesheetEvents.id,
1374
+ trackedDuration: schema.timesheetEvents.trackedDuration,
1375
+ projectId: schema.timesheetEvents.projectId,
1376
+ aiSessionId: schema.timesheetEvents.aiSessionId
1377
+ });
1378
+ agendaEntry = updated ?? null;
1379
+ wasUpdated = true;
1380
+ }
1381
+ }
1382
+ if (!agendaEntry) {
1383
+ const startTime = new Date(now.getTime() - durationSeconds * 1e3);
1384
+ const [created] = await db.insert(schema.timesheetEvents).values({
1385
+ teamId: insertTeamId,
1386
+ userId: ctx.userId,
1387
+ projectId: project?.id ?? null,
1388
+ aiSessionId: aiSession?.id ?? null,
1389
+ title: workDescription,
1390
+ description: chatContextSummary ?? workDescription,
1391
+ startTime: startTime.toISOString(),
1392
+ endTime: now.toISOString(),
1393
+ type: "work",
1394
+ status: "draft",
1395
+ allDay: false,
1396
+ isTracked: true,
1397
+ trackedDuration: durationSeconds
1398
+ }).returning({
1399
+ id: schema.timesheetEvents.id,
1400
+ trackedDuration: schema.timesheetEvents.trackedDuration,
1401
+ projectId: schema.timesheetEvents.projectId,
1402
+ aiSessionId: schema.timesheetEvents.aiSessionId
1403
+ });
1404
+ agendaEntry = created ?? null;
1405
+ if (agendaEntry && ticket?.id) {
1406
+ await db.insert(schema.timesheetEventTickets).values({ timesheetEventId: agendaEntry.id, ticketId: ticket.id }).onConflictDoNothing();
1407
+ }
1408
+ }
1409
+ if (!agendaEntry) {
1410
+ throw new Error(`Failed to ${wasUpdated ? "update" : "create"} time entry`);
1411
+ }
1412
+ let responseText = `\u23F1\uFE0F **Hours ${wasUpdated ? "Added to Existing Entry" : "Logged Successfully"}!**
1413
+
1414
+ `;
1415
+ if (wasUpdated) {
1416
+ responseText += `\u{1F504} **Updated existing draft entry** (avoiding duplicates)
1417
+ `;
1418
+ responseText += ` \u2022 New total: ${Math.round((agendaEntry.trackedDuration ?? 0) / 3600 * 10) / 10}h
1419
+
1420
+ `;
1421
+ }
1422
+ if (consolidatedCount > 0) {
1423
+ responseText += `\u{1F9F9} **Cleaned up ${consolidatedCount} duplicate entries**
1424
+
1425
+ `;
1426
+ }
1427
+ responseText += `\u{1F4CB} **Entry Details:**
1428
+ `;
1429
+ responseText += ` \u2022 Project: ${project ? project.name : "(No project assigned)"}
1430
+ `;
1431
+ if (ticket)
1432
+ responseText += ` \u2022 Ticket: ${ticket.title} (${ticket.status})
1433
+ `;
1434
+ if (aiSession)
1435
+ responseText += ` \u2022 AI Session: ${aiSession.id} (${aiSession.status})
1436
+ `;
1437
+ responseText += ` \u2022 Description: ${workDescription}
1438
+ `;
1439
+ responseText += ` \u2022 ${wasUpdated ? "Added" : "Estimated"} Hours: ${estimatedHours}h (${Math.floor(estimatedHours)}h ${Math.round(estimatedHours % 1 * 60)}m)
1440
+ `;
1441
+ responseText += ` \u2022 Status: DRAFT (not billed yet)
1442
+ `;
1443
+ responseText += ` \u2022 Entry ID: ${agendaEntry.id}
1444
+
1445
+ `;
1446
+ if (chatContextSummary) {
1447
+ responseText += `\u{1F4CA} **Work Context:**
1448
+ `;
1449
+ responseText += `${chatContextSummary.substring(0, 200)}${chatContextSummary.length > 200 ? "..." : ""}
1450
+
1451
+ `;
1452
+ }
1453
+ responseText += `\u2705 Time entry ${wasUpdated ? "updated" : "created"} and ready for review in the agenda!`;
1454
+ return { content: [{ type: "text", text: responseText }] };
1455
+ }
1456
+
1457
+ // src/tools/projects.ts
1458
+ init_auth();
1459
+ init_db();
1460
+ async function handleGetProjects(input) {
1461
+ const ctx = authContext;
1462
+ const { customerId, q, pageSize = 20 } = input;
1463
+ const resolved = await resolveTeamId(input.teamId);
1464
+ if (!resolved.ok) return resolved.response;
1465
+ const projectIds = await getAccessibleProjectIds(ctx.userId, resolved.teamId);
1466
+ if (projectIds.length === 0) {
873
1467
  return {
874
- content: [{ type: "text", text: `Error executing ${name}: ${message}` }]
1468
+ content: [
1469
+ {
1470
+ type: "text",
1471
+ text: "No projects found or no access to any projects."
1472
+ }
1473
+ ]
875
1474
  };
876
1475
  }
877
- });
878
- async function handleGetTickets(input) {
879
- const ctx = authContext;
880
- const { status, priority, projectId, customerId, q, pageSize = 20 } = input;
881
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
882
- const projectIds = await getAccessibleProjectIds(ctx.userId, ctx.teamId);
883
- const customerIds = await getAccessibleCustomerIds(ctx.teamId);
884
- const accessPredicate = buildTicketAccessPredicate(
885
- teamIds,
886
- projectIds,
887
- customerIds
888
- );
889
- const filters = [accessPredicate, eq(schema.tickets.isDeleted, false)];
890
- if (status) filters.push(eq(schema.tickets.status, status));
891
- if (priority) filters.push(eq(schema.tickets.priority, priority));
892
- if (projectId) filters.push(eq(schema.tickets.projectId, projectId));
893
- if (customerId) filters.push(eq(schema.tickets.customerId, customerId));
894
- if (q) {
895
- const pattern = `%${q}%`;
896
- filters.push(
897
- or(
898
- ilike(schema.tickets.ticketNumber, pattern),
899
- ilike(schema.tickets.title, pattern),
900
- ilike(schema.tickets.description, pattern)
901
- )
902
- );
903
- }
1476
+ const filters = [inArray(schema.projects.id, projectIds)];
1477
+ if (customerId) filters.push(eq(schema.projects.customerId, customerId));
1478
+ if (q) filters.push(ilike(schema.projects.name, `%${q}%`));
904
1479
  const rows = await db.select({
905
- id: schema.tickets.id,
906
- ticketNumber: schema.tickets.ticketNumber,
907
- title: schema.tickets.title,
908
- description: schema.tickets.description,
909
- status: schema.tickets.status,
910
- priority: schema.tickets.priority,
911
- type: schema.tickets.type,
912
- createdAt: schema.tickets.createdAt,
913
- projectId: schema.tickets.projectId,
914
- customerId: schema.tickets.customerId,
915
- projectName: schema.projects.name,
916
- customerName: schema.customers.name
917
- }).from(schema.tickets).leftJoin(schema.projects, eq(schema.projects.id, schema.tickets.projectId)).leftJoin(
918
- schema.customers,
919
- eq(schema.customers.id, schema.tickets.customerId)
920
- ).where(and(...filters)).orderBy(desc(schema.tickets.createdAt)).limit(Math.min(pageSize, 100));
1480
+ id: schema.projects.id,
1481
+ name: schema.projects.name,
1482
+ description: schema.projects.description,
1483
+ customerId: schema.projects.customerId,
1484
+ createdAt: schema.projects.createdAt
1485
+ }).from(schema.projects).where(and(...filters)).orderBy(asc(schema.projects.name)).limit(Math.min(pageSize, 100));
921
1486
  return {
922
1487
  content: [
923
1488
  {
924
1489
  type: "text",
925
- text: `Found ${rows.length} tickets:
1490
+ text: `Found ${rows.length} projects:
926
1491
 
927
1492
  ${rows.map(
928
- (t) => `**${t.ticketNumber}**: ${t.title}
929
- Status: ${t.status} | Priority: ${t.priority}
930
- ${t.projectName ? `Project: ${t.projectName}
931
- ` : ""}${t.customerName ? `Customer: ${t.customerName}
932
- ` : ""}Created: ${new Date(t.createdAt).toLocaleDateString()}
1493
+ (p) => `**${p.name}** (ID: ${p.id})
1494
+ ${p.description ? `Description: ${p.description}
1495
+ ` : ""}Created: ${new Date(p.createdAt).toLocaleDateString()}
933
1496
  `
934
- ).join("\n") || "No tickets found."}`
1497
+ ).join("\n") || "No projects found."}`
935
1498
  }
936
1499
  ]
937
1500
  };
938
1501
  }
939
- async function handleGetTicketById(input) {
940
- const ctx = authContext;
941
- const { id } = input;
942
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
943
- const projectIds = await getAccessibleProjectIds(ctx.userId, ctx.teamId);
944
- const customerIds = await getAccessibleCustomerIds(ctx.teamId);
945
- const ticketRow = await db.query.tickets.findFirst({
946
- where: eq(schema.tickets.id, id),
947
- with: {
948
- project: { columns: { id: true, name: true } },
949
- customer: { columns: { id: true, name: true } },
950
- assignee: { columns: { id: true, fullName: true, email: true } },
951
- requester: { columns: { id: true, fullName: true, email: true } }
952
- }
1502
+ async function handleCreateProject(input) {
1503
+ const { name, description, customerId } = input;
1504
+ const resolved = await resolveTeamId(input.teamId);
1505
+ if (!resolved.ok) return resolved.response;
1506
+ await db.insert(schema.projects).values({
1507
+ teamId: resolved.teamId,
1508
+ name,
1509
+ description: description ?? null,
1510
+ customerId: customerId ?? null
953
1511
  });
954
- if (!ticketRow) {
955
- throw new Error(`Ticket not found: ${id}`);
956
- }
957
- let hasAccess = false;
958
- if (teamIds.includes(ticketRow.teamId)) hasAccess = true;
959
- if (!hasAccess && ticketRow.projectId && projectIds.includes(ticketRow.projectId))
960
- hasAccess = true;
961
- if (!hasAccess && ticketRow.customerId && customerIds.includes(ticketRow.customerId))
962
- hasAccess = true;
963
- if (!hasAccess) {
964
- throw new Error(
965
- "Access denied: You do not have permission to view this ticket"
966
- );
967
- }
968
- const attachments = await db.select({
969
- id: schema.ticketAttachments.id,
970
- fileName: schema.ticketAttachments.fileName,
971
- fileSize: schema.ticketAttachments.fileSize,
972
- mimeType: schema.ticketAttachments.mimeType,
973
- storageKey: schema.ticketAttachments.storageKey,
974
- createdAt: schema.ticketAttachments.createdAt,
975
- uploaderId: schema.ticketAttachments.userId,
976
- uploaderName: schema.users.fullName
977
- }).from(schema.ticketAttachments).leftJoin(
978
- schema.users,
979
- eq(schema.users.id, schema.ticketAttachments.userId)
980
- ).where(eq(schema.ticketAttachments.ticketId, id)).orderBy(asc(schema.ticketAttachments.createdAt));
981
- const comments = await db.select({
982
- id: schema.ticketComments.id,
983
- content: schema.ticketComments.content,
984
- createdAt: schema.ticketComments.createdAt,
985
- userId: schema.ticketComments.userId
986
- }).from(schema.ticketComments).where(eq(schema.ticketComments.ticketId, id)).orderBy(asc(schema.ticketComments.createdAt));
987
- const commentUserIds = [
988
- ...new Set(
989
- comments.map((c) => c.userId).filter((v) => Boolean(v))
990
- )
991
- ];
992
- const commentUserMap = /* @__PURE__ */ new Map();
993
- if (commentUserIds.length > 0) {
994
- const commentUsers = await db.select({ id: schema.users.id, fullName: schema.users.fullName }).from(schema.users).where(inArray(schema.users.id, commentUserIds));
995
- commentUsers.forEach((u) => commentUserMap.set(u.id, u));
996
- }
997
- const commentIds = comments.map((c) => c.id);
998
- const commentAttachments = commentIds.length > 0 ? await db.select({
999
- id: schema.ticketCommentAttachments.id,
1000
- commentId: schema.ticketCommentAttachments.commentId,
1001
- fileName: schema.ticketCommentAttachments.fileName,
1002
- fileSize: schema.ticketCommentAttachments.fileSize,
1003
- mimeType: schema.ticketCommentAttachments.mimeType,
1004
- storageKey: schema.ticketCommentAttachments.storageKey,
1005
- createdAt: schema.ticketCommentAttachments.createdAt
1006
- }).from(schema.ticketCommentAttachments).where(
1007
- inArray(schema.ticketCommentAttachments.commentId, commentIds)
1008
- ) : [];
1009
- const content = [
1010
- {
1011
- type: "text",
1012
- text: `**Ticket Details:**
1512
+ return {
1513
+ content: [
1514
+ {
1515
+ type: "text",
1516
+ text: `\u2705 **Project Created Successfully!**
1013
1517
 
1014
- **${ticketRow.ticketNumber}**: ${ticketRow.title}
1015
- Status: ${ticketRow.status}
1016
- Priority: ${ticketRow.priority}
1017
- Type: ${ticketRow.type}
1018
- ${ticketRow.description ? `Description: ${ticketRow.description}
1019
- ` : ""}${ticketRow.project?.name ? `Project: ${ticketRow.project.name}
1020
- ` : ""}${ticketRow.customer?.name ? `Customer: ${ticketRow.customer.name}
1021
- ` : ""}${ticketRow.assignee?.fullName ? `Assignee: ${ticketRow.assignee.fullName}
1022
- ` : ""}Requester: ${ticketRow.requester?.fullName || "Unknown"}
1023
- Created: ${new Date(ticketRow.createdAt).toLocaleDateString()}
1024
- ${attachments.length > 0 ? `
1025
- \u{1F4CE} Attachments: ${attachments.length}
1026
- ` : ""}${comments.length > 0 ? `\u{1F4AC} Comments: ${comments.length}
1518
+ Name: ${name}
1519
+ ${description ? `Description: ${description}
1027
1520
  ` : ""}`
1028
- }
1029
- ];
1030
- if (attachments.length > 0) {
1031
- console.error(`\u{1F4CE} Processing ${attachments.length} ticket attachments...`);
1032
- for (const attachment of attachments) {
1033
- if (isImageFile(attachment.mimeType)) {
1034
- console.error(`\u{1F5BC}\uFE0F Downloading image: ${attachment.fileName}`);
1035
- const base64 = await downloadImageAsBase64(attachment.storageKey);
1036
- if (base64) {
1037
- content.push({
1038
- type: "image",
1039
- data: base64,
1040
- mimeType: attachment.mimeType
1041
- });
1042
- content.push({
1043
- type: "text",
1044
- text: `
1045
- \u{1F4F8} **Image from ticket**: ${attachment.fileName} (${Math.round(
1046
- attachment.fileSize / 1024
1047
- )}KB, uploaded by ${attachment.uploaderName || "Unknown"} on ${new Date(
1048
- attachment.createdAt
1049
- ).toLocaleDateString()})
1050
- `
1051
- });
1052
- }
1053
- }
1054
- }
1055
- }
1056
- if (commentAttachments.length > 0) {
1057
- console.error(
1058
- `\u{1F4CE} Processing ${commentAttachments.length} comment attachments...`
1059
- );
1060
- for (const attachment of commentAttachments) {
1061
- if (isImageFile(attachment.mimeType)) {
1062
- console.error(
1063
- `\u{1F5BC}\uFE0F Downloading comment image: ${attachment.fileName}`
1064
- );
1065
- const base64 = await downloadImageAsBase64(attachment.storageKey);
1066
- if (base64) {
1067
- const comment = comments.find((c) => c.id === attachment.commentId);
1068
- const author = comment?.userId ? commentUserMap.get(comment.userId)?.fullName : null;
1069
- content.push({
1070
- type: "image",
1071
- data: base64,
1072
- mimeType: attachment.mimeType
1073
- });
1074
- content.push({
1075
- type: "text",
1076
- text: `
1077
- \u{1F4F8} **Image from comment** by ${author || "Unknown"} on ${new Date(
1078
- attachment.createdAt
1079
- ).toLocaleDateString()}: ${attachment.fileName} (${Math.round(
1080
- attachment.fileSize / 1024
1081
- )}KB)
1082
- ` + (comment?.content ? `Comment text: "${comment.content.substring(0, 100)}${comment.content.length > 100 ? "..." : ""}"
1083
- ` : "")
1084
- });
1085
- }
1086
1521
  }
1522
+ ]
1523
+ };
1524
+ }
1525
+
1526
+ // src/tools/session-completion.ts
1527
+ init_auth();
1528
+ init_db();
1529
+ async function handleGetCompletionContext(input) {
1530
+ const {
1531
+ aiSessionId,
1532
+ includeFollowUps = true,
1533
+ includeTimeMetrics = true,
1534
+ includeTodos = true
1535
+ } = input;
1536
+ const scope = await resolveTeamScope(input.teamId);
1537
+ if (!scope.ok) return scope.response;
1538
+ const prefix = aiSessionId.replace("ai-sess-", "");
1539
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
1540
+ if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1541
+ const [session] = await db.select({
1542
+ id: schema.aiSessions.id,
1543
+ ticketId: schema.aiSessions.ticketId,
1544
+ aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
1545
+ actualTimeMinutes: schema.aiSessions.actualTimeMinutes,
1546
+ efficiencyScore: schema.aiSessions.efficiencyScore,
1547
+ createdAt: schema.aiSessions.createdAt,
1548
+ completedAt: schema.aiSessions.completedAt,
1549
+ status: schema.aiSessions.status,
1550
+ complexityScore: schema.aiSessions.complexityScore
1551
+ }).from(schema.aiSessions).where(eq(schema.aiSessions.id, fullSessionId)).limit(1);
1552
+ if (!session) throw new Error(`Session not found: ${aiSessionId}`);
1553
+ const [ticket] = await db.select({
1554
+ ticketNumber: schema.tickets.ticketNumber,
1555
+ title: schema.tickets.title,
1556
+ description: schema.tickets.description,
1557
+ type: schema.tickets.type,
1558
+ priority: schema.tickets.priority
1559
+ }).from(schema.tickets).where(eq(schema.tickets.id, session.ticketId)).limit(1);
1560
+ if (!ticket) throw new Error("Ticket not found for session");
1561
+ const contextData = {
1562
+ session: {
1563
+ id: aiSessionId,
1564
+ status: session.status,
1565
+ complexity: session.complexityScore,
1566
+ createdAt: session.createdAt,
1567
+ completedAt: session.completedAt
1568
+ },
1569
+ ticket: {
1570
+ number: ticket.ticketNumber,
1571
+ title: ticket.title,
1572
+ description: ticket.description,
1573
+ type: ticket.type,
1574
+ priority: ticket.priority
1087
1575
  }
1576
+ };
1577
+ if (includeTimeMetrics) {
1578
+ const timeSaved = session.aiTimeEstimateMinutes && session.actualTimeMinutes ? Math.max(0, session.aiTimeEstimateMinutes - session.actualTimeMinutes) : null;
1579
+ contextData.timeMetrics = {
1580
+ estimatedMinutes: session.aiTimeEstimateMinutes,
1581
+ actualMinutes: session.actualTimeMinutes,
1582
+ timeSaved,
1583
+ efficiency: session.efficiencyScore,
1584
+ sessionDuration: session.completedAt && session.createdAt ? Math.round(
1585
+ (new Date(session.completedAt).getTime() - new Date(session.createdAt).getTime()) / 6e4
1586
+ ) : null
1587
+ };
1088
1588
  }
1089
- console.error(
1090
- `\u2705 Returning ticket with ${content.filter((c) => c.type === "image").length} images`
1091
- );
1092
- return { content };
1093
- }
1094
- async function handleCreateTicket(input) {
1095
- const ctx = authContext;
1096
- const {
1097
- title,
1098
- description,
1099
- status = "open",
1100
- priority = "medium",
1101
- type = "task",
1102
- projectId,
1103
- customerId
1104
- } = input;
1105
- const year = (/* @__PURE__ */ new Date()).getFullYear();
1106
- let resolvedTeamId = ctx.teamId;
1107
- let resolvedCustomerId = customerId;
1108
- let projectAbbreviation = "";
1109
- if (projectId) {
1110
- const [project] = await db.select({
1111
- name: schema.projects.name,
1112
- teamId: schema.projects.teamId,
1113
- customerId: schema.projects.customerId
1114
- }).from(schema.projects).where(eq(schema.projects.id, projectId)).limit(1);
1115
- if (project) {
1116
- if (project.teamId) resolvedTeamId = project.teamId;
1117
- if (!resolvedCustomerId && project.customerId) {
1118
- resolvedCustomerId = project.customerId;
1119
- }
1120
- if (project.name) {
1121
- const upper = project.name.toUpperCase().replace(/[^A-Z0-9\s]/g, "");
1122
- const words = upper.split(/\s+/).filter(Boolean);
1123
- if (words.length >= 2) {
1124
- projectAbbreviation = words.slice(0, 2).map((w) => w.substring(0, 3)).join("").substring(0, 5);
1125
- } else if (words.length === 1 && words[0]) {
1126
- projectAbbreviation = words[0].substring(0, 5);
1127
- }
1128
- }
1129
- }
1589
+ if (includeTodos) {
1590
+ const todos = await db.select({
1591
+ content: schema.aiTodos.content,
1592
+ status: schema.aiTodos.status,
1593
+ estimatedMinutes: schema.aiTodos.estimatedMinutes,
1594
+ actualMinutes: schema.aiTodos.actualMinutes,
1595
+ completedAt: schema.aiTodos.completedAt
1596
+ }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, session.id)).orderBy(asc(schema.aiTodos.createdAt));
1597
+ contextData.todos = todos;
1130
1598
  }
1131
- let ticketNumber;
1132
- if (projectId && projectAbbreviation) {
1133
- const pattern = `${year}-${projectAbbreviation}-%`;
1134
- const [highest] = await db.select({ ticketNumber: schema.tickets.ticketNumber }).from(schema.tickets).where(
1135
- and(
1136
- eq(schema.tickets.projectId, projectId),
1137
- ilike(schema.tickets.ticketNumber, pattern)
1138
- )
1139
- ).orderBy(desc(schema.tickets.ticketNumber)).limit(1);
1140
- let nextSequence = 1;
1141
- if (highest?.ticketNumber) {
1142
- const parts = highest.ticketNumber.split("-");
1143
- if (parts.length === 3 && parts[2]) {
1144
- const lastSeq = Number.parseInt(parts[2], 10);
1145
- if (!Number.isNaN(lastSeq)) nextSequence = lastSeq + 1;
1146
- }
1147
- }
1148
- ticketNumber = `${year}-${projectAbbreviation}-${String(nextSequence).padStart(3, "0")}`;
1149
- } else {
1150
- const [countRow] = await db.select({ n: sql`count(*)::int` }).from(schema.tickets).where(eq(schema.tickets.teamId, resolvedTeamId));
1151
- const count = Number(countRow?.n ?? 0);
1152
- ticketNumber = `${year}-${String(count + 1).padStart(3, "0")}`;
1599
+ if (includeFollowUps) {
1600
+ const followUps = await db.select({
1601
+ followUpReason: schema.manualFollowUps.followUpReason,
1602
+ outcome: schema.manualFollowUps.outcome,
1603
+ timeSpentMinutes: schema.manualFollowUps.timeSpentMinutes,
1604
+ createdAt: schema.manualFollowUps.createdAt
1605
+ }).from(schema.manualFollowUps).where(eq(schema.manualFollowUps.aiSessionId, session.id)).orderBy(asc(schema.manualFollowUps.createdAt));
1606
+ contextData.followUps = followUps;
1153
1607
  }
1154
- await db.insert(schema.tickets).values({
1155
- teamId: resolvedTeamId,
1156
- ticketNumber,
1157
- title,
1158
- description: description ?? null,
1159
- status,
1160
- priority,
1161
- type,
1162
- projectId: projectId ?? null,
1163
- customerId: resolvedCustomerId ?? null,
1164
- requesterId: ctx.userId
1608
+ const todosLen = contextData.todos ?? [];
1609
+ const completedTodos = todosLen.filter(
1610
+ (t) => t.status === "completed"
1611
+ ).length;
1612
+ const followUpsLen = contextData.followUps?.length ?? 0;
1613
+ return {
1614
+ content: [
1615
+ {
1616
+ type: "text",
1617
+ text: `\u{1F4CB} **Completion Context Retrieved!**
1618
+
1619
+ \u{1F3AB} **Ticket:** ${ticket.ticketNumber} - ${ticket.title}
1620
+ \u{1F194} **Session:** ${aiSessionId} (${session.status})
1621
+ \u23F1\uFE0F **Time:** ${session.actualTimeMinutes || "N/A"}/${session.aiTimeEstimateMinutes || "N/A"} minutes
1622
+ \u{1F4CB} **Todos:** ${completedTodos}/${todosLen.length} completed
1623
+ \u{1F504} **Follow-ups:** ${followUpsLen}
1624
+
1625
+ \u2705 **Full context ready for Cursor AI to generate customer response!**
1626
+
1627
+ **Context Data:**
1628
+ \`\`\`json
1629
+ ${JSON.stringify(contextData, null, 2)}\`\`\``
1630
+ }
1631
+ ]
1632
+ };
1633
+ }
1634
+ async function handleSaveCustomerResponse(input) {
1635
+ const { aiSessionId, customerResponse, responseType = "completion" } = input;
1636
+ const scope = await resolveTeamScope(input.teamId);
1637
+ if (!scope.ok) return scope.response;
1638
+ const prefix = aiSessionId.replace("ai-sess-", "");
1639
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
1640
+ if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1641
+ await db.insert(schema.aiResponses).values({
1642
+ aiSessionId: fullSessionId,
1643
+ responseType,
1644
+ content: customerResponse,
1645
+ isReadyForCustomer: true,
1646
+ providerApproved: false
1165
1647
  });
1166
1648
  return {
1167
1649
  content: [
1168
1650
  {
1169
1651
  type: "text",
1170
- text: `\u2705 **Ticket Created Successfully!**
1652
+ text: `\u{1F4BE} **Customer Response Saved!**
1171
1653
 
1172
- Ticket Number: **${ticketNumber}**
1173
- Title: ${title}
1174
- Status: ${status}
1175
- Priority: ${priority}
1176
- Type: ${type}
1177
- `
1654
+ \u{1F194} Session: ${aiSessionId}
1655
+ \u{1F4DD} Response Type: ${responseType}
1656
+ \u{1F4C4} Length: ${customerResponse.length} characters
1657
+
1658
+ \u2705 **Response ready for provider approval**
1659
+ \u{1F50D} Provider can review in AI tab before sending to customer
1660
+
1661
+ **Preview:**
1662
+ \`\`\`
1663
+ ${customerResponse.substring(0, 200)}${customerResponse.length > 200 ? "..." : ""}\`\`\``
1178
1664
  }
1179
1665
  ]
1180
1666
  };
1181
1667
  }
1182
- async function handleGetCustomers(input) {
1668
+ async function handleCompleteAiSession(input) {
1183
1669
  const ctx = authContext;
1184
- const { q, pageSize = 20 } = input;
1185
- const customerIds = await getAccessibleCustomerIds(ctx.teamId);
1186
- if (customerIds.length === 0) {
1187
- return {
1188
- content: [
1189
- {
1190
- type: "text",
1191
- text: "No customers found or no access to any customers."
1192
- }
1193
- ]
1194
- };
1670
+ const {
1671
+ aiSessionId,
1672
+ workCompleted,
1673
+ technicalSummary,
1674
+ invoiceDescription,
1675
+ efficiencyNotes
1676
+ } = input;
1677
+ const scope = await resolveTeamScope(input.teamId);
1678
+ if (!scope.ok) return scope.response;
1679
+ const prefix = aiSessionId.replace("ai-sess-", "");
1680
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
1681
+ if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1682
+ const [existingSession] = await db.select({
1683
+ id: schema.aiSessions.id,
1684
+ ticketId: schema.aiSessions.ticketId,
1685
+ aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
1686
+ createdAt: schema.aiSessions.createdAt,
1687
+ teamId: schema.aiSessions.teamId
1688
+ }).from(schema.aiSessions).where(eq(schema.aiSessions.id, fullSessionId)).limit(1);
1689
+ if (!existingSession) {
1690
+ throw new Error(`Session not found: ${aiSessionId}`);
1691
+ }
1692
+ const completionTime = /* @__PURE__ */ new Date();
1693
+ const sessionStartTime = new Date(existingSession.createdAt);
1694
+ const timeSpentMinutes = Math.round(
1695
+ (completionTime.getTime() - sessionStartTime.getTime()) / 6e4
1696
+ );
1697
+ const [session] = await db.update(schema.aiSessions).set({
1698
+ status: "completed",
1699
+ actualTimeMinutes: timeSpentMinutes,
1700
+ completedAt: completionTime.toISOString(),
1701
+ efficiencyScore: null
1702
+ }).where(eq(schema.aiSessions.id, existingSession.id)).returning({
1703
+ id: schema.aiSessions.id,
1704
+ ticketId: schema.aiSessions.ticketId,
1705
+ aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
1706
+ createdAt: schema.aiSessions.createdAt
1707
+ });
1708
+ if (!session) throw new Error(`Failed to update session: ${aiSessionId}`);
1709
+ const efficiencyScore = session.aiTimeEstimateMinutes ? timeSpentMinutes / session.aiTimeEstimateMinutes : 1;
1710
+ await db.update(schema.aiSessions).set({ efficiencyScore: efficiencyScore.toFixed(2) }).where(eq(schema.aiSessions.id, session.id));
1711
+ const activePhases = await db.select().from(schema.aiTimeLogs).where(
1712
+ and(
1713
+ eq(schema.aiTimeLogs.aiSessionId, existingSession.id),
1714
+ eq(schema.aiTimeLogs.status, "in_progress")
1715
+ )
1716
+ );
1717
+ for (const phase of activePhases) {
1718
+ const duration = Math.round(
1719
+ (completionTime.getTime() - new Date(phase.startedAt).getTime()) / 1e3
1720
+ );
1721
+ await db.update(schema.aiTimeLogs).set({
1722
+ endedAt: completionTime.toISOString(),
1723
+ durationSeconds: duration,
1724
+ status: "completed"
1725
+ }).where(eq(schema.aiTimeLogs.id, phase.id));
1726
+ }
1727
+ await db.update(schema.aiTimeLogs).set({ status: "skipped" }).where(
1728
+ and(
1729
+ eq(schema.aiTimeLogs.aiSessionId, existingSession.id),
1730
+ eq(schema.aiTimeLogs.status, "pending"),
1731
+ eq(schema.aiTimeLogs.estimatedDurationSeconds, 0)
1732
+ )
1733
+ );
1734
+ const sessionDuration = Math.round(
1735
+ (completionTime.getTime() - new Date(session.createdAt).getTime()) / 6e4
1736
+ );
1737
+ const workSummary = `Completed ${workCompleted.length} tasks including: ${workCompleted.slice(0, 3).join(", ")}${workCompleted.length > 3 ? " and more" : ""}.`;
1738
+ const [ticketInfo] = await db.select({
1739
+ ticketNumber: schema.tickets.ticketNumber,
1740
+ title: schema.tickets.title,
1741
+ projectId: schema.tickets.projectId
1742
+ }).from(schema.tickets).where(eq(schema.tickets.id, session.ticketId)).limit(1);
1743
+ let completionDescription;
1744
+ if (invoiceDescription) {
1745
+ completionDescription = `${ticketInfo?.ticketNumber || "Ticket"}: ${invoiceDescription}`;
1746
+ } else {
1747
+ const workDescription = workCompleted.map((task, index) => `${index + 1}. ${task}`).join("\n");
1748
+ completionDescription = `${ticketInfo?.ticketNumber || "Ticket"}: ${technicalSummary || workSummary}
1749
+
1750
+ Completed work:
1751
+ ${workDescription}`;
1752
+ }
1753
+ const estimatedMinutes = session.aiTimeEstimateMinutes ?? timeSpentMinutes;
1754
+ const sessionStart = new Date(session.createdAt);
1755
+ const estimatedEnd = new Date(
1756
+ sessionStart.getTime() + estimatedMinutes * 6e4
1757
+ );
1758
+ const existingAgendaEntries = await db.select({
1759
+ id: schema.timesheetEvents.id,
1760
+ trackedDuration: schema.timesheetEvents.trackedDuration
1761
+ }).from(schema.timesheetEvents).where(
1762
+ and(
1763
+ eq(schema.timesheetEvents.aiSessionId, session.id),
1764
+ eq(schema.timesheetEvents.status, "draft")
1765
+ )
1766
+ ).orderBy(desc(schema.timesheetEvents.createdAt));
1767
+ let timesheetEventId = null;
1768
+ let wasUpdated = false;
1769
+ let consolidatedCount = 0;
1770
+ const existingAgendaEntry = existingAgendaEntries[0] ?? null;
1771
+ if (existingAgendaEntries.length > 1) {
1772
+ const duplicateIds = existingAgendaEntries.slice(1).map((e) => e.id);
1773
+ await db.delete(schema.timesheetEvents).where(inArray(schema.timesheetEvents.id, duplicateIds));
1774
+ consolidatedCount = existingAgendaEntries.length - 1;
1195
1775
  }
1196
- const filters = [inArray(schema.customers.id, customerIds)];
1197
- if (q) {
1198
- const pattern = `%${q}%`;
1199
- filters.push(
1200
- or(
1201
- ilike(schema.customers.name, pattern),
1202
- ilike(schema.customers.email, pattern)
1203
- )
1776
+ try {
1777
+ if (existingAgendaEntry) {
1778
+ const [updated] = await db.update(schema.timesheetEvents).set({
1779
+ title: ticketInfo?.title || "Development Work",
1780
+ description: completionDescription,
1781
+ endTime: estimatedEnd.toISOString(),
1782
+ projectId: ticketInfo?.projectId ?? null,
1783
+ trackedDuration: estimatedMinutes * 60
1784
+ }).where(eq(schema.timesheetEvents.id, existingAgendaEntry.id)).returning({ id: schema.timesheetEvents.id });
1785
+ timesheetEventId = updated?.id ?? null;
1786
+ wasUpdated = true;
1787
+ } else {
1788
+ const [created] = await db.insert(schema.timesheetEvents).values({
1789
+ teamId: existingSession.teamId,
1790
+ userId: ctx.userId,
1791
+ title: ticketInfo?.title || "Development Work",
1792
+ description: completionDescription,
1793
+ startTime: sessionStart.toISOString(),
1794
+ endTime: estimatedEnd.toISOString(),
1795
+ projectId: ticketInfo?.projectId ?? null,
1796
+ aiSessionId: session.id,
1797
+ type: "work",
1798
+ status: "draft",
1799
+ allDay: false,
1800
+ isTracked: true,
1801
+ trackedDuration: estimatedMinutes * 60
1802
+ }).returning({ id: schema.timesheetEvents.id });
1803
+ timesheetEventId = created?.id ?? null;
1804
+ }
1805
+ if (timesheetEventId && session.ticketId) {
1806
+ await db.insert(schema.timesheetEventTickets).values({
1807
+ timesheetEventId,
1808
+ ticketId: session.ticketId
1809
+ }).onConflictDoNothing();
1810
+ }
1811
+ } catch (err) {
1812
+ console.error(
1813
+ `\u26A0\uFE0F Failed to ${wasUpdated ? "update" : "create"} agenda event:`,
1814
+ err
1204
1815
  );
1205
1816
  }
1206
- const rows = await db.select({
1207
- id: schema.customers.id,
1208
- name: schema.customers.name,
1209
- email: schema.customers.email,
1210
- website: schema.customers.website,
1211
- createdAt: schema.customers.createdAt
1212
- }).from(schema.customers).where(and(...filters)).orderBy(asc(schema.customers.name)).limit(Math.min(pageSize, 100));
1213
- return {
1214
- content: [
1215
- {
1216
- type: "text",
1217
- text: `Found ${rows.length} customers:
1817
+ if (consolidatedCount > 0) {
1818
+ console.log(
1819
+ `\u{1F9F9} Cleaned up ${consolidatedCount} duplicate agenda entries for session ${aiSessionId}`
1820
+ );
1821
+ }
1822
+ let responseText = `\u{1F389} **AI Session Completed Successfully!**
1218
1823
 
1219
- ${rows.map(
1220
- (c) => `**${c.name}**
1221
- ${c.email ? `Email: ${c.email}
1222
- ` : ""}${c.website ? `Website: ${c.website}
1223
- ` : ""}Created: ${new Date(c.createdAt).toLocaleDateString()}
1224
- `
1225
- ).join("\n") || "No customers found."}`
1226
- }
1227
- ]
1228
- };
1229
- }
1230
- async function handleCreateCustomer(input) {
1231
- const ctx = authContext;
1232
- const { name, email, website } = input;
1233
- await db.insert(schema.customers).values({
1234
- teamId: ctx.teamId,
1235
- name,
1236
- email: email ?? "",
1237
- website: website ?? null
1824
+ `;
1825
+ responseText += `\u{1F194} Session: ${aiSessionId}
1826
+ `;
1827
+ responseText += `\u{1F4CA} **Performance Summary:**
1828
+ `;
1829
+ responseText += ` \u2022 Tasks Completed: ${workCompleted.length}
1830
+ `;
1831
+ responseText += ` \u2022 Time Spent: ${timeSpentMinutes} minutes
1832
+ `;
1833
+ responseText += ` \u2022 Estimated Time: ${session.aiTimeEstimateMinutes || "N/A"} minutes
1834
+ `;
1835
+ responseText += ` \u2022 Efficiency: ${efficiencyScore < 1 ? "\u{1F680}" : efficiencyScore > 1.5 ? "\u26A0\uFE0F" : "\u23F1\uFE0F"} ${(efficiencyScore * 100).toFixed(0)}%
1836
+ `;
1837
+ responseText += ` \u2022 Session Duration: ${sessionDuration} minutes
1838
+
1839
+ `;
1840
+ responseText += `\u2705 **Work Completed:**
1841
+ `;
1842
+ workCompleted.forEach((task, index) => {
1843
+ responseText += `${index + 1}. ${task}
1844
+ `;
1238
1845
  });
1239
- return {
1240
- content: [
1241
- {
1242
- type: "text",
1243
- text: `\u2705 **Customer Created Successfully!**
1846
+ responseText += `
1847
+ `;
1848
+ if (technicalSummary) {
1849
+ responseText += `\u{1F527} **Technical Summary:**
1850
+ ${technicalSummary}
1244
1851
 
1245
- Name: ${name}
1246
- ${email ? `Email: ${email}
1247
- ` : ""}${website ? `Website: ${website}
1248
- ` : ""}`
1249
- }
1250
- ]
1251
- };
1252
- }
1253
- async function handleGetProjects(input) {
1254
- const ctx = authContext;
1255
- const { customerId, q, pageSize = 20 } = input;
1256
- const projectIds = await getAccessibleProjectIds(ctx.userId, ctx.teamId);
1257
- if (projectIds.length === 0) {
1258
- return {
1259
- content: [
1260
- {
1261
- type: "text",
1262
- text: "No projects found or no access to any projects."
1263
- }
1264
- ]
1265
- };
1852
+ `;
1266
1853
  }
1267
- const filters = [inArray(schema.projects.id, projectIds)];
1268
- if (customerId) filters.push(eq(schema.projects.customerId, customerId));
1269
- if (q) filters.push(ilike(schema.projects.name, `%${q}%`));
1270
- const rows = await db.select({
1271
- id: schema.projects.id,
1272
- name: schema.projects.name,
1273
- description: schema.projects.description,
1274
- customerId: schema.projects.customerId,
1275
- createdAt: schema.projects.createdAt
1276
- }).from(schema.projects).where(and(...filters)).orderBy(asc(schema.projects.name)).limit(Math.min(pageSize, 100));
1277
- return {
1278
- content: [
1279
- {
1280
- type: "text",
1281
- text: `Found ${rows.length} projects:
1854
+ if (efficiencyNotes) {
1855
+ responseText += `\u{1F4C8} **Efficiency Notes:**
1856
+ ${efficiencyNotes}
1282
1857
 
1283
- ${rows.map(
1284
- (p) => `**${p.name}** (ID: ${p.id})
1285
- ${p.description ? `Description: ${p.description}
1286
- ` : ""}Created: ${new Date(p.createdAt).toLocaleDateString()}
1287
- `
1288
- ).join("\n") || "No projects found."}`
1289
- }
1290
- ]
1291
- };
1858
+ `;
1859
+ }
1860
+ if (timesheetEventId) {
1861
+ responseText += `\u{1F4C5} **Timetrack Entry ${wasUpdated ? "Updated" : "Created"}:**
1862
+ `;
1863
+ responseText += ` \u2022 Agenda event ${wasUpdated ? "updated with final" : "created with"} work summary
1864
+ `;
1865
+ responseText += ` \u2022 Status: DRAFT (requires approval in agenda)
1866
+ `;
1867
+ responseText += ` \u2022 Duration: ${estimatedMinutes} minutes
1868
+ `;
1869
+ responseText += ` \u2022 Period: ${sessionStart.toLocaleString()} - ${completionTime.toLocaleString()}
1870
+
1871
+ `;
1872
+ }
1873
+ responseText += `\u{1F4CB} **Context for Customer Response:**
1874
+ `;
1875
+ responseText += ` \u2022 Use "get-completion-context" to retrieve full context
1876
+ `;
1877
+ responseText += ` \u2022 Generate customer-friendly response based on completed work
1878
+ `;
1879
+ responseText += ` \u2022 Focus on business value and customer benefits
1880
+
1881
+ `;
1882
+ responseText += `\u{1F3AF} **Session archived successfully!**`;
1883
+ return { content: [{ type: "text", text: responseText }] };
1292
1884
  }
1293
- async function handleCreateProject(input) {
1294
- const ctx = authContext;
1295
- const { name, description, customerId } = input;
1296
- await db.insert(schema.projects).values({
1297
- teamId: ctx.teamId,
1298
- name,
1299
- description: description ?? null,
1300
- customerId: customerId ?? null
1301
- });
1302
- return {
1303
- content: [
1304
- {
1305
- type: "text",
1306
- text: `\u2705 **Project Created Successfully!**
1307
1885
 
1308
- Name: ${name}
1309
- ${description ? `Description: ${description}
1310
- ` : ""}`
1886
+ // src/tools/sessions.ts
1887
+ init_auth();
1888
+ init_db();
1889
+ async function transitionToNextPhase(sessionId, currentPhase) {
1890
+ try {
1891
+ const now = /* @__PURE__ */ new Date();
1892
+ const phaseOrder = [
1893
+ "analysis",
1894
+ "bug_investigation",
1895
+ "development",
1896
+ "communication"
1897
+ ];
1898
+ const allPhases = await db.select().from(schema.aiTimeLogs).where(eq(schema.aiTimeLogs.aiSessionId, sessionId)).orderBy(asc(schema.aiTimeLogs.activityType));
1899
+ let currentPhaseType = currentPhase;
1900
+ if (!currentPhaseType) {
1901
+ const activePhase = allPhases.find((p) => p.status === "in_progress");
1902
+ currentPhaseType = activePhase?.activityType ?? void 0;
1903
+ }
1904
+ if (!currentPhaseType) {
1905
+ const analysisPhase = allPhases.find(
1906
+ (p) => p.activityType === "analysis"
1907
+ );
1908
+ if (analysisPhase && analysisPhase.status === "pending" && (analysisPhase.estimatedDurationSeconds ?? 0) > 0) {
1909
+ await db.update(schema.aiTimeLogs).set({ status: "in_progress", startedAt: now.toISOString() }).where(eq(schema.aiTimeLogs.id, analysisPhase.id));
1910
+ console.error("\u2705 Started analysis phase");
1911
+ }
1912
+ return;
1913
+ }
1914
+ const currentPhaseRecord = allPhases.find(
1915
+ (p) => p.activityType === currentPhaseType && p.status === "in_progress"
1916
+ );
1917
+ if (currentPhaseRecord) {
1918
+ const duration = Math.round(
1919
+ (now.getTime() - new Date(currentPhaseRecord.startedAt).getTime()) / 1e3
1920
+ );
1921
+ await db.update(schema.aiTimeLogs).set({
1922
+ status: "completed",
1923
+ endedAt: now.toISOString(),
1924
+ durationSeconds: duration
1925
+ }).where(eq(schema.aiTimeLogs.id, currentPhaseRecord.id));
1926
+ console.error(`\u2705 Completed phase: ${currentPhaseType} (${duration}s)`);
1927
+ }
1928
+ const currentIndex = phaseOrder.indexOf(currentPhaseType);
1929
+ if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) {
1930
+ console.error("No next phase to transition to");
1931
+ return;
1932
+ }
1933
+ for (let i = currentIndex + 1; i < phaseOrder.length; i++) {
1934
+ const nextPhaseType = phaseOrder[i];
1935
+ const nextPhase = allPhases.find((p) => p.activityType === nextPhaseType);
1936
+ if (!nextPhase) continue;
1937
+ if ((nextPhase.estimatedDurationSeconds ?? 0) === 0) {
1938
+ await db.update(schema.aiTimeLogs).set({ status: "skipped" }).where(eq(schema.aiTimeLogs.id, nextPhase.id));
1939
+ console.error(
1940
+ `\u23ED\uFE0F Skipped phase: ${nextPhaseType} (0 minutes estimated)`
1941
+ );
1942
+ continue;
1943
+ }
1944
+ if (nextPhase.status === "pending") {
1945
+ await db.update(schema.aiTimeLogs).set({ status: "in_progress", startedAt: now.toISOString() }).where(eq(schema.aiTimeLogs.id, nextPhase.id));
1946
+ console.error(`\u2705 Started next phase: ${nextPhaseType}`);
1947
+ return;
1311
1948
  }
1312
- ]
1313
- };
1949
+ }
1950
+ console.error("All remaining phases skipped or completed");
1951
+ } catch (error) {
1952
+ console.error("Error transitioning to next phase:", error);
1953
+ }
1314
1954
  }
1315
1955
  async function handleStartAiSession(input) {
1316
1956
  const ctx = authContext;
1317
- const {
1318
- ticketId,
1319
- cursorSessionId,
1320
- totalEstimatedMinutes,
1321
- complexityScore
1322
- } = input;
1957
+ const { ticketId, cursorSessionId, totalEstimatedMinutes, complexityScore } = input;
1323
1958
  if (!totalEstimatedMinutes) {
1324
1959
  throw new Error("totalEstimatedMinutes is required");
1325
1960
  }
1961
+ const scope = await resolveTeamScope(input.teamId);
1962
+ if (!scope.ok) return scope.response;
1963
+ const [ticketRow] = await db.select({
1964
+ teamId: schema.tickets.teamId,
1965
+ projectId: schema.tickets.projectId,
1966
+ customerId: schema.tickets.customerId
1967
+ }).from(schema.tickets).where(eq(schema.tickets.id, ticketId)).limit(1);
1968
+ if (!ticketRow) {
1969
+ throw new Error(
1970
+ `Ticket not found: ${ticketId}. Call get-tickets to find the correct ticket.`
1971
+ );
1972
+ }
1973
+ const hasTicketAccess = scope.teamIds.includes(ticketRow.teamId) || !!ticketRow.projectId && scope.projectIds.includes(ticketRow.projectId) || !!ticketRow.customerId && scope.customerIds.includes(ticketRow.customerId);
1974
+ if (!hasTicketAccess) {
1975
+ throw new Error(`No access to ticket: ${ticketId}.`);
1976
+ }
1977
+ const insertTeamId = ticketRow.teamId;
1326
1978
  const roundedMinutes = roundToNearest15Minutes(totalEstimatedMinutes);
1327
1979
  const sessionStartTime = /* @__PURE__ */ new Date();
1328
1980
  const [sessionData] = await db.insert(schema.aiSessions).values({
1329
1981
  ticketId,
1330
1982
  providerUserId: ctx.userId,
1331
- teamId: ctx.teamId,
1983
+ teamId: insertTeamId,
1332
1984
  cursorSessionId: cursorSessionId ?? null,
1333
1985
  aiTimeEstimateMinutes: roundedMinutes,
1334
1986
  complexityScore: complexityScore ?? null,
@@ -1372,9 +2024,10 @@ async function handleTrackManualFollowUp(input) {
1372
2024
  estimatedMinutes,
1373
2025
  workDescription
1374
2026
  } = input;
2027
+ const scope = await resolveTeamScope(input.teamId);
2028
+ if (!scope.ok) return scope.response;
1375
2029
  const prefix = aiSessionId.replace("ai-sess-", "");
1376
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
1377
- const fullSessionId = await resolveAiSessionId(prefix, teamIds);
2030
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
1378
2031
  if (!fullSessionId) {
1379
2032
  throw new Error(`Session not found: ${aiSessionId}`);
1380
2033
  }
@@ -1382,14 +2035,13 @@ async function handleTrackManualFollowUp(input) {
1382
2035
  id: schema.aiSessions.id,
1383
2036
  status: schema.aiSessions.status,
1384
2037
  createdAt: schema.aiSessions.createdAt,
1385
- aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes
2038
+ aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
2039
+ teamId: schema.aiSessions.teamId
1386
2040
  }).from(schema.aiSessions).where(eq(schema.aiSessions.id, fullSessionId)).limit(1);
1387
2041
  if (!session) throw new Error(`Session not found: ${aiSessionId}`);
1388
2042
  const followUpTime = /* @__PURE__ */ new Date();
1389
2043
  const oldEstimate = session.aiTimeEstimateMinutes ?? 60;
1390
- const roundedFollowUpMinutes = roundToNearest15Minutes(
1391
- estimatedMinutes || 0
1392
- );
2044
+ const roundedFollowUpMinutes = roundToNearest15Minutes(estimatedMinutes || 0);
1393
2045
  const newEstimate = oldEstimate + roundedFollowUpMinutes;
1394
2046
  await db.update(schema.aiSessions).set({
1395
2047
  status: "in_progress",
@@ -1398,7 +2050,7 @@ async function handleTrackManualFollowUp(input) {
1398
2050
  await db.insert(schema.manualFollowUps).values({
1399
2051
  aiSessionId: session.id,
1400
2052
  developerId: ctx.userId,
1401
- teamId: ctx.teamId,
2053
+ teamId: session.teamId,
1402
2054
  originalPrompt,
1403
2055
  aiResponse,
1404
2056
  followUpPrompt: developerFollowUp,
@@ -1425,17 +2077,17 @@ async function handleTrackManualFollowUp(input) {
1425
2077
  actualTimeMinutes: totalMinutesElapsed
1426
2078
  }).where(eq(schema.aiSessions.id, session.id));
1427
2079
  const existingEntries = await db.select({
1428
- id: schema.agendaEvents.id,
1429
- trackedDuration: schema.agendaEvents.trackedDuration,
1430
- title: schema.agendaEvents.title,
1431
- description: schema.agendaEvents.description,
1432
- startTime: schema.agendaEvents.startTime
1433
- }).from(schema.agendaEvents).where(
2080
+ id: schema.timesheetEvents.id,
2081
+ trackedDuration: schema.timesheetEvents.trackedDuration,
2082
+ title: schema.timesheetEvents.title,
2083
+ description: schema.timesheetEvents.description,
2084
+ startTime: schema.timesheetEvents.startTime
2085
+ }).from(schema.timesheetEvents).where(
1434
2086
  and(
1435
- eq(schema.agendaEvents.aiSessionId, session.id),
1436
- eq(schema.agendaEvents.status, "draft")
2087
+ eq(schema.timesheetEvents.aiSessionId, session.id),
2088
+ eq(schema.timesheetEvents.status, "draft")
1437
2089
  )
1438
- ).orderBy(desc(schema.agendaEvents.createdAt));
2090
+ ).orderBy(desc(schema.timesheetEvents.createdAt));
1439
2091
  let trackerAction = "";
1440
2092
  let trackerDetails = "";
1441
2093
  let existingEntry = existingEntries[0] ?? null;
@@ -1445,32 +2097,33 @@ async function handleTrackManualFollowUp(input) {
1445
2097
  0
1446
2098
  );
1447
2099
  const duplicateIds = existingEntries.slice(1).map((e) => e.id);
1448
- await db.delete(schema.agendaEvents).where(inArray(schema.agendaEvents.id, duplicateIds));
2100
+ await db.delete(schema.timesheetEvents).where(inArray(schema.timesheetEvents.id, duplicateIds));
1449
2101
  if (existingEntry && totalExistingDuration > (existingEntry.trackedDuration ?? 0)) {
1450
- await db.update(schema.agendaEvents).set({ trackedDuration: totalExistingDuration }).where(eq(schema.agendaEvents.id, existingEntry.id));
1451
- existingEntry = { ...existingEntry, trackedDuration: totalExistingDuration };
2102
+ await db.update(schema.timesheetEvents).set({ trackedDuration: totalExistingDuration }).where(eq(schema.timesheetEvents.id, existingEntry.id));
2103
+ existingEntry = {
2104
+ ...existingEntry,
2105
+ trackedDuration: totalExistingDuration
2106
+ };
1452
2107
  }
1453
2108
  trackerAction = `Consolidated ${existingEntries.length} duplicate entries`;
1454
2109
  }
1455
2110
  if (existingEntry) {
1456
2111
  const newDuration = (existingEntry.trackedDuration ?? 0) + roundedFollowUpMinutes * 60;
1457
- await db.update(schema.agendaEvents).set({
2112
+ await db.update(schema.timesheetEvents).set({
1458
2113
  trackedDuration: newDuration,
1459
2114
  endTime: followUpTime.toISOString(),
1460
2115
  title: workDescription,
1461
2116
  description: workDescription
1462
- }).where(eq(schema.agendaEvents.id, existingEntry.id));
2117
+ }).where(eq(schema.timesheetEvents.id, existingEntry.id));
1463
2118
  trackerAction = trackerAction || "Updated existing tracker";
1464
2119
  trackerDetails = ` \u2022 Total tracked time: ${Math.round(newDuration / 60)} minutes (+${roundedFollowUpMinutes} min)
1465
2120
  \u2022 Description: ${workDescription}
1466
2121
  `;
1467
2122
  } else {
1468
2123
  const durationSeconds = roundedFollowUpMinutes * 60;
1469
- const startTime = new Date(
1470
- followUpTime.getTime() - durationSeconds * 1e3
1471
- );
1472
- await db.insert(schema.agendaEvents).values({
1473
- teamId: ctx.teamId,
2124
+ const startTime = new Date(followUpTime.getTime() - durationSeconds * 1e3);
2125
+ await db.insert(schema.timesheetEvents).values({
2126
+ teamId: session.teamId,
1474
2127
  userId: ctx.userId,
1475
2128
  aiSessionId: session.id,
1476
2129
  title: workDescription,
@@ -1515,16 +2168,16 @@ async function handleTrackManualFollowUp(input) {
1515
2168
  };
1516
2169
  }
1517
2170
  async function handleGetSessionContext(input) {
1518
- const ctx = authContext;
1519
2171
  const {
1520
2172
  aiSessionId,
1521
2173
  includeTicketData = true,
1522
2174
  includeTodoProgress = true,
1523
2175
  includeFollowUpHistory = false
1524
2176
  } = input;
2177
+ const scope = await resolveTeamScope(input.teamId);
2178
+ if (!scope.ok) return scope.response;
1525
2179
  const prefix = aiSessionId.replace("ai-sess-", "");
1526
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
1527
- const fullSessionId = await resolveAiSessionId(prefix, teamIds);
2180
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
1528
2181
  if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1529
2182
  const [session] = await db.select({
1530
2183
  id: schema.aiSessions.id,
@@ -1556,1011 +2209,1129 @@ async function handleGetSessionContext(input) {
1556
2209
  }).from(schema.tickets).where(eq(schema.tickets.id, session.ticketId)).limit(1);
1557
2210
  context.ticketData = ticket ?? null;
1558
2211
  }
1559
- if (includeTodoProgress) {
1560
- const todos = await db.select({
1561
- id: schema.aiTodos.id,
1562
- content: schema.aiTodos.content,
1563
- status: schema.aiTodos.status,
1564
- estimatedMinutes: schema.aiTodos.estimatedMinutes,
1565
- actualMinutes: schema.aiTodos.actualMinutes
1566
- }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, session.id)).orderBy(asc(schema.aiTodos.sequenceOrder));
1567
- context.todos = todos;
1568
- context.todoProgress = {
1569
- total: todos.length,
1570
- completed: todos.filter((t) => t.status === "completed").length,
1571
- inProgress: todos.filter((t) => t.status === "in_progress").length
1572
- };
1573
- }
1574
- if (includeFollowUpHistory) {
1575
- const followUps = await db.select({
1576
- followUpReason: schema.manualFollowUps.followUpReason,
1577
- outcome: schema.manualFollowUps.outcome,
1578
- timeSpentMinutes: schema.manualFollowUps.timeSpentMinutes,
1579
- createdAt: schema.manualFollowUps.createdAt
1580
- }).from(schema.manualFollowUps).where(eq(schema.manualFollowUps.aiSessionId, session.id)).orderBy(asc(schema.manualFollowUps.createdAt));
1581
- context.followUpHistory = followUps;
1582
- }
1583
- const ticketData = context.ticketData;
1584
- const todoProgress = context.todoProgress;
1585
- const followUpHistory = context.followUpHistory;
1586
- return {
1587
- content: [
1588
- {
1589
- type: "text",
1590
- text: `\u{1F3AF} **Session Context Retrieved**
1591
-
1592
- Session: ${aiSessionId}
1593
- Status: ${session.status}
1594
- ${ticketData ? `Ticket: ${ticketData.ticketNumber} - ${ticketData.title}
1595
- ` : ""}${todoProgress ? `Todo Progress: ${todoProgress.completed}/${todoProgress.total} completed
1596
- ` : ""}${followUpHistory ? `Follow-ups: ${followUpHistory.length}
1597
- ` : ""}
1598
- \u{1F4CB} Full context preserved for seamless continuation!`
1599
- }
1600
- ]
1601
- };
1602
- }
1603
- async function handleSyncSessionTodos(input) {
1604
- const ctx = authContext;
1605
- const { aiSessionId, todos, replaceAll = true } = input;
1606
- const prefix = aiSessionId.replace("ai-sess-", "");
1607
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
1608
- const fullSessionId = await resolveAiSessionId(prefix, teamIds);
1609
- if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1610
- if (replaceAll) {
1611
- await db.delete(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId));
1612
- }
1613
- if (todos && todos.length > 0) {
1614
- let startSequence = 0;
1615
- if (!replaceAll) {
1616
- const [maxTodo] = await db.select({ sequenceOrder: schema.aiTodos.sequenceOrder }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId)).orderBy(desc(schema.aiTodos.sequenceOrder)).limit(1);
1617
- startSequence = (maxTodo?.sequenceOrder ?? 0) + 1;
1618
- }
1619
- await db.insert(schema.aiTodos).values(
1620
- todos.map((todo, index) => ({
1621
- aiSessionId: fullSessionId,
1622
- content: todo.content,
1623
- status: todo.status,
1624
- cursorTodoId: todo.todoId ?? null,
1625
- estimatedMinutes: todo.estimatedMinutes ?? null,
1626
- sequenceOrder: startSequence + index
1627
- }))
1628
- );
1629
- }
1630
- let phaseTransition = null;
1631
- const currentTodos = await db.select({ status: schema.aiTodos.status }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId));
1632
- if (currentTodos.length > 0) {
1633
- const hasInProgress = currentTodos.some((t) => t.status === "in_progress");
1634
- const allCompleted = currentTodos.every((t) => t.status === "completed");
1635
- const [currentPhase] = await db.select({
1636
- activityType: schema.aiTimeLogs.activityType,
1637
- status: schema.aiTimeLogs.status
1638
- }).from(schema.aiTimeLogs).where(
1639
- and(
1640
- eq(schema.aiTimeLogs.aiSessionId, fullSessionId),
1641
- eq(schema.aiTimeLogs.status, "in_progress")
1642
- )
1643
- ).limit(1);
1644
- if (hasInProgress && currentPhase?.activityType === "analysis") {
1645
- await transitionToNextPhase(fullSessionId, "analysis");
1646
- phaseTransition = "Analysis completed \u2192 Next phase started (Investigation/Development)";
1647
- }
1648
- if (hasInProgress && currentPhase?.activityType === "bug_investigation") {
1649
- const completedCount = currentTodos.filter(
1650
- (t) => t.status === "completed"
1651
- ).length;
1652
- if (completedCount > 0) {
1653
- await transitionToNextPhase(fullSessionId, "bug_investigation");
1654
- phaseTransition = "Investigation completed \u2192 Development phase started";
1655
- }
1656
- }
1657
- if (allCompleted && currentPhase?.activityType === "development") {
1658
- await transitionToNextPhase(fullSessionId, "development");
1659
- phaseTransition = "Development completed \u2192 Communication phase started";
1660
- }
1661
- }
1662
- return {
1663
- content: [
1664
- {
1665
- type: "text",
1666
- text: `\u2705 **Todos ${replaceAll ? "Synced" : "Added"} Successfully!**
1667
-
1668
- Session: ${aiSessionId}
1669
- ${replaceAll ? "Synced" : "Added"} ${todos?.length || 0} todos
1670
- ${replaceAll ? "" : "\u2795 Added to existing todo list\n"}${phaseTransition ? `\u{1F504} Phase Transition: ${phaseTransition}
1671
- ` : ""}
1672
- \u{1F4DD} Todo list updated and tracked for progress monitoring!`
1673
- }
1674
- ]
1675
- };
1676
- }
1677
- async function handleAddFollowUpTodos(input) {
1678
- const ctx = authContext;
1679
- const { aiSessionId, newTodos, followUpReason } = input;
1680
- const prefix = aiSessionId.replace("ai-sess-", "");
1681
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
1682
- const fullSessionId = await resolveAiSessionId(prefix, teamIds);
1683
- if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1684
- if (newTodos && newTodos.length > 0) {
1685
- const [maxTodo] = await db.select({ sequenceOrder: schema.aiTodos.sequenceOrder }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId)).orderBy(desc(schema.aiTodos.sequenceOrder)).limit(1);
1686
- const startSequence = (maxTodo?.sequenceOrder ?? 0) + 1;
1687
- await db.insert(schema.aiTodos).values(
1688
- newTodos.map((todo, index) => ({
1689
- aiSessionId: fullSessionId,
1690
- content: `[Follow-up] ${todo.content}`,
1691
- status: todo.status ?? "pending",
1692
- estimatedMinutes: todo.estimatedMinutes ?? null,
1693
- sequenceOrder: startSequence + index
1694
- }))
1695
- );
1696
- }
1697
- return {
1698
- content: [
1699
- {
1700
- type: "text",
1701
- text: `\u2705 **Follow-up Todos Added Successfully!**
1702
-
1703
- Session: ${aiSessionId}
1704
- Added ${newTodos?.length || 0} new todos from follow-up
1705
- ${followUpReason ? `Reason: ${followUpReason}
1706
- ` : ""}
1707
- \u{1F4DD} New tasks identified and added to existing workflow!`
1708
- }
1709
- ]
1710
- };
1711
- }
1712
- async function handleUpdateSessionStatus(input) {
1713
- const ctx = authContext;
1714
- const { aiSessionId, status, actualTimeMinutes, completionNotes } = input;
1715
- const prefix = aiSessionId.replace("ai-sess-", "");
1716
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
1717
- const fullSessionId = await resolveAiSessionId(prefix, teamIds);
1718
- if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1719
- await db.update(schema.aiSessions).set({
1720
- status,
1721
- actualTimeMinutes: actualTimeMinutes ?? null,
1722
- completedAt: status === "completed" ? (/* @__PURE__ */ new Date()).toISOString() : null
1723
- }).where(eq(schema.aiSessions.id, fullSessionId));
1724
- return {
1725
- content: [
1726
- {
1727
- type: "text",
1728
- text: `\u{1F3AF} **Session Status Updated!**
1729
-
1730
- Session: ${aiSessionId}
1731
- Status: ${status}
1732
- ${actualTimeMinutes ? `Actual Time: ${actualTimeMinutes} minutes
1733
- ` : ""}${status === "completed" ? `\u2705 Session completed successfully!
1734
- ` : ""}${completionNotes ? `Notes: ${completionNotes}
1735
- ` : ""}`
1736
- }
1737
- ]
1738
- };
1739
- }
1740
- async function handleGetCompletionContext(input) {
1741
- const ctx = authContext;
1742
- const {
1743
- aiSessionId,
1744
- includeFollowUps = true,
1745
- includeTimeMetrics = true,
1746
- includeTodos = true
1747
- } = input;
1748
- const prefix = aiSessionId.replace("ai-sess-", "");
1749
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
1750
- const fullSessionId = await resolveAiSessionId(prefix, teamIds);
1751
- if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1752
- const [session] = await db.select({
1753
- id: schema.aiSessions.id,
1754
- ticketId: schema.aiSessions.ticketId,
1755
- aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
1756
- actualTimeMinutes: schema.aiSessions.actualTimeMinutes,
1757
- efficiencyScore: schema.aiSessions.efficiencyScore,
1758
- createdAt: schema.aiSessions.createdAt,
1759
- completedAt: schema.aiSessions.completedAt,
1760
- status: schema.aiSessions.status,
1761
- complexityScore: schema.aiSessions.complexityScore
1762
- }).from(schema.aiSessions).where(eq(schema.aiSessions.id, fullSessionId)).limit(1);
1763
- if (!session) throw new Error(`Session not found: ${aiSessionId}`);
1764
- const [ticket] = await db.select({
1765
- ticketNumber: schema.tickets.ticketNumber,
1766
- title: schema.tickets.title,
1767
- description: schema.tickets.description,
1768
- type: schema.tickets.type,
1769
- priority: schema.tickets.priority
1770
- }).from(schema.tickets).where(eq(schema.tickets.id, session.ticketId)).limit(1);
1771
- if (!ticket) throw new Error("Ticket not found for session");
1772
- const contextData = {
1773
- session: {
1774
- id: aiSessionId,
1775
- status: session.status,
1776
- complexity: session.complexityScore,
1777
- createdAt: session.createdAt,
1778
- completedAt: session.completedAt
1779
- },
1780
- ticket: {
1781
- number: ticket.ticketNumber,
1782
- title: ticket.title,
1783
- description: ticket.description,
1784
- type: ticket.type,
1785
- priority: ticket.priority
1786
- }
1787
- };
1788
- if (includeTimeMetrics) {
1789
- const timeSaved = session.aiTimeEstimateMinutes && session.actualTimeMinutes ? Math.max(
1790
- 0,
1791
- session.aiTimeEstimateMinutes - session.actualTimeMinutes
1792
- ) : null;
1793
- contextData.timeMetrics = {
1794
- estimatedMinutes: session.aiTimeEstimateMinutes,
1795
- actualMinutes: session.actualTimeMinutes,
1796
- timeSaved,
1797
- efficiency: session.efficiencyScore,
1798
- sessionDuration: session.completedAt && session.createdAt ? Math.round(
1799
- (new Date(session.completedAt).getTime() - new Date(session.createdAt).getTime()) / 6e4
1800
- ) : null
1801
- };
1802
- }
1803
- if (includeTodos) {
2212
+ if (includeTodoProgress) {
1804
2213
  const todos = await db.select({
2214
+ id: schema.aiTodos.id,
1805
2215
  content: schema.aiTodos.content,
1806
2216
  status: schema.aiTodos.status,
1807
2217
  estimatedMinutes: schema.aiTodos.estimatedMinutes,
1808
- actualMinutes: schema.aiTodos.actualMinutes,
1809
- completedAt: schema.aiTodos.completedAt
1810
- }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, session.id)).orderBy(asc(schema.aiTodos.createdAt));
1811
- contextData.todos = todos;
2218
+ actualMinutes: schema.aiTodos.actualMinutes
2219
+ }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, session.id)).orderBy(asc(schema.aiTodos.sequenceOrder));
2220
+ context.todos = todos;
2221
+ context.todoProgress = {
2222
+ total: todos.length,
2223
+ completed: todos.filter((t) => t.status === "completed").length,
2224
+ inProgress: todos.filter((t) => t.status === "in_progress").length
2225
+ };
1812
2226
  }
1813
- if (includeFollowUps) {
2227
+ if (includeFollowUpHistory) {
1814
2228
  const followUps = await db.select({
1815
2229
  followUpReason: schema.manualFollowUps.followUpReason,
1816
2230
  outcome: schema.manualFollowUps.outcome,
1817
2231
  timeSpentMinutes: schema.manualFollowUps.timeSpentMinutes,
1818
2232
  createdAt: schema.manualFollowUps.createdAt
1819
2233
  }).from(schema.manualFollowUps).where(eq(schema.manualFollowUps.aiSessionId, session.id)).orderBy(asc(schema.manualFollowUps.createdAt));
1820
- contextData.followUps = followUps;
2234
+ context.followUpHistory = followUps;
1821
2235
  }
1822
- const todosLen = contextData.todos ?? [];
1823
- const completedTodos = todosLen.filter((t) => t.status === "completed").length;
1824
- const followUpsLen = contextData.followUps?.length ?? 0;
1825
- return {
1826
- content: [
1827
- {
1828
- type: "text",
1829
- text: `\u{1F4CB} **Completion Context Retrieved!**
1830
-
1831
- \u{1F3AB} **Ticket:** ${ticket.ticketNumber} - ${ticket.title}
1832
- \u{1F194} **Session:** ${aiSessionId} (${session.status})
1833
- \u23F1\uFE0F **Time:** ${session.actualTimeMinutes || "N/A"}/${session.aiTimeEstimateMinutes || "N/A"} minutes
1834
- \u{1F4CB} **Todos:** ${completedTodos}/${todosLen.length} completed
1835
- \u{1F504} **Follow-ups:** ${followUpsLen}
1836
-
1837
- \u2705 **Full context ready for Cursor AI to generate customer response!**
1838
-
1839
- **Context Data:**
1840
- \`\`\`json
1841
- ${JSON.stringify(contextData, null, 2)}\`\`\``
1842
- }
1843
- ]
1844
- };
1845
- }
1846
- async function handleSaveCustomerResponse(input) {
1847
- const ctx = authContext;
1848
- const { aiSessionId, customerResponse, responseType = "completion" } = input;
1849
- const prefix = aiSessionId.replace("ai-sess-", "");
1850
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
1851
- const fullSessionId = await resolveAiSessionId(prefix, teamIds);
1852
- if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1853
- await db.insert(schema.aiResponses).values({
1854
- aiSessionId: fullSessionId,
1855
- responseType,
1856
- content: customerResponse,
1857
- isReadyForCustomer: true,
1858
- providerApproved: false
1859
- });
2236
+ const ticketData = context.ticketData;
2237
+ const todoProgress = context.todoProgress;
2238
+ const followUpHistory = context.followUpHistory;
1860
2239
  return {
1861
2240
  content: [
1862
2241
  {
1863
2242
  type: "text",
1864
- text: `\u{1F4BE} **Customer Response Saved!**
1865
-
1866
- \u{1F194} Session: ${aiSessionId}
1867
- \u{1F4DD} Response Type: ${responseType}
1868
- \u{1F4C4} Length: ${customerResponse.length} characters
1869
-
1870
- \u2705 **Response ready for provider approval**
1871
- \u{1F50D} Provider can review in AI tab before sending to customer
2243
+ text: `\u{1F3AF} **Session Context Retrieved**
1872
2244
 
1873
- **Preview:**
1874
- \`\`\`
1875
- ${customerResponse.substring(0, 200)}${customerResponse.length > 200 ? "..." : ""}\`\`\``
2245
+ Session: ${aiSessionId}
2246
+ Status: ${session.status}
2247
+ ${ticketData ? `Ticket: ${ticketData.ticketNumber} - ${ticketData.title}
2248
+ ` : ""}${todoProgress ? `Todo Progress: ${todoProgress.completed}/${todoProgress.total} completed
2249
+ ` : ""}${followUpHistory ? `Follow-ups: ${followUpHistory.length}
2250
+ ` : ""}
2251
+ \u{1F4CB} Full context preserved for seamless continuation!`
1876
2252
  }
1877
2253
  ]
1878
2254
  };
1879
2255
  }
1880
- async function handleCompleteAiSession(input) {
1881
- const ctx = authContext;
1882
- const {
1883
- aiSessionId,
1884
- workCompleted,
1885
- technicalSummary,
1886
- invoiceDescription,
1887
- efficiencyNotes
1888
- } = input;
2256
+ async function handleSyncSessionTodos(input) {
2257
+ const { aiSessionId, todos, replaceAll = true } = input;
2258
+ const scope = await resolveTeamScope(input.teamId);
2259
+ if (!scope.ok) return scope.response;
1889
2260
  const prefix = aiSessionId.replace("ai-sess-", "");
1890
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
1891
- const fullSessionId = await resolveAiSessionId(prefix, teamIds);
2261
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
1892
2262
  if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
1893
- const [existingSession] = await db.select({
1894
- id: schema.aiSessions.id,
1895
- ticketId: schema.aiSessions.ticketId,
1896
- aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
1897
- createdAt: schema.aiSessions.createdAt
1898
- }).from(schema.aiSessions).where(eq(schema.aiSessions.id, fullSessionId)).limit(1);
1899
- if (!existingSession) {
1900
- throw new Error(`Session not found: ${aiSessionId}`);
2263
+ if (replaceAll) {
2264
+ await db.delete(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId));
1901
2265
  }
1902
- const completionTime = /* @__PURE__ */ new Date();
1903
- const sessionStartTime = new Date(existingSession.createdAt);
1904
- const timeSpentMinutes = Math.round(
1905
- (completionTime.getTime() - sessionStartTime.getTime()) / 6e4
1906
- );
1907
- const [session] = await db.update(schema.aiSessions).set({
1908
- status: "completed",
1909
- actualTimeMinutes: timeSpentMinutes,
1910
- completedAt: completionTime.toISOString(),
1911
- efficiencyScore: null
1912
- }).where(eq(schema.aiSessions.id, existingSession.id)).returning({
1913
- id: schema.aiSessions.id,
1914
- ticketId: schema.aiSessions.ticketId,
1915
- aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
1916
- createdAt: schema.aiSessions.createdAt
1917
- });
1918
- if (!session) throw new Error(`Failed to update session: ${aiSessionId}`);
1919
- const efficiencyScore = session.aiTimeEstimateMinutes ? timeSpentMinutes / session.aiTimeEstimateMinutes : 1;
1920
- await db.update(schema.aiSessions).set({ efficiencyScore: efficiencyScore.toFixed(2) }).where(eq(schema.aiSessions.id, session.id));
1921
- const activePhases = await db.select().from(schema.aiTimeLogs).where(
1922
- and(
1923
- eq(schema.aiTimeLogs.aiSessionId, existingSession.id),
1924
- eq(schema.aiTimeLogs.status, "in_progress")
1925
- )
1926
- );
1927
- for (const phase of activePhases) {
1928
- const duration = Math.round(
1929
- (completionTime.getTime() - new Date(phase.startedAt).getTime()) / 1e3
2266
+ if (todos && todos.length > 0) {
2267
+ let startSequence = 0;
2268
+ if (!replaceAll) {
2269
+ const [maxTodo] = await db.select({ sequenceOrder: schema.aiTodos.sequenceOrder }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId)).orderBy(desc(schema.aiTodos.sequenceOrder)).limit(1);
2270
+ startSequence = (maxTodo?.sequenceOrder ?? 0) + 1;
2271
+ }
2272
+ await db.insert(schema.aiTodos).values(
2273
+ todos.map((todo, index) => ({
2274
+ aiSessionId: fullSessionId,
2275
+ content: todo.content,
2276
+ status: todo.status,
2277
+ cursorTodoId: todo.todoId ?? null,
2278
+ estimatedMinutes: todo.estimatedMinutes ?? null,
2279
+ sequenceOrder: startSequence + index
2280
+ }))
1930
2281
  );
1931
- await db.update(schema.aiTimeLogs).set({
1932
- endedAt: completionTime.toISOString(),
1933
- durationSeconds: duration,
1934
- status: "completed"
1935
- }).where(eq(schema.aiTimeLogs.id, phase.id));
1936
- }
1937
- await db.update(schema.aiTimeLogs).set({ status: "skipped" }).where(
1938
- and(
1939
- eq(schema.aiTimeLogs.aiSessionId, existingSession.id),
1940
- eq(schema.aiTimeLogs.status, "pending"),
1941
- eq(schema.aiTimeLogs.estimatedDurationSeconds, 0)
1942
- )
1943
- );
1944
- const sessionDuration = Math.round(
1945
- (completionTime.getTime() - new Date(session.createdAt).getTime()) / 6e4
1946
- );
1947
- const workSummary = `Completed ${workCompleted.length} tasks including: ${workCompleted.slice(0, 3).join(", ")}${workCompleted.length > 3 ? " and more" : ""}.`;
1948
- const [ticketInfo] = await db.select({
1949
- ticketNumber: schema.tickets.ticketNumber,
1950
- title: schema.tickets.title,
1951
- projectId: schema.tickets.projectId
1952
- }).from(schema.tickets).where(eq(schema.tickets.id, session.ticketId)).limit(1);
1953
- let completionDescription;
1954
- if (invoiceDescription) {
1955
- completionDescription = `${ticketInfo?.ticketNumber || "Ticket"}: ${invoiceDescription}`;
1956
- } else {
1957
- const workDescription = workCompleted.map((task, index) => `${index + 1}. ${task}`).join("\n");
1958
- completionDescription = `${ticketInfo?.ticketNumber || "Ticket"}: ${technicalSummary || workSummary}
1959
-
1960
- Completed work:
1961
- ${workDescription}`;
1962
- }
1963
- const estimatedMinutes = session.aiTimeEstimateMinutes ?? timeSpentMinutes;
1964
- const sessionStart = new Date(session.createdAt);
1965
- const estimatedEnd = new Date(
1966
- sessionStart.getTime() + estimatedMinutes * 6e4
1967
- );
1968
- const existingAgendaEntries = await db.select({
1969
- id: schema.agendaEvents.id,
1970
- trackedDuration: schema.agendaEvents.trackedDuration
1971
- }).from(schema.agendaEvents).where(
1972
- and(
1973
- eq(schema.agendaEvents.aiSessionId, session.id),
1974
- eq(schema.agendaEvents.status, "draft")
1975
- )
1976
- ).orderBy(desc(schema.agendaEvents.createdAt));
1977
- let agendaEventId = null;
1978
- let wasUpdated = false;
1979
- let consolidatedCount = 0;
1980
- const existingAgendaEntry = existingAgendaEntries[0] ?? null;
1981
- if (existingAgendaEntries.length > 1) {
1982
- const duplicateIds = existingAgendaEntries.slice(1).map((e) => e.id);
1983
- await db.delete(schema.agendaEvents).where(inArray(schema.agendaEvents.id, duplicateIds));
1984
- consolidatedCount = existingAgendaEntries.length - 1;
1985
2282
  }
1986
- try {
1987
- if (existingAgendaEntry) {
1988
- const [updated] = await db.update(schema.agendaEvents).set({
1989
- title: ticketInfo?.title || "Development Work",
1990
- description: completionDescription,
1991
- endTime: estimatedEnd.toISOString(),
1992
- projectId: ticketInfo?.projectId ?? null,
1993
- trackedDuration: estimatedMinutes * 60
1994
- }).where(eq(schema.agendaEvents.id, existingAgendaEntry.id)).returning({ id: schema.agendaEvents.id });
1995
- agendaEventId = updated?.id ?? null;
1996
- wasUpdated = true;
1997
- } else {
1998
- const [created] = await db.insert(schema.agendaEvents).values({
1999
- teamId: ctx.teamId,
2000
- userId: ctx.userId,
2001
- title: ticketInfo?.title || "Development Work",
2002
- description: completionDescription,
2003
- startTime: sessionStart.toISOString(),
2004
- endTime: estimatedEnd.toISOString(),
2005
- projectId: ticketInfo?.projectId ?? null,
2006
- aiSessionId: session.id,
2007
- type: "work",
2008
- status: "draft",
2009
- allDay: false,
2010
- isTracked: true,
2011
- trackedDuration: estimatedMinutes * 60
2012
- }).returning({ id: schema.agendaEvents.id });
2013
- agendaEventId = created?.id ?? null;
2283
+ let phaseTransition = null;
2284
+ const currentTodos = await db.select({ status: schema.aiTodos.status }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId));
2285
+ if (currentTodos.length > 0) {
2286
+ const hasInProgress = currentTodos.some((t) => t.status === "in_progress");
2287
+ const allCompleted = currentTodos.every((t) => t.status === "completed");
2288
+ const [currentPhase] = await db.select({
2289
+ activityType: schema.aiTimeLogs.activityType,
2290
+ status: schema.aiTimeLogs.status
2291
+ }).from(schema.aiTimeLogs).where(
2292
+ and(
2293
+ eq(schema.aiTimeLogs.aiSessionId, fullSessionId),
2294
+ eq(schema.aiTimeLogs.status, "in_progress")
2295
+ )
2296
+ ).limit(1);
2297
+ if (hasInProgress && currentPhase?.activityType === "analysis") {
2298
+ await transitionToNextPhase(fullSessionId, "analysis");
2299
+ phaseTransition = "Analysis completed \u2192 Next phase started (Investigation/Development)";
2014
2300
  }
2015
- if (agendaEventId && session.ticketId) {
2016
- await db.insert(schema.agendaEventTickets).values({
2017
- agendaEventId,
2018
- ticketId: session.ticketId
2019
- }).onConflictDoNothing();
2301
+ if (hasInProgress && currentPhase?.activityType === "bug_investigation") {
2302
+ const completedCount = currentTodos.filter(
2303
+ (t) => t.status === "completed"
2304
+ ).length;
2305
+ if (completedCount > 0) {
2306
+ await transitionToNextPhase(fullSessionId, "bug_investigation");
2307
+ phaseTransition = "Investigation completed \u2192 Development phase started";
2308
+ }
2309
+ }
2310
+ if (allCompleted && currentPhase?.activityType === "development") {
2311
+ await transitionToNextPhase(fullSessionId, "development");
2312
+ phaseTransition = "Development completed \u2192 Communication phase started";
2020
2313
  }
2021
- } catch (err) {
2022
- console.error(
2023
- `\u26A0\uFE0F Failed to ${wasUpdated ? "update" : "create"} agenda event:`,
2024
- err
2025
- );
2026
2314
  }
2027
- if (consolidatedCount > 0) {
2028
- console.log(
2029
- `\u{1F9F9} Cleaned up ${consolidatedCount} duplicate agenda entries for session ${aiSessionId}`
2315
+ return {
2316
+ content: [
2317
+ {
2318
+ type: "text",
2319
+ text: `\u2705 **Todos ${replaceAll ? "Synced" : "Added"} Successfully!**
2320
+
2321
+ Session: ${aiSessionId}
2322
+ ${replaceAll ? "Synced" : "Added"} ${todos?.length || 0} todos
2323
+ ${replaceAll ? "" : "\u2795 Added to existing todo list\n"}${phaseTransition ? `\u{1F504} Phase Transition: ${phaseTransition}
2324
+ ` : ""}
2325
+ \u{1F4DD} Todo list updated and tracked for progress monitoring!`
2326
+ }
2327
+ ]
2328
+ };
2329
+ }
2330
+ async function handleAddFollowUpTodos(input) {
2331
+ const { aiSessionId, newTodos, followUpReason } = input;
2332
+ const scope = await resolveTeamScope(input.teamId);
2333
+ if (!scope.ok) return scope.response;
2334
+ const prefix = aiSessionId.replace("ai-sess-", "");
2335
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
2336
+ if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
2337
+ if (newTodos && newTodos.length > 0) {
2338
+ const [maxTodo] = await db.select({ sequenceOrder: schema.aiTodos.sequenceOrder }).from(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId)).orderBy(desc(schema.aiTodos.sequenceOrder)).limit(1);
2339
+ const startSequence = (maxTodo?.sequenceOrder ?? 0) + 1;
2340
+ await db.insert(schema.aiTodos).values(
2341
+ newTodos.map((todo, index) => ({
2342
+ aiSessionId: fullSessionId,
2343
+ content: `[Follow-up] ${todo.content}`,
2344
+ status: todo.status ?? "pending",
2345
+ estimatedMinutes: todo.estimatedMinutes ?? null,
2346
+ sequenceOrder: startSequence + index
2347
+ }))
2030
2348
  );
2031
2349
  }
2032
- let responseText = `\u{1F389} **AI Session Completed Successfully!**
2350
+ return {
2351
+ content: [
2352
+ {
2353
+ type: "text",
2354
+ text: `\u2705 **Follow-up Todos Added Successfully!**
2033
2355
 
2034
- `;
2035
- responseText += `\u{1F194} Session: ${aiSessionId}
2036
- `;
2037
- responseText += `\u{1F4CA} **Performance Summary:**
2038
- `;
2039
- responseText += ` \u2022 Tasks Completed: ${workCompleted.length}
2040
- `;
2041
- responseText += ` \u2022 Time Spent: ${timeSpentMinutes} minutes
2042
- `;
2043
- responseText += ` \u2022 Estimated Time: ${session.aiTimeEstimateMinutes || "N/A"} minutes
2044
- `;
2045
- responseText += ` \u2022 Efficiency: ${efficiencyScore < 1 ? "\u{1F680}" : efficiencyScore > 1.5 ? "\u26A0\uFE0F" : "\u23F1\uFE0F"} ${(efficiencyScore * 100).toFixed(0)}%
2046
- `;
2047
- responseText += ` \u2022 Session Duration: ${sessionDuration} minutes
2356
+ Session: ${aiSessionId}
2357
+ Added ${newTodos?.length || 0} new todos from follow-up
2358
+ ${followUpReason ? `Reason: ${followUpReason}
2359
+ ` : ""}
2360
+ \u{1F4DD} New tasks identified and added to existing workflow!`
2361
+ }
2362
+ ]
2363
+ };
2364
+ }
2365
+ async function handleUpdateSessionStatus(input) {
2366
+ const { aiSessionId, status, actualTimeMinutes, completionNotes } = input;
2367
+ const scope = await resolveTeamScope(input.teamId);
2368
+ if (!scope.ok) return scope.response;
2369
+ const prefix = aiSessionId.replace("ai-sess-", "");
2370
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
2371
+ if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
2372
+ await db.update(schema.aiSessions).set({
2373
+ status,
2374
+ actualTimeMinutes: actualTimeMinutes ?? null,
2375
+ completedAt: status === "completed" ? (/* @__PURE__ */ new Date()).toISOString() : null
2376
+ }).where(eq(schema.aiSessions.id, fullSessionId));
2377
+ return {
2378
+ content: [
2379
+ {
2380
+ type: "text",
2381
+ text: `\u{1F3AF} **Session Status Updated!**
2048
2382
 
2049
- `;
2050
- responseText += `\u2705 **Work Completed:**
2051
- `;
2052
- workCompleted.forEach((task, index) => {
2053
- responseText += `${index + 1}. ${task}
2054
- `;
2055
- });
2056
- responseText += `
2057
- `;
2058
- if (technicalSummary) {
2059
- responseText += `\u{1F527} **Technical Summary:**
2060
- ${technicalSummary}
2383
+ Session: ${aiSessionId}
2384
+ Status: ${status}
2385
+ ${actualTimeMinutes ? `Actual Time: ${actualTimeMinutes} minutes
2386
+ ` : ""}${status === "completed" ? `\u2705 Session completed successfully!
2387
+ ` : ""}${completionNotes ? `Notes: ${completionNotes}
2388
+ ` : ""}`
2389
+ }
2390
+ ]
2391
+ };
2392
+ }
2061
2393
 
2062
- `;
2394
+ // src/tools/teams.ts
2395
+ init_auth();
2396
+ init_db();
2397
+ async function handleGetTeams() {
2398
+ const ctx = authContext;
2399
+ const teams2 = await getUserProviderTeams(ctx.userId);
2400
+ if (teams2.length === 0) {
2401
+ return {
2402
+ content: [
2403
+ {
2404
+ type: "text",
2405
+ text: "You are not a member of any provider team."
2406
+ }
2407
+ ]
2408
+ };
2063
2409
  }
2064
- if (efficiencyNotes) {
2065
- responseText += `\u{1F4C8} **Efficiency Notes:**
2066
- ${efficiencyNotes}
2410
+ const list = teams2.map((t) => `- ${t.name ?? "(unnamed provider)"} (teamId: ${t.id})`).join("\n");
2411
+ return {
2412
+ content: [
2413
+ {
2414
+ type: "text",
2415
+ text: `You can act on ${teams2.length} provider${teams2.length === 1 ? "" : "s"}. Pass the chosen \`teamId\` to other tools when needed.
2067
2416
 
2068
- `;
2069
- }
2070
- if (agendaEventId) {
2071
- responseText += `\u{1F4C5} **Timetrack Entry ${wasUpdated ? "Updated" : "Created"}:**
2072
- `;
2073
- responseText += ` \u2022 Agenda event ${wasUpdated ? "updated with final" : "created with"} work summary
2074
- `;
2075
- responseText += ` \u2022 Status: DRAFT (requires approval in agenda)
2076
- `;
2077
- responseText += ` \u2022 Duration: ${estimatedMinutes} minutes
2078
- `;
2079
- responseText += ` \u2022 Period: ${sessionStart.toLocaleString()} - ${completionTime.toLocaleString()}
2417
+ ${list}
2080
2418
 
2081
- `;
2419
+ ${JSON.stringify(teams2)}`
2420
+ }
2421
+ ]
2422
+ };
2423
+ }
2424
+
2425
+ // src/tools/ticket-attachments.ts
2426
+ init_db();
2427
+ var _storage = null;
2428
+ function buildClient() {
2429
+ const endpoint = process.env.R2_ENDPOINT;
2430
+ const accessKeyId = process.env.R2_ACCESS_KEY_ID;
2431
+ const secretAccessKey = process.env.R2_SECRET_ACCESS_KEY;
2432
+ if (!endpoint || !accessKeyId || !secretAccessKey) {
2433
+ throw new Error(
2434
+ "R2 storage is not configured. Set R2_ENDPOINT, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY."
2435
+ );
2082
2436
  }
2083
- responseText += `\u{1F4CB} **Context for Customer Response:**
2084
- `;
2085
- responseText += ` \u2022 Use "get-completion-context" to retrieve full context
2086
- `;
2087
- responseText += ` \u2022 Generate customer-friendly response based on completed work
2088
- `;
2089
- responseText += ` \u2022 Focus on business value and customer benefits
2437
+ return createStorageClient({
2438
+ endpoint,
2439
+ accessKeyId,
2440
+ secretAccessKey,
2441
+ publicDomain: process.env.R2_PUBLIC_DOMAIN || void 0,
2442
+ publicBuckets: [
2443
+ "vault",
2444
+ "avatars",
2445
+ "team-logos",
2446
+ "blog-images",
2447
+ "customer-assets"
2448
+ ]
2449
+ });
2450
+ }
2451
+ var storage = new Proxy({}, {
2452
+ get(_target, prop) {
2453
+ if (!_storage) _storage = buildClient();
2454
+ return Reflect.get(_storage, prop, _storage);
2455
+ }
2456
+ });
2090
2457
 
2091
- `;
2092
- responseText += `\u{1F3AF} **Session archived successfully!**`;
2093
- return { content: [{ type: "text", text: responseText }] };
2458
+ // src/tools/ticket-access.ts
2459
+ init_db();
2460
+ function notFoundResponse(ticketId) {
2461
+ return {
2462
+ content: [
2463
+ {
2464
+ type: "text",
2465
+ text: `Ticket not found or no access: ${ticketId}. Call get-tickets to find the correct ticket.`
2466
+ }
2467
+ ]
2468
+ };
2094
2469
  }
2095
- async function handleLogHours(input) {
2096
- const ctx = authContext;
2097
- const {
2098
- projectId,
2099
- ticketId,
2100
- aiSessionId,
2101
- workDescription,
2102
- estimatedHours,
2103
- chatContextSummary
2104
- } = input;
2105
- let project = null;
2106
- let ticket = null;
2107
- let aiSession = null;
2108
- if (projectId) {
2109
- const projectIds = await getAccessibleProjectIds(ctx.userId, ctx.teamId);
2110
- if (!projectIds.includes(projectId)) {
2111
- throw new Error(
2112
- `Project not found or no access: ${projectId}. Please call get-projects first to find the correct project.`
2113
- );
2114
- }
2115
- const [projectData] = await db.select({
2116
- id: schema.projects.id,
2117
- name: schema.projects.name,
2118
- teamId: schema.projects.teamId
2119
- }).from(schema.projects).where(eq(schema.projects.id, projectId)).limit(1);
2120
- if (!projectData) throw new Error(`Project not found: ${projectId}.`);
2121
- project = projectData;
2470
+ async function loadAccessibleTicket(requestedTeamId, ticketId) {
2471
+ const scope = await resolveTeamScope(requestedTeamId);
2472
+ if (!scope.ok) return scope;
2473
+ const [ticket] = await db.select({
2474
+ id: schema.tickets.id,
2475
+ teamId: schema.tickets.teamId,
2476
+ projectId: schema.tickets.projectId,
2477
+ customerId: schema.tickets.customerId,
2478
+ ticketNumber: schema.tickets.ticketNumber,
2479
+ title: schema.tickets.title,
2480
+ status: schema.tickets.status,
2481
+ priority: schema.tickets.priority,
2482
+ type: schema.tickets.type,
2483
+ assigneeId: schema.tickets.assigneeId
2484
+ }).from(schema.tickets).where(eq(schema.tickets.id, ticketId)).limit(1);
2485
+ if (!ticket) return { ok: false, response: notFoundResponse(ticketId) };
2486
+ const hasAccess = scope.teamIds.includes(ticket.teamId) || !!ticket.projectId && scope.projectIds.includes(ticket.projectId) || !!ticket.customerId && scope.customerIds.includes(ticket.customerId);
2487
+ if (!hasAccess) return { ok: false, response: notFoundResponse(ticketId) };
2488
+ return { ok: true, ticket };
2489
+ }
2490
+
2491
+ // src/tools/ticket-attachments.ts
2492
+ async function findAttachment(attachmentId) {
2493
+ const [ticketAtt] = await db.select({
2494
+ ticketId: schema.ticketAttachments.ticketId,
2495
+ fileName: schema.ticketAttachments.fileName,
2496
+ fileSize: schema.ticketAttachments.fileSize,
2497
+ mimeType: schema.ticketAttachments.mimeType,
2498
+ storageKey: schema.ticketAttachments.storageKey
2499
+ }).from(schema.ticketAttachments).where(eq(schema.ticketAttachments.id, attachmentId)).limit(1);
2500
+ if (ticketAtt) return { ...ticketAtt, source: "ticket" };
2501
+ const [commentAtt] = await db.select({
2502
+ ticketId: schema.ticketCommentAttachments.ticketId,
2503
+ fileName: schema.ticketCommentAttachments.fileName,
2504
+ fileSize: schema.ticketCommentAttachments.fileSize,
2505
+ mimeType: schema.ticketCommentAttachments.mimeType,
2506
+ storageKey: schema.ticketCommentAttachments.storageKey
2507
+ }).from(schema.ticketCommentAttachments).where(eq(schema.ticketCommentAttachments.id, attachmentId)).limit(1);
2508
+ if (commentAtt) return { ...commentAtt, source: "comment" };
2509
+ return null;
2510
+ }
2511
+ async function handleGetTicketAttachment(input) {
2512
+ const { attachmentId } = input;
2513
+ const attachment = await findAttachment(attachmentId);
2514
+ if (!attachment) {
2515
+ return {
2516
+ content: [
2517
+ {
2518
+ type: "text",
2519
+ text: `Attachment not found: ${attachmentId}. Use get-ticket-by-id to list attachment ids.`
2520
+ }
2521
+ ]
2522
+ };
2122
2523
  }
2123
- if (ticketId) {
2124
- const [ticketData] = await db.select({
2125
- id: schema.tickets.id,
2126
- title: schema.tickets.title,
2127
- status: schema.tickets.status,
2128
- teamId: schema.tickets.teamId,
2129
- projectId: schema.tickets.projectId,
2130
- customerId: schema.tickets.customerId
2131
- }).from(schema.tickets).where(eq(schema.tickets.id, ticketId)).limit(1);
2132
- if (!ticketData) {
2133
- throw new Error(
2134
- `Ticket not found: ${ticketId}. Please call get-tickets first to find the correct ticket.`
2135
- );
2136
- }
2137
- let hasAccess = false;
2138
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
2139
- if (teamIds.includes(ticketData.teamId)) hasAccess = true;
2140
- if (!hasAccess && ticketData.projectId) {
2141
- const projectIds = await getAccessibleProjectIds(ctx.userId, ctx.teamId);
2142
- if (projectIds.includes(ticketData.projectId)) hasAccess = true;
2143
- }
2144
- if (!hasAccess && ticketData.customerId) {
2145
- const customerIds = await getAccessibleCustomerIds(ctx.teamId);
2146
- if (customerIds.includes(ticketData.customerId)) hasAccess = true;
2147
- }
2148
- if (!hasAccess) {
2149
- throw new Error(
2150
- `No access to ticket: ${ticketId}. Please call get-tickets first to find the correct ticket.`
2151
- );
2152
- }
2153
- ticket = ticketData;
2524
+ const access = await loadAccessibleTicket(input.teamId, attachment.ticketId);
2525
+ if (!access.ok) return access.response;
2526
+ let url;
2527
+ try {
2528
+ const signed = await storage.createSignedUrl({
2529
+ bucket: "vault",
2530
+ path: attachment.storageKey,
2531
+ expiresIn: 3600
2532
+ });
2533
+ url = signed.url;
2534
+ } catch (error) {
2535
+ return {
2536
+ content: [
2537
+ {
2538
+ type: "text",
2539
+ text: `Failed to create a download URL for attachment ${attachmentId}: ${error instanceof Error ? error.message : String(error)}`
2540
+ }
2541
+ ]
2542
+ };
2543
+ }
2544
+ return {
2545
+ content: [
2546
+ {
2547
+ type: "text",
2548
+ text: `\u{1F4CE} **${attachment.fileName}**
2549
+ Type: ${attachment.mimeType}
2550
+ Size: ${Math.round(attachment.fileSize / 1024)}KB
2551
+ Source: ${attachment.source} attachment
2552
+
2553
+ Download URL (valid for 1 hour):
2554
+ ${url}`
2555
+ }
2556
+ ]
2557
+ };
2558
+ }
2559
+
2560
+ // src/tools/ticket-comments.ts
2561
+ init_auth();
2562
+ init_db();
2563
+
2564
+ // src/tools/tiptap-text.ts
2565
+ function renderNode(node) {
2566
+ if (!node) return "";
2567
+ if (node.type === "text") return node.text ?? "";
2568
+ if (node.type === "hardBreak") return "\n";
2569
+ const inner = (node.content ?? []).map(renderNode).join("");
2570
+ switch (node.type) {
2571
+ case "paragraph":
2572
+ return `${inner}
2573
+ `;
2574
+ case "heading":
2575
+ return `${inner}
2576
+ `;
2577
+ case "listItem":
2578
+ return `- ${inner.trimEnd()}
2579
+ `;
2580
+ case "bulletList":
2581
+ case "orderedList":
2582
+ return inner;
2583
+ case "blockquote":
2584
+ return `${inner}
2585
+ `;
2586
+ default:
2587
+ return inner;
2154
2588
  }
2155
- if (aiSessionId) {
2156
- const [sessionData] = await db.select({
2157
- id: schema.aiSessions.id,
2158
- ticketId: schema.aiSessions.ticketId,
2159
- status: schema.aiSessions.status
2160
- }).from(schema.aiSessions).where(eq(schema.aiSessions.id, aiSessionId)).limit(1);
2161
- if (!sessionData) throw new Error(`AI Session not found: ${aiSessionId}.`);
2162
- aiSession = sessionData;
2589
+ }
2590
+ function tiptapToPlainText(content) {
2591
+ if (!content) return "";
2592
+ const trimmed = content.trim();
2593
+ if (!trimmed.startsWith("{")) return content;
2594
+ let doc;
2595
+ try {
2596
+ doc = JSON.parse(trimmed);
2597
+ } catch {
2598
+ return content;
2163
2599
  }
2164
- const durationSeconds = Math.round(estimatedHours * 3600);
2165
- const now = /* @__PURE__ */ new Date();
2166
- let agendaEntry = null;
2167
- let wasUpdated = false;
2168
- let consolidatedCount = 0;
2169
- if (aiSession?.id || ticket?.id) {
2170
- let existingEntries = [];
2171
- if (aiSession?.id) {
2172
- existingEntries = await db.select({
2173
- id: schema.agendaEvents.id,
2174
- trackedDuration: schema.agendaEvents.trackedDuration,
2175
- projectId: schema.agendaEvents.projectId,
2176
- aiSessionId: schema.agendaEvents.aiSessionId
2177
- }).from(schema.agendaEvents).where(
2178
- and(
2179
- eq(schema.agendaEvents.status, "draft"),
2180
- eq(schema.agendaEvents.userId, ctx.userId),
2181
- eq(schema.agendaEvents.aiSessionId, aiSession.id)
2182
- )
2183
- ).orderBy(desc(schema.agendaEvents.createdAt));
2184
- } else if (ticket?.id) {
2185
- const linkedEvents = await db.select({
2186
- agendaEventId: schema.agendaEventTickets.agendaEventId
2187
- }).from(schema.agendaEventTickets).where(eq(schema.agendaEventTickets.ticketId, ticket.id));
2188
- const eventIds = linkedEvents.map((e) => e.agendaEventId);
2189
- if (eventIds.length > 0) {
2190
- existingEntries = await db.select({
2191
- id: schema.agendaEvents.id,
2192
- trackedDuration: schema.agendaEvents.trackedDuration,
2193
- projectId: schema.agendaEvents.projectId,
2194
- aiSessionId: schema.agendaEvents.aiSessionId
2195
- }).from(schema.agendaEvents).where(
2196
- and(
2197
- inArray(schema.agendaEvents.id, eventIds),
2198
- eq(schema.agendaEvents.status, "draft"),
2199
- eq(schema.agendaEvents.userId, ctx.userId)
2200
- )
2201
- ).orderBy(desc(schema.agendaEvents.createdAt));
2600
+ if (doc?.type !== "doc" || !Array.isArray(doc.content)) return content;
2601
+ return doc.content.map(renderNode).join("").trim();
2602
+ }
2603
+
2604
+ // src/tools/ticket-comments.ts
2605
+ async function handleAddTicketComment(input) {
2606
+ const ctx = authContext;
2607
+ const isInternal = input.isInternal ?? false;
2608
+ const access = await loadAccessibleTicket(input.teamId, input.ticketId);
2609
+ if (!access.ok) return access.response;
2610
+ const ticket = access.ticket;
2611
+ const content = ensureTipTapFormat(input.content) ?? input.content;
2612
+ const [comment] = await db.insert(schema.ticketComments).values({
2613
+ ticketId: ticket.id,
2614
+ teamId: ticket.teamId,
2615
+ userId: ctx.userId,
2616
+ content,
2617
+ isInternal
2618
+ }).returning({ id: schema.ticketComments.id });
2619
+ await db.update(schema.tickets).set({ updatedAt: sql`NOW()`, updatedBy: ctx.userId }).where(eq(schema.tickets.id, ticket.id));
2620
+ await db.insert(schema.ticketActivity).values({
2621
+ ticketId: ticket.id,
2622
+ teamId: ticket.teamId,
2623
+ userId: ctx.userId,
2624
+ activityType: isInternal ? "comment_internal_added" : "comment_added"
2625
+ });
2626
+ return {
2627
+ content: [
2628
+ {
2629
+ type: "text",
2630
+ text: `\u2705 **Comment added to ${ticket.ticketNumber}**${isInternal ? " (internal)" : ""}
2631
+
2632
+ Comment id: ${comment?.id}`
2202
2633
  }
2203
- }
2204
- if (existingEntries.length > 0) {
2205
- const existingEntry = existingEntries[0];
2206
- if (existingEntries.length > 1) {
2207
- const duplicateIds = existingEntries.slice(1).map((e) => e.id);
2208
- await db.delete(schema.agendaEvents).where(inArray(schema.agendaEvents.id, duplicateIds));
2209
- consolidatedCount = existingEntries.length - 1;
2634
+ ]
2635
+ };
2636
+ }
2637
+ async function handleGetTicketComments(input) {
2638
+ const access = await loadAccessibleTicket(input.teamId, input.ticketId);
2639
+ if (!access.ok) return access.response;
2640
+ const ticket = access.ticket;
2641
+ const comments = await db.select({
2642
+ id: schema.ticketComments.id,
2643
+ content: schema.ticketComments.content,
2644
+ isInternal: schema.ticketComments.isInternal,
2645
+ createdAt: schema.ticketComments.createdAt,
2646
+ userName: schema.ticketComments.userName,
2647
+ authorName: schema.users.fullName
2648
+ }).from(schema.ticketComments).leftJoin(schema.users, eq(schema.users.id, schema.ticketComments.userId)).where(eq(schema.ticketComments.ticketId, ticket.id)).orderBy(desc(schema.ticketComments.createdAt));
2649
+ if (comments.length === 0) {
2650
+ return {
2651
+ content: [
2652
+ {
2653
+ type: "text",
2654
+ text: `No comments on ticket ${ticket.ticketNumber}.`
2655
+ }
2656
+ ]
2657
+ };
2658
+ }
2659
+ const rendered = comments.map((c) => {
2660
+ const author = c.authorName ?? c.userName ?? "Unknown";
2661
+ const flag = c.isInternal ? " [internal]" : "";
2662
+ const text = tiptapToPlainText(c.content) || "(empty)";
2663
+ return `\u2022 ${author}${flag} \u2014 ${c.createdAt}
2664
+ ${text}`;
2665
+ }).join("\n\n");
2666
+ return {
2667
+ content: [
2668
+ {
2669
+ type: "text",
2670
+ text: `\u{1F4AC} **Comments on ${ticket.ticketNumber}** (${comments.length})
2671
+
2672
+ ${rendered}`
2210
2673
  }
2211
- const newDuration = (existingEntry.trackedDuration ?? 0) + durationSeconds;
2212
- const [updated] = await db.update(schema.agendaEvents).set({
2213
- trackedDuration: newDuration,
2214
- endTime: now.toISOString(),
2215
- title: workDescription,
2216
- description: chatContextSummary ?? workDescription,
2217
- projectId: project?.id ?? existingEntry.projectId
2218
- }).where(eq(schema.agendaEvents.id, existingEntry.id)).returning({
2219
- id: schema.agendaEvents.id,
2220
- trackedDuration: schema.agendaEvents.trackedDuration,
2221
- projectId: schema.agendaEvents.projectId,
2222
- aiSessionId: schema.agendaEvents.aiSessionId
2223
- });
2224
- agendaEntry = updated ?? null;
2225
- wasUpdated = true;
2674
+ ]
2675
+ };
2676
+ }
2677
+
2678
+ // src/tools/ticket-update.ts
2679
+ init_auth();
2680
+ init_db();
2681
+ async function handleUpdateTicket(input) {
2682
+ const ctx = authContext;
2683
+ const { id } = input;
2684
+ const access = await loadAccessibleTicket(input.teamId, id);
2685
+ if (!access.ok) return access.response;
2686
+ const ticket = access.ticket;
2687
+ if (input.assigneeId !== void 0 && input.assigneeId !== null) {
2688
+ const member = await isUserTeamMember(input.assigneeId, ticket.teamId);
2689
+ if (!member) {
2690
+ return {
2691
+ content: [
2692
+ {
2693
+ type: "text",
2694
+ text: `Cannot assign ticket: user ${input.assigneeId} is not a member of the ticket's team (${ticket.teamId}).`
2695
+ }
2696
+ ]
2697
+ };
2226
2698
  }
2227
2699
  }
2228
- if (!agendaEntry) {
2229
- const startTime = new Date(now.getTime() - durationSeconds * 1e3);
2230
- const [created] = await db.insert(schema.agendaEvents).values({
2231
- teamId: ctx.teamId,
2232
- userId: ctx.userId,
2233
- projectId: project?.id ?? null,
2234
- aiSessionId: aiSession?.id ?? null,
2235
- title: workDescription,
2236
- description: chatContextSummary ?? workDescription,
2237
- startTime: startTime.toISOString(),
2238
- endTime: now.toISOString(),
2239
- type: "work",
2240
- status: "draft",
2241
- allDay: false,
2242
- isTracked: true,
2243
- trackedDuration: durationSeconds
2244
- }).returning({
2245
- id: schema.agendaEvents.id,
2246
- trackedDuration: schema.agendaEvents.trackedDuration,
2247
- projectId: schema.agendaEvents.projectId,
2248
- aiSessionId: schema.agendaEvents.aiSessionId
2700
+ const updateValues = {
2701
+ updatedAt: sql`NOW()`,
2702
+ updatedBy: ctx.userId
2703
+ };
2704
+ if (input.title !== void 0) updateValues.title = input.title;
2705
+ if (input.description !== void 0) {
2706
+ updateValues.description = ensureTipTapFormat(input.description);
2707
+ }
2708
+ if (input.status !== void 0) {
2709
+ updateValues.status = input.status;
2710
+ }
2711
+ if (input.priority !== void 0) {
2712
+ updateValues.priority = input.priority;
2713
+ }
2714
+ if (input.type !== void 0) updateValues.type = input.type;
2715
+ if (input.projectId !== void 0) updateValues.projectId = input.projectId;
2716
+ if (input.customerId !== void 0) {
2717
+ updateValues.customerId = input.customerId;
2718
+ }
2719
+ if (input.assigneeId !== void 0) {
2720
+ updateValues.assigneeId = input.assigneeId;
2721
+ }
2722
+ if (input.estimatedHours !== void 0) {
2723
+ updateValues.estimatedHours = input.estimatedHours;
2724
+ }
2725
+ await db.update(schema.tickets).set(updateValues).where(eq(schema.tickets.id, ticket.id));
2726
+ const changes = [];
2727
+ const activities = [];
2728
+ if (input.status !== void 0 && input.status !== ticket.status) {
2729
+ activities.push({
2730
+ activityType: "status_changed",
2731
+ oldValue: ticket.status,
2732
+ newValue: input.status
2249
2733
  });
2250
- agendaEntry = created ?? null;
2251
- if (agendaEntry && ticket?.id) {
2252
- await db.insert(schema.agendaEventTickets).values({ agendaEventId: agendaEntry.id, ticketId: ticket.id }).onConflictDoNothing();
2253
- }
2734
+ changes.push(`status ${ticket.status} -> ${input.status}`);
2254
2735
  }
2255
- if (!agendaEntry) {
2256
- throw new Error(
2257
- `Failed to ${wasUpdated ? "update" : "create"} time entry`
2736
+ if (input.priority !== void 0 && input.priority !== ticket.priority) {
2737
+ activities.push({
2738
+ activityType: "priority_changed",
2739
+ oldValue: ticket.priority,
2740
+ newValue: input.priority
2741
+ });
2742
+ changes.push(`priority ${ticket.priority} -> ${input.priority}`);
2743
+ }
2744
+ if (input.type !== void 0 && input.type !== ticket.type) {
2745
+ activities.push({
2746
+ activityType: "type_changed",
2747
+ oldValue: ticket.type,
2748
+ newValue: input.type
2749
+ });
2750
+ changes.push(`type ${ticket.type} -> ${input.type}`);
2751
+ }
2752
+ if (input.assigneeId !== void 0 && (input.assigneeId ?? null) !== ticket.assigneeId) {
2753
+ activities.push({
2754
+ activityType: "assignee_changed",
2755
+ oldValue: ticket.assigneeId ?? void 0,
2756
+ newValue: input.assigneeId ?? void 0
2757
+ });
2758
+ changes.push(
2759
+ input.assigneeId ? `assigned to ${input.assigneeId}` : "unassigned"
2258
2760
  );
2259
2761
  }
2260
- let responseText = `\u23F1\uFE0F **Hours ${wasUpdated ? "Added to Existing Entry" : "Logged Successfully"}!**
2762
+ if (input.title !== void 0 && input.title !== ticket.title) {
2763
+ changes.push("title updated");
2764
+ }
2765
+ if (input.description !== void 0) changes.push("description updated");
2766
+ if (input.projectId !== void 0) changes.push("project updated");
2767
+ if (input.customerId !== void 0) changes.push("customer updated");
2768
+ if (input.estimatedHours !== void 0) changes.push("estimated hours updated");
2769
+ for (const activity of activities) {
2770
+ await db.insert(schema.ticketActivity).values({
2771
+ ticketId: ticket.id,
2772
+ teamId: ticket.teamId,
2773
+ userId: ctx.userId,
2774
+ activityType: activity.activityType,
2775
+ oldValue: activity.oldValue,
2776
+ newValue: activity.newValue
2777
+ });
2778
+ }
2779
+ return {
2780
+ content: [
2781
+ {
2782
+ type: "text",
2783
+ text: `\u2705 **Ticket Updated: ${ticket.ticketNumber}**
2261
2784
 
2262
- `;
2263
- if (wasUpdated) {
2264
- responseText += `\u{1F504} **Updated existing draft entry** (avoiding duplicates)
2265
- `;
2266
- responseText += ` \u2022 New total: ${Math.round((agendaEntry.trackedDuration ?? 0) / 3600 * 10) / 10}h
2785
+ ${changes.length > 0 ? `Changes:
2786
+ ${changes.map((c) => ` \u2022 ${c}`).join("\n")}` : "No field changes were applied."}`
2787
+ }
2788
+ ]
2789
+ };
2790
+ }
2267
2791
 
2268
- `;
2792
+ // src/tools/tickets.ts
2793
+ init_auth();
2794
+ init_db();
2795
+ function isImageFile(mimeType) {
2796
+ return mimeType.startsWith("image/") && ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"].includes(
2797
+ mimeType
2798
+ );
2799
+ }
2800
+ async function downloadImageAsBase64(storageKey) {
2801
+ try {
2802
+ let signedUrl;
2803
+ try {
2804
+ const { url } = await storage.createSignedUrl({
2805
+ bucket: "vault",
2806
+ path: storageKey,
2807
+ expiresIn: 3600
2808
+ });
2809
+ signedUrl = url;
2810
+ } catch (err) {
2811
+ console.error(`Failed to create signed URL for ${storageKey}:`, err);
2812
+ return null;
2813
+ }
2814
+ const response = await fetch(signedUrl);
2815
+ if (!response.ok) {
2816
+ console.error(
2817
+ `Failed to download file ${storageKey}: ${response.status}`
2818
+ );
2819
+ return null;
2820
+ }
2821
+ const arrayBuffer = await response.arrayBuffer();
2822
+ return Buffer.from(arrayBuffer).toString("base64");
2823
+ } catch (error) {
2824
+ console.error(`Error downloading image ${storageKey}:`, error);
2825
+ return null;
2269
2826
  }
2270
- if (consolidatedCount > 0) {
2271
- responseText += `\u{1F9F9} **Cleaned up ${consolidatedCount} duplicate entries**
2827
+ }
2828
+ async function handleGetTickets(input) {
2829
+ const ctx = authContext;
2830
+ const { status, priority, projectId, customerId, q, pageSize = 20 } = input;
2831
+ const resolved = await resolveTeamId(input.teamId);
2832
+ if (!resolved.ok) return resolved.response;
2833
+ const teamId = resolved.teamId;
2834
+ const teamIds = await getAccessibleTeamIds(teamId);
2835
+ const projectIds = await getAccessibleProjectIds(ctx.userId, teamId);
2836
+ const customerIds = await getAccessibleCustomerIds(teamId);
2837
+ const accessPredicate = buildTicketAccessPredicate(
2838
+ teamIds,
2839
+ projectIds,
2840
+ customerIds
2841
+ );
2842
+ const filters = [accessPredicate, eq(schema.tickets.isDeleted, false)];
2843
+ if (status) filters.push(eq(schema.tickets.status, status));
2844
+ if (priority) filters.push(eq(schema.tickets.priority, priority));
2845
+ if (projectId) filters.push(eq(schema.tickets.projectId, projectId));
2846
+ if (customerId) filters.push(eq(schema.tickets.customerId, customerId));
2847
+ if (q) {
2848
+ const pattern = `%${q}%`;
2849
+ filters.push(
2850
+ or(
2851
+ ilike(schema.tickets.ticketNumber, pattern),
2852
+ ilike(schema.tickets.title, pattern),
2853
+ ilike(schema.tickets.description, pattern)
2854
+ )
2855
+ );
2856
+ }
2857
+ const rows = await db.select({
2858
+ id: schema.tickets.id,
2859
+ ticketNumber: schema.tickets.ticketNumber,
2860
+ title: schema.tickets.title,
2861
+ description: schema.tickets.description,
2862
+ status: schema.tickets.status,
2863
+ priority: schema.tickets.priority,
2864
+ type: schema.tickets.type,
2865
+ createdAt: schema.tickets.createdAt,
2866
+ projectId: schema.tickets.projectId,
2867
+ customerId: schema.tickets.customerId,
2868
+ projectName: schema.projects.name,
2869
+ customerName: schema.customers.name
2870
+ }).from(schema.tickets).leftJoin(schema.projects, eq(schema.projects.id, schema.tickets.projectId)).leftJoin(
2871
+ schema.customers,
2872
+ eq(schema.customers.id, schema.tickets.customerId)
2873
+ ).where(and(...filters)).orderBy(desc(schema.tickets.createdAt)).limit(Math.min(pageSize, 100));
2874
+ return {
2875
+ content: [
2876
+ {
2877
+ type: "text",
2878
+ text: `Found ${rows.length} tickets:
2272
2879
 
2273
- `;
2880
+ ${rows.map(
2881
+ (t) => `**${t.ticketNumber}**: ${t.title}
2882
+ Status: ${t.status} | Priority: ${t.priority}
2883
+ ${t.projectName ? `Project: ${t.projectName}
2884
+ ` : ""}${t.customerName ? `Customer: ${t.customerName}
2885
+ ` : ""}Created: ${new Date(t.createdAt).toLocaleDateString()}
2886
+ `
2887
+ ).join("\n") || "No tickets found."}`
2888
+ }
2889
+ ]
2890
+ };
2891
+ }
2892
+ async function handleGetTicketById(input) {
2893
+ const { id } = input;
2894
+ const scope = await resolveTeamScope(input.teamId);
2895
+ if (!scope.ok) return scope.response;
2896
+ const { teamIds, projectIds, customerIds } = scope;
2897
+ const ticketRow = await db.query.tickets.findFirst({
2898
+ where: eq(schema.tickets.id, id),
2899
+ with: {
2900
+ project: { columns: { id: true, name: true } },
2901
+ customer: { columns: { id: true, name: true } },
2902
+ assignee: { columns: { id: true, fullName: true, email: true } },
2903
+ requester: { columns: { id: true, fullName: true, email: true } }
2904
+ }
2905
+ });
2906
+ if (!ticketRow) {
2907
+ throw new Error(`Ticket not found: ${id}`);
2908
+ }
2909
+ let hasAccess = false;
2910
+ if (teamIds.includes(ticketRow.teamId)) hasAccess = true;
2911
+ if (!hasAccess && ticketRow.projectId && projectIds.includes(ticketRow.projectId))
2912
+ hasAccess = true;
2913
+ if (!hasAccess && ticketRow.customerId && customerIds.includes(ticketRow.customerId))
2914
+ hasAccess = true;
2915
+ if (!hasAccess) {
2916
+ throw new Error(
2917
+ "Access denied: You do not have permission to view this ticket"
2918
+ );
2919
+ }
2920
+ const attachments = await db.select({
2921
+ id: schema.ticketAttachments.id,
2922
+ fileName: schema.ticketAttachments.fileName,
2923
+ fileSize: schema.ticketAttachments.fileSize,
2924
+ mimeType: schema.ticketAttachments.mimeType,
2925
+ storageKey: schema.ticketAttachments.storageKey,
2926
+ createdAt: schema.ticketAttachments.createdAt,
2927
+ uploaderId: schema.ticketAttachments.userId,
2928
+ uploaderName: schema.users.fullName
2929
+ }).from(schema.ticketAttachments).leftJoin(
2930
+ schema.users,
2931
+ eq(schema.users.id, schema.ticketAttachments.userId)
2932
+ ).where(eq(schema.ticketAttachments.ticketId, id)).orderBy(asc(schema.ticketAttachments.createdAt));
2933
+ const comments = await db.select({
2934
+ id: schema.ticketComments.id,
2935
+ content: schema.ticketComments.content,
2936
+ createdAt: schema.ticketComments.createdAt,
2937
+ userId: schema.ticketComments.userId
2938
+ }).from(schema.ticketComments).where(eq(schema.ticketComments.ticketId, id)).orderBy(asc(schema.ticketComments.createdAt));
2939
+ const commentUserIds = [
2940
+ ...new Set(
2941
+ comments.map((c) => c.userId).filter((v) => Boolean(v))
2942
+ )
2943
+ ];
2944
+ const commentUserMap = /* @__PURE__ */ new Map();
2945
+ if (commentUserIds.length > 0) {
2946
+ const commentUsers = await db.select({ id: schema.users.id, fullName: schema.users.fullName }).from(schema.users).where(inArray(schema.users.id, commentUserIds));
2947
+ commentUsers.forEach((u) => commentUserMap.set(u.id, u));
2274
2948
  }
2275
- responseText += `\u{1F4CB} **Entry Details:**
2276
- `;
2277
- responseText += ` \u2022 Project: ${project ? project.name : "(No project assigned)"}
2278
- `;
2279
- if (ticket) responseText += ` \u2022 Ticket: ${ticket.title} (${ticket.status})
2280
- `;
2281
- if (aiSession)
2282
- responseText += ` \u2022 AI Session: ${aiSession.id} (${aiSession.status})
2283
- `;
2284
- responseText += ` \u2022 Description: ${workDescription}
2285
- `;
2286
- responseText += ` \u2022 ${wasUpdated ? "Added" : "Estimated"} Hours: ${estimatedHours}h (${Math.floor(estimatedHours)}h ${Math.round(estimatedHours % 1 * 60)}m)
2287
- `;
2288
- responseText += ` \u2022 Status: DRAFT (not billed yet)
2289
- `;
2290
- responseText += ` \u2022 Entry ID: ${agendaEntry.id}
2291
-
2949
+ const commentIds = comments.map((c) => c.id);
2950
+ const commentAttachments = commentIds.length > 0 ? await db.select({
2951
+ id: schema.ticketCommentAttachments.id,
2952
+ commentId: schema.ticketCommentAttachments.commentId,
2953
+ fileName: schema.ticketCommentAttachments.fileName,
2954
+ fileSize: schema.ticketCommentAttachments.fileSize,
2955
+ mimeType: schema.ticketCommentAttachments.mimeType,
2956
+ storageKey: schema.ticketCommentAttachments.storageKey,
2957
+ createdAt: schema.ticketCommentAttachments.createdAt
2958
+ }).from(schema.ticketCommentAttachments).where(
2959
+ inArray(schema.ticketCommentAttachments.commentId, commentIds)
2960
+ ) : [];
2961
+ const allAttachments = [
2962
+ ...attachments.map((a) => ({
2963
+ id: a.id,
2964
+ fileName: a.fileName,
2965
+ mimeType: a.mimeType,
2966
+ fileSize: a.fileSize,
2967
+ source: "ticket"
2968
+ })),
2969
+ ...commentAttachments.map((a) => ({
2970
+ id: a.id,
2971
+ fileName: a.fileName,
2972
+ mimeType: a.mimeType,
2973
+ fileSize: a.fileSize,
2974
+ source: "comment"
2975
+ }))
2976
+ ];
2977
+ const attachmentList = allAttachments.length > 0 ? `
2978
+ \u{1F4CE} **Attachments (${allAttachments.length})** \u2014 call get-ticket-attachment with the id for a download URL (images are also shown inline below):
2979
+ ` + allAttachments.map(
2980
+ (a) => ` \u2022 ${a.fileName} (${a.mimeType}, ${Math.round(
2981
+ a.fileSize / 1024
2982
+ )}KB) \u2014 id: ${a.id}${a.source === "comment" ? " [on a comment]" : ""}`
2983
+ ).join("\n") + "\n" : "";
2984
+ const commentList = comments.length > 0 ? `
2985
+ \u{1F4AC} **Comments (${comments.length})**:
2986
+ ` + comments.map((c) => {
2987
+ const author = c.userId ? commentUserMap.get(c.userId)?.fullName ?? "Unknown" : "Unknown";
2988
+ const text = tiptapToPlainText(c.content) || "(empty)";
2989
+ return ` \u2022 ${author} (${new Date(
2990
+ c.createdAt
2991
+ ).toLocaleDateString()}):
2992
+ ${text.split("\n").map((l) => ` ${l}`).join("\n")}`;
2993
+ }).join("\n") : "";
2994
+ const requesterLine = ticketRow.requester ? `Requester (creator): ${ticketRow.requester.fullName || "Unknown"} [id: ${ticketRow.requester.id}]
2995
+ ` : `Requester (creator): Unknown
2292
2996
  `;
2293
- if (chatContextSummary) {
2294
- responseText += `\u{1F4CA} **Work Context:**
2997
+ const assigneeLine = ticketRow.assignee ? `Assignee: ${ticketRow.assignee.fullName || "Unknown"} [id: ${ticketRow.assignee.id}]
2998
+ ` : ticketRow.requester ? `Assignee: (unassigned) \u2014 use requester id ${ticketRow.requester.id} for review handoff
2999
+ ` : `Assignee: (unassigned)
2295
3000
  `;
2296
- responseText += `${chatContextSummary.substring(0, 200)}${chatContextSummary.length > 200 ? "..." : ""}
3001
+ const content = [
3002
+ {
3003
+ type: "text",
3004
+ text: `**Ticket Details:**
2297
3005
 
2298
- `;
2299
- }
2300
- responseText += `\u2705 Time entry ${wasUpdated ? "updated" : "created"} and ready for review in the agenda!`;
2301
- return { content: [{ type: "text", text: responseText }] };
2302
- }
2303
- async function handleGetGithubFile(input) {
2304
- const ctx = authContext;
2305
- const { projectId, filePath, ref } = input;
2306
- const githubInfo = await getGithubTokenForProject(projectId, ctx.teamId);
2307
- if (!githubInfo) {
2308
- return {
2309
- content: [
2310
- { type: "text", text: "\u274C GitHub not configured for this project." }
2311
- ]
2312
- };
3006
+ **${ticketRow.ticketNumber}**: ${ticketRow.title}
3007
+ Ticket id: ${ticketRow.id}
3008
+ Team id: ${ticketRow.teamId}
3009
+ Status: ${ticketRow.status}
3010
+ Priority: ${ticketRow.priority}
3011
+ Type: ${ticketRow.type}
3012
+ ${ticketRow.description ? `Description: ${tiptapToPlainText(ticketRow.description)}
3013
+ ` : ""}${ticketRow.project?.name ? `Project: ${ticketRow.project.name}
3014
+ ` : ""}${ticketRow.customer?.name ? `Customer: ${ticketRow.customer.name}
3015
+ ` : ""}` + assigneeLine + requesterLine + `Created: ${new Date(ticketRow.createdAt).toLocaleDateString()}
3016
+ ` + attachmentList + commentList
3017
+ }
3018
+ ];
3019
+ if (attachments.length > 0) {
3020
+ console.error(`\u{1F4CE} Processing ${attachments.length} ticket attachments...`);
3021
+ for (const attachment of attachments) {
3022
+ if (isImageFile(attachment.mimeType)) {
3023
+ console.error(`\u{1F5BC}\uFE0F Downloading image: ${attachment.fileName}`);
3024
+ const base64 = await downloadImageAsBase64(attachment.storageKey);
3025
+ if (base64) {
3026
+ content.push({
3027
+ type: "image",
3028
+ data: base64,
3029
+ mimeType: attachment.mimeType
3030
+ });
3031
+ content.push({
3032
+ type: "text",
3033
+ text: `
3034
+ \u{1F4F8} **Image from ticket**: ${attachment.fileName} (${Math.round(
3035
+ attachment.fileSize / 1024
3036
+ )}KB, uploaded by ${attachment.uploaderName || "Unknown"} on ${new Date(
3037
+ attachment.createdAt
3038
+ ).toLocaleDateString()})
3039
+ `
3040
+ });
3041
+ }
3042
+ }
3043
+ }
2313
3044
  }
2314
- try {
2315
- const octokit = new Octokit({ auth: githubInfo.token });
3045
+ if (commentAttachments.length > 0) {
2316
3046
  console.error(
2317
- `\u{1F4C4} Reading file: ${filePath} from ${githubInfo.repositoryFullName}`
3047
+ `\u{1F4CE} Processing ${commentAttachments.length} comment attachments...`
2318
3048
  );
2319
- const { data } = await octokit.rest.repos.getContent({
2320
- owner: githubInfo.owner,
2321
- repo: githubInfo.repo,
2322
- path: filePath,
2323
- ref
2324
- });
2325
- if (Array.isArray(data) || data.type !== "file") {
2326
- return {
2327
- content: [
2328
- {
3049
+ for (const attachment of commentAttachments) {
3050
+ if (isImageFile(attachment.mimeType)) {
3051
+ console.error(
3052
+ `\u{1F5BC}\uFE0F Downloading comment image: ${attachment.fileName}`
3053
+ );
3054
+ const base64 = await downloadImageAsBase64(attachment.storageKey);
3055
+ if (base64) {
3056
+ const comment = comments.find((c) => c.id === attachment.commentId);
3057
+ const author = comment?.userId ? commentUserMap.get(comment.userId)?.fullName : null;
3058
+ content.push({
3059
+ type: "image",
3060
+ data: base64,
3061
+ mimeType: attachment.mimeType
3062
+ });
3063
+ content.push({
2329
3064
  type: "text",
2330
- text: `\u274C "${filePath}" is not a file or contains multiple items.`
2331
- }
2332
- ]
2333
- };
2334
- }
2335
- const content = Buffer.from(data.content, "base64").toString("utf-8");
2336
- let responseText = `\u{1F4C4} **File: ${filePath}**
2337
- `;
2338
- responseText += `Repository: ${githubInfo.repositoryFullName}
2339
- `;
2340
- responseText += `Size: ${data.size} bytes
2341
- `;
2342
- responseText += `URL: ${data.html_url}
2343
-
2344
- `;
2345
- responseText += `**Content:**
2346
- \`\`\`
2347
- ${content}
2348
- \`\`\``;
2349
- return { content: [{ type: "text", text: responseText }] };
2350
- } catch (error) {
2351
- console.error("GitHub get file error:", error);
2352
- const status = error?.status;
2353
- if (status === 404) {
2354
- return {
2355
- content: [{ type: "text", text: `\u274C File not found: ${filePath}` }]
2356
- };
3065
+ text: `
3066
+ \u{1F4F8} **Image from comment** by ${author || "Unknown"} on ${new Date(
3067
+ attachment.createdAt
3068
+ ).toLocaleDateString()}: ${attachment.fileName} (${Math.round(
3069
+ attachment.fileSize / 1024
3070
+ )}KB)
3071
+ ` + (comment?.content ? `Comment text: "${comment.content.substring(0, 100)}${comment.content.length > 100 ? "..." : ""}"
3072
+ ` : "")
3073
+ });
3074
+ }
3075
+ }
2357
3076
  }
2358
- const message = error instanceof Error ? error.message : "Unknown error";
2359
- return {
2360
- content: [
2361
- { type: "text", text: `\u274C Failed to read file: ${message}` }
2362
- ]
2363
- };
2364
3077
  }
3078
+ console.error(
3079
+ `\u2705 Returning ticket with ${content.filter((c) => c.type === "image").length} images`
3080
+ );
3081
+ return { content };
2365
3082
  }
2366
- async function handleListGithubDirectory(input) {
3083
+ async function handleCreateTicket(input) {
2367
3084
  const ctx = authContext;
2368
- const { projectId, directoryPath, ref } = input;
2369
- const githubInfo = await getGithubTokenForProject(projectId, ctx.teamId);
2370
- if (!githubInfo) {
2371
- return {
2372
- content: [
2373
- { type: "text", text: "\u274C GitHub not configured for this project." }
2374
- ]
2375
- };
2376
- }
2377
- try {
2378
- const octokit = new Octokit({ auth: githubInfo.token });
2379
- const normalizedPath = !directoryPath || directoryPath === "/" ? "" : directoryPath;
2380
- console.error(
2381
- `\u{1F4C1} Listing directory: ${normalizedPath || "(root)"} in ${githubInfo.repositoryFullName}`
2382
- );
2383
- const { data } = await octokit.rest.repos.getContent({
2384
- owner: githubInfo.owner,
2385
- repo: githubInfo.repo,
2386
- path: normalizedPath,
2387
- ref
2388
- });
2389
- if (!Array.isArray(data)) {
2390
- return {
2391
- content: [
2392
- {
2393
- type: "text",
2394
- text: `\u274C "${directoryPath}" is not a directory.`
2395
- }
2396
- ]
2397
- };
2398
- }
2399
- let responseText = `\u{1F4C1} **Directory: ${directoryPath || "(root)"}**
2400
- `;
2401
- responseText += `Repository: ${githubInfo.repositoryFullName}
2402
- `;
2403
- responseText += `Items: ${data.length}
2404
-
2405
- `;
2406
- const directories = data.filter((item) => item.type === "dir");
2407
- const files = data.filter((item) => item.type === "file");
2408
- if (directories.length > 0) {
2409
- responseText += `**\u{1F4C1} Directories (${directories.length}):**
2410
- `;
2411
- for (const dir of directories) responseText += ` - ${dir.name}/
2412
- `;
2413
- responseText += `
2414
- `;
2415
- }
2416
- if (files.length > 0) {
2417
- responseText += `**\u{1F4C4} Files (${files.length}):**
2418
- `;
2419
- for (const file of files)
2420
- responseText += ` - ${file.name} (${file.size} bytes)
2421
- `;
3085
+ const {
3086
+ title,
3087
+ description,
3088
+ status = "open",
3089
+ priority = "medium",
3090
+ type = "task",
3091
+ projectId,
3092
+ customerId
3093
+ } = input;
3094
+ const resolved = await resolveTeamId(input.teamId);
3095
+ if (!resolved.ok) return resolved.response;
3096
+ const year = (/* @__PURE__ */ new Date()).getFullYear();
3097
+ let resolvedTeamId = resolved.teamId;
3098
+ let resolvedCustomerId = customerId;
3099
+ let projectAbbreviation = "";
3100
+ if (projectId) {
3101
+ const [project] = await db.select({
3102
+ name: schema.projects.name,
3103
+ teamId: schema.projects.teamId,
3104
+ customerId: schema.projects.customerId
3105
+ }).from(schema.projects).where(eq(schema.projects.id, projectId)).limit(1);
3106
+ if (project) {
3107
+ if (project.teamId) {
3108
+ const member = await isUserTeamMember(ctx.userId, project.teamId);
3109
+ if (!member) return notAMemberResponse(project.teamId);
3110
+ resolvedTeamId = project.teamId;
3111
+ }
3112
+ if (!resolvedCustomerId && project.customerId) {
3113
+ resolvedCustomerId = project.customerId;
3114
+ }
3115
+ if (project.name) {
3116
+ const upper = project.name.toUpperCase().replace(/[^A-Z0-9\s]/g, "");
3117
+ const words = upper.split(/\s+/).filter(Boolean);
3118
+ if (words.length >= 2) {
3119
+ projectAbbreviation = words.slice(0, 2).map((w) => w.substring(0, 3)).join("").substring(0, 5);
3120
+ } else if (words.length === 1 && words[0]) {
3121
+ projectAbbreviation = words[0].substring(0, 5);
3122
+ }
3123
+ }
2422
3124
  }
2423
- return { content: [{ type: "text", text: responseText }] };
2424
- } catch (error) {
2425
- console.error("GitHub list directory error:", error);
2426
- const status = error?.status;
2427
- if (status === 404) {
2428
- return {
2429
- content: [
2430
- { type: "text", text: `\u274C Directory not found: ${directoryPath}` }
2431
- ]
2432
- };
3125
+ }
3126
+ let ticketNumber;
3127
+ if (projectId && projectAbbreviation) {
3128
+ const pattern = `${year}-${projectAbbreviation}-%`;
3129
+ const [highest] = await db.select({ ticketNumber: schema.tickets.ticketNumber }).from(schema.tickets).where(
3130
+ and(
3131
+ eq(schema.tickets.projectId, projectId),
3132
+ ilike(schema.tickets.ticketNumber, pattern)
3133
+ )
3134
+ ).orderBy(desc(schema.tickets.ticketNumber)).limit(1);
3135
+ let nextSequence = 1;
3136
+ if (highest?.ticketNumber) {
3137
+ const parts = highest.ticketNumber.split("-");
3138
+ if (parts.length === 3 && parts[2]) {
3139
+ const lastSeq = Number.parseInt(parts[2], 10);
3140
+ if (!Number.isNaN(lastSeq)) nextSequence = lastSeq + 1;
3141
+ }
2433
3142
  }
2434
- const message = error instanceof Error ? error.message : "Unknown error";
2435
- return {
2436
- content: [
2437
- { type: "text", text: `\u274C Failed to list directory: ${message}` }
2438
- ]
2439
- };
3143
+ ticketNumber = `${year}-${projectAbbreviation}-${String(nextSequence).padStart(3, "0")}`;
3144
+ } else {
3145
+ const [countRow] = await db.select({ n: sql`count(*)::int` }).from(schema.tickets).where(eq(schema.tickets.teamId, resolvedTeamId));
3146
+ const count = Number(countRow?.n ?? 0);
3147
+ ticketNumber = `${year}-${String(count + 1).padStart(3, "0")}`;
2440
3148
  }
3149
+ await db.insert(schema.tickets).values({
3150
+ teamId: resolvedTeamId,
3151
+ ticketNumber,
3152
+ title,
3153
+ description: description ?? null,
3154
+ status,
3155
+ priority,
3156
+ type,
3157
+ projectId: projectId ?? null,
3158
+ customerId: resolvedCustomerId ?? null,
3159
+ requesterId: ctx.userId
3160
+ });
3161
+ return {
3162
+ content: [
3163
+ {
3164
+ type: "text",
3165
+ text: `\u2705 **Ticket Created Successfully!**
3166
+
3167
+ Ticket Number: **${ticketNumber}**
3168
+ Title: ${title}
3169
+ Status: ${status}
3170
+ Priority: ${priority}
3171
+ Type: ${type}
3172
+ `
3173
+ }
3174
+ ]
3175
+ };
2441
3176
  }
2442
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
2443
- if (!authContext) {
3177
+
3178
+ // src/index.ts
3179
+ var args = process.argv.slice(2);
3180
+ function readArg(name) {
3181
+ const prefix = `--${name}=`;
3182
+ const hit = args.find((a) => a.startsWith(prefix));
3183
+ return hit ? hit.slice(prefix.length) : void 0;
3184
+ }
3185
+ var apiKey = readArg("api-key") ?? process.env.MG_TICKETS_API_KEY;
3186
+ var databaseUrl = readArg("database-url") ?? process.env.DATABASE_PRIMARY_POOLER_URL ?? process.env.DATABASE_URL;
3187
+ if (!apiKey) {
3188
+ console.error(
3189
+ "\u274C API key is required. Use --api-key=your_key or set MG_TICKETS_API_KEY environment variable"
3190
+ );
3191
+ process.exit(1);
3192
+ }
3193
+ if (!databaseUrl) {
3194
+ console.error(
3195
+ "\u274C Database URL is required. Use --database-url=postgresql://... or set DATABASE_PRIMARY_POOLER_URL (or DATABASE_URL) environment variable."
3196
+ );
3197
+ process.exit(1);
3198
+ }
3199
+ process.env.DATABASE_PRIMARY_POOLER_URL = databaseUrl;
3200
+ var server = new Server(
3201
+ {
3202
+ name: "mg-tickets-mcp-bridge",
3203
+ version: "3.0.2"
3204
+ },
3205
+ {
3206
+ capabilities: {
3207
+ tools: {},
3208
+ resources: {}
3209
+ }
3210
+ }
3211
+ );
3212
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOLS }));
3213
+ server.setRequestHandler(ListResourcesRequestSchema, async () => ({
3214
+ resources: RESOURCES
3215
+ }));
3216
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
3217
+ const { authContext: authContext2 } = await Promise.resolve().then(() => (init_auth(), auth_exports));
3218
+ if (!authContext2) {
2444
3219
  return {
2445
- contents: [
3220
+ content: [
2446
3221
  {
2447
- uri: request.params.uri,
2448
- mimeType: "text/plain",
3222
+ type: "text",
2449
3223
  text: "Error: Not authenticated. API key validation failed."
2450
3224
  }
2451
3225
  ]
2452
3226
  };
2453
3227
  }
2454
- const ctx = authContext;
2455
- const { uri } = request.params;
2456
- console.error(`\u{1F4DA} Reading resource: ${uri}`);
3228
+ const { name, arguments: toolArgs } = request.params;
3229
+ console.error(`\u{1F6E0}\uFE0F Executing tool: ${name} for team ${authContext2.teamId}`);
2457
3230
  try {
2458
- switch (uri) {
2459
- case "tickets://recent": {
2460
- const teamIds = await getAccessibleTeamIds(ctx.teamId);
2461
- const projectIds = await getAccessibleProjectIds(ctx.userId, ctx.teamId);
2462
- const customerIds = await getAccessibleCustomerIds(ctx.teamId);
2463
- const accessPredicate = buildTicketAccessPredicate(
2464
- teamIds,
2465
- projectIds,
2466
- customerIds
3231
+ switch (name) {
3232
+ case "get-teams":
3233
+ return await handleGetTeams();
3234
+ case "get-tickets":
3235
+ return await handleGetTickets(asToolArgs(toolArgs));
3236
+ case "get-ticket-by-id":
3237
+ return await handleGetTicketById(asToolArgs(toolArgs));
3238
+ case "create-ticket":
3239
+ return await handleCreateTicket(asToolArgs(toolArgs));
3240
+ case "update-ticket":
3241
+ return await handleUpdateTicket(asToolArgs(toolArgs));
3242
+ case "add-ticket-comment":
3243
+ return await handleAddTicketComment(
3244
+ asToolArgs(toolArgs)
3245
+ );
3246
+ case "get-ticket-comments":
3247
+ return await handleGetTicketComments(
3248
+ asToolArgs(toolArgs)
3249
+ );
3250
+ case "get-ticket-attachment":
3251
+ return await handleGetTicketAttachment(
3252
+ asToolArgs(toolArgs)
3253
+ );
3254
+ case "get-customers":
3255
+ return await handleGetCustomers(asToolArgs(toolArgs));
3256
+ case "create-customer":
3257
+ return await handleCreateCustomer(asToolArgs(toolArgs));
3258
+ case "get-projects":
3259
+ return await handleGetProjects(asToolArgs(toolArgs));
3260
+ case "create-project":
3261
+ return await handleCreateProject(asToolArgs(toolArgs));
3262
+ case "start-ai-session-smart":
3263
+ return await handleStartAiSession(
3264
+ asToolArgs(toolArgs)
3265
+ );
3266
+ case "track-manual-follow-up":
3267
+ return await handleTrackManualFollowUp(
3268
+ asToolArgs(toolArgs)
3269
+ );
3270
+ case "get-session-context":
3271
+ return await handleGetSessionContext(
3272
+ asToolArgs(toolArgs)
3273
+ );
3274
+ case "sync-session-todos":
3275
+ return await handleSyncSessionTodos(
3276
+ asToolArgs(toolArgs)
3277
+ );
3278
+ case "add-follow-up-todos":
3279
+ return await handleAddFollowUpTodos(
3280
+ asToolArgs(toolArgs)
3281
+ );
3282
+ case "update-session-status":
3283
+ return await handleUpdateSessionStatus(
3284
+ asToolArgs(toolArgs)
3285
+ );
3286
+ case "get-completion-context":
3287
+ return await handleGetCompletionContext(
3288
+ asToolArgs(toolArgs)
3289
+ );
3290
+ case "save-customer-response":
3291
+ return await handleSaveCustomerResponse(
3292
+ asToolArgs(toolArgs)
3293
+ );
3294
+ case "complete-ai-session":
3295
+ return await handleCompleteAiSession(
3296
+ asToolArgs(toolArgs)
3297
+ );
3298
+ case "log-hours":
3299
+ return await handleLogHours(asToolArgs(toolArgs));
3300
+ case "get-github-file":
3301
+ return await handleGetGithubFile(asToolArgs(toolArgs));
3302
+ case "list-github-directory":
3303
+ return await handleListGithubDirectory(
3304
+ asToolArgs(toolArgs)
2467
3305
  );
2468
- const rows = await db.select({
2469
- id: schema.tickets.id,
2470
- ticketNumber: schema.tickets.ticketNumber,
2471
- title: schema.tickets.title,
2472
- status: schema.tickets.status,
2473
- priority: schema.tickets.priority,
2474
- createdAt: schema.tickets.createdAt
2475
- }).from(schema.tickets).where(and(accessPredicate, eq(schema.tickets.isDeleted, false))).orderBy(desc(schema.tickets.createdAt)).limit(20);
2476
- return {
2477
- contents: [
2478
- {
2479
- uri,
2480
- mimeType: "application/json",
2481
- text: JSON.stringify(rows, null, 2)
2482
- }
2483
- ]
2484
- };
2485
- }
2486
- case "customers://all": {
2487
- const customerIds = await getAccessibleCustomerIds(ctx.teamId);
2488
- if (customerIds.length === 0) {
2489
- return {
2490
- contents: [
2491
- { uri, mimeType: "application/json", text: JSON.stringify([], null, 2) }
2492
- ]
2493
- };
2494
- }
2495
- const rows = await db.select({
2496
- id: schema.customers.id,
2497
- name: schema.customers.name,
2498
- email: schema.customers.email,
2499
- website: schema.customers.website,
2500
- createdAt: schema.customers.createdAt
2501
- }).from(schema.customers).where(inArray(schema.customers.id, customerIds)).orderBy(asc(schema.customers.name)).limit(50);
2502
- return {
2503
- contents: [
2504
- { uri, mimeType: "application/json", text: JSON.stringify(rows, null, 2) }
2505
- ]
2506
- };
2507
- }
2508
- case "projects://active": {
2509
- const projectIds = await getAccessibleProjectIds(ctx.userId, ctx.teamId);
2510
- if (projectIds.length === 0) {
2511
- return {
2512
- contents: [
2513
- { uri, mimeType: "application/json", text: JSON.stringify([], null, 2) }
2514
- ]
2515
- };
2516
- }
2517
- const rows = await db.select({
2518
- id: schema.projects.id,
2519
- name: schema.projects.name,
2520
- description: schema.projects.description,
2521
- createdAt: schema.projects.createdAt,
2522
- customerId: schema.projects.customerId,
2523
- customerName: schema.customers.name
2524
- }).from(schema.projects).leftJoin(
2525
- schema.customers,
2526
- eq(schema.customers.id, schema.projects.customerId)
2527
- ).where(inArray(schema.projects.id, projectIds)).orderBy(asc(schema.projects.name)).limit(50);
2528
- return {
2529
- contents: [
2530
- { uri, mimeType: "application/json", text: JSON.stringify(rows, null, 2) }
2531
- ]
2532
- };
2533
- }
2534
3306
  default:
2535
- throw new Error(`Unknown resource: ${uri}`);
3307
+ throw new Error(`Unknown tool: ${name}`);
2536
3308
  }
2537
3309
  } catch (error) {
2538
- console.error("\u274C Resource read error:", error);
3310
+ console.error("\u274C Tool execution error:", error);
3311
+ const message = error instanceof Error ? error.message : typeof error === "string" ? error : JSON.stringify(error);
2539
3312
  return {
2540
- contents: [
2541
- {
2542
- uri,
2543
- mimeType: "text/plain",
2544
- text: `Error reading ${uri}: ${error instanceof Error ? error.message : "Unknown error"}`
2545
- }
2546
- ]
3313
+ content: [{ type: "text", text: `Error executing ${name}: ${message}` }]
2547
3314
  };
2548
3315
  }
2549
3316
  });
3317
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
3318
+ return await handleReadResource(request.params.uri);
3319
+ });
2550
3320
  async function main() {
2551
3321
  console.error("\u{1F680} Starting Refront MCP Bridge Server...");
2552
3322
  console.error(`\u{1F511} API Key: ${apiKey?.substring(0, 10)}...`);
2553
- authContext = await validateApiKey(apiKey);
2554
- if (!authContext) {
3323
+ const ctx = await validateApiKey(apiKey);
3324
+ if (!ctx) {
2555
3325
  console.error(
2556
3326
  "\u274C API key validation failed. Please check your key and try again."
2557
3327
  );
2558
3328
  process.exit(1);
2559
3329
  }
3330
+ setAuthContext(ctx);
2560
3331
  console.error(
2561
- `\u2705 Authenticated as user ${authContext.userId} in team ${authContext.teamId}`
3332
+ `\u2705 Authenticated as user ${ctx.userId} in team ${ctx.teamId}`
2562
3333
  );
2563
- console.error(`\u{1F4CB} Available scopes: ${authContext.scopes.join(", ")}`);
3334
+ console.error(`\u{1F4CB} Available scopes: ${ctx.scopes.join(", ")}`);
2564
3335
  console.error("\u{1F4E1} MCP Bridge Server ready for connections");
2565
3336
  const transport = new StdioServerTransport();
2566
3337
  await server.connect(transport);