@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/README.md +115 -154
- package/dist/index.js +2782 -1515
- package/dist/index.js.map +1 -1
- package/package.json +9 -5
- package/dist/index.d.ts +0 -1
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
|
|
10
|
-
var
|
|
11
|
-
var
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
function
|
|
19
|
-
if (
|
|
20
|
-
|
|
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
|
|
24
|
-
|
|
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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
|
59
|
-
console.error(
|
|
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.
|
|
62
|
-
teamId: apiKeyData.
|
|
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
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
229
|
+
const ctx = authContext;
|
|
230
|
+
console.error(`\u{1F4DA} Reading resource: ${uri}`);
|
|
125
231
|
try {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
-
|
|
174
|
-
await
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
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("
|
|
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
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
207
|
-
|
|
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: {
|
|
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
|
|
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: {
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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: {
|
|
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: {
|
|
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: [
|
|
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: [
|
|
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: {
|
|
683
|
+
todoId: {
|
|
684
|
+
type: "string",
|
|
685
|
+
description: "Optional external todo ID for tracking"
|
|
686
|
+
},
|
|
378
687
|
content: { type: "string" },
|
|
379
|
-
status: {
|
|
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: {
|
|
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: {
|
|
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
|
-
|
|
537
|
-
|
|
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
|
-
|
|
559
|
-
|
|
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
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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: [
|
|
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
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
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
|
-
|
|
654
|
-
}]
|
|
655
|
-
};
|
|
1033
|
+
).join("\n") || "No customers found."}`
|
|
656
1034
|
}
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
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
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
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: `\
|
|
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
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
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
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
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
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
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
|
-
${
|
|
887
|
-
|
|
888
|
-
${
|
|
889
|
-
` : ""}
|
|
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
|
-
|
|
893
|
-
}]
|
|
894
|
-
};
|
|
1497
|
+
).join("\n") || "No projects found."}`
|
|
895
1498
|
}
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
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: ${
|
|
912
|
-
${
|
|
913
|
-
` : ""}${website ? `Website: ${website}
|
|
1518
|
+
Name: ${name}
|
|
1519
|
+
${description ? `Description: ${description}
|
|
914
1520
|
` : ""}`
|
|
915
|
-
}]
|
|
916
|
-
};
|
|
917
1521
|
}
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
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
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
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
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
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
|
-
|
|
971
|
-
|
|
972
|
-
|
|
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
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
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
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
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
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
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
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
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
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
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
|
-
${
|
|
1191
|
-
` : ""}${
|
|
1192
|
-
` : ""}${
|
|
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
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
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
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
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
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
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
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
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
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
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
|
-
|
|
2417
|
+
${list}
|
|
1407
2418
|
|
|
1408
|
-
|
|
1409
|
-
\`\`\`json
|
|
1410
|
-
${JSON.stringify(contextData, null, 2)}\`\`\``
|
|
1411
|
-
}]
|
|
1412
|
-
};
|
|
2419
|
+
${JSON.stringify(teams2)}`
|
|
1413
2420
|
}
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
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
|
-
|
|
1445
|
-
|
|
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
|
-
|
|
1448
|
-
|
|
1449
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
1464
|
-
|
|
1465
|
-
|
|
1466
|
-
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
1471
|
-
|
|
1472
|
-
|
|
1473
|
-
|
|
1474
|
-
|
|
1475
|
-
|
|
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
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
|
|
1513
|
-
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
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
|
-
|
|
1561
|
-
|
|
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
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
1571
|
-
|
|
1572
|
-
|
|
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
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
2577
|
+
case "listItem":
|
|
2578
|
+
return `- ${inner.trimEnd()}
|
|
1607
2579
|
`;
|
|
1608
|
-
|
|
2580
|
+
case "bulletList":
|
|
2581
|
+
case "orderedList":
|
|
2582
|
+
return inner;
|
|
2583
|
+
case "blockquote":
|
|
2584
|
+
return `${inner}
|
|
1609
2585
|
`;
|
|
1610
|
-
|
|
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
|
-
|
|
1615
|
-
|
|
1616
|
-
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
-
|
|
1632
|
-
|
|
1633
|
-
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
1643
|
-
|
|
1644
|
-
|
|
1645
|
-
|
|
1646
|
-
|
|
1647
|
-
|
|
1648
|
-
|
|
1649
|
-
|
|
1650
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1743
|
-
|
|
1744
|
-
|
|
1745
|
-
|
|
2672
|
+
${rendered}`
|
|
2673
|
+
}
|
|
2674
|
+
]
|
|
2675
|
+
};
|
|
2676
|
+
}
|
|
1746
2677
|
|
|
1747
|
-
|
|
1748
|
-
|
|
1749
|
-
|
|
1750
|
-
|
|
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
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
|
|
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
|
-
|
|
1781
|
-
|
|
1782
|
-
|
|
1783
|
-
|
|
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
|
-
|
|
1788
|
-
|
|
1789
|
-
|
|
1790
|
-
|
|
1791
|
-
|
|
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
|
-
|
|
1796
|
-
|
|
1797
|
-
|
|
1798
|
-
|
|
1799
|
-
|
|
1800
|
-
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
|
|
1810
|
-
|
|
1811
|
-
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3001
|
+
const content = [
|
|
3002
|
+
{
|
|
3003
|
+
type: "text",
|
|
3004
|
+
text: `**Ticket Details:**
|
|
1832
3005
|
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
${
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
1857
|
-
|
|
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
|
-
|
|
1863
|
-
|
|
1864
|
-
|
|
1865
|
-
|
|
1866
|
-
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
}
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
const
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
|
|
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
|
-
}
|
|
1946
|
-
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
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:
|
|
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(
|
|
1957
|
-
|
|
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
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
3220
|
+
content: [
|
|
3221
|
+
{
|
|
3222
|
+
type: "text",
|
|
3223
|
+
text: "Error: Not authenticated. API key validation failed."
|
|
3224
|
+
}
|
|
3225
|
+
]
|
|
1964
3226
|
};
|
|
1965
3227
|
}
|
|
1966
|
-
const {
|
|
1967
|
-
console.error(`\u{
|
|
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 (
|
|
1970
|
-
case "
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
|
|
1994
|
-
|
|
1995
|
-
|
|
1996
|
-
|
|
1997
|
-
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
2003
|
-
|
|
2004
|
-
|
|
2005
|
-
|
|
2006
|
-
|
|
2007
|
-
|
|
2008
|
-
|
|
2009
|
-
|
|
2010
|
-
|
|
2011
|
-
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
|
|
2016
|
-
case "
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2040
|
-
|
|
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
|
|
3307
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
2046
3308
|
}
|
|
2047
3309
|
} catch (error) {
|
|
2048
|
-
console.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
|
-
|
|
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
|
|
3321
|
+
console.error("\u{1F680} Starting Refront MCP Bridge Server...");
|
|
2060
3322
|
console.error(`\u{1F511} API Key: ${apiKey?.substring(0, 10)}...`);
|
|
2061
|
-
|
|
2062
|
-
if (!
|
|
2063
|
-
console.error(
|
|
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
|
-
|
|
2067
|
-
console.error(
|
|
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);
|