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