Skip to content

Commit 8233a33

Browse files
authored
Merge pull request #104 from nilenso/jadoo-better-errors
Improve jadoo error handling
2 parents 008493b + 2196cd1 commit 8233a33

4 files changed

Lines changed: 201 additions & 43 deletions

File tree

jadoo/src/index.ts

Lines changed: 60 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -7,54 +7,80 @@
77
import { Bot } from "./bot.js";
88
import { loadAIConfig, loadGoogleCalendarConfig, loadHarvestConfig, loadSlackConfig } from "./config/index.js";
99
import { openDatabase, runMigrations } from "./db/index.js";
10+
import { AdminPlugin } from "./plugins/admin/index.js";
11+
import { LeavePlugin } from "./plugins/leave/index.js";
1012
import { PiAIService } from "./services/ai/pi-ai-service.js";
1113
import { GCalService } from "./services/calendar/gcal-service.js";
1214
import { HarvestAPIService } from "./services/harvest/harvest-service.js";
1315
import { BoltSlackService } from "./services/slack/bolt-slack-service.js";
1416
import { BackgroundWorker } from "./worker.js";
1517

16-
// Load configuration from environment
17-
const slackConfig = loadSlackConfig();
18-
const aiConfig = loadAIConfig();
19-
const gcalConfig = loadGoogleCalendarConfig();
20-
const harvestConfig = loadHarvestConfig();
18+
let bot: Bot | undefined;
19+
let worker: BackgroundWorker | undefined;
20+
let db: ReturnType<typeof openDatabase> | undefined;
2121

22-
// Database
23-
const db = openDatabase();
24-
runMigrations(db);
22+
async function main(): Promise<void> {
23+
try {
24+
console.log("[jadoo] loading configuration");
25+
const slackConfig = loadSlackConfig();
26+
const aiConfig = loadAIConfig();
27+
const gcalConfig = loadGoogleCalendarConfig();
28+
const harvestConfig = loadHarvestConfig();
2529

26-
// Build services
27-
const slack = new BoltSlackService(slackConfig);
28-
const ai = new PiAIService(aiConfig);
29-
const calendar = new GCalService(gcalConfig);
30-
const harvest = new HarvestAPIService(harvestConfig);
30+
console.log("[jadoo] opening database");
31+
db = openDatabase();
32+
runMigrations(db);
3133

32-
import { AdminPlugin } from "./plugins/admin/index.js";
33-
import { LeavePlugin } from "./plugins/leave/index.js";
34+
console.log("[jadoo] building services");
35+
const slack = new BoltSlackService(slackConfig);
36+
const ai = new PiAIService(aiConfig);
37+
const calendar = new GCalService(gcalConfig);
38+
const harvest = new HarvestAPIService(harvestConfig);
3439

35-
// Assemble bot
36-
const bot = new Bot({ ai, calendar, harvest, slack });
40+
console.log("[jadoo] registering plugins");
41+
bot = new Bot({ ai, calendar, harvest, slack });
42+
bot.register(new AdminPlugin(db));
43+
bot.register(new LeavePlugin(db));
3744

38-
// Register plugins here:
39-
bot.register(new AdminPlugin(db));
40-
bot.register(new LeavePlugin(db));
45+
worker = new BackgroundWorker({ db, calendar, harvest, slack });
4146

42-
// Background worker — processes confirmed leave actions + expires stale ones
43-
const worker = new BackgroundWorker({ db, calendar, harvest, slack });
47+
console.log("[jadoo] starting bot");
48+
await bot.start();
49+
worker.start();
50+
console.log("🚀 Jadoo is live");
51+
} catch (err) {
52+
const msg = err instanceof Error ? (err.stack ?? err.message) : String(err);
53+
console.error(`[jadoo] fatal startup error: ${msg}`);
4454

45-
await bot.start();
46-
worker.start();
47-
console.log("🚀 Jadoo is live");
55+
try {
56+
worker?.stop();
57+
await bot?.stop();
58+
db?.close();
59+
} catch (cleanupErr) {
60+
console.error(`[jadoo] cleanup after startup failure also failed: ${cleanupErr}`);
61+
}
4862

49-
// Graceful shutdown
50-
function shutdown() {
51-
console.log("\n[jadoo] shutting down…");
52-
worker.stop();
53-
bot.stop();
54-
db.close();
55-
process.exit(0);
63+
process.exit(1);
64+
}
5665
}
57-
process.on("SIGINT", shutdown);
58-
process.on("SIGTERM", shutdown);
66+
67+
async function shutdown(signal: string): Promise<void> {
68+
console.log(`\n[jadoo] shutting down on ${signal}…`);
69+
70+
try {
71+
worker?.stop();
72+
await bot?.stop();
73+
db?.close();
74+
process.exit(0);
75+
} catch (err) {
76+
console.error(`[jadoo] shutdown failed: ${err}`);
77+
process.exit(1);
78+
}
79+
}
80+
81+
process.on("SIGINT", () => void shutdown("SIGINT"));
82+
process.on("SIGTERM", () => void shutdown("SIGTERM"));
83+
84+
await main();
5985

6086
export { bot, worker };

jadoo/src/services/slack/bolt-slack-service.ts

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,10 @@ export class BoltSlackService implements SlackService {
2020
private app: App;
2121
private handlers: MessageHandler[] = [];
2222

23+
private formatError(err: unknown): string {
24+
return err instanceof Error ? err.message : String(err);
25+
}
26+
2327
constructor(config: SlackConfig) {
2428
this.app = new BoltApp({
2529
token: config.botToken,
@@ -54,9 +58,20 @@ export class BoltSlackService implements SlackService {
5458
};
5559

5660
for (const handler of this.handlers) {
57-
const reply = await handler(incoming);
58-
if (reply) {
59-
await say({ text: reply, thread_ts: msg.thread_ts ?? msg.ts });
61+
try {
62+
const reply = await handler(incoming);
63+
if (reply) {
64+
await say({ text: reply, thread_ts: msg.thread_ts ?? msg.ts });
65+
}
66+
} catch (err) {
67+
const errorMessage = this.formatError(err);
68+
console.error(
69+
`[slack] message handler failed for user=${incoming.userId} channel=${incoming.channelId} ts=${incoming.ts}: ${errorMessage}`,
70+
);
71+
await say({
72+
text: "⚠️ Sorry, something went wrong while processing that message. Please try again or contact an admin if it keeps happening.",
73+
thread_ts: msg.thread_ts ?? msg.ts,
74+
});
6075
}
6176
}
6277
});
@@ -91,14 +106,35 @@ export class BoltSlackService implements SlackService {
91106

92107
if (!action?.action_id || !user?.id || !channel?.id || !message?.ts) return;
93108

94-
await handler({
109+
const event = {
95110
actionId: action.action_id,
96111
value: action.value ?? "",
97112
userId: user.id,
98113
channelId: channel.id,
99114
messageTs: message.ts,
100115
threadTs: message.thread_ts,
101-
});
116+
};
117+
118+
try {
119+
await handler(event);
120+
} catch (err) {
121+
const errorMessage = this.formatError(err);
122+
console.error(
123+
`[slack] action handler failed for action=${event.actionId} user=${event.userId} channel=${event.channelId} ts=${event.messageTs}: ${errorMessage}`,
124+
);
125+
await this.updateMessage(event.channelId, event.messageTs, {
126+
text: "⚠️ Sorry, something went wrong while processing that action. Please try again or contact an admin if it keeps happening.",
127+
blocks: [
128+
{
129+
type: "section",
130+
text: {
131+
type: "mrkdwn",
132+
text: "⚠️ *Something went wrong while processing that action.* Please try again or contact an admin if it keeps happening.",
133+
},
134+
},
135+
],
136+
});
137+
}
102138
});
103139
}
104140

jadoo/src/worker.ts

Lines changed: 78 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,12 @@ export interface CancelLeavePayload {
4343
dates: string[]; // YYYY-MM-DD
4444
}
4545

46+
interface LeaveProcessingFailure {
47+
date: string;
48+
stage: "calendar" | "harvest" | "cancel" | "validation";
49+
message: string;
50+
}
51+
4652
// ─── Config ─────────────────────────────────────────────
4753

4854
export interface WorkerConfig {
@@ -179,10 +185,18 @@ export class BackgroundWorker {
179185
if (!user) {
180186
console.error(`[worker] user ${action.user_id} not found for action ${action.id}`);
181187
updatePendingActionStatus(this.db, action.id, "failed");
188+
await this.notifyFailed(action, [
189+
{
190+
date: payload.dates.join(", "),
191+
stage: "validation",
192+
message: `User ${action.user_id} no longer exists in Jadoo's database.`,
193+
},
194+
]);
182195
return;
183196
}
184197

185198
let allSucceeded = true;
199+
const failures: LeaveProcessingFailure[] = [];
186200

187201
for (const date of payload.dates) {
188202
// Upsert a leave record in 'confirmed' state
@@ -196,6 +210,7 @@ export class BackgroundWorker {
196210
status: "confirmed",
197211
});
198212

213+
let stage: LeaveProcessingFailure["stage"] = "calendar";
199214
try {
200215
// Sync to Calendar
201216
const start = new Date(`${date}T00:00:00`);
@@ -210,6 +225,7 @@ export class BackgroundWorker {
210225
// Sync to Harvest (only if user has a Harvest mapping)
211226
let harvestEntryId: number | null = null;
212227
if (user.harvest_user_id) {
228+
stage = "harvest";
213229
harvestEntryId = await this.harvest.createTimeEntry({
214230
harvestUserId: user.harvest_user_id,
215231
date,
@@ -229,6 +245,8 @@ export class BackgroundWorker {
229245
const msg = err instanceof Error ? err.message : String(err);
230246
const retryCount = incrementLeaveRecordRetry(this.db, record.id, msg);
231247

248+
failures.push({ date, stage, message: msg });
249+
232250
if (retryCount >= this.maxRetries) {
233251
updateLeaveRecordStatus(this.db, record.id, {
234252
status: "failed",
@@ -263,7 +281,7 @@ export class BackgroundWorker {
263281
} else {
264282
// All dates either completed or failed
265283
updatePendingActionStatus(this.db, action.id, "failed");
266-
await this.notifyFailed(action);
284+
await this.notifyFailed(action, failures, payload);
267285
}
268286
}
269287
}
@@ -274,6 +292,13 @@ export class BackgroundWorker {
274292
if (!user) {
275293
console.error(`[worker] user ${action.user_id} not found for action ${action.id}`);
276294
updatePendingActionStatus(this.db, action.id, "failed");
295+
await this.notifyFailed(action, [
296+
{
297+
date: payload.dates.join(", "),
298+
stage: "validation",
299+
message: `User ${action.user_id} no longer exists in Jadoo's database.`,
300+
},
301+
]);
277302
return;
278303
}
279304

@@ -289,6 +314,21 @@ export class BackgroundWorker {
289314
)
290315
.all(user.id, ...payload.dates);
291316

317+
if (records.length === 0) {
318+
updatePendingActionStatus(this.db, action.id, "failed");
319+
await this.notifyFailed(
320+
action,
321+
payload.dates.map((date) => ({
322+
date,
323+
stage: "validation",
324+
message: "No matching leave record was found to cancel.",
325+
})),
326+
);
327+
return;
328+
}
329+
330+
const failures: LeaveProcessingFailure[] = [];
331+
292332
for (const record of records) {
293333
try {
294334
if (record.calendar_event_id) {
@@ -301,13 +341,20 @@ export class BackgroundWorker {
301341
} catch (err) {
302342
const msg = err instanceof Error ? err.message : String(err);
303343
console.error(`[worker] failed to cancel leave record ${record.id}: ${msg}`);
344+
failures.push({ date: record.date, stage: "cancel", message: msg });
304345
updateLeaveRecordStatus(this.db, record.id, {
305346
status: "failed",
306347
errorMessage: msg,
307348
});
308349
}
309350
}
310351

352+
if (failures.length > 0) {
353+
updatePendingActionStatus(this.db, action.id, "failed");
354+
await this.notifyFailed(action, failures);
355+
return;
356+
}
357+
311358
updatePendingActionStatus(this.db, action.id, "completed");
312359
await this.notifyCancelled(action, payload);
313360
}
@@ -338,20 +385,47 @@ export class BackgroundWorker {
338385
}
339386
}
340387

341-
private async notifyFailed(action: DbPendingAction): Promise<void> {
388+
private async notifyFailed(
389+
action: DbPendingAction,
390+
failures: LeaveProcessingFailure[],
391+
payload?: CreateLeavePayload,
392+
): Promise<void> {
342393
const channel = action.slack_channel_id;
343394
const ts = action.slack_bot_message_ts;
344395
if (!channel || !ts) return;
345396

397+
const totalDates = payload?.dates.length;
398+
const uniqueFailures = failures.slice(0, 5).map((failure) => {
399+
const stageLabel =
400+
failure.stage === "calendar"
401+
? "Calendar"
402+
: failure.stage === "harvest"
403+
? "Harvest"
404+
: failure.stage === "cancel"
405+
? "Cancellation"
406+
: "Validation";
407+
return `• ${failure.date}: ${stageLabel}${failure.message}`;
408+
});
409+
const summary =
410+
totalDates && totalDates > failures.length
411+
? `Some dates may have succeeded, but ${failures.length} date(s) failed.`
412+
: "The request could not be completed.";
413+
const message = [
414+
"❌ Leave processing failed.",
415+
summary,
416+
"Please try again or contact an admin.",
417+
...uniqueFailures,
418+
].join("\n");
419+
346420
try {
347421
await this.slack.updateMessage(channel, ts, {
348-
text: "❌ Leave sync failed after retries. Please contact an admin.",
422+
text: message,
349423
blocks: [
350424
{
351425
type: "section",
352426
text: {
353427
type: "mrkdwn",
354-
text: "❌ *Leave sync failed* after retries. Please contact an admin.",
428+
text: `❌ *Leave processing failed*\n${summary}\nPlease try again or contact an admin.${uniqueFailures.length ? `\n\n${uniqueFailures.join("\n")}` : ""}`,
355429
},
356430
},
357431
],

jadoo/test/worker.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ describe("processTick — create_leave", () => {
189189
expect(slack.updatedMessages.length).toBeGreaterThanOrEqual(1);
190190
const lastUpdate = slack.updatedMessages[slack.updatedMessages.length - 1];
191191
expect(lastUpdate.options.text).toContain("❌");
192+
expect(lastUpdate.options.text).toContain("Calendar");
193+
expect(lastUpdate.options.text).toContain("2026-04-01");
192194
});
193195

194196
it("still processes leave for a deactivated user", async () => {
@@ -333,6 +335,26 @@ describe("processTick — cancel_leave", () => {
333335
const records = getLeaveRecordsByStatus(db, "cancelled");
334336
expect(records).toHaveLength(1);
335337
});
338+
339+
it("fails cancellation with a helpful message when no matching leave exists", async () => {
340+
const action = createPendingAction(db, {
341+
userId: user.id,
342+
actionType: "cancel_leave",
343+
payload: { dates: ["2026-04-09"] },
344+
slackChannelId: "C1",
345+
expiresAt: futureExpiry(),
346+
});
347+
updatePendingActionBotMessageTs(db, action.id, "bot-msg-404");
348+
updatePendingActionStatus(db, action.id, "confirmed");
349+
350+
worker = new BackgroundWorker(deps(), { processIntervalMs: 999999, expiryIntervalMs: 999999 });
351+
await worker.processTick();
352+
353+
expect(getPendingActionById(db, action.id)?.status).toBe("failed");
354+
expect(slack.updatedMessages).toHaveLength(1);
355+
expect(slack.updatedMessages[0].options.text).toContain("No matching leave record");
356+
expect(slack.updatedMessages[0].options.text).toContain("2026-04-09");
357+
});
336358
});
337359

338360
// ─── expiryTick ─────────────────────────────────────────

0 commit comments

Comments
 (0)