@mgsoftwarebv/mcp-server-bridge 3.0.0 → 3.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,46 +1,148 @@
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
- import { createClient } from '@supabase/supabase-js';
6
- import { createHash } from 'crypto';
7
9
  import { Octokit } from '@octokit/rest';
10
+ import { createStorageClient } from '@refront/storage';
11
+ import { ensureTipTapFormat } from '@refront/utils/tiptap';
8
12
 
9
- var args = process.argv.slice(2);
10
- var apiKey = args.find((arg) => arg.startsWith("--api-key="))?.split("=")[1] || process.env.MG_TICKETS_API_KEY;
11
- var supabaseUrl = args.find((arg) => arg.startsWith("--supabase-url="))?.split("=")[1] || process.env.SUPABASE_URL || "https://supabase.refront.nl";
12
- var supabaseKey = args.find((arg) => arg.startsWith("--supabase-key="))?.split("=")[1] || process.env.SUPABASE_SERVICE_ROLE_KEY || "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoic2VydmljZV9yb2xlIiwiaXNzIjoic3VwYWJhc2UiLCJpYXQiOjE3NzIwMzE4MzUsImV4cCI6MTkyOTcxMTgzNX0.M0glA9Ry0v87f-WzLKjLfS2Or4141b8c6Czoj1m5fjE";
13
- if (!apiKey) {
14
- console.error("\u274C API key is required. Use --api-key=your_key or set MG_TICKETS_API_KEY environment variable");
15
- process.exit(1);
16
- }
17
- var supabase = createClient(supabaseUrl, supabaseKey);
18
- function roundToNearest15Minutes(minutes) {
19
- if (minutes <= 0) return 0;
20
- return Math.round(minutes / 15) * 15;
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
+ };
22
+ function getClient() {
23
+ if (!_client) {
24
+ if (!process.env.DATABASE_PRIMARY_POOLER_URL) {
25
+ throw new Error(
26
+ "DATABASE_PRIMARY_POOLER_URL is not set. Pass --database-url=... or export the env var before starting the MCP bridge."
27
+ );
28
+ }
29
+ _client = createJobDb();
30
+ }
31
+ return _client;
21
32
  }
22
33
  async function getAccessibleTeamIds(teamId) {
23
- const { data: accessibleTeams } = await supabase.from("teams").select("id").or(`id.eq.${teamId},parent_team_id.eq.${teamId}`);
24
- return accessibleTeams?.map((t) => t.id) || [teamId];
34
+ const rows = await db.select({ id: schema.teams.id }).from(schema.teams).where(
35
+ or(eq(schema.teams.id, teamId), eq(schema.teams.parentTeamId, teamId))
36
+ );
37
+ const ids = rows.map((r) => r.id);
38
+ return ids.length > 0 ? ids : [teamId];
25
39
  }
26
40
  async function getAccessibleProjectIds(userId, teamId) {
27
- const { data, error } = await supabase.rpc("get_accessible_project_ids", {
28
- p_user_id: userId,
29
- p_team_id: teamId
30
- });
31
- if (error) {
41
+ try {
42
+ const rows = await db.execute(
43
+ sql`SELECT project_id FROM get_accessible_project_ids(${userId}::uuid, ${teamId}::uuid)`
44
+ );
45
+ return rows.map((r) => r.project_id).filter(Boolean);
46
+ } catch (error) {
32
47
  console.error("\u274C Error getting accessible project IDs:", error);
33
48
  return [];
34
49
  }
35
- return data?.map((row) => row.project_id) || [];
36
50
  }
37
51
  async function getAccessibleCustomerIds(teamId) {
38
52
  const teamIds = await getAccessibleTeamIds(teamId);
39
- const { data: ownCustomers } = await supabase.from("customers").select("id").in("team_id", teamIds);
40
- const ownCustomerIds = ownCustomers?.map((c) => c.id) || [];
41
- const { data: sharedCustomers } = await supabase.from("customer_shared_teams").select("customer_id").eq("team_id", teamId);
42
- const sharedCustomerIds = sharedCustomers?.map((c) => c.customer_id) || [];
43
- return [.../* @__PURE__ */ new Set([...ownCustomerIds, ...sharedCustomerIds])];
53
+ const ownCustomers = await db.select({ id: schema.customers.id }).from(schema.customers).where(inArray(schema.customers.teamId, teamIds));
54
+ const sharedCustomers = await db.select({ customerId: schema.customerSharedTeams.customerId }).from(schema.customerSharedTeams).where(eq(schema.customerSharedTeams.teamId, teamId));
55
+ return [
56
+ .../* @__PURE__ */ new Set([
57
+ ...ownCustomers.map((c) => c.id),
58
+ ...sharedCustomers.map((c) => c.customerId)
59
+ ])
60
+ ];
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
+ }
113
+ async function resolveAiSessionId(prefix, teamIds) {
114
+ if (teamIds.length === 0) return null;
115
+ const rows = await db.select({ id: schema.aiSessions.id }).from(schema.aiSessions).where(
116
+ and(
117
+ teamIds.length === 1 ? eq(schema.aiSessions.teamId, teamIds[0]) : sql`${schema.aiSessions.teamId} = ANY(${teamIds}::uuid[])`,
118
+ sql`${schema.aiSessions.id}::text LIKE ${`${prefix}%`}`
119
+ )
120
+ ).limit(1);
121
+ return rows[0]?.id ?? null;
122
+ }
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
+ });
134
+ }
135
+ });
136
+
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;
44
146
  }
45
147
  async function validateApiKey(key) {
46
148
  if (!key.startsWith("mid_") || key.length !== 68) {
@@ -50,164 +152,219 @@ async function validateApiKey(key) {
50
152
  try {
51
153
  const keyHash = createHash("sha256").update(key).digest("hex");
52
154
  console.error(`\u{1F50D} Validating API key hash: ${keyHash.substring(0, 16)}...`);
53
- const { data: apiKeyData, error } = await supabase.from("api_keys").select("id, user_id, team_id, scopes, last_used_at").eq("key_hash", keyHash).single();
54
- if (error || !apiKeyData) {
55
- console.error("\u274C API key not found or invalid:", error?.message);
155
+ const [apiKeyData] = await db.select({
156
+ id: schema.apiKeys.id,
157
+ userId: schema.apiKeys.userId,
158
+ teamId: schema.apiKeys.teamId,
159
+ scopes: schema.apiKeys.scopes,
160
+ lastUsedAt: schema.apiKeys.lastUsedAt
161
+ }).from(schema.apiKeys).where(eq(schema.apiKeys.keyHash, keyHash)).limit(1);
162
+ if (!apiKeyData) {
163
+ console.error("\u274C API key not found or invalid");
56
164
  return null;
57
165
  }
58
- await supabase.from("api_keys").update({ last_used_at: (/* @__PURE__ */ new Date()).toISOString() }).eq("id", apiKeyData.id);
59
- console.error(`\u2705 API key validated for user ${apiKeyData.user_id} in team ${apiKeyData.team_id}`);
166
+ await db.update(schema.apiKeys).set({ lastUsedAt: (/* @__PURE__ */ new Date()).toISOString() }).where(eq(schema.apiKeys.id, apiKeyData.id));
167
+ console.error(
168
+ `\u2705 API key validated for user ${apiKeyData.userId} in team ${apiKeyData.teamId}`
169
+ );
60
170
  return {
61
- userId: apiKeyData.user_id,
62
- teamId: apiKeyData.team_id,
63
- scopes: apiKeyData.scopes || []
171
+ userId: apiKeyData.userId,
172
+ teamId: apiKeyData.teamId,
173
+ scopes: apiKeyData.scopes ?? []
64
174
  };
65
175
  } catch (error) {
66
176
  console.error("\u{1F4A5} API key validation error:", error);
67
177
  return null;
68
178
  }
69
179
  }
70
- var authContext = null;
71
- function isImageFile(mimeType) {
72
- return mimeType.startsWith("image/") && ["image/jpeg", "image/jpg", "image/png", "image/gif", "image/webp"].includes(mimeType);
73
- }
74
- async function downloadImageAsBase64(storageKey) {
75
- try {
76
- const { data: urlData, error: urlError } = await supabase.storage.from("vault").createSignedUrl(storageKey, 3600);
77
- if (urlError || !urlData?.signedUrl) {
78
- console.error(`Failed to create signed URL for ${storageKey}:`, urlError);
79
- return null;
80
- }
81
- const response = await fetch(urlData.signedUrl);
82
- if (!response.ok) {
83
- console.error(`Failed to download file ${storageKey}: ${response.status}`);
84
- return null;
85
- }
86
- const arrayBuffer = await response.arrayBuffer();
87
- const buffer = Buffer.from(arrayBuffer);
88
- return buffer.toString("base64");
89
- } catch (error) {
90
- console.error(`Error downloading image ${storageKey}:`, error);
91
- return null;
180
+ var authContext;
181
+ var init_auth = __esm({
182
+ "src/auth.ts"() {
183
+ init_db();
184
+ authContext = null;
92
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 ?? {};
93
199
  }
94
- async function getGithubTokenForProject(projectId, teamId) {
95
- try {
96
- const { data: repoData, error: repoError } = await supabase.from("project_github_repositories").select("repository_full_name").eq("project_id", projectId).eq("team_id", teamId).single();
97
- if (repoError || !repoData) {
98
- console.error(`No GitHub repository linked to project ${projectId}`);
99
- return null;
100
- }
101
- const { data: appData, error: appError } = await supabase.from("apps").select("config").eq("team_id", teamId).eq("app_id", "github").single();
102
- if (appError || !appData?.config?.access_token) {
103
- console.error(`GitHub app not connected for team ${teamId}`);
104
- return null;
105
- }
106
- const accessToken = appData.config.access_token;
107
- const repositoryFullName = repoData.repository_full_name;
108
- const [owner, repo] = repositoryFullName.split("/");
109
- if (!owner || !repo) {
110
- console.error(`Invalid repository full name: ${repositoryFullName}`);
111
- return null;
112
- }
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) {
113
219
  return {
114
- token: accessToken,
115
- repositoryFullName,
116
- owner,
117
- repo
220
+ contents: [
221
+ {
222
+ uri,
223
+ mimeType: "text/plain",
224
+ text: "Error: Not authenticated. API key validation failed."
225
+ }
226
+ ]
118
227
  };
119
- } catch (error) {
120
- console.error("Error getting GitHub token for project:", error);
121
- return null;
122
228
  }
123
- }
124
- async function transitionToNextPhase(sessionId, currentPhase) {
229
+ const ctx = authContext;
230
+ console.error(`\u{1F4DA} Reading resource: ${uri}`);
125
231
  try {
126
- const now = /* @__PURE__ */ new Date();
127
- const phaseOrder = ["analysis", "bug_investigation", "development", "communication"];
128
- const { data: allPhases, error: fetchError } = await supabase.from("ai_time_logs").select("*").eq("ai_session_id", sessionId).order("activity_type");
129
- if (fetchError || !allPhases) {
130
- console.error("Failed to fetch phases for transition:", fetchError);
131
- return;
132
- }
133
- let currentPhaseType = currentPhase;
134
- if (!currentPhaseType) {
135
- const activePhase = allPhases.find((p) => p.status === "in_progress");
136
- currentPhaseType = activePhase?.activity_type;
137
- }
138
- if (!currentPhaseType) {
139
- const analysisPhase = allPhases.find((p) => p.activity_type === "analysis");
140
- if (analysisPhase && analysisPhase.status === "pending" && analysisPhase.estimated_duration_seconds > 0) {
141
- await supabase.from("ai_time_logs").update({
142
- status: "in_progress",
143
- started_at: now.toISOString()
144
- }).eq("id", analysisPhase.id);
145
- console.error("\u2705 Started analysis phase");
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
241
+ );
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
+ };
146
259
  }
147
- return;
148
- }
149
- const currentPhaseRecord = allPhases.find((p) => p.activity_type === currentPhaseType && p.status === "in_progress");
150
- if (currentPhaseRecord) {
151
- const duration = Math.round((now.getTime() - new Date(currentPhaseRecord.started_at).getTime()) / 1e3);
152
- await supabase.from("ai_time_logs").update({
153
- status: "completed",
154
- ended_at: now.toISOString(),
155
- duration_seconds: duration
156
- }).eq("id", currentPhaseRecord.id);
157
- console.error(`\u2705 Completed phase: ${currentPhaseType} (${duration}s)`);
158
- }
159
- const currentIndex = phaseOrder.indexOf(currentPhaseType);
160
- if (currentIndex === -1 || currentIndex === phaseOrder.length - 1) {
161
- console.error("No next phase to transition to");
162
- return;
163
- }
164
- for (let i = currentIndex + 1; i < phaseOrder.length; i++) {
165
- const nextPhaseType = phaseOrder[i];
166
- const nextPhase = allPhases.find((p) => p.activity_type === nextPhaseType);
167
- if (!nextPhase) continue;
168
- if (nextPhase.estimated_duration_seconds === 0) {
169
- await supabase.from("ai_time_logs").update({ status: "skipped" }).eq("id", nextPhase.id);
170
- console.error(`\u23ED\uFE0F Skipped phase: ${nextPhaseType} (0 minutes estimated)`);
171
- continue;
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
+ };
172
281
  }
173
- if (nextPhase.status === "pending") {
174
- await supabase.from("ai_time_logs").update({
175
- status: "in_progress",
176
- started_at: now.toISOString()
177
- }).eq("id", nextPhase.id);
178
- console.error(`\u2705 Started next phase: ${nextPhaseType}`);
179
- return;
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
+ };
180
307
  }
308
+ default:
309
+ throw new Error(`Unknown resource: ${uri}`);
181
310
  }
182
- console.error("All remaining phases skipped or completed");
183
311
  } catch (error) {
184
- console.error("Error transitioning to next phase:", 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
+ };
185
322
  }
186
323
  }
187
- var server = new Server(
188
- {
189
- name: "mg-tickets-mcp-bridge",
190
- version: "2.0.0"
191
- },
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
+ };
330
+ var TOOLS = [
192
331
  {
193
- capabilities: {
194
- tools: {},
195
- resources: {}
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: []
196
338
  }
197
- }
198
- );
199
- var TOOLS = [
339
+ },
200
340
  {
201
341
  name: "get-tickets",
202
342
  description: "Get tickets with optional filtering by status, priority, project, customer, or search query",
203
343
  inputSchema: {
204
344
  type: "object",
205
345
  properties: {
206
- status: { type: "string", enum: ["open", "in_progress", "review", "resolved", "closed", "backlog"] },
207
- priority: { type: "string", enum: ["low", "medium", "high", "critical"] },
346
+ teamId: teamIdProp,
347
+ status: {
348
+ type: "string",
349
+ enum: [
350
+ "open",
351
+ "in_progress",
352
+ "review",
353
+ "resolved",
354
+ "closed",
355
+ "backlog"
356
+ ]
357
+ },
358
+ priority: {
359
+ type: "string",
360
+ enum: ["low", "medium", "high", "critical"]
361
+ },
208
362
  projectId: { type: "string" },
209
363
  customerId: { type: "string" },
210
- q: { type: "string", description: "Search query for ticket number, title, or description" },
364
+ q: {
365
+ type: "string",
366
+ description: "Search query for ticket number, title, or description"
367
+ },
211
368
  pageSize: { type: "number", default: 20, maximum: 100 }
212
369
  },
213
370
  required: []
@@ -215,10 +372,11 @@ var TOOLS = [
215
372
  },
216
373
  {
217
374
  name: "get-ticket-by-id",
218
- 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.",
219
376
  inputSchema: {
220
377
  type: "object",
221
378
  properties: {
379
+ teamId: teamIdProp,
222
380
  id: { type: "string", description: "Ticket ID" }
223
381
  },
224
382
  required: ["id"]
@@ -230,24 +388,146 @@ var TOOLS = [
230
388
  inputSchema: {
231
389
  type: "object",
232
390
  properties: {
391
+ teamId: teamIdProp,
233
392
  title: { type: "string", description: "Ticket title" },
234
393
  description: { type: "string" },
235
- status: { type: "string", enum: ["open", "in_progress", "review", "resolved", "closed", "backlog"], default: "open" },
236
- priority: { type: "string", enum: ["low", "medium", "high", "critical"], default: "medium" },
237
- type: { type: "string", enum: ["task", "bug", "feature", "support", "question", "improvement"], default: "task" },
394
+ status: {
395
+ type: "string",
396
+ enum: [
397
+ "open",
398
+ "in_progress",
399
+ "review",
400
+ "resolved",
401
+ "closed",
402
+ "backlog"
403
+ ],
404
+ default: "open"
405
+ },
406
+ priority: {
407
+ type: "string",
408
+ enum: ["low", "medium", "high", "critical"],
409
+ default: "medium"
410
+ },
411
+ type: {
412
+ type: "string",
413
+ enum: [
414
+ "task",
415
+ "bug",
416
+ "feature",
417
+ "support",
418
+ "question",
419
+ "improvement"
420
+ ],
421
+ default: "task"
422
+ },
238
423
  projectId: { type: "string" },
239
424
  customerId: { type: "string" }
240
425
  },
241
426
  required: ["title"]
242
427
  }
243
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
+ },
244
520
  {
245
521
  name: "get-customers",
246
522
  description: "Get customers with optional search",
247
523
  inputSchema: {
248
524
  type: "object",
249
525
  properties: {
250
- q: { type: "string", description: "Search query for customer name or email" },
526
+ teamId: teamIdProp,
527
+ q: {
528
+ type: "string",
529
+ description: "Search query for customer name or email"
530
+ },
251
531
  pageSize: { type: "number", default: 20, maximum: 100 }
252
532
  },
253
533
  required: []
@@ -259,6 +539,7 @@ var TOOLS = [
259
539
  inputSchema: {
260
540
  type: "object",
261
541
  properties: {
542
+ teamId: teamIdProp,
262
543
  name: { type: "string", description: "Customer name" },
263
544
  email: { type: "string" },
264
545
  website: { type: "string" }
@@ -272,6 +553,7 @@ var TOOLS = [
272
553
  inputSchema: {
273
554
  type: "object",
274
555
  properties: {
556
+ teamId: teamIdProp,
275
557
  customerId: { type: "string", description: "Filter by customer ID" },
276
558
  q: { type: "string", description: "Search query for project name" },
277
559
  pageSize: { type: "number", default: 20, maximum: 100 }
@@ -285,24 +567,32 @@ var TOOLS = [
285
567
  inputSchema: {
286
568
  type: "object",
287
569
  properties: {
570
+ teamId: teamIdProp,
288
571
  name: { type: "string", description: "Project name" },
289
572
  description: { type: "string" },
290
573
  customerId: { type: "string" },
291
- status: { type: "string", enum: ["active", "on_hold", "completed", "cancelled"], default: "active" }
574
+ status: {
575
+ type: "string",
576
+ enum: ["active", "on_hold", "completed", "cancelled"],
577
+ default: "active"
578
+ }
292
579
  },
293
580
  required: ["name"]
294
581
  }
295
582
  },
296
- // === NEW AI SESSION TOOLS ===
297
583
  {
298
584
  name: "start-ai-session-smart",
299
585
  description: "Start a new AI development session with automatic tracking",
300
586
  inputSchema: {
301
587
  type: "object",
302
588
  properties: {
589
+ teamId: teamIdProp,
303
590
  ticketId: { type: "string" },
304
591
  ticketUrl: { type: "string", description: "URL to the ticket" },
305
- cursorSessionId: { type: "string", description: "Cursor session identifier" },
592
+ cursorSessionId: {
593
+ type: "string",
594
+ description: "Cursor session identifier"
595
+ },
306
596
  totalEstimatedMinutes: {
307
597
  type: "number",
308
598
  description: "Total estimated time in minutes (senior dev WITHOUT AI, rounded to 15 min)"
@@ -323,13 +613,19 @@ var TOOLS = [
323
613
  inputSchema: {
324
614
  type: "object",
325
615
  properties: {
616
+ teamId: teamIdProp,
326
617
  aiSessionId: { type: "string" },
327
618
  originalPrompt: { type: "string" },
328
619
  aiResponse: { type: "string" },
329
620
  developerFollowUp: { type: "string" },
330
621
  followUpReason: {
331
622
  type: "string",
332
- enum: ["incomplete_result", "wrong_approach", "needs_clarification", "error_in_code"]
623
+ enum: [
624
+ "incomplete_result",
625
+ "wrong_approach",
626
+ "needs_clarification",
627
+ "error_in_code"
628
+ ]
333
629
  },
334
630
  outcome: {
335
631
  type: "string",
@@ -345,7 +641,15 @@ var TOOLS = [
345
641
  description: "Detailed work description generated by AI (2-3 sentences, summarizing all work done in session including follow-ups)"
346
642
  }
347
643
  },
348
- required: ["aiSessionId", "originalPrompt", "aiResponse", "developerFollowUp", "followUpReason", "estimatedMinutes", "workDescription"]
644
+ required: [
645
+ "aiSessionId",
646
+ "originalPrompt",
647
+ "aiResponse",
648
+ "developerFollowUp",
649
+ "followUpReason",
650
+ "estimatedMinutes",
651
+ "workDescription"
652
+ ]
349
653
  }
350
654
  },
351
655
  {
@@ -354,6 +658,7 @@ var TOOLS = [
354
658
  inputSchema: {
355
659
  type: "object",
356
660
  properties: {
661
+ teamId: teamIdProp,
357
662
  aiSessionId: { type: "string" },
358
663
  includeTicketData: { type: "boolean", default: true },
359
664
  includeTodoProgress: { type: "boolean", default: true },
@@ -368,15 +673,22 @@ var TOOLS = [
368
673
  inputSchema: {
369
674
  type: "object",
370
675
  properties: {
676
+ teamId: teamIdProp,
371
677
  aiSessionId: { type: "string" },
372
678
  todos: {
373
679
  type: "array",
374
680
  items: {
375
681
  type: "object",
376
682
  properties: {
377
- todoId: { type: "string", description: "Optional external todo ID for tracking" },
683
+ todoId: {
684
+ type: "string",
685
+ description: "Optional external todo ID for tracking"
686
+ },
378
687
  content: { type: "string" },
379
- status: { type: "string", enum: ["pending", "in_progress", "completed", "cancelled"] },
688
+ status: {
689
+ type: "string",
690
+ enum: ["pending", "in_progress", "completed", "cancelled"]
691
+ },
380
692
  estimatedMinutes: { type: "number" }
381
693
  },
382
694
  required: ["content", "status"]
@@ -397,6 +709,7 @@ var TOOLS = [
397
709
  inputSchema: {
398
710
  type: "object",
399
711
  properties: {
712
+ teamId: teamIdProp,
400
713
  aiSessionId: { type: "string" },
401
714
  newTodos: {
402
715
  type: "array",
@@ -404,7 +717,11 @@ var TOOLS = [
404
717
  type: "object",
405
718
  properties: {
406
719
  content: { type: "string" },
407
- status: { type: "string", enum: ["pending", "in_progress"], default: "pending" },
720
+ status: {
721
+ type: "string",
722
+ enum: ["pending", "in_progress"],
723
+ default: "pending"
724
+ },
408
725
  estimatedMinutes: { type: "number" },
409
726
  addedInFollowUp: { type: "boolean", default: true }
410
727
  },
@@ -425,6 +742,7 @@ var TOOLS = [
425
742
  inputSchema: {
426
743
  type: "object",
427
744
  properties: {
745
+ teamId: teamIdProp,
428
746
  aiSessionId: { type: "string" },
429
747
  status: {
430
748
  type: "string",
@@ -442,6 +760,7 @@ var TOOLS = [
442
760
  inputSchema: {
443
761
  type: "object",
444
762
  properties: {
763
+ teamId: teamIdProp,
445
764
  aiSessionId: { type: "string" },
446
765
  includeFollowUps: { type: "boolean", default: true },
447
766
  includeTimeMetrics: { type: "boolean", default: true },
@@ -456,8 +775,12 @@ var TOOLS = [
456
775
  inputSchema: {
457
776
  type: "object",
458
777
  properties: {
778
+ teamId: teamIdProp,
459
779
  aiSessionId: { type: "string" },
460
- customerResponse: { type: "string", description: "Customer response generated by Cursor AI" },
780
+ customerResponse: {
781
+ type: "string",
782
+ description: "Customer response generated by Cursor AI"
783
+ },
461
784
  responseType: {
462
785
  type: "string",
463
786
  enum: ["completion", "progress_update", "needs_clarification"],
@@ -473,6 +796,7 @@ var TOOLS = [
473
796
  inputSchema: {
474
797
  type: "object",
475
798
  properties: {
799
+ teamId: teamIdProp,
476
800
  aiSessionId: { type: "string" },
477
801
  workCompleted: {
478
802
  type: "array",
@@ -498,6 +822,7 @@ var TOOLS = [
498
822
  inputSchema: {
499
823
  type: "object",
500
824
  properties: {
825
+ teamId: teamIdProp,
501
826
  projectId: {
502
827
  type: "string",
503
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."
@@ -526,17 +851,14 @@ var TOOLS = [
526
851
  required: ["workDescription", "estimatedHours"]
527
852
  }
528
853
  },
529
- // === GITHUB TOOLS ===
530
854
  {
531
855
  name: "get-github-file",
532
856
  description: "Get the contents of a specific file from a GitHub repository. Use this after finding relevant files to read their full content.",
533
857
  inputSchema: {
534
858
  type: "object",
535
859
  properties: {
536
- projectId: {
537
- type: "string",
538
- description: "Project ID (UUID)"
539
- },
860
+ teamId: teamIdProp,
861
+ projectId: { type: "string", description: "Project ID (UUID)" },
540
862
  filePath: {
541
863
  type: "string",
542
864
  description: 'Full path to the file in the repository (e.g., "src/components/Button.tsx")'
@@ -555,10 +877,8 @@ var TOOLS = [
555
877
  inputSchema: {
556
878
  type: "object",
557
879
  properties: {
558
- projectId: {
559
- type: "string",
560
- description: "Project ID (UUID)"
561
- },
880
+ teamId: teamIdProp,
881
+ projectId: { type: "string", description: "Project ID (UUID)" },
562
882
  directoryPath: {
563
883
  type: "string",
564
884
  description: 'Path to directory (e.g., "src/components"). Use empty string or "/" for root directory.'
@@ -592,411 +912,1094 @@ var RESOURCES = [
592
912
  mimeType: "application/json"
593
913
  }
594
914
  ];
595
- server.setRequestHandler(ListToolsRequestSchema, async () => {
596
- return { tools: TOOLS };
597
- });
598
- server.setRequestHandler(ListResourcesRequestSchema, async () => {
599
- return { resources: RESOURCES };
600
- });
601
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
602
- 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) {
603
995
  return {
604
- content: [{ type: "text", text: "Error: Not authenticated. API key validation failed." }]
996
+ content: [
997
+ {
998
+ type: "text",
999
+ text: "No customers found or no access to any customers."
1000
+ }
1001
+ ]
605
1002
  };
606
1003
  }
607
- const { name, arguments: args2 } = request.params;
608
- console.error(`\u{1F6E0}\uFE0F Executing tool: ${name} for team ${authContext.teamId}`);
609
- try {
610
- switch (name) {
611
- case "get-tickets": {
612
- const { status, priority, projectId, customerId, q, pageSize = 20 } = args2;
613
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
614
- const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId);
615
- const customerIds = await getAccessibleCustomerIds(authContext.teamId);
616
- let teamFilter = `team_id.in.(${teamIds.join(",")})`;
617
- let projectFilter = projectIds.length > 0 ? `project_id.in.(${projectIds.join(",")})` : null;
618
- let customerFilter = customerIds.length > 0 ? `customer_id.in.(${customerIds.join(",")})` : null;
619
- const filters = [teamFilter, projectFilter, customerFilter].filter(Boolean);
620
- let query = supabase.from("tickets").select(`
621
- id,
622
- ticket_number,
623
- title,
624
- description,
625
- status,
626
- priority,
627
- type,
628
- created_at,
629
- project_id,
630
- customer_id,
631
- projects:project_id(id, name),
632
- customers:customer_id(id, name)
633
- `).or(filters.join(",")).limit(Math.min(pageSize, 100));
634
- if (status) query = query.eq("status", status);
635
- if (priority) query = query.eq("priority", priority);
636
- if (projectId) query = query.eq("project_id", projectId);
637
- if (customerId) query = query.eq("customer_id", customerId);
638
- if (q) query = query.or(`ticket_number.ilike.%${q}%,title.ilike.%${q}%,description.ilike.%${q}%`);
639
- const { data, error } = await query.order("created_at", { ascending: false });
640
- if (error) throw error;
641
- return {
642
- content: [{
643
- type: "text",
644
- text: `Found ${data?.length || 0} tickets:
645
-
646
- ${data?.map(
647
- (ticket) => `**${ticket.ticket_number}**: ${ticket.title}
648
- Status: ${ticket.status} | Priority: ${ticket.priority}
649
- ${ticket.projects?.name ? `Project: ${ticket.projects.name}
650
- ` : ""}${ticket.customers?.name ? `Customer: ${ticket.customers.name}
651
- ` : ""}Created: ${new Date(ticket.created_at).toLocaleDateString()}
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()}
652
1032
  `
653
- ).join("\n") || "No tickets found."}`
654
- }]
655
- };
1033
+ ).join("\n") || "No customers found."}`
656
1034
  }
657
- case "get-ticket-by-id": {
658
- const { id } = args2;
659
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
660
- const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId);
661
- const customerIds = await getAccessibleCustomerIds(authContext.teamId);
662
- const { data: ticketData, error } = await supabase.from("tickets").select(`
663
- *,
664
- projects:project_id(id, name),
665
- customers:customer_id(id, name),
666
- assignee:assignee_id(id, full_name, email),
667
- requester:requester_id(id, full_name, email)
668
- `).eq("id", id).single();
669
- if (error) {
670
- if (error.code === "PGRST116") {
671
- throw new Error(`Ticket not found: ${id}`);
672
- }
673
- throw new Error(error.message || JSON.stringify(error));
674
- }
675
- let hasAccess = false;
676
- if (teamIds.includes(ticketData.team_id)) hasAccess = true;
677
- if (!hasAccess && ticketData.project_id && projectIds.includes(ticketData.project_id)) hasAccess = true;
678
- if (!hasAccess && ticketData.customer_id && customerIds.includes(ticketData.customer_id)) hasAccess = true;
679
- if (!hasAccess) {
680
- throw new Error("Access denied: You do not have permission to view this ticket");
681
- }
682
- const data = ticketData;
683
- const { data: attachments, error: attachmentsError } = await supabase.from("ticket_attachments").select(`
684
- id,
685
- file_name,
686
- file_size,
687
- mime_type,
688
- storage_key,
689
- created_at,
690
- users:user_id(id, full_name)
691
- `).eq("ticket_id", id).order("created_at", { ascending: true });
692
- if (attachmentsError) {
693
- console.error("Error fetching attachments:", attachmentsError);
694
- }
695
- const { data: comments, error: commentsError } = await supabase.from("ticket_comments").select(`
696
- id,
697
- content,
698
- created_at,
699
- user_id
700
- `).eq("ticket_id", id).order("created_at", { ascending: true });
701
- if (commentsError) {
702
- console.error("Error fetching comments:", commentsError);
703
- }
704
- const commentUserIds = [...new Set(comments?.map((c) => c.user_id).filter(Boolean) || [])];
705
- const commentUserMap = /* @__PURE__ */ new Map();
706
- if (commentUserIds.length > 0) {
707
- const { data: commentUsers } = await supabase.from("users").select("id, full_name").in("id", commentUserIds);
708
- commentUsers?.forEach((u) => commentUserMap.set(u.id, u));
709
- }
710
- const commentIds = comments?.map((c) => c.id) || [];
711
- let commentAttachments = [];
712
- if (commentIds.length > 0) {
713
- const { data: commAttachments, error: commAttachmentsError } = await supabase.from("ticket_comment_attachments").select(`
714
- id,
715
- comment_id,
716
- file_name,
717
- file_size,
718
- mime_type,
719
- storage_key,
720
- created_at
721
- `).in("comment_id", commentIds);
722
- if (commAttachmentsError) {
723
- console.error("Error fetching comment attachments:", commAttachmentsError);
724
- } else {
725
- commentAttachments = commAttachments || [];
726
- }
727
- }
728
- const content = [{
729
- type: "text",
730
- text: `**Ticket Details:**
731
-
732
- **${data.ticket_number}**: ${data.title}
733
- Status: ${data.status}
734
- Priority: ${data.priority}
735
- Type: ${data.type}
736
- ${data.description ? `Description: ${data.description}
737
- ` : ""}${data.projects?.name ? `Project: ${data.projects.name}
738
- ` : ""}${data.customers?.name ? `Customer: ${data.customers.name}
739
- ` : ""}${data.assignee?.full_name ? `Assignee: ${data.assignee.full_name}
740
- ` : ""}Requester: ${data.requester?.full_name || "Unknown"}
741
- Created: ${new Date(data.created_at).toLocaleDateString()}
742
- ${attachments && attachments.length > 0 ? `
743
- \u{1F4CE} Attachments: ${attachments.length}
744
- ` : ""}${comments && comments.length > 0 ? `\u{1F4AC} Comments: ${comments.length}
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}
745
1057
  ` : ""}`
746
- }];
747
- if (attachments && attachments.length > 0) {
748
- console.error(`\u{1F4CE} Processing ${attachments.length} ticket attachments...`);
749
- for (const attachment of attachments) {
750
- if (isImageFile(attachment.mime_type)) {
751
- console.error(`\u{1F5BC}\uFE0F Downloading image: ${attachment.file_name}`);
752
- const base64Data = await downloadImageAsBase64(attachment.storage_key);
753
- if (base64Data) {
754
- content.push({
755
- type: "image",
756
- data: base64Data,
757
- mimeType: attachment.mime_type
758
- });
759
- content.push({
760
- type: "text",
761
- text: `
762
- \u{1F4F8} **Image from ticket**: ${attachment.file_name} (${Math.round(attachment.file_size / 1024)}KB, uploaded by ${attachment.users?.full_name || "Unknown"} on ${new Date(attachment.created_at).toLocaleDateString()})
763
- `
764
- });
765
- }
766
- }
767
- }
768
- }
769
- if (commentAttachments.length > 0) {
770
- console.error(`\u{1F4CE} Processing ${commentAttachments.length} comment attachments...`);
771
- for (const attachment of commentAttachments) {
772
- if (isImageFile(attachment.mime_type)) {
773
- console.error(`\u{1F5BC}\uFE0F Downloading comment image: ${attachment.file_name}`);
774
- const base64Data = await downloadImageAsBase64(attachment.storage_key);
775
- if (base64Data) {
776
- const comment = comments?.find((c) => c.id === attachment.comment_id);
777
- content.push({
778
- type: "image",
779
- data: base64Data,
780
- mimeType: attachment.mime_type
781
- });
782
- content.push({
783
- type: "text",
784
- text: `
785
- \u{1F4F8} **Image from comment** by ${commentUserMap.get(comment?.user_id)?.full_name || "Unknown"} on ${new Date(attachment.created_at).toLocaleDateString()}: ${attachment.file_name} (${Math.round(attachment.file_size / 1024)}KB)
786
- ` + (comment?.content ? `Comment text: "${comment.content.substring(0, 100)}${comment.content.length > 100 ? "..." : ""}"
787
- ` : "")
788
- });
789
- }
790
- }
791
- }
792
- }
793
- console.error(`\u2705 Returning ticket with ${content.filter((c) => c.type === "image").length} images`);
794
- return { content };
795
1058
  }
796
- case "create-ticket": {
797
- const { title, description, status = "open", priority = "medium", type = "task", projectId, customerId } = args2;
798
- const year = (/* @__PURE__ */ new Date()).getFullYear();
799
- let resolvedTeamId = authContext.teamId;
800
- let resolvedCustomerId = customerId;
801
- let projectAbbreviation = "";
802
- if (projectId) {
803
- const { data: project } = await supabase.from("projects").select("name, team_id, customer_id").eq("id", projectId).single();
804
- if (project) {
805
- if (project.team_id) {
806
- resolvedTeamId = project.team_id;
807
- }
808
- if (!resolvedCustomerId && project.customer_id) {
809
- resolvedCustomerId = project.customer_id;
810
- }
811
- if (project.name) {
812
- const name2 = project.name.toUpperCase().replace(/[^A-Z0-9\s]/g, "");
813
- const words = name2.split(/\s+/).filter(Boolean);
814
- if (words.length >= 2) {
815
- projectAbbreviation = words.slice(0, 2).map((w) => w.substring(0, 3)).join("").substring(0, 5);
816
- } else if (words.length === 1 && words[0]) {
817
- projectAbbreviation = words[0].substring(0, 5);
818
- }
819
- }
820
- }
821
- }
822
- let ticketNumber;
823
- if (projectId && projectAbbreviation) {
824
- const pattern = `${year}-${projectAbbreviation}-%`;
825
- const { data: existingTickets } = await supabase.from("tickets").select("ticket_number").eq("project_id", projectId).like("ticket_number", pattern).order("ticket_number", { ascending: false }).limit(1);
826
- let nextSequence = 1;
827
- if (existingTickets && existingTickets.length > 0 && existingTickets[0]?.ticket_number) {
828
- const parts = existingTickets[0].ticket_number.split("-");
829
- if (parts.length === 3) {
830
- const lastSeq = parseInt(parts[2], 10);
831
- if (!isNaN(lastSeq)) nextSequence = lastSeq + 1;
832
- }
1059
+ ]
1060
+ };
1061
+ }
1062
+
1063
+ // src/tools/github.ts
1064
+ init_db();
1065
+ async function getGithubTokenForProject(projectId, teamIds) {
1066
+ try {
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;
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 };
1097
+ } catch (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.`
833
1131
  }
834
- ticketNumber = `${year}-${projectAbbreviation}-${String(nextSequence).padStart(3, "0")}`;
835
- } else {
836
- const { count } = await supabase.from("tickets").select("*", { count: "exact", head: true }).eq("team_id", resolvedTeamId);
837
- ticketNumber = `${year}-${String((count || 0) + 1).padStart(3, "0")}`;
838
- }
839
- const { data, error } = await supabase.from("tickets").insert({
840
- team_id: resolvedTeamId,
841
- ticket_number: ticketNumber,
842
- title,
843
- description,
844
- status,
845
- priority,
846
- type,
847
- project_id: projectId || null,
848
- customer_id: resolvedCustomerId || null,
849
- requester_id: authContext.userId
850
- }).select().single();
851
- if (error) throw error;
852
- return {
853
- content: [{
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
+ {
854
1194
  type: "text",
855
- text: `\u2705 **Ticket Created Successfully!**
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}
856
1205
 
857
- Ticket Number: **${ticketNumber}**
858
- Title: ${title}
859
- Status: ${status}
860
- Priority: ${priority}
861
- Type: ${type}
862
- `
863
- }]
864
- };
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));
865
1356
  }
866
- case "get-customers": {
867
- const { q, pageSize = 20 } = args2;
868
- const customerIds = await getAccessibleCustomerIds(authContext.teamId);
869
- if (customerIds.length === 0) {
870
- return {
871
- content: [{
872
- type: "text",
873
- text: "No customers found or no access to any customers."
874
- }]
875
- };
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) {
1467
+ return {
1468
+ content: [
1469
+ {
1470
+ type: "text",
1471
+ text: "No projects found or no access to any projects."
876
1472
  }
877
- let query = supabase.from("customers").select("id, name, email, website, created_at").in("id", customerIds).limit(Math.min(pageSize, 100));
878
- if (q) query = query.or(`name.ilike.%${q}%,email.ilike.%${q}%`);
879
- const { data, error } = await query.order("name");
880
- if (error) throw error;
881
- return {
882
- content: [{
883
- type: "text",
884
- text: `Found ${data?.length || 0} customers:
1473
+ ]
1474
+ };
1475
+ }
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}%`));
1479
+ const rows = await db.select({
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));
1486
+ return {
1487
+ content: [
1488
+ {
1489
+ type: "text",
1490
+ text: `Found ${rows.length} projects:
885
1491
 
886
- ${data?.map(
887
- (customer) => `**${customer.name}**
888
- ${customer.email ? `Email: ${customer.email}
889
- ` : ""}${customer.website ? `Website: ${customer.website}
890
- ` : ""}Created: ${new Date(customer.created_at).toLocaleDateString()}
1492
+ ${rows.map(
1493
+ (p) => `**${p.name}** (ID: ${p.id})
1494
+ ${p.description ? `Description: ${p.description}
1495
+ ` : ""}Created: ${new Date(p.createdAt).toLocaleDateString()}
891
1496
  `
892
- ).join("\n") || "No customers found."}`
893
- }]
894
- };
1497
+ ).join("\n") || "No projects found."}`
895
1498
  }
896
- case "create-customer": {
897
- const { name: name2, email, website } = args2;
898
- const { data, error } = await supabase.from("customers").insert({
899
- team_id: authContext.teamId,
900
- name: name2,
901
- email: email || null,
902
- website: website || null,
903
- user_id: authContext.userId
904
- }).select().single();
905
- if (error) throw error;
906
- return {
907
- content: [{
908
- type: "text",
909
- text: `\u2705 **Customer Created Successfully!**
1499
+ ]
1500
+ };
1501
+ }
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
1511
+ });
1512
+ return {
1513
+ content: [
1514
+ {
1515
+ type: "text",
1516
+ text: `\u2705 **Project Created Successfully!**
910
1517
 
911
- Name: ${name2}
912
- ${email ? `Email: ${email}
913
- ` : ""}${website ? `Website: ${website}
1518
+ Name: ${name}
1519
+ ${description ? `Description: ${description}
914
1520
  ` : ""}`
915
- }]
916
- };
917
1521
  }
918
- case "get-projects": {
919
- const { customerId, q, pageSize = 20 } = args2;
920
- const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId);
921
- if (projectIds.length === 0) {
922
- return {
923
- content: [{
924
- type: "text",
925
- text: "No projects found or no access to any projects."
926
- }]
927
- };
928
- }
929
- let query = supabase.from("projects").select(`
930
- id,
931
- name,
932
- description,
933
- customer_id,
934
- created_at
935
- `).in("id", projectIds).limit(Math.min(pageSize, 100));
936
- if (customerId) query = query.eq("customer_id", customerId);
937
- if (q) query = query.ilike("name", `%${q}%`);
938
- const { data, error } = await query.order("name");
939
- if (error) throw error;
940
- return {
941
- content: [{
942
- type: "text",
943
- text: `Found ${data?.length || 0} projects:
1522
+ ]
1523
+ };
1524
+ }
944
1525
 
945
- ${data?.map(
946
- (project) => `**${project.name}** (ID: ${project.id})
947
- ${project.description ? `Description: ${project.description}
948
- ` : ""}Created: ${new Date(project.created_at).toLocaleDateString()}
949
- `
950
- ).join("\n") || "No projects found."}`
951
- }]
952
- };
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
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
+ };
1588
+ }
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;
1598
+ }
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;
1607
+ }
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)}\`\`\``
953
1630
  }
954
- case "create-project": {
955
- const { name: name2, description, customerId, status = "active" } = args2;
956
- const { data, error } = await supabase.from("projects").insert({
957
- team_id: authContext.teamId,
958
- name: name2,
959
- description: description || null,
960
- customer_id: customerId || null,
961
- status,
962
- user_id: authContext.userId
963
- }).select().single();
964
- if (error) throw error;
965
- return {
966
- content: [{
967
- type: "text",
968
- text: `\u2705 **Project Created Successfully!**
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
1647
+ });
1648
+ return {
1649
+ content: [
1650
+ {
1651
+ type: "text",
1652
+ text: `\u{1F4BE} **Customer Response Saved!**
969
1653
 
970
- Name: ${name2}
971
- Status: ${status}
972
- ${description ? `Description: ${description}
973
- ` : ""}`
974
- }]
975
- };
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 ? "..." : ""}\`\`\``
976
1664
  }
977
- // === AI SESSION TOOLS ===
978
- case "start-ai-session-smart": {
979
- const { ticketId, ticketUrl, cursorSessionId, totalEstimatedMinutes, complexityScore } = args2;
980
- if (!totalEstimatedMinutes) {
981
- throw new Error("totalEstimatedMinutes is required");
982
- }
983
- const roundedMinutes = roundToNearest15Minutes(totalEstimatedMinutes);
984
- const sessionStartTime = /* @__PURE__ */ new Date();
985
- const { data: sessionData, error } = await supabase.from("ai_sessions").insert({
986
- ticket_id: ticketId,
987
- provider_user_id: authContext.userId,
988
- team_id: authContext.teamId,
989
- cursor_session_id: cursorSessionId || null,
990
- ai_time_estimate_minutes: roundedMinutes,
991
- complexity_score: complexityScore || null,
992
- status: "in_progress"
993
- }).select("id, ticket_id, cursor_session_id, created_at").single();
994
- if (error) throw error;
995
- const sessionId = `ai-sess-${sessionData.id.substring(0, 8)}`;
996
- return {
997
- content: [{
998
- type: "text",
999
- text: `\u{1F680} **AI Session Started!**
1665
+ ]
1666
+ };
1667
+ }
1668
+ async function handleCompleteAiSession(input) {
1669
+ const ctx = authContext;
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;
1775
+ }
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
1815
+ );
1816
+ }
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!**
1823
+
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
+ `;
1845
+ });
1846
+ responseText += `
1847
+ `;
1848
+ if (technicalSummary) {
1849
+ responseText += `\u{1F527} **Technical Summary:**
1850
+ ${technicalSummary}
1851
+
1852
+ `;
1853
+ }
1854
+ if (efficiencyNotes) {
1855
+ responseText += `\u{1F4C8} **Efficiency Notes:**
1856
+ ${efficiencyNotes}
1857
+
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 }] };
1884
+ }
1885
+
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;
1948
+ }
1949
+ }
1950
+ console.error("All remaining phases skipped or completed");
1951
+ } catch (error) {
1952
+ console.error("Error transitioning to next phase:", error);
1953
+ }
1954
+ }
1955
+ async function handleStartAiSession(input) {
1956
+ const ctx = authContext;
1957
+ const { ticketId, cursorSessionId, totalEstimatedMinutes, complexityScore } = input;
1958
+ if (!totalEstimatedMinutes) {
1959
+ throw new Error("totalEstimatedMinutes is required");
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;
1978
+ const roundedMinutes = roundToNearest15Minutes(totalEstimatedMinutes);
1979
+ const sessionStartTime = /* @__PURE__ */ new Date();
1980
+ const [sessionData] = await db.insert(schema.aiSessions).values({
1981
+ ticketId,
1982
+ providerUserId: ctx.userId,
1983
+ teamId: insertTeamId,
1984
+ cursorSessionId: cursorSessionId ?? null,
1985
+ aiTimeEstimateMinutes: roundedMinutes,
1986
+ complexityScore: complexityScore ?? null,
1987
+ status: "in_progress"
1988
+ }).returning({
1989
+ id: schema.aiSessions.id,
1990
+ ticketId: schema.aiSessions.ticketId,
1991
+ cursorSessionId: schema.aiSessions.cursorSessionId,
1992
+ createdAt: schema.aiSessions.createdAt
1993
+ });
1994
+ if (!sessionData) {
1995
+ throw new Error("Failed to create AI session");
1996
+ }
1997
+ const sessionId = `ai-sess-${sessionData.id.substring(0, 8)}`;
1998
+ return {
1999
+ content: [
2000
+ {
2001
+ type: "text",
2002
+ text: `\u{1F680} **AI Session Started!**
1000
2003
 
1001
2004
  \u{1F194} Session ID: **${sessionId}**
1002
2005
  \u{1F3AB} Ticket: ${ticketId}
@@ -1005,115 +2008,144 @@ ${complexityScore ? `\u{1F3AF} Complexity: ${complexityScore}/10
1005
2008
  ` : ""}\u{1F4C5} Started: ${sessionStartTime.toLocaleString()}
1006
2009
 
1007
2010
  \u{1F4DD} Timetrack entry will be created when you complete the session.`
1008
- }]
1009
- };
1010
2011
  }
1011
- case "track-manual-follow-up": {
1012
- const { aiSessionId, originalPrompt, aiResponse, developerFollowUp, followUpReason, outcome = "success", estimatedMinutes, workDescription } = args2;
1013
- const sessionUuid = aiSessionId.replace("ai-sess-", "");
1014
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1015
- const { data: allSessions, error: sessionError } = await supabase.from("ai_sessions").select("id, status, created_at, ai_time_estimate_minutes").in("team_id", teamIds);
1016
- if (sessionError) {
1017
- throw new Error(`Database error: ${sessionError.message}`);
1018
- }
1019
- const session = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid));
1020
- if (!session) {
1021
- throw new Error(`Session not found: ${aiSessionId} (searched ${allSessions?.length || 0} sessions)`);
1022
- }
1023
- const followUpTime = /* @__PURE__ */ new Date();
1024
- const oldEstimate = session.ai_time_estimate_minutes || 60;
1025
- const roundedFollowUpMinutes = roundToNearest15Minutes(estimatedMinutes || 0);
1026
- const newEstimate = oldEstimate + roundedFollowUpMinutes;
1027
- await supabase.from("ai_sessions").update({
1028
- status: "in_progress",
1029
- // Restart active tracking
1030
- ai_time_estimate_minutes: newEstimate
1031
- // Increase estimate based on follow-up work
1032
- // Don't update completed_at - session continues
1033
- }).eq("id", session.id);
1034
- const { data, error } = await supabase.from("manual_follow_ups").insert({
1035
- ai_session_id: session.id,
1036
- developer_id: authContext.userId,
1037
- team_id: authContext.teamId,
1038
- original_prompt: originalPrompt,
1039
- ai_response: aiResponse,
1040
- follow_up_prompt: developerFollowUp,
1041
- follow_up_reason: followUpReason,
1042
- outcome,
1043
- time_spent_minutes: null,
1044
- // Calculated automatically from session timestamps
1045
- resolved_at: outcome === "success" ? (/* @__PURE__ */ new Date()).toISOString() : null
1046
- }).select().single();
1047
- if (error) throw error;
1048
- await supabase.from("ai_time_logs").insert({
1049
- ai_session_id: session.id,
1050
- activity_type: "debugging",
1051
- // Follow-ups are typically debugging/fixing
1052
- description: `Follow-up: ${followUpReason.replace("_", " ")} - ${outcome}`,
1053
- duration_seconds: 0,
1054
- // Duration calculated automatically from timestamps
1055
- productivity_score: outcome === "success" ? 9 : outcome === "partial_success" ? 6 : 4,
1056
- started_at: followUpTime.toISOString()
1057
- });
1058
- const sessionStartTime = new Date(session.created_at);
1059
- const totalMinutesElapsed = Math.round((followUpTime.getTime() - sessionStartTime.getTime()) / 6e4);
1060
- const currentEfficiency = totalMinutesElapsed > 0 ? totalMinutesElapsed / newEstimate : 1;
1061
- await supabase.from("ai_sessions").update({
1062
- efficiency_score: currentEfficiency.toFixed(2),
1063
- actual_time_minutes: totalMinutesElapsed
1064
- }).eq("id", session.id);
1065
- const { data: existingEntries, error: entryError } = await supabase.from("agenda_events").select("id, tracked_duration, title, description, start_time").eq("ai_session_id", session.id).eq("status", "draft").order("created_at", { ascending: false });
1066
- let trackerAction = "";
1067
- let trackerDetails = "";
1068
- const existingEntry = existingEntries && existingEntries.length > 0 ? existingEntries[0] : null;
1069
- if (existingEntries && existingEntries.length > 1) {
1070
- const totalExistingDuration = existingEntries.reduce((sum, entry) => sum + (entry.tracked_duration || 0), 0);
1071
- const duplicateIds = existingEntries.slice(1).map((e) => e.id);
1072
- await supabase.from("agenda_events").delete().in("id", duplicateIds);
1073
- if (totalExistingDuration > (existingEntry?.tracked_duration || 0)) {
1074
- await supabase.from("agenda_events").update({ tracked_duration: totalExistingDuration }).eq("id", existingEntry.id);
1075
- existingEntry.tracked_duration = totalExistingDuration;
1076
- }
1077
- trackerAction = `Consolidated ${existingEntries.length} duplicate entries`;
1078
- }
1079
- if (existingEntry) {
1080
- const newDuration = (existingEntry.tracked_duration || 0) + roundedFollowUpMinutes * 60;
1081
- await supabase.from("agenda_events").update({
1082
- tracked_duration: newDuration,
1083
- end_time: followUpTime.toISOString(),
1084
- title: workDescription,
1085
- description: workDescription
1086
- }).eq("id", existingEntry.id);
1087
- trackerAction = trackerAction || "Updated existing tracker";
1088
- trackerDetails = ` \u2022 Total tracked time: ${Math.round(newDuration / 60)} minutes (+${roundedFollowUpMinutes} min)
2012
+ ]
2013
+ };
2014
+ }
2015
+ async function handleTrackManualFollowUp(input) {
2016
+ const ctx = authContext;
2017
+ const {
2018
+ aiSessionId,
2019
+ originalPrompt,
2020
+ aiResponse,
2021
+ developerFollowUp,
2022
+ followUpReason,
2023
+ outcome = "success",
2024
+ estimatedMinutes,
2025
+ workDescription
2026
+ } = input;
2027
+ const scope = await resolveTeamScope(input.teamId);
2028
+ if (!scope.ok) return scope.response;
2029
+ const prefix = aiSessionId.replace("ai-sess-", "");
2030
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
2031
+ if (!fullSessionId) {
2032
+ throw new Error(`Session not found: ${aiSessionId}`);
2033
+ }
2034
+ const [session] = await db.select({
2035
+ id: schema.aiSessions.id,
2036
+ status: schema.aiSessions.status,
2037
+ createdAt: schema.aiSessions.createdAt,
2038
+ aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
2039
+ teamId: schema.aiSessions.teamId
2040
+ }).from(schema.aiSessions).where(eq(schema.aiSessions.id, fullSessionId)).limit(1);
2041
+ if (!session) throw new Error(`Session not found: ${aiSessionId}`);
2042
+ const followUpTime = /* @__PURE__ */ new Date();
2043
+ const oldEstimate = session.aiTimeEstimateMinutes ?? 60;
2044
+ const roundedFollowUpMinutes = roundToNearest15Minutes(estimatedMinutes || 0);
2045
+ const newEstimate = oldEstimate + roundedFollowUpMinutes;
2046
+ await db.update(schema.aiSessions).set({
2047
+ status: "in_progress",
2048
+ aiTimeEstimateMinutes: newEstimate
2049
+ }).where(eq(schema.aiSessions.id, session.id));
2050
+ await db.insert(schema.manualFollowUps).values({
2051
+ aiSessionId: session.id,
2052
+ developerId: ctx.userId,
2053
+ teamId: session.teamId,
2054
+ originalPrompt,
2055
+ aiResponse,
2056
+ followUpPrompt: developerFollowUp,
2057
+ followUpReason,
2058
+ outcome,
2059
+ timeSpentMinutes: null,
2060
+ resolvedAt: outcome === "success" ? (/* @__PURE__ */ new Date()).toISOString() : null
2061
+ });
2062
+ await db.insert(schema.aiTimeLogs).values({
2063
+ aiSessionId: session.id,
2064
+ activityType: "debugging",
2065
+ description: `Follow-up: ${followUpReason.replace("_", " ")} - ${outcome}`,
2066
+ durationSeconds: 0,
2067
+ productivityScore: outcome === "success" ? 9 : outcome === "partial_success" ? 6 : 4,
2068
+ startedAt: followUpTime.toISOString()
2069
+ });
2070
+ const sessionStartTime = new Date(session.createdAt);
2071
+ const totalMinutesElapsed = Math.round(
2072
+ (followUpTime.getTime() - sessionStartTime.getTime()) / 6e4
2073
+ );
2074
+ const currentEfficiency = totalMinutesElapsed > 0 ? totalMinutesElapsed / newEstimate : 1;
2075
+ await db.update(schema.aiSessions).set({
2076
+ efficiencyScore: currentEfficiency.toFixed(2),
2077
+ actualTimeMinutes: totalMinutesElapsed
2078
+ }).where(eq(schema.aiSessions.id, session.id));
2079
+ const existingEntries = await db.select({
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(
2086
+ and(
2087
+ eq(schema.timesheetEvents.aiSessionId, session.id),
2088
+ eq(schema.timesheetEvents.status, "draft")
2089
+ )
2090
+ ).orderBy(desc(schema.timesheetEvents.createdAt));
2091
+ let trackerAction = "";
2092
+ let trackerDetails = "";
2093
+ let existingEntry = existingEntries[0] ?? null;
2094
+ if (existingEntries.length > 1) {
2095
+ const totalExistingDuration = existingEntries.reduce(
2096
+ (sum, entry) => sum + (entry.trackedDuration ?? 0),
2097
+ 0
2098
+ );
2099
+ const duplicateIds = existingEntries.slice(1).map((e) => e.id);
2100
+ await db.delete(schema.timesheetEvents).where(inArray(schema.timesheetEvents.id, duplicateIds));
2101
+ if (existingEntry && totalExistingDuration > (existingEntry.trackedDuration ?? 0)) {
2102
+ await db.update(schema.timesheetEvents).set({ trackedDuration: totalExistingDuration }).where(eq(schema.timesheetEvents.id, existingEntry.id));
2103
+ existingEntry = {
2104
+ ...existingEntry,
2105
+ trackedDuration: totalExistingDuration
2106
+ };
2107
+ }
2108
+ trackerAction = `Consolidated ${existingEntries.length} duplicate entries`;
2109
+ }
2110
+ if (existingEntry) {
2111
+ const newDuration = (existingEntry.trackedDuration ?? 0) + roundedFollowUpMinutes * 60;
2112
+ await db.update(schema.timesheetEvents).set({
2113
+ trackedDuration: newDuration,
2114
+ endTime: followUpTime.toISOString(),
2115
+ title: workDescription,
2116
+ description: workDescription
2117
+ }).where(eq(schema.timesheetEvents.id, existingEntry.id));
2118
+ trackerAction = trackerAction || "Updated existing tracker";
2119
+ trackerDetails = ` \u2022 Total tracked time: ${Math.round(newDuration / 60)} minutes (+${roundedFollowUpMinutes} min)
1089
2120
  \u2022 Description: ${workDescription}
1090
2121
  `;
1091
- } else {
1092
- const durationSeconds = roundedFollowUpMinutes * 60;
1093
- const startTime = new Date(followUpTime.getTime() - durationSeconds * 1e3);
1094
- await supabase.from("agenda_events").insert({
1095
- team_id: authContext.teamId,
1096
- user_id: authContext.userId,
1097
- ai_session_id: session.id,
1098
- title: workDescription,
1099
- description: workDescription,
1100
- start_time: startTime.toISOString(),
1101
- end_time: followUpTime.toISOString(),
1102
- type: "work",
1103
- status: "draft",
1104
- all_day: false,
1105
- is_tracked: true,
1106
- tracked_duration: durationSeconds
1107
- });
1108
- trackerAction = "Created new tracker";
1109
- trackerDetails = ` \u2022 Tracked time: ${roundedFollowUpMinutes} minutes
2122
+ } else {
2123
+ const durationSeconds = roundedFollowUpMinutes * 60;
2124
+ const startTime = new Date(followUpTime.getTime() - durationSeconds * 1e3);
2125
+ await db.insert(schema.timesheetEvents).values({
2126
+ teamId: session.teamId,
2127
+ userId: ctx.userId,
2128
+ aiSessionId: session.id,
2129
+ title: workDescription,
2130
+ description: workDescription,
2131
+ startTime: startTime.toISOString(),
2132
+ endTime: followUpTime.toISOString(),
2133
+ type: "work",
2134
+ status: "draft",
2135
+ allDay: false,
2136
+ isTracked: true,
2137
+ trackedDuration: durationSeconds
2138
+ });
2139
+ trackerAction = "Created new tracker";
2140
+ trackerDetails = ` \u2022 Tracked time: ${roundedFollowUpMinutes} minutes
1110
2141
  \u2022 Description: ${workDescription}
1111
2142
  `;
1112
- }
1113
- return {
1114
- content: [{
1115
- type: "text",
1116
- text: `\u{1F504} **Follow-up Tracked & Session Restarted!**
2143
+ }
2144
+ return {
2145
+ content: [
2146
+ {
2147
+ type: "text",
2148
+ text: `\u{1F504} **Follow-up Tracked & Session Restarted!**
1117
2149
 
1118
2150
  \u{1F194} Session: ${aiSessionId} (back to active)
1119
2151
  \u{1F50D} Reason: ${followUpReason.replace("_", " ")}
@@ -1131,197 +2163,222 @@ ${complexityScore ? `\u{1F3AF} Complexity: ${complexityScore}/10
1131
2163
  \u23F1\uFE0F **Tracker Entry: ${trackerAction}**
1132
2164
  ` + trackerDetails + `
1133
2165
  \u26A1 **Time tracking resumed** - continue with confidence!`
1134
- }]
1135
- };
1136
2166
  }
1137
- case "get-session-context": {
1138
- const { aiSessionId, includeTicketData = true, includeTodoProgress = true, includeFollowUpHistory = false } = args2;
1139
- const sessionUuid = aiSessionId.replace("ai-sess-", "");
1140
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1141
- const { data: allSessions, error: sessionError } = await supabase.from("ai_sessions").select(`
1142
- id,
1143
- ticket_id,
1144
- status,
1145
- ai_time_estimate_minutes,
1146
- actual_time_minutes,
1147
- complexity_score,
1148
- created_at,
1149
- cursor_session_id
1150
- `).in("team_id", teamIds);
1151
- if (sessionError) {
1152
- throw new Error(`Database error: ${sessionError.message}`);
1153
- }
1154
- const session = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid));
1155
- if (sessionError || !session) {
1156
- throw new Error(`Session not found: ${aiSessionId}`);
1157
- }
1158
- let context = {
1159
- sessionId: aiSessionId,
1160
- status: session.status,
1161
- timeEstimate: session.ai_time_estimate_minutes,
1162
- actualTime: session.actual_time_minutes,
1163
- complexity: session.complexity_score,
1164
- createdAt: session.created_at
1165
- };
1166
- if (includeTicketData) {
1167
- const { data: ticket } = await supabase.from("tickets").select("id, ticket_number, title, description, status, priority, type").eq("id", session.ticket_id).single();
1168
- context.ticketData = ticket;
1169
- }
1170
- if (includeTodoProgress) {
1171
- const { data: todos } = await supabase.from("ai_todos").select("id, content, status, estimated_minutes, actual_minutes").eq("ai_session_id", session.id).order("sequence_order");
1172
- context.todos = todos || [];
1173
- context.todoProgress = {
1174
- total: todos?.length || 0,
1175
- completed: todos?.filter((t) => t.status === "completed").length || 0,
1176
- inProgress: todos?.filter((t) => t.status === "in_progress").length || 0
1177
- };
1178
- }
1179
- if (includeFollowUpHistory) {
1180
- const { data: followUps } = await supabase.from("manual_follow_ups").select("follow_up_reason, outcome, time_spent_minutes, created_at").eq("ai_session_id", session.id).order("created_at");
1181
- context.followUpHistory = followUps || [];
1182
- }
1183
- return {
1184
- content: [{
1185
- type: "text",
1186
- text: `\u{1F3AF} **Session Context Retrieved**
2167
+ ]
2168
+ };
2169
+ }
2170
+ async function handleGetSessionContext(input) {
2171
+ const {
2172
+ aiSessionId,
2173
+ includeTicketData = true,
2174
+ includeTodoProgress = true,
2175
+ includeFollowUpHistory = false
2176
+ } = input;
2177
+ const scope = await resolveTeamScope(input.teamId);
2178
+ if (!scope.ok) return scope.response;
2179
+ const prefix = aiSessionId.replace("ai-sess-", "");
2180
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
2181
+ if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
2182
+ const [session] = await db.select({
2183
+ id: schema.aiSessions.id,
2184
+ ticketId: schema.aiSessions.ticketId,
2185
+ status: schema.aiSessions.status,
2186
+ aiTimeEstimateMinutes: schema.aiSessions.aiTimeEstimateMinutes,
2187
+ actualTimeMinutes: schema.aiSessions.actualTimeMinutes,
2188
+ complexityScore: schema.aiSessions.complexityScore,
2189
+ createdAt: schema.aiSessions.createdAt,
2190
+ cursorSessionId: schema.aiSessions.cursorSessionId
2191
+ }).from(schema.aiSessions).where(eq(schema.aiSessions.id, fullSessionId)).limit(1);
2192
+ if (!session) throw new Error(`Session not found: ${aiSessionId}`);
2193
+ const context = {
2194
+ status: session.status,
2195
+ timeEstimate: session.aiTimeEstimateMinutes,
2196
+ actualTime: session.actualTimeMinutes,
2197
+ complexity: session.complexityScore,
2198
+ createdAt: session.createdAt
2199
+ };
2200
+ if (includeTicketData) {
2201
+ const [ticket] = await db.select({
2202
+ id: schema.tickets.id,
2203
+ ticketNumber: schema.tickets.ticketNumber,
2204
+ title: schema.tickets.title,
2205
+ description: schema.tickets.description,
2206
+ status: schema.tickets.status,
2207
+ priority: schema.tickets.priority,
2208
+ type: schema.tickets.type
2209
+ }).from(schema.tickets).where(eq(schema.tickets.id, session.ticketId)).limit(1);
2210
+ context.ticketData = ticket ?? null;
2211
+ }
2212
+ if (includeTodoProgress) {
2213
+ const todos = await db.select({
2214
+ id: schema.aiTodos.id,
2215
+ content: schema.aiTodos.content,
2216
+ status: schema.aiTodos.status,
2217
+ estimatedMinutes: schema.aiTodos.estimatedMinutes,
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
+ };
2226
+ }
2227
+ if (includeFollowUpHistory) {
2228
+ const followUps = await db.select({
2229
+ followUpReason: schema.manualFollowUps.followUpReason,
2230
+ outcome: schema.manualFollowUps.outcome,
2231
+ timeSpentMinutes: schema.manualFollowUps.timeSpentMinutes,
2232
+ createdAt: schema.manualFollowUps.createdAt
2233
+ }).from(schema.manualFollowUps).where(eq(schema.manualFollowUps.aiSessionId, session.id)).orderBy(asc(schema.manualFollowUps.createdAt));
2234
+ context.followUpHistory = followUps;
2235
+ }
2236
+ const ticketData = context.ticketData;
2237
+ const todoProgress = context.todoProgress;
2238
+ const followUpHistory = context.followUpHistory;
2239
+ return {
2240
+ content: [
2241
+ {
2242
+ type: "text",
2243
+ text: `\u{1F3AF} **Session Context Retrieved**
1187
2244
 
1188
2245
  Session: ${aiSessionId}
1189
2246
  Status: ${session.status}
1190
- ${context.ticketData ? `Ticket: ${context.ticketData.ticket_number} - ${context.ticketData.title}
1191
- ` : ""}${context.todoProgress ? `Todo Progress: ${context.todoProgress.completed}/${context.todoProgress.total} completed
1192
- ` : ""}${context.followUpHistory ? `Follow-ups: ${context.followUpHistory.length}
2247
+ ${ticketData ? `Ticket: ${ticketData.ticketNumber} - ${ticketData.title}
2248
+ ` : ""}${todoProgress ? `Todo Progress: ${todoProgress.completed}/${todoProgress.total} completed
2249
+ ` : ""}${followUpHistory ? `Follow-ups: ${followUpHistory.length}
1193
2250
  ` : ""}
1194
2251
  \u{1F4CB} Full context preserved for seamless continuation!`
1195
- }]
1196
- };
1197
2252
  }
1198
- case "sync-session-todos": {
1199
- const { aiSessionId, todos, replaceAll = true } = args2;
1200
- const sessionUuid = aiSessionId.replace("ai-sess-", "");
1201
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1202
- const { data: allSessions, error: sessionError } = await supabase.from("ai_sessions").select("id").in("team_id", teamIds);
1203
- if (sessionError) {
1204
- throw new Error(`Database error: ${sessionError.message}`);
1205
- }
1206
- const session = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid));
1207
- if (!session) {
1208
- throw new Error(`Session not found: ${aiSessionId} (searched ${allSessions?.length || 0} sessions)`);
1209
- }
1210
- if (replaceAll) {
1211
- await supabase.from("ai_todos").delete().eq("ai_session_id", session.id);
1212
- }
1213
- if (todos && todos.length > 0) {
1214
- let startSequence = 0;
1215
- if (!replaceAll) {
1216
- const { data: maxTodo } = await supabase.from("ai_todos").select("sequence_order").eq("ai_session_id", session.id).order("sequence_order", { ascending: false }).limit(1).single();
1217
- startSequence = (maxTodo?.sequence_order || 0) + 1;
1218
- }
1219
- const todoInserts = todos.map((todo, index) => ({
1220
- ai_session_id: session.id,
1221
- content: todo.content,
1222
- status: todo.status,
1223
- cursor_todo_id: todo.todoId || null,
1224
- estimated_minutes: todo.estimatedMinutes || null,
1225
- sequence_order: startSequence + index
1226
- }));
1227
- const { error: insertError } = await supabase.from("ai_todos").insert(todoInserts);
1228
- if (insertError) throw insertError;
1229
- }
1230
- let phaseTransition = null;
1231
- const { data: currentTodos } = await supabase.from("ai_todos").select("status").eq("ai_session_id", session.id);
1232
- if (currentTodos && currentTodos.length > 0) {
1233
- const hasInProgress = currentTodos.some((t) => t.status === "in_progress");
1234
- const allCompleted = currentTodos.every((t) => t.status === "completed");
1235
- const { data: currentPhase } = await supabase.from("ai_time_logs").select("activity_type, status").eq("ai_session_id", session.id).eq("status", "in_progress").single();
1236
- if (hasInProgress && currentPhase?.activity_type === "analysis") {
1237
- await transitionToNextPhase(session.id, "analysis");
1238
- phaseTransition = "Analysis completed \u2192 Next phase started (Investigation/Development)";
1239
- }
1240
- if (hasInProgress && currentPhase?.activity_type === "bug_investigation") {
1241
- const completedCount = currentTodos.filter((t) => t.status === "completed").length;
1242
- if (completedCount > 0) {
1243
- await transitionToNextPhase(session.id, "bug_investigation");
1244
- phaseTransition = "Investigation completed \u2192 Development phase started";
1245
- }
1246
- }
1247
- if (allCompleted && currentPhase?.activity_type === "development") {
1248
- await transitionToNextPhase(session.id, "development");
1249
- phaseTransition = "Development completed \u2192 Communication phase started";
1250
- }
1251
- }
1252
- return {
1253
- content: [{
1254
- type: "text",
1255
- text: `\u2705 **Todos ${replaceAll ? "Synced" : "Added"} Successfully!**
2253
+ ]
2254
+ };
2255
+ }
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;
2260
+ const prefix = aiSessionId.replace("ai-sess-", "");
2261
+ const fullSessionId = await resolveAiSessionId(prefix, scope.teamIds);
2262
+ if (!fullSessionId) throw new Error(`Session not found: ${aiSessionId}`);
2263
+ if (replaceAll) {
2264
+ await db.delete(schema.aiTodos).where(eq(schema.aiTodos.aiSessionId, fullSessionId));
2265
+ }
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
+ }))
2281
+ );
2282
+ }
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)";
2300
+ }
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";
2313
+ }
2314
+ }
2315
+ return {
2316
+ content: [
2317
+ {
2318
+ type: "text",
2319
+ text: `\u2705 **Todos ${replaceAll ? "Synced" : "Added"} Successfully!**
1256
2320
 
1257
2321
  Session: ${aiSessionId}
1258
2322
  ${replaceAll ? "Synced" : "Added"} ${todos?.length || 0} todos
1259
2323
  ${replaceAll ? "" : "\u2795 Added to existing todo list\n"}${phaseTransition ? `\u{1F504} Phase Transition: ${phaseTransition}
1260
2324
  ` : ""}
1261
2325
  \u{1F4DD} Todo list updated and tracked for progress monitoring!`
1262
- }]
1263
- };
1264
2326
  }
1265
- case "add-follow-up-todos": {
1266
- const { aiSessionId, newTodos, followUpReason } = args2;
1267
- const sessionUuid = aiSessionId.replace("ai-sess-", "");
1268
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1269
- const { data: allSessions, error: sessionError } = await supabase.from("ai_sessions").select("id").in("team_id", teamIds);
1270
- if (sessionError) {
1271
- throw new Error(`Database error: ${sessionError.message}`);
1272
- }
1273
- const session = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid));
1274
- if (!session) {
1275
- throw new Error(`Session not found: ${aiSessionId} (searched ${allSessions?.length || 0} sessions)`);
1276
- }
1277
- if (newTodos && newTodos.length > 0) {
1278
- const { data: maxTodo } = await supabase.from("ai_todos").select("sequence_order").eq("ai_session_id", session.id).order("sequence_order", { ascending: false }).limit(1).single();
1279
- const startSequence = (maxTodo?.sequence_order || 0) + 1;
1280
- const todoInserts = newTodos.map((todo, index) => ({
1281
- ai_session_id: session.id,
1282
- content: `[Follow-up] ${todo.content}`,
1283
- status: todo.status || "pending",
1284
- estimated_minutes: todo.estimatedMinutes || null,
1285
- sequence_order: startSequence + index
1286
- }));
1287
- const { error: insertError } = await supabase.from("ai_todos").insert(todoInserts);
1288
- if (insertError) throw insertError;
1289
- }
1290
- return {
1291
- content: [{
1292
- type: "text",
1293
- text: `\u2705 **Follow-up Todos Added Successfully!**
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
+ }))
2348
+ );
2349
+ }
2350
+ return {
2351
+ content: [
2352
+ {
2353
+ type: "text",
2354
+ text: `\u2705 **Follow-up Todos Added Successfully!**
1294
2355
 
1295
2356
  Session: ${aiSessionId}
1296
2357
  Added ${newTodos?.length || 0} new todos from follow-up
1297
2358
  ${followUpReason ? `Reason: ${followUpReason}
1298
2359
  ` : ""}
1299
2360
  \u{1F4DD} New tasks identified and added to existing workflow!`
1300
- }]
1301
- };
1302
2361
  }
1303
- case "update-session-status": {
1304
- const { aiSessionId, status, actualTimeMinutes, completionNotes } = args2;
1305
- const sessionUuid = aiSessionId.replace("ai-sess-", "");
1306
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1307
- const { data: allSessions, error: findError } = await supabase.from("ai_sessions").select("id").in("team_id", teamIds);
1308
- if (findError) {
1309
- throw new Error(`Database error: ${findError.message}`);
1310
- }
1311
- const foundSession = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid));
1312
- if (!foundSession) {
1313
- throw new Error(`Session not found: ${aiSessionId} (searched ${allSessions?.length || 0} sessions)`);
1314
- }
1315
- const { data, error } = await supabase.from("ai_sessions").update({
1316
- status,
1317
- actual_time_minutes: actualTimeMinutes || null,
1318
- completed_at: status === "completed" ? (/* @__PURE__ */ new Date()).toISOString() : null
1319
- }).eq("id", foundSession.id).select().single();
1320
- if (error) throw error;
1321
- return {
1322
- content: [{
1323
- type: "text",
1324
- text: `\u{1F3AF} **Session Status Updated!**
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!**
1325
2382
 
1326
2383
  Session: ${aiSessionId}
1327
2384
  Status: ${status}
@@ -1329,742 +2386,952 @@ ${actualTimeMinutes ? `Actual Time: ${actualTimeMinutes} minutes
1329
2386
  ` : ""}${status === "completed" ? `\u2705 Session completed successfully!
1330
2387
  ` : ""}${completionNotes ? `Notes: ${completionNotes}
1331
2388
  ` : ""}`
1332
- }]
1333
- };
1334
2389
  }
1335
- case "get-completion-context": {
1336
- const { aiSessionId, includeFollowUps = true, includeTimeMetrics = true, includeTodos = true } = args2;
1337
- const sessionUuid = aiSessionId.replace("ai-sess-", "");
1338
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1339
- const { data: allSessions, error: sessionError } = await supabase.from("ai_sessions").select(`
1340
- id,
1341
- ticket_id,
1342
- ai_time_estimate_minutes,
1343
- actual_time_minutes,
1344
- efficiency_score,
1345
- created_at,
1346
- completed_at,
1347
- status,
1348
- complexity_score
1349
- `).in("team_id", teamIds);
1350
- if (sessionError) {
1351
- throw new Error(`Database error: ${sessionError.message}`);
1352
- }
1353
- const session = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid));
1354
- if (!session) {
1355
- throw new Error(`Session not found: ${aiSessionId} (searched ${allSessions?.length || 0} sessions)`);
1356
- }
1357
- const { data: ticket, error: ticketError } = await supabase.from("tickets").select("ticket_number, title, description, type, priority").eq("id", session.ticket_id).single();
1358
- if (ticketError || !ticket) {
1359
- throw new Error("Ticket not found for session");
1360
- }
1361
- let contextData = {
1362
- session: {
1363
- id: aiSessionId,
1364
- status: session.status,
1365
- complexity: session.complexity_score,
1366
- createdAt: session.created_at,
1367
- completedAt: session.completed_at
1368
- },
1369
- ticket: {
1370
- number: ticket.ticket_number,
1371
- title: ticket.title,
1372
- description: ticket.description,
1373
- type: ticket.type,
1374
- priority: ticket.priority
1375
- }
1376
- };
1377
- if (includeTimeMetrics) {
1378
- const timeSaved = session.ai_time_estimate_minutes && session.actual_time_minutes ? Math.max(0, session.ai_time_estimate_minutes - session.actual_time_minutes) : null;
1379
- contextData.timeMetrics = {
1380
- estimatedMinutes: session.ai_time_estimate_minutes,
1381
- actualMinutes: session.actual_time_minutes,
1382
- timeSaved,
1383
- efficiency: session.efficiency_score,
1384
- sessionDuration: session.completed_at && session.created_at ? Math.round((new Date(session.completed_at).getTime() - new Date(session.created_at).getTime()) / 6e4) : null
1385
- };
1386
- }
1387
- if (includeTodos) {
1388
- const { data: todos } = await supabase.from("ai_todos").select("content, status, estimated_minutes, actual_minutes, completed_at").eq("ai_session_id", session.id).order("created_at", { ascending: true });
1389
- contextData.todos = todos || [];
1390
- }
1391
- if (includeFollowUps) {
1392
- const { data: followUps } = await supabase.from("manual_follow_ups").select("follow_up_reason, outcome, time_spent_minutes, created_at").eq("ai_session_id", session.id).order("created_at", { ascending: true });
1393
- contextData.followUps = followUps || [];
1394
- }
1395
- return {
1396
- content: [{
1397
- type: "text",
1398
- text: `\u{1F4CB} **Completion Context Retrieved!**
2390
+ ]
2391
+ };
2392
+ }
1399
2393
 
1400
- \u{1F3AB} **Ticket:** ${ticket.ticket_number} - ${ticket.title}
1401
- \u{1F194} **Session:** ${aiSessionId} (${session.status})
1402
- \u23F1\uFE0F **Time:** ${session.actual_time_minutes || "N/A"}/${session.ai_time_estimate_minutes || "N/A"} minutes
1403
- \u{1F4CB} **Todos:** ${contextData.todos?.filter((t) => t.status === "completed").length || 0}/${contextData.todos?.length || 0} completed
1404
- \u{1F504} **Follow-ups:** ${contextData.followUps?.length || 0}
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
+ };
2409
+ }
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.
1405
2416
 
1406
- \u2705 **Full context ready for Cursor AI to generate customer response!**
2417
+ ${list}
1407
2418
 
1408
- **Context Data:**
1409
- \`\`\`json
1410
- ${JSON.stringify(contextData, null, 2)}\`\`\``
1411
- }]
1412
- };
2419
+ ${JSON.stringify(teams2)}`
1413
2420
  }
1414
- case "save-customer-response": {
1415
- const { aiSessionId, customerResponse, responseType = "completion" } = args2;
1416
- const sessionUuid = aiSessionId.replace("ai-sess-", "");
1417
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1418
- const { data: allSessions, error: sessionError } = await supabase.from("ai_sessions").select("id").in("team_id", teamIds);
1419
- if (sessionError) {
1420
- throw new Error(`Database error: ${sessionError.message}`);
1421
- }
1422
- const session = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid));
1423
- if (!session) {
1424
- throw new Error(`Session not found: ${aiSessionId} (searched ${allSessions?.length || 0} sessions)`);
1425
- }
1426
- const { data: responseData, error: responseError } = await supabase.from("ai_responses").insert({
1427
- ai_session_id: session.id,
1428
- response_type: responseType,
1429
- content: customerResponse,
1430
- is_ready_for_customer: true,
1431
- provider_approved: false
1432
- // Needs manual approval
1433
- }).select().single();
1434
- if (responseError) throw responseError;
1435
- return {
1436
- content: [{
1437
- type: "text",
1438
- text: `\u{1F4BE} **Customer Response Saved!**
1439
-
1440
- \u{1F194} Session: ${aiSessionId}
1441
- \u{1F4DD} Response Type: ${responseType}
1442
- \u{1F4C4} Length: ${customerResponse.length} characters
2421
+ ]
2422
+ };
2423
+ }
1443
2424
 
1444
- \u2705 **Response ready for provider approval**
1445
- \u{1F50D} Provider can review in AI tab before sending to customer
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
+ );
2436
+ }
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
+ });
1446
2457
 
1447
- **Preview:**
1448
- \`\`\`
1449
- ${customerResponse.substring(0, 200)}${customerResponse.length > 200 ? "..." : ""}\`\`\``
1450
- }]
1451
- };
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.`
1452
2466
  }
1453
- case "complete-ai-session": {
1454
- const { aiSessionId, workCompleted, technicalSummary, invoiceDescription, efficiencyNotes } = args2;
1455
- const sessionUuid = aiSessionId.replace("ai-sess-", "");
1456
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1457
- const { data: allSessions, error: getSessionError } = await supabase.from("ai_sessions").select("id, ticket_id, ai_time_estimate_minutes, created_at").in("team_id", teamIds);
1458
- if (getSessionError) {
1459
- throw new Error(`Database error: ${getSessionError.message}`);
1460
- }
1461
- const existingSession = allSessions?.find((s) => s.id.toString().startsWith(sessionUuid));
1462
- if (!existingSession) {
1463
- throw new Error(`Session not found: ${aiSessionId} (searched ${allSessions?.length || 0} sessions)`);
1464
- }
1465
- const completionTime = /* @__PURE__ */ new Date();
1466
- const sessionStartTime = new Date(existingSession.created_at);
1467
- const timeSpentMinutes = Math.round((completionTime.getTime() - sessionStartTime.getTime()) / 6e4);
1468
- const { data: session, error: sessionError } = await supabase.from("ai_sessions").update({
1469
- status: "completed",
1470
- actual_time_minutes: timeSpentMinutes,
1471
- completed_at: completionTime.toISOString(),
1472
- efficiency_score: null
1473
- // Will be calculated
1474
- }).eq("id", existingSession.id).select("id, ticket_id, ai_time_estimate_minutes, created_at").single();
1475
- if (sessionError || !session) {
1476
- throw new Error(`Failed to update session: ${aiSessionId}`);
1477
- }
1478
- const efficiencyScore = session.ai_time_estimate_minutes ? timeSpentMinutes / session.ai_time_estimate_minutes : 1;
1479
- await supabase.from("ai_sessions").update({ efficiency_score: efficiencyScore.toFixed(2) }).eq("id", session.id);
1480
- const { data: activePhases } = await supabase.from("ai_time_logs").select("*").eq("ai_session_id", existingSession.id).eq("status", "in_progress");
1481
- for (const phase of activePhases || []) {
1482
- const duration = Math.round(
1483
- (completionTime.getTime() - new Date(phase.started_at).getTime()) / 1e3
1484
- );
1485
- await supabase.from("ai_time_logs").update({
1486
- ended_at: completionTime.toISOString(),
1487
- duration_seconds: duration,
1488
- status: "completed"
1489
- }).eq("id", phase.id);
1490
- }
1491
- await supabase.from("ai_time_logs").update({ status: "skipped" }).eq("ai_session_id", existingSession.id).eq("status", "pending").eq("estimated_duration_seconds", 0);
1492
- const sessionDuration = Math.round(
1493
- (completionTime.getTime() - new Date(session.created_at).getTime()) / 6e4
1494
- );
1495
- const workSummary = `Completed ${workCompleted.length} tasks including: ${workCompleted.slice(0, 3).join(", ")}${workCompleted.length > 3 ? " and more" : ""}.`;
1496
- const { data: ticketInfo } = await supabase.from("tickets").select("ticket_number, title, project_id").eq("id", session.ticket_id).single();
1497
- let completionDescription;
1498
- if (invoiceDescription) {
1499
- completionDescription = `${ticketInfo?.ticket_number || "Ticket"}: ${invoiceDescription}`;
1500
- } else {
1501
- const workDescription = workCompleted.map((task, index) => `${index + 1}. ${task}`).join("\n");
1502
- completionDescription = `${ticketInfo?.ticket_number || "Ticket"}: ${technicalSummary || workSummary}
2467
+ ]
2468
+ };
2469
+ }
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
+ }
1503
2490
 
1504
- Completed work:
1505
- ${workDescription}`;
1506
- }
1507
- const estimatedMinutes = session.ai_time_estimate_minutes || timeSpentMinutes;
1508
- const sessionStart = new Date(session.created_at);
1509
- const estimatedEnd = new Date(sessionStart.getTime() + estimatedMinutes * 6e4);
1510
- const { data: existingAgendaEntries } = await supabase.from("agenda_events").select("id, tracked_duration").eq("ai_session_id", session.id).eq("status", "draft").order("created_at", { ascending: false });
1511
- let agendaEvent = null;
1512
- let agendaError = null;
1513
- let wasUpdated = false;
1514
- let consolidatedCount = 0;
1515
- const existingAgendaEntry = existingAgendaEntries && existingAgendaEntries.length > 0 ? existingAgendaEntries[0] : null;
1516
- if (existingAgendaEntries && existingAgendaEntries.length > 1) {
1517
- const duplicateIds = existingAgendaEntries.slice(1).map((e) => e.id);
1518
- await supabase.from("agenda_events").delete().in("id", duplicateIds);
1519
- consolidatedCount = existingAgendaEntries.length - 1;
1520
- }
1521
- if (existingAgendaEntry) {
1522
- const { data: updated, error: updateError } = await supabase.from("agenda_events").update({
1523
- title: ticketInfo?.title || "Development Work",
1524
- description: completionDescription,
1525
- end_time: estimatedEnd.toISOString(),
1526
- project_id: ticketInfo?.project_id || null,
1527
- ticket_id: session.ticket_id,
1528
- tracked_duration: estimatedMinutes * 60
1529
- // Use AI estimate in seconds for billing
1530
- }).eq("id", existingAgendaEntry.id).select("id").single();
1531
- agendaEvent = updated;
1532
- agendaError = updateError;
1533
- wasUpdated = true;
1534
- } else {
1535
- const { data: created, error: createError } = await supabase.from("agenda_events").insert({
1536
- team_id: authContext.teamId,
1537
- user_id: authContext.userId,
1538
- title: ticketInfo?.title || "Development Work",
1539
- description: completionDescription,
1540
- start_time: sessionStart.toISOString(),
1541
- end_time: estimatedEnd.toISOString(),
1542
- project_id: ticketInfo?.project_id || null,
1543
- ticket_id: session.ticket_id,
1544
- ai_session_id: session.id,
1545
- // Use the actual UUID, not the readable ID
1546
- type: "work",
1547
- status: "draft",
1548
- // Mark as draft for manual approval
1549
- all_day: false,
1550
- is_tracked: true,
1551
- tracked_duration: estimatedMinutes * 60
1552
- // Use AI estimate in seconds for billing
1553
- }).select("id").single();
1554
- agendaEvent = created;
1555
- agendaError = createError;
1556
- }
1557
- if (agendaError) {
1558
- console.error(`\u26A0\uFE0F Failed to ${wasUpdated ? "update" : "create"} agenda event:`, agendaError);
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.`
1559
2520
  }
1560
- if (consolidatedCount > 0) {
1561
- console.log(`\u{1F9F9} Cleaned up ${consolidatedCount} duplicate agenda entries for session ${aiSessionId}`);
2521
+ ]
2522
+ };
2523
+ }
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)}`
1562
2540
  }
1563
- let responseText = `\u{1F389} **AI Session Completed Successfully!**
1564
-
1565
- `;
1566
- responseText += `\u{1F194} Session: ${aiSessionId}
1567
- `;
1568
- responseText += `\u{1F4CA} **Performance Summary:**
1569
- `;
1570
- responseText += ` \u2022 Tasks Completed: ${workCompleted.length}
1571
- `;
1572
- responseText += ` \u2022 Time Spent: ${timeSpentMinutes} minutes
1573
- `;
1574
- responseText += ` \u2022 Estimated Time: ${session.ai_time_estimate_minutes || "N/A"} minutes
1575
- `;
1576
- responseText += ` \u2022 Efficiency: ${efficiencyScore < 1 ? "\u{1F680}" : efficiencyScore > 1.5 ? "\u26A0\uFE0F" : "\u23F1\uFE0F"} ${(efficiencyScore * 100).toFixed(0)}%
1577
- `;
1578
- responseText += ` \u2022 Session Duration: ${sessionDuration} minutes
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
1579
2552
 
1580
- `;
1581
- responseText += `\u2705 **Work Completed:**
1582
- `;
1583
- workCompleted.forEach((task, index) => {
1584
- responseText += `${index + 1}. ${task}
1585
- `;
1586
- });
1587
- responseText += `
1588
- `;
1589
- if (technicalSummary) {
1590
- responseText += `\u{1F527} **Technical Summary:**
1591
- ${technicalSummary}
2553
+ Download URL (valid for 1 hour):
2554
+ ${url}`
2555
+ }
2556
+ ]
2557
+ };
2558
+ }
1592
2559
 
1593
- `;
1594
- }
1595
- if (efficiencyNotes) {
1596
- responseText += `\u{1F4C8} **Efficiency Notes:**
1597
- ${efficiencyNotes}
2560
+ // src/tools/ticket-comments.ts
2561
+ init_auth();
2562
+ init_db();
1598
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}
1599
2573
  `;
1600
- }
1601
- if (agendaEvent) {
1602
- responseText += `\u{1F4C5} **Timetrack Entry ${wasUpdated ? "Updated" : "Created"}:**
1603
- `;
1604
- responseText += ` \u2022 Agenda event ${wasUpdated ? "updated with final" : "created with"} work summary
2574
+ case "heading":
2575
+ return `${inner}
1605
2576
  `;
1606
- responseText += ` \u2022 Status: DRAFT (requires approval in agenda)
2577
+ case "listItem":
2578
+ return `- ${inner.trimEnd()}
1607
2579
  `;
1608
- responseText += ` \u2022 Duration: ${estimatedMinutes} minutes
2580
+ case "bulletList":
2581
+ case "orderedList":
2582
+ return inner;
2583
+ case "blockquote":
2584
+ return `${inner}
1609
2585
  `;
1610
- responseText += ` \u2022 Period: ${sessionStart.toLocaleString()} - ${completionTime.toLocaleString()}
2586
+ default:
2587
+ return inner;
2588
+ }
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;
2599
+ }
2600
+ if (doc?.type !== "doc" || !Array.isArray(doc.content)) return content;
2601
+ return doc.content.map(renderNode).join("").trim();
2602
+ }
1611
2603
 
1612
- `;
1613
- }
1614
- responseText += `\u{1F4CB} **Context for Customer Response:**
1615
- `;
1616
- responseText += ` \u2022 Use "get-completion-context" to retrieve full context
1617
- `;
1618
- responseText += ` \u2022 Generate customer-friendly response based on completed work
1619
- `;
1620
- responseText += ` \u2022 Focus on business value and customer benefits
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)" : ""}
1621
2631
 
1622
- `;
1623
- responseText += `\u{1F3AF} **Session archived successfully!**`;
1624
- return {
1625
- content: [{
1626
- type: "text",
1627
- text: responseText
1628
- }]
1629
- };
2632
+ Comment id: ${comment?.id}`
1630
2633
  }
1631
- case "log-hours": {
1632
- const { projectId, ticketId, aiSessionId, workDescription, estimatedHours, chatContextSummary } = args2;
1633
- let project = null;
1634
- let ticket = null;
1635
- let aiSession = null;
1636
- if (projectId) {
1637
- const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId);
1638
- if (!projectIds.includes(projectId)) {
1639
- throw new Error(`Project not found or no access: ${projectId}. Please call get-projects first to find the correct project.`);
1640
- }
1641
- const { data: projectData, error: projectError } = await supabase.from("projects").select("id, name, team_id").eq("id", projectId).single();
1642
- if (projectError || !projectData) {
1643
- throw new Error(`Project not found: ${projectId}.`);
1644
- }
1645
- project = projectData;
1646
- }
1647
- if (ticketId) {
1648
- const { data: ticketData, error: ticketError } = await supabase.from("tickets").select("id, title, status, team_id, project_id, customer_id").eq("id", ticketId).single();
1649
- if (ticketError || !ticketData) {
1650
- throw new Error(`Ticket not found: ${ticketId}. Please call get-tickets first to find the correct ticket.`);
1651
- }
1652
- let hasAccess = false;
1653
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1654
- if (teamIds.includes(ticketData.team_id)) {
1655
- hasAccess = true;
1656
- }
1657
- if (!hasAccess && ticketData.project_id) {
1658
- const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId);
1659
- if (projectIds.includes(ticketData.project_id)) {
1660
- hasAccess = true;
1661
- }
1662
- }
1663
- if (!hasAccess && ticketData.customer_id) {
1664
- const customerIds = await getAccessibleCustomerIds(authContext.teamId);
1665
- if (customerIds.includes(ticketData.customer_id)) {
1666
- hasAccess = true;
1667
- }
1668
- }
1669
- if (!hasAccess) {
1670
- throw new Error(`No access to ticket: ${ticketId}. Please call get-tickets first to find the correct ticket.`);
1671
- }
1672
- ticket = ticketData;
1673
- }
1674
- if (aiSessionId) {
1675
- const { data: sessionData, error: sessionError } = await supabase.from("ai_dev_sessions").select("id, ticket_id, status").eq("id", aiSessionId).single();
1676
- if (sessionError || !sessionData) {
1677
- throw new Error(`AI Session not found: ${aiSessionId}.`);
1678
- }
1679
- aiSession = sessionData;
1680
- }
1681
- const durationSeconds = Math.round(estimatedHours * 3600);
1682
- const now = /* @__PURE__ */ new Date();
1683
- let agendaEntry = null;
1684
- let agendaError = null;
1685
- let wasUpdated = false;
1686
- let consolidatedCount = 0;
1687
- if (aiSession?.id || ticket?.id) {
1688
- let query = supabase.from("agenda_events").select("id, tracked_duration, project_id, ticket_id, ai_session_id").eq("status", "draft").eq("user_id", authContext.userId).order("created_at", { ascending: false });
1689
- if (aiSession?.id) {
1690
- query = query.eq("ai_session_id", aiSession.id);
1691
- } else if (ticket?.id) {
1692
- query = query.eq("ticket_id", ticket.id);
1693
- }
1694
- const { data: existingEntries } = await query;
1695
- if (existingEntries && existingEntries.length > 0) {
1696
- const existingEntry = existingEntries[0];
1697
- if (existingEntries.length > 1) {
1698
- const duplicateIds = existingEntries.slice(1).map((e) => e.id);
1699
- await supabase.from("agenda_events").delete().in("id", duplicateIds);
1700
- consolidatedCount = existingEntries.length - 1;
1701
- }
1702
- const newDuration = (existingEntry.tracked_duration || 0) + durationSeconds;
1703
- const { data: updated, error: updateError } = await supabase.from("agenda_events").update({
1704
- tracked_duration: newDuration,
1705
- end_time: now.toISOString(),
1706
- title: workDescription,
1707
- description: chatContextSummary || workDescription,
1708
- project_id: project?.id || existingEntry.project_id
1709
- }).eq("id", existingEntry.id).select("id, tracked_duration, project_id, ticket_id, ai_session_id").single();
1710
- agendaEntry = updated;
1711
- agendaError = updateError;
1712
- wasUpdated = true;
1713
- }
1714
- }
1715
- if (!agendaEntry) {
1716
- const startTime = new Date(now.getTime() - durationSeconds * 1e3);
1717
- const { data: created, error: createError } = await supabase.from("agenda_events").insert({
1718
- team_id: authContext.teamId,
1719
- user_id: authContext.userId,
1720
- project_id: project?.id || null,
1721
- ticket_id: ticket?.id || null,
1722
- ai_session_id: aiSession?.id || null,
1723
- title: workDescription,
1724
- description: chatContextSummary || workDescription,
1725
- start_time: startTime.toISOString(),
1726
- end_time: now.toISOString(),
1727
- type: "work",
1728
- status: "draft",
1729
- all_day: false,
1730
- is_tracked: true,
1731
- tracked_duration: durationSeconds
1732
- }).select("id, tracked_duration, project_id, ticket_id, ai_session_id").single();
1733
- agendaEntry = created;
1734
- agendaError = createError;
1735
- }
1736
- if (agendaError || !agendaEntry) {
1737
- throw new Error(`Failed to ${wasUpdated ? "update" : "create"} time entry: ${agendaError?.message || "Unknown error"}`);
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}.`
1738
2655
  }
1739
- let responseText = `\u23F1\uFE0F **Hours ${wasUpdated ? "Added to Existing Entry" : "Logged Successfully"}!**
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})
1740
2671
 
1741
- `;
1742
- if (wasUpdated) {
1743
- responseText += `\u{1F504} **Updated existing draft entry** (avoiding duplicates)
1744
- `;
1745
- responseText += ` \u2022 New total: ${Math.round((agendaEntry.tracked_duration || 0) / 3600 * 10) / 10}h
2672
+ ${rendered}`
2673
+ }
2674
+ ]
2675
+ };
2676
+ }
1746
2677
 
1747
- `;
1748
- }
1749
- if (consolidatedCount > 0) {
1750
- responseText += `\u{1F9F9} **Cleaned up ${consolidatedCount} duplicate entries**
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
+ };
2698
+ }
2699
+ }
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
2733
+ });
2734
+ changes.push(`status ${ticket.status} -> ${input.status}`);
2735
+ }
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"
2760
+ );
2761
+ }
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}**
1751
2784
 
1752
- `;
1753
- }
1754
- responseText += `\u{1F4CB} **Entry Details:**
1755
- `;
1756
- if (project) {
1757
- responseText += ` \u2022 Project: ${project.name}
1758
- `;
1759
- } else {
1760
- responseText += ` \u2022 Project: (No project assigned)
1761
- `;
1762
- }
1763
- if (ticket) {
1764
- responseText += ` \u2022 Ticket: ${ticket.title} (${ticket.status})
1765
- `;
1766
- }
1767
- if (aiSession) {
1768
- responseText += ` \u2022 AI Session: ${aiSession.id} (${aiSession.status})
1769
- `;
1770
- }
1771
- responseText += ` \u2022 Description: ${workDescription}
1772
- `;
1773
- responseText += ` \u2022 ${wasUpdated ? "Added" : "Estimated"} Hours: ${estimatedHours}h (${Math.floor(estimatedHours)}h ${Math.round(estimatedHours % 1 * 60)}m)
1774
- `;
1775
- responseText += ` \u2022 Status: DRAFT (not billed yet)
1776
- `;
1777
- responseText += ` \u2022 Entry ID: ${agendaEntry.id}
2785
+ ${changes.length > 0 ? `Changes:
2786
+ ${changes.map((c) => ` \u2022 ${c}`).join("\n")}` : "No field changes were applied."}`
2787
+ }
2788
+ ]
2789
+ };
2790
+ }
1778
2791
 
1779
- `;
1780
- if (chatContextSummary) {
1781
- responseText += `\u{1F4CA} **Work Context:**
1782
- `;
1783
- responseText += `${chatContextSummary.substring(0, 200)}${chatContextSummary.length > 200 ? "..." : ""}
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;
2826
+ }
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:
1784
2879
 
1785
- `;
1786
- }
1787
- responseText += `\u2705 Time entry ${wasUpdated ? "updated" : "created"} and ready for review in the agenda!`;
1788
- return {
1789
- content: [{
1790
- type: "text",
1791
- text: responseText
1792
- }]
1793
- };
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."}`
1794
2888
  }
1795
- // === GITHUB TOOLS ===
1796
- case "get-github-file": {
1797
- const { projectId, filePath, ref } = args2;
1798
- const githubInfo = await getGithubTokenForProject(projectId, authContext.teamId);
1799
- if (!githubInfo) {
1800
- return {
1801
- content: [{
1802
- type: "text",
1803
- text: "\u274C GitHub not configured for this project."
1804
- }]
1805
- };
1806
- }
1807
- try {
1808
- const octokit = new Octokit({ auth: githubInfo.token });
1809
- console.error(`\u{1F4C4} Reading file: ${filePath} from ${githubInfo.repositoryFullName}`);
1810
- const { data } = await octokit.rest.repos.getContent({
1811
- owner: githubInfo.owner,
1812
- repo: githubInfo.repo,
1813
- path: filePath,
1814
- ref
1815
- });
1816
- if (Array.isArray(data) || data.type !== "file") {
1817
- return {
1818
- content: [{
1819
- type: "text",
1820
- text: `\u274C "${filePath}" is not a file or contains multiple items.`
1821
- }]
1822
- };
1823
- }
1824
- const content = Buffer.from(data.content, "base64").toString("utf-8");
1825
- let responseText = `\u{1F4C4} **File: ${filePath}**
1826
- `;
1827
- responseText += `Repository: ${githubInfo.repositoryFullName}
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));
2948
+ }
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
1828
2996
  `;
1829
- responseText += `Size: ${data.size} bytes
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)
1830
3000
  `;
1831
- responseText += `URL: ${data.html_url}
3001
+ const content = [
3002
+ {
3003
+ type: "text",
3004
+ text: `**Ticket Details:**
1832
3005
 
1833
- `;
1834
- responseText += `**Content:**
1835
- \`\`\`
1836
- ${content}
1837
- \`\`\``;
1838
- return {
1839
- content: [{
1840
- type: "text",
1841
- text: responseText
1842
- }]
1843
- };
1844
- } catch (error) {
1845
- console.error("GitHub get file error:", error);
1846
- if (error.status === 404) {
1847
- return {
1848
- content: [{
1849
- type: "text",
1850
- text: `\u274C File not found: ${filePath}`
1851
- }]
1852
- };
1853
- }
1854
- return {
1855
- content: [{
1856
- type: "text",
1857
- text: `\u274C Failed to read file: ${error.message || "Unknown error"}`
1858
- }]
1859
- };
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
+ });
1860
3041
  }
1861
3042
  }
1862
- case "list-github-directory": {
1863
- const { projectId, directoryPath, ref } = args2;
1864
- const githubInfo = await getGithubTokenForProject(projectId, authContext.teamId);
1865
- if (!githubInfo) {
1866
- return {
1867
- content: [{
1868
- type: "text",
1869
- text: "\u274C GitHub not configured for this project."
1870
- }]
1871
- };
1872
- }
1873
- try {
1874
- const octokit = new Octokit({ auth: githubInfo.token });
1875
- const normalizedPath = !directoryPath || directoryPath === "/" ? "" : directoryPath;
1876
- console.error(`\u{1F4C1} Listing directory: ${normalizedPath || "(root)"} in ${githubInfo.repositoryFullName}`);
1877
- const { data } = await octokit.rest.repos.getContent({
1878
- owner: githubInfo.owner,
1879
- repo: githubInfo.repo,
1880
- path: normalizedPath,
1881
- ref
3043
+ }
3044
+ }
3045
+ if (commentAttachments.length > 0) {
3046
+ console.error(
3047
+ `\u{1F4CE} Processing ${commentAttachments.length} comment attachments...`
3048
+ );
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({
3064
+ type: "text",
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
+ ` : "")
1882
3073
  });
1883
- if (!Array.isArray(data)) {
1884
- return {
1885
- content: [{
1886
- type: "text",
1887
- text: `\u274C "${directoryPath}" is not a directory.`
1888
- }]
1889
- };
1890
- }
1891
- let responseText = `\u{1F4C1} **Directory: ${directoryPath || "(root)"}**
1892
- `;
1893
- responseText += `Repository: ${githubInfo.repositoryFullName}
1894
- `;
1895
- responseText += `Items: ${data.length}
1896
-
1897
- `;
1898
- const directories = data.filter((item) => item.type === "dir");
1899
- const files = data.filter((item) => item.type === "file");
1900
- if (directories.length > 0) {
1901
- responseText += `**\u{1F4C1} Directories (${directories.length}):**
1902
- `;
1903
- for (const dir of directories) {
1904
- responseText += ` - ${dir.name}/
1905
- `;
1906
- }
1907
- responseText += `
1908
- `;
1909
- }
1910
- if (files.length > 0) {
1911
- responseText += `**\u{1F4C4} Files (${files.length}):**
1912
- `;
1913
- for (const file of files) {
1914
- responseText += ` - ${file.name} (${file.size} bytes)
1915
- `;
1916
- }
1917
- }
1918
- return {
1919
- content: [{
1920
- type: "text",
1921
- text: responseText
1922
- }]
1923
- };
1924
- } catch (error) {
1925
- console.error("GitHub list directory error:", error);
1926
- if (error.status === 404) {
1927
- return {
1928
- content: [{
1929
- type: "text",
1930
- text: `\u274C Directory not found: ${directoryPath}`
1931
- }]
1932
- };
1933
- }
1934
- return {
1935
- content: [{
1936
- type: "text",
1937
- text: `\u274C Failed to list directory: ${error.message || "Unknown error"}`
1938
- }]
1939
- };
1940
3074
  }
1941
3075
  }
1942
- default:
1943
- throw new Error(`Unknown tool: ${name}`);
1944
3076
  }
1945
- } catch (error) {
1946
- console.error(`\u274C Tool execution error:`, error);
1947
- const message = error instanceof Error ? error.message : error?.message || error?.details || (typeof error === "string" ? error : JSON.stringify(error));
1948
- return {
1949
- content: [{
3077
+ }
3078
+ console.error(
3079
+ `\u2705 Returning ticket with ${content.filter((c) => c.type === "image").length} images`
3080
+ );
3081
+ return { content };
3082
+ }
3083
+ async function handleCreateTicket(input) {
3084
+ const ctx = authContext;
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
+ }
3124
+ }
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
+ }
3142
+ }
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")}`;
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
+ {
1950
3164
  type: "text",
1951
- text: `Error executing ${name}: ${message}`
1952
- }]
1953
- };
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
+ };
3176
+ }
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
+ }
1954
3210
  }
1955
- });
1956
- server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
1957
- if (!authContext) {
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) {
1958
3219
  return {
1959
- contents: [{
1960
- uri: request.params.uri,
1961
- mimeType: "text/plain",
1962
- text: "Error: Not authenticated. API key validation failed."
1963
- }]
3220
+ content: [
3221
+ {
3222
+ type: "text",
3223
+ text: "Error: Not authenticated. API key validation failed."
3224
+ }
3225
+ ]
1964
3226
  };
1965
3227
  }
1966
- const { uri } = request.params;
1967
- 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}`);
1968
3230
  try {
1969
- switch (uri) {
1970
- case "tickets://recent": {
1971
- const teamIds = await getAccessibleTeamIds(authContext.teamId);
1972
- const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId);
1973
- const customerIds = await getAccessibleCustomerIds(authContext.teamId);
1974
- let teamFilter = `team_id.in.(${teamIds.join(",")})`;
1975
- let projectFilter = projectIds.length > 0 ? `project_id.in.(${projectIds.join(",")})` : null;
1976
- let customerFilter = customerIds.length > 0 ? `customer_id.in.(${customerIds.join(",")})` : null;
1977
- const filters = [teamFilter, projectFilter, customerFilter].filter(Boolean);
1978
- const { data, error } = await supabase.from("tickets").select(`
1979
- id,
1980
- ticket_number,
1981
- title,
1982
- status,
1983
- priority,
1984
- created_at
1985
- `).or(filters.join(",")).order("created_at", { ascending: false }).limit(20);
1986
- if (error) throw error;
1987
- return {
1988
- contents: [{
1989
- uri,
1990
- mimeType: "application/json",
1991
- text: JSON.stringify(data, null, 2)
1992
- }]
1993
- };
1994
- }
1995
- case "customers://all": {
1996
- const customerIds = await getAccessibleCustomerIds(authContext.teamId);
1997
- if (customerIds.length === 0) {
1998
- return {
1999
- contents: [{
2000
- uri,
2001
- mimeType: "application/json",
2002
- text: JSON.stringify([], null, 2)
2003
- }]
2004
- };
2005
- }
2006
- const { data, error } = await supabase.from("customers").select("id, name, email, website, created_at").in("id", customerIds).order("name").limit(50);
2007
- if (error) throw error;
2008
- return {
2009
- contents: [{
2010
- uri,
2011
- mimeType: "application/json",
2012
- text: JSON.stringify(data, null, 2)
2013
- }]
2014
- };
2015
- }
2016
- case "projects://active": {
2017
- const projectIds = await getAccessibleProjectIds(authContext.userId, authContext.teamId);
2018
- if (projectIds.length === 0) {
2019
- return {
2020
- contents: [{
2021
- uri,
2022
- mimeType: "application/json",
2023
- text: JSON.stringify([], null, 2)
2024
- }]
2025
- };
2026
- }
2027
- const { data, error } = await supabase.from("projects").select(`
2028
- id,
2029
- name,
2030
- description,
2031
- status,
2032
- created_at,
2033
- customers:customer_id(id, name)
2034
- `).in("id", projectIds).eq("status", "active").order("name").limit(50);
2035
- if (error) throw error;
2036
- return {
2037
- contents: [{
2038
- uri,
2039
- mimeType: "application/json",
2040
- text: JSON.stringify(data, null, 2)
2041
- }]
2042
- };
2043
- }
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)
3305
+ );
2044
3306
  default:
2045
- throw new Error(`Unknown resource: ${uri}`);
3307
+ throw new Error(`Unknown tool: ${name}`);
2046
3308
  }
2047
3309
  } catch (error) {
2048
- 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);
2049
3312
  return {
2050
- contents: [{
2051
- uri,
2052
- mimeType: "text/plain",
2053
- text: `Error reading ${uri}: ${error instanceof Error ? error.message : "Unknown error"}`
2054
- }]
3313
+ content: [{ type: "text", text: `Error executing ${name}: ${message}` }]
2055
3314
  };
2056
3315
  }
2057
3316
  });
3317
+ server.setRequestHandler(ReadResourceRequestSchema, async (request) => {
3318
+ return await handleReadResource(request.params.uri);
3319
+ });
2058
3320
  async function main() {
2059
- console.error("\u{1F680} Starting MG Tickets MCP Bridge Server...");
3321
+ console.error("\u{1F680} Starting Refront MCP Bridge Server...");
2060
3322
  console.error(`\u{1F511} API Key: ${apiKey?.substring(0, 10)}...`);
2061
- authContext = await validateApiKey(apiKey);
2062
- if (!authContext) {
2063
- console.error("\u274C API key validation failed. Please check your key and try again.");
3323
+ const ctx = await validateApiKey(apiKey);
3324
+ if (!ctx) {
3325
+ console.error(
3326
+ "\u274C API key validation failed. Please check your key and try again."
3327
+ );
2064
3328
  process.exit(1);
2065
3329
  }
2066
- console.error(`\u2705 Authenticated as user ${authContext.userId} in team ${authContext.teamId}`);
2067
- console.error(`\u{1F4CB} Available scopes: ${authContext.scopes.join(", ")}`);
3330
+ setAuthContext(ctx);
3331
+ console.error(
3332
+ `\u2705 Authenticated as user ${ctx.userId} in team ${ctx.teamId}`
3333
+ );
3334
+ console.error(`\u{1F4CB} Available scopes: ${ctx.scopes.join(", ")}`);
2068
3335
  console.error("\u{1F4E1} MCP Bridge Server ready for connections");
2069
3336
  const transport = new StdioServerTransport();
2070
3337
  await server.connect(transport);