From cf7fae0f5071985d2c44e0a7a2a2480f48d586dd Mon Sep 17 00:00:00 2001 From: Dhairyashil Date: Sat, 23 May 2026 05:46:21 +0000 Subject: [PATCH 1/9] feat(chat): add push notification delivery endpoint and notify commands - Add POST /api/notifications/deliver route with secret-based auth - Add ChatPushPayload type and buildPushCard() card formatter - Add per-identifier Slack and Telegram delivery modules - Add deliverNotifications() fan-out service using Promise.allSettled - Add /cal notify on|off Slack subcommand - Add /notify on|off Telegram command - Add 4 subscription management methods to Cal.com client - Add CALCOM_DELIVERY_SECRET env var validation --- apps/chat/.env.example | 5 + .../app/api/notifications/deliver/route.ts | 43 ++++++++ apps/chat/lib/calcom/client.ts | 42 +++++++ apps/chat/lib/env.ts | 10 ++ apps/chat/lib/handlers/slack.ts | 41 +++++++ apps/chat/lib/handlers/telegram.ts | 40 +++++++ .../lib/push-notifications/deliver-slack.ts | 39 +++++++ .../push-notifications/deliver-telegram.ts | 26 +++++ apps/chat/lib/push-notifications/formatter.ts | 103 ++++++++++++++++++ apps/chat/lib/push-notifications/service.ts | 39 +++++++ 10 files changed, 388 insertions(+) create mode 100644 apps/chat/app/api/notifications/deliver/route.ts create mode 100644 apps/chat/lib/push-notifications/deliver-slack.ts create mode 100644 apps/chat/lib/push-notifications/deliver-telegram.ts create mode 100644 apps/chat/lib/push-notifications/formatter.ts create mode 100644 apps/chat/lib/push-notifications/service.ts diff --git a/apps/chat/.env.example b/apps/chat/.env.example index 4de42e9e..10ee57c1 100644 --- a/apps/chat/.env.example +++ b/apps/chat/.env.example @@ -40,6 +40,11 @@ CALCOM_WEBHOOK_SECRET=your-calcom-webhook-secret # Cal.com app URL for links CALCOM_APP_URL=https://app.cal.com +# Shared secret for the push-notification delivery endpoint (POST /api/notifications/deliver). +# Must match CALCOM_CHAT_DELIVERY_SECRET on the /cal backend. +# Generate with: openssl rand -hex 32 +CALCOM_DELIVERY_SECRET=your-delivery-secret + # ─── AI / LLM (Vercel AI Gateway) ──────────────────────────────────────────── # Single API key for all AI models. Get yours at vercel.com/ai-gateway AI_GATEWAY_API_KEY=your-ai-gateway-api-key diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts new file mode 100644 index 00000000..32e0d42e --- /dev/null +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -0,0 +1,43 @@ +import crypto from "node:crypto"; +import { type DeliverRequest, deliverNotifications } from "@/lib/push-notifications/service"; + +function verifyDeliverySecret(header: string | null): boolean { + const secret = process.env.CALCOM_DELIVERY_SECRET; + if (!secret || !header) return false; + try { + return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(secret)); + } catch { + return false; + } +} + +function parseDeliverRequest(body: unknown): DeliverRequest | null { + if (typeof body !== "object" || body === null) return null; + const b = body as Record; + if (b.platform !== "SLACK" && b.platform !== "TELEGRAM") return null; + if (!Array.isArray(b.subscriptions) || b.subscriptions.length === 0) return null; + if (typeof b.payload !== "object" || b.payload === null) return null; + return body as DeliverRequest; +} + +export async function POST(request: Request) { + const secret = request.headers.get("x-cal-delivery-secret"); + if (!verifyDeliverySecret(secret)) { + return new Response(null, { status: 401 }); + } + + let body: unknown; + try { + body = await request.json(); + } catch { + return new Response(null, { status: 400 }); + } + + const parsed = parseDeliverRequest(body); + if (!parsed) { + return new Response(null, { status: 400 }); + } + + const results = await deliverNotifications(parsed); + return Response.json({ results }); +} diff --git a/apps/chat/lib/calcom/client.ts b/apps/chat/lib/calcom/client.ts index 9c9ce4bf..f2a408fd 100644 --- a/apps/chat/lib/calcom/client.ts +++ b/apps/chat/lib/calcom/client.ts @@ -648,3 +648,45 @@ export async function chargeCredits( }), }, API_VERSION, 0); } + +// ─── Chat push notification subscriptions ──────────────────────────────────── + +export async function registerSlackSubscription( + accessToken: string, + input: { identifier: string; deviceId: string } +): Promise { + await calcomFetch("/v2/notifications/subscriptions/slack", accessToken, { + method: "POST", + body: JSON.stringify(input), + }, API_VERSION, 0); +} + +export async function removeSlackSubscription( + accessToken: string, + input: { identifier: string } +): Promise { + await calcomFetch("/v2/notifications/subscriptions/slack", accessToken, { + method: "DELETE", + body: JSON.stringify(input), + }, API_VERSION, 0); +} + +export async function registerTelegramSubscription( + accessToken: string, + input: { identifier: string } +): Promise { + await calcomFetch("/v2/notifications/subscriptions/telegram", accessToken, { + method: "POST", + body: JSON.stringify(input), + }, API_VERSION, 0); +} + +export async function removeTelegramSubscription( + accessToken: string, + input: { identifier: string } +): Promise { + await calcomFetch("/v2/notifications/subscriptions/telegram", accessToken, { + method: "DELETE", + body: JSON.stringify(input), + }, API_VERSION, 0); +} diff --git a/apps/chat/lib/env.ts b/apps/chat/lib/env.ts index 343a9712..f22d4d8c 100644 --- a/apps/chat/lib/env.ts +++ b/apps/chat/lib/env.ts @@ -22,6 +22,16 @@ export function validateRequiredEnv(): void { ); } + if (process.env.NODE_ENV === "production" && !process.env.CALCOM_DELIVERY_SECRET) { + throw new Error( + "CALCOM_DELIVERY_SECRET is required in production. Set it to the same value as CALCOM_CHAT_DELIVERY_SECRET on the /cal backend." + ); + } else if (!process.env.CALCOM_DELIVERY_SECRET) { + console.warn( + "CALCOM_DELIVERY_SECRET not set — POST /api/notifications/deliver will reject all requests." + ); + } + if (missing.length > 0) { throw new Error(`Missing required environment variables: ${missing.join(", ")}`); } diff --git a/apps/chat/lib/handlers/slack.ts b/apps/chat/lib/handlers/slack.ts index e08c66dc..4bd84804 100644 --- a/apps/chat/lib/handlers/slack.ts +++ b/apps/chat/lib/handlers/slack.ts @@ -30,6 +30,8 @@ import { getBookings, getEventTypesByUsername, getSchedules, + registerSlackSubscription, + removeSlackSubscription, rescheduleBooking, } from "../calcom/client"; import { generateAuthUrl } from "../calcom/oauth"; @@ -398,6 +400,45 @@ export function registerSlackHandlers( case "unlink": await handleUnlink(event, teamId, userId); break; + case "notify": { + const notifyArg = args[1]?.toLowerCase(); + if (notifyArg !== "on" && notifyArg !== "off") { + await event.channel.postEphemeral( + event.user, + "Usage: `/cal notify on` or `/cal notify off`", + { fallbackToDM: true } + ); + break; + } + const notifyToken = await getValidAccessToken(teamId, userId); + if (!notifyToken) { + await event.channel.postEphemeral( + event.user, + oauthLinkMessage("slack", teamId, userId), + { fallbackToDM: true } + ); + break; + } + if (notifyArg === "on") { + await registerSlackSubscription(notifyToken, { + identifier: userId, + deviceId: teamId, + }); + await event.channel.postEphemeral( + event.user, + "✅ You'll now receive booking notifications here.", + { fallbackToDM: true } + ); + } else { + await removeSlackSubscription(notifyToken, { identifier: userId }); + await event.channel.postEphemeral( + event.user, + "🔕 Booking push notifications turned off.", + { fallbackToDM: true } + ); + } + break; + } case "help": await event.channel.postEphemeral(event.user, helpCard(), { fallbackToDM: true }); break; diff --git a/apps/chat/lib/handlers/telegram.ts b/apps/chat/lib/handlers/telegram.ts index a778c42e..c9efd8b4 100644 --- a/apps/chat/lib/handlers/telegram.ts +++ b/apps/chat/lib/handlers/telegram.ts @@ -7,6 +7,8 @@ import { getBookings, getEventTypesByUsername, getSchedules, + registerTelegramSubscription, + removeTelegramSubscription, rescheduleBooking, } from "../calcom/client"; import { generateAuthUrl } from "../calcom/oauth"; @@ -47,6 +49,7 @@ export const TELEGRAM_COMMANDS = [ "help", "link", "unlink", + "notify", "bookings", "availability", "profile", @@ -192,6 +195,43 @@ export async function handleTelegramCommand( return; } + if (cmd === "notify") { + const notifyArg = rest.split(/\s+/)[0]?.toLowerCase(); + if (notifyArg !== "on" && notifyArg !== "off") { + await postPrivately( + thread, + message, + "Usage: `/notify on` or `/notify off`", + isGroup + ); + return; + } + const auth = await requireAuth(); + if (!auth) return; + if (notifyArg === "on") { + await registerTelegramSubscription(auth.accessToken, { + identifier: ctx.userId, + }); + await postPrivately( + thread, + message, + "✅ You'll now receive booking notifications here.", + isGroup + ); + } else { + await removeTelegramSubscription(auth.accessToken, { + identifier: ctx.userId, + }); + await postPrivately( + thread, + message, + "🔕 Booking push notifications turned off.", + isGroup + ); + } + return; + } + if (cmd === "bookings") { const auth = await requireAuth(); if (!auth) return; diff --git a/apps/chat/lib/push-notifications/deliver-slack.ts b/apps/chat/lib/push-notifications/deliver-slack.ts new file mode 100644 index 00000000..7e36f3bc --- /dev/null +++ b/apps/chat/lib/push-notifications/deliver-slack.ts @@ -0,0 +1,39 @@ +import type { ChatElement } from "chat"; +import { bot, slackAdapter } from "@/lib/bot"; + +export type DeliverResult = { + identifier: string; + success: boolean; + invalidIdentifier?: boolean; +}; + +const INVALID_SLACK_ERROR_CODES = new Set([ + "channel_not_found", + "not_in_channel", + "account_inactive", +]); + +export async function deliverSlack( + identifier: string, + teamId: string, + card: ChatElement +): Promise { + const installation = await slackAdapter.getInstallation(teamId); + if (!installation) { + return { identifier, success: false, invalidIdentifier: true }; + } + + try { + await slackAdapter.withBotToken(installation.botToken, async () => { + await bot.channel(`slack:${identifier}`).post(card); + }); + return { identifier, success: true }; + } catch (err) { + const msg = String(err).toLowerCase(); + const isInvalid = [...INVALID_SLACK_ERROR_CODES].some((code) => msg.includes(code)); + if (isInvalid) { + return { identifier, success: false, invalidIdentifier: true }; + } + return { identifier, success: false }; + } +} diff --git a/apps/chat/lib/push-notifications/deliver-telegram.ts b/apps/chat/lib/push-notifications/deliver-telegram.ts new file mode 100644 index 00000000..d81de353 --- /dev/null +++ b/apps/chat/lib/push-notifications/deliver-telegram.ts @@ -0,0 +1,26 @@ +import type { ChatElement } from "chat"; +import { bot } from "@/lib/bot"; +import type { DeliverResult } from "./deliver-slack"; + +const TELEGRAM_NOT_FOUND_PHRASES = [ + "chat not found", + "user not found", + "bot was blocked by the user", +]; + +export async function deliverTelegram( + identifier: string, + card: ChatElement +): Promise { + try { + await bot.channel(`telegram:${identifier}`).post(card); + return { identifier, success: true }; + } catch (err) { + const msg = String(err).toLowerCase(); + const isInvalid = TELEGRAM_NOT_FOUND_PHRASES.some((phrase) => msg.includes(phrase)); + if (isInvalid) { + return { identifier, success: false, invalidIdentifier: true }; + } + return { identifier, success: false }; + } +} diff --git a/apps/chat/lib/push-notifications/formatter.ts b/apps/chat/lib/push-notifications/formatter.ts new file mode 100644 index 00000000..7e62d929 --- /dev/null +++ b/apps/chat/lib/push-notifications/formatter.ts @@ -0,0 +1,103 @@ +// Mirrors ChatPushPayload from calcom/cal PR #2927 +// packages/features/notifications/send-chat-push-notification.ts + +import type { ChatElement } from "chat"; +import { Actions, Card, Divider, Field, Fields, LinkButton } from "chat"; + +export type ChatPushPayload = { + title: string; + body: string; + data?: Record; + notificationType: + | "BOOKING_CONFIRMED" + | "BOOKING_CANCELLED" + | "BOOKING_RESCHEDULED" + | "BOOKING_REQUESTED" + | "BOOKING_REJECTED"; + hosts: Array<{ name: string; email: string }>; + attendees: Array<{ name: string; email: string }>; + start: string; + end: string; + timeZone: string; + location?: string; + meetingUrl?: string; + cancellationReason?: string; +}; + +const NOTIFICATION_BADGES: Record = { + BOOKING_CONFIRMED: "✅ Booking Confirmed", + BOOKING_CANCELLED: "❌ Booking Cancelled", + BOOKING_RESCHEDULED: "🔄 Booking Rescheduled", + BOOKING_REQUESTED: "🕐 Booking Requested", + BOOKING_REJECTED: "🚫 Booking Rejected", +}; + +function formatPushTime(start: string, end: string, timeZone: string): string { + const startDate = new Date(start); + const endDate = new Date(end); + const dateFmt = new Intl.DateTimeFormat("en-US", { + timeZone, + weekday: "short", + month: "short", + day: "numeric", + }); + const timeFmt = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + minute: "2-digit", + hour12: true, + timeZoneName: "short", + }); + const startTimeFmt = new Intl.DateTimeFormat("en-US", { + timeZone, + hour: "numeric", + minute: "2-digit", + hour12: true, + }); + return `${dateFmt.format(startDate)} · ${startTimeFmt.format(startDate)}–${timeFmt.format(endDate)}`; +} + +function formatPeople(people: Array<{ name: string; email: string }>): string { + return people.map((p) => `${p.name} · ${p.email}`).join(", "); +} + +export function buildPushCard(payload: ChatPushPayload): ChatElement { + const badge = NOTIFICATION_BADGES[payload.notificationType]; + const when = formatPushTime(payload.start, payload.end, payload.timeZone); + + const meetingField = payload.meetingUrl + ? [Field({ label: "Meeting", value: `[Join](${payload.meetingUrl})` })] + : payload.location + ? [Field({ label: "Location", value: payload.location })] + : []; + + const reasonField = + payload.notificationType === "BOOKING_CANCELLED" && payload.cancellationReason + ? [Field({ label: "Reason", value: payload.cancellationReason })] + : []; + + return Card({ + title: badge, + subtitle: payload.title, + children: [ + Fields([ + Field({ label: "When", value: when }), + ...(payload.hosts.length > 0 + ? [Field({ label: "Hosts", value: formatPeople(payload.hosts) })] + : []), + ...(payload.attendees.length > 0 + ? [Field({ label: "Attendees", value: formatPeople(payload.attendees) })] + : []), + ...meetingField, + ...reasonField, + ]), + Divider(), + Actions([ + LinkButton({ + url: payload.data?.url ?? "https://app.cal.com/bookings", + label: "View Booking", + }), + ]), + ], + }); +} diff --git a/apps/chat/lib/push-notifications/service.ts b/apps/chat/lib/push-notifications/service.ts new file mode 100644 index 00000000..ab27440a --- /dev/null +++ b/apps/chat/lib/push-notifications/service.ts @@ -0,0 +1,39 @@ +import { type DeliverResult, deliverSlack } from "./deliver-slack"; +import { deliverTelegram } from "./deliver-telegram"; +import { buildPushCard, type ChatPushPayload } from "./formatter"; + +export type DeliverRequest = + | { + platform: "SLACK"; + subscriptions: Array<{ identifier: string; teamId: string }>; + payload: ChatPushPayload; + } + | { + platform: "TELEGRAM"; + subscriptions: Array<{ identifier: string }>; + payload: ChatPushPayload; + }; + +export type { DeliverResult }; + +export async function deliverNotifications(request: DeliverRequest): Promise { + const card = buildPushCard(request.payload); + + const settled = await Promise.allSettled( + request.subscriptions.map((sub) => { + if (request.platform === "SLACK") { + const slackSub = sub as { identifier: string; teamId: string }; + return deliverSlack(slackSub.identifier, slackSub.teamId, card); + } + return deliverTelegram(sub.identifier, card); + }) + ); + + return settled.map((result, i) => { + if (result.status === "fulfilled") return result.value; + return { + identifier: request.subscriptions[i].identifier, + success: false, + }; + }); +} From bd1a04e2c68eb849b689dd23be1fa2a58e7d3db7 Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Sat, 23 May 2026 11:31:27 +0530 Subject: [PATCH 2/9] Update apps/chat/app/api/notifications/deliver/route.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/chat/app/api/notifications/deliver/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts index 32e0d42e..7a12ed4c 100644 --- a/apps/chat/app/api/notifications/deliver/route.ts +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -4,8 +4,9 @@ import { type DeliverRequest, deliverNotifications } from "@/lib/push-notificati function verifyDeliverySecret(header: string | null): boolean { const secret = process.env.CALCOM_DELIVERY_SECRET; if (!secret || !header) return false; - try { - return crypto.timingSafeEqual(Buffer.from(header), Buffer.from(secret)); + const a = crypto.createHmac('sha256', 'delivery-verify').update(header).digest(); + const b = crypto.createHmac('sha256', 'delivery-verify').update(secret).digest(); + return crypto.timingSafeEqual(a, b); } catch { return false; } From 89701b4d1b759f5bf10054676d3b26da5239c46c Mon Sep 17 00:00:00 2001 From: Dhairyashil Date: Sat, 23 May 2026 06:02:11 +0000 Subject: [PATCH 3/9] fix(chat): restore try block around HMAC secret comparison --- apps/chat/app/api/notifications/deliver/route.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts index 7a12ed4c..d7580f6a 100644 --- a/apps/chat/app/api/notifications/deliver/route.ts +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -4,8 +4,9 @@ import { type DeliverRequest, deliverNotifications } from "@/lib/push-notificati function verifyDeliverySecret(header: string | null): boolean { const secret = process.env.CALCOM_DELIVERY_SECRET; if (!secret || !header) return false; - const a = crypto.createHmac('sha256', 'delivery-verify').update(header).digest(); - const b = crypto.createHmac('sha256', 'delivery-verify').update(secret).digest(); + try { + const a = crypto.createHmac("sha256", "delivery-verify").update(header).digest(); + const b = crypto.createHmac("sha256", "delivery-verify").update(secret).digest(); return crypto.timingSafeEqual(a, b); } catch { return false; From b51d1f2b17424d2c51e6923cbfb280ef4241f0af Mon Sep 17 00:00:00 2001 From: Dhairyashil Date: Sat, 23 May 2026 14:06:37 +0000 Subject: [PATCH 4/9] fix(chat): address PR review feedback - Validate Slack teamId at runtime in parseDeliverRequest - Use structured SlackAPIError detection instead of string coercion - Strip comma from weekday in formatPushTime - Show reason for BOOKING_REJECTED (not just CANCELLED) - Validate meetingUrl and data.url as https before rendering - Extract DeliverResult to types.ts (no sibling imports) - Split service.ts map by platform branch (remove type cast) - Catch 404 on notify off for not-subscribed users - Pre-compute secret HMAC at module load - Guard env warning with NODE_ENV === development --- .../app/api/notifications/deliver/route.ts | 27 +++++++++++++--- apps/chat/lib/env.ts | 2 +- apps/chat/lib/handlers/slack.ts | 24 ++++++++++---- apps/chat/lib/handlers/telegram.ts | 32 +++++++++++++------ .../lib/push-notifications/deliver-slack.ts | 21 ++++++------ .../push-notifications/deliver-telegram.ts | 2 +- apps/chat/lib/push-notifications/formatter.ts | 28 +++++++++++----- apps/chat/lib/push-notifications/service.ts | 20 ++++++------ apps/chat/lib/push-notifications/types.ts | 5 +++ 9 files changed, 111 insertions(+), 50 deletions(-) create mode 100644 apps/chat/lib/push-notifications/types.ts diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts index d7580f6a..69a79e8d 100644 --- a/apps/chat/app/api/notifications/deliver/route.ts +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -1,13 +1,18 @@ import crypto from "node:crypto"; import { type DeliverRequest, deliverNotifications } from "@/lib/push-notifications/service"; +const secretHmac = process.env.CALCOM_DELIVERY_SECRET + ? crypto + .createHmac("sha256", "delivery-verify") + .update(process.env.CALCOM_DELIVERY_SECRET) + .digest() + : null; + function verifyDeliverySecret(header: string | null): boolean { - const secret = process.env.CALCOM_DELIVERY_SECRET; - if (!secret || !header) return false; + if (!secretHmac || !header) return false; try { - const a = crypto.createHmac("sha256", "delivery-verify").update(header).digest(); - const b = crypto.createHmac("sha256", "delivery-verify").update(secret).digest(); - return crypto.timingSafeEqual(a, b); + const headerHmac = crypto.createHmac("sha256", "delivery-verify").update(header).digest(); + return crypto.timingSafeEqual(headerHmac, secretHmac); } catch { return false; } @@ -19,6 +24,18 @@ function parseDeliverRequest(body: unknown): DeliverRequest | null { if (b.platform !== "SLACK" && b.platform !== "TELEGRAM") return null; if (!Array.isArray(b.subscriptions) || b.subscriptions.length === 0) return null; if (typeof b.payload !== "object" || b.payload === null) return null; + + if (b.platform === "SLACK") { + const valid = b.subscriptions.every( + (s: unknown) => + typeof s === "object" && + s !== null && + typeof (s as Record).identifier === "string" && + typeof (s as Record).teamId === "string" + ); + if (!valid) return null; + } + return body as DeliverRequest; } diff --git a/apps/chat/lib/env.ts b/apps/chat/lib/env.ts index f22d4d8c..fc613556 100644 --- a/apps/chat/lib/env.ts +++ b/apps/chat/lib/env.ts @@ -26,7 +26,7 @@ export function validateRequiredEnv(): void { throw new Error( "CALCOM_DELIVERY_SECRET is required in production. Set it to the same value as CALCOM_CHAT_DELIVERY_SECRET on the /cal backend." ); - } else if (!process.env.CALCOM_DELIVERY_SECRET) { + } else if (process.env.NODE_ENV === "development" && !process.env.CALCOM_DELIVERY_SECRET) { console.warn( "CALCOM_DELIVERY_SECRET not set — POST /api/notifications/deliver will reject all requests." ); diff --git a/apps/chat/lib/handlers/slack.ts b/apps/chat/lib/handlers/slack.ts index 4bd84804..06522788 100644 --- a/apps/chat/lib/handlers/slack.ts +++ b/apps/chat/lib/handlers/slack.ts @@ -430,12 +430,24 @@ export function registerSlackHandlers( { fallbackToDM: true } ); } else { - await removeSlackSubscription(notifyToken, { identifier: userId }); - await event.channel.postEphemeral( - event.user, - "🔕 Booking push notifications turned off.", - { fallbackToDM: true } - ); + try { + await removeSlackSubscription(notifyToken, { identifier: userId }); + await event.channel.postEphemeral( + event.user, + "🔕 Booking push notifications turned off.", + { fallbackToDM: true } + ); + } catch (err) { + if (err instanceof CalcomApiError && err.statusCode === 404) { + await event.channel.postEphemeral( + event.user, + "You don't have push notifications enabled.", + { fallbackToDM: true } + ); + } else { + throw err; + } + } } break; } diff --git a/apps/chat/lib/handlers/telegram.ts b/apps/chat/lib/handlers/telegram.ts index c9efd8b4..7d918bf2 100644 --- a/apps/chat/lib/handlers/telegram.ts +++ b/apps/chat/lib/handlers/telegram.ts @@ -1,6 +1,7 @@ import type { Chat, ChatElement, Message, Thread } from "chat"; import { Actions, Button, Card, CardText, LinkButton } from "chat"; import { + CalcomApiError, cancelBooking, createBookingPublic, getAvailableSlotsPublic, @@ -219,15 +220,28 @@ export async function handleTelegramCommand( isGroup ); } else { - await removeTelegramSubscription(auth.accessToken, { - identifier: ctx.userId, - }); - await postPrivately( - thread, - message, - "🔕 Booking push notifications turned off.", - isGroup - ); + try { + await removeTelegramSubscription(auth.accessToken, { + identifier: ctx.userId, + }); + await postPrivately( + thread, + message, + "🔕 Booking push notifications turned off.", + isGroup + ); + } catch (err) { + if (err instanceof CalcomApiError && err.statusCode === 404) { + await postPrivately( + thread, + message, + "You don't have push notifications enabled.", + isGroup + ); + } else { + throw err; + } + } } return; } diff --git a/apps/chat/lib/push-notifications/deliver-slack.ts b/apps/chat/lib/push-notifications/deliver-slack.ts index 7e36f3bc..0c39e215 100644 --- a/apps/chat/lib/push-notifications/deliver-slack.ts +++ b/apps/chat/lib/push-notifications/deliver-slack.ts @@ -1,11 +1,6 @@ import type { ChatElement } from "chat"; import { bot, slackAdapter } from "@/lib/bot"; - -export type DeliverResult = { - identifier: string; - success: boolean; - invalidIdentifier?: boolean; -}; +import type { DeliverResult } from "./types"; const INVALID_SLACK_ERROR_CODES = new Set([ "channel_not_found", @@ -29,10 +24,16 @@ export async function deliverSlack( }); return { identifier, success: true }; } catch (err) { - const msg = String(err).toLowerCase(); - const isInvalid = [...INVALID_SLACK_ERROR_CODES].some((code) => msg.includes(code)); - if (isInvalid) { - return { identifier, success: false, invalidIdentifier: true }; + if ( + err instanceof Error && + "code" in err && + (err as Record).code === "slack_webapi_platform_error" + ) { + const data = (err as Record).data as Record | undefined; + const slackError = typeof data?.error === "string" ? data.error : ""; + if (INVALID_SLACK_ERROR_CODES.has(slackError)) { + return { identifier, success: false, invalidIdentifier: true }; + } } return { identifier, success: false }; } diff --git a/apps/chat/lib/push-notifications/deliver-telegram.ts b/apps/chat/lib/push-notifications/deliver-telegram.ts index d81de353..e85ca16d 100644 --- a/apps/chat/lib/push-notifications/deliver-telegram.ts +++ b/apps/chat/lib/push-notifications/deliver-telegram.ts @@ -1,6 +1,6 @@ import type { ChatElement } from "chat"; import { bot } from "@/lib/bot"; -import type { DeliverResult } from "./deliver-slack"; +import type { DeliverResult } from "./types"; const TELEGRAM_NOT_FOUND_PHRASES = [ "chat not found", diff --git a/apps/chat/lib/push-notifications/formatter.ts b/apps/chat/lib/push-notifications/formatter.ts index 7e62d929..7b85d129 100644 --- a/apps/chat/lib/push-notifications/formatter.ts +++ b/apps/chat/lib/push-notifications/formatter.ts @@ -32,6 +32,10 @@ const NOTIFICATION_BADGES: Record = BOOKING_REJECTED: "🚫 Booking Rejected", }; +function isHttpUrl(url: string): boolean { + return /^https?:\/\//i.test(url); +} + function formatPushTime(start: string, end: string, timeZone: string): string { const startDate = new Date(start); const endDate = new Date(end); @@ -54,7 +58,8 @@ function formatPushTime(start: string, end: string, timeZone: string): string { minute: "2-digit", hour12: true, }); - return `${dateFmt.format(startDate)} · ${startTimeFmt.format(startDate)}–${timeFmt.format(endDate)}`; + const datePart = dateFmt.format(startDate).replace(/,/g, ""); + return `${datePart} · ${startTimeFmt.format(startDate)}–${timeFmt.format(endDate)}`; } function formatPeople(people: Array<{ name: string; email: string }>): string { @@ -65,17 +70,24 @@ export function buildPushCard(payload: ChatPushPayload): ChatElement { const badge = NOTIFICATION_BADGES[payload.notificationType]; const when = formatPushTime(payload.start, payload.end, payload.timeZone); - const meetingField = payload.meetingUrl - ? [Field({ label: "Meeting", value: `[Join](${payload.meetingUrl})` })] - : payload.location - ? [Field({ label: "Location", value: payload.location })] - : []; + const meetingField = + payload.meetingUrl && isHttpUrl(payload.meetingUrl) + ? [Field({ label: "Meeting", value: `[Join](${payload.meetingUrl})` })] + : payload.location + ? [Field({ label: "Location", value: payload.location })] + : []; const reasonField = - payload.notificationType === "BOOKING_CANCELLED" && payload.cancellationReason + (payload.notificationType === "BOOKING_CANCELLED" || + payload.notificationType === "BOOKING_REJECTED") && + payload.cancellationReason ? [Field({ label: "Reason", value: payload.cancellationReason })] : []; + const bookingUrl = payload.data?.url; + const viewBookingUrl = + bookingUrl && isHttpUrl(bookingUrl) ? bookingUrl : "https://app.cal.com/bookings"; + return Card({ title: badge, subtitle: payload.title, @@ -94,7 +106,7 @@ export function buildPushCard(payload: ChatPushPayload): ChatElement { Divider(), Actions([ LinkButton({ - url: payload.data?.url ?? "https://app.cal.com/bookings", + url: viewBookingUrl, label: "View Booking", }), ]), diff --git a/apps/chat/lib/push-notifications/service.ts b/apps/chat/lib/push-notifications/service.ts index ab27440a..1a31e483 100644 --- a/apps/chat/lib/push-notifications/service.ts +++ b/apps/chat/lib/push-notifications/service.ts @@ -1,6 +1,7 @@ -import { type DeliverResult, deliverSlack } from "./deliver-slack"; +import { deliverSlack } from "./deliver-slack"; import { deliverTelegram } from "./deliver-telegram"; import { buildPushCard, type ChatPushPayload } from "./formatter"; +import type { DeliverResult } from "./types"; export type DeliverRequest = | { @@ -19,15 +20,14 @@ export type { DeliverResult }; export async function deliverNotifications(request: DeliverRequest): Promise { const card = buildPushCard(request.payload); - const settled = await Promise.allSettled( - request.subscriptions.map((sub) => { - if (request.platform === "SLACK") { - const slackSub = sub as { identifier: string; teamId: string }; - return deliverSlack(slackSub.identifier, slackSub.teamId, card); - } - return deliverTelegram(sub.identifier, card); - }) - ); + const settled = + request.platform === "SLACK" + ? await Promise.allSettled( + request.subscriptions.map((sub) => deliverSlack(sub.identifier, sub.teamId, card)) + ) + : await Promise.allSettled( + request.subscriptions.map((sub) => deliverTelegram(sub.identifier, card)) + ); return settled.map((result, i) => { if (result.status === "fulfilled") return result.value; diff --git a/apps/chat/lib/push-notifications/types.ts b/apps/chat/lib/push-notifications/types.ts new file mode 100644 index 00000000..482180a5 --- /dev/null +++ b/apps/chat/lib/push-notifications/types.ts @@ -0,0 +1,5 @@ +export type DeliverResult = { + identifier: string; + success: boolean; + invalidIdentifier?: boolean; +}; From 48d629635cae2701859560334dc823658e43ba72 Mon Sep 17 00:00:00 2001 From: Dhairyashil Date: Sat, 23 May 2026 14:12:46 +0000 Subject: [PATCH 5/9] fix(chat): address round 2 PR review feedback - Reject /notify in Telegram groups with DM hint (#3) - Validate payload fields (title, timeZone, start, end, hosts, attendees) in parseDeliverRequest (#4) - Wrap deliverNotifications in try/catch, return 422 on formatter crash (#4) - Add logging across route, service, and delivery modules (#5) - Add /notify to Slack and Telegram help cards (#7) - Add error field to DeliverResult for backend retry/unsubscribe decisions (#9) - Change confirmation message from 'here' to 'via DM' (#10) - Validate timeZone with Intl.DateTimeFormat before processing (#11) - Add fallback badge for unknown notificationType (#12) - Reject /notify in groups with 'check DMs' message (#13) --- .../app/api/notifications/deliver/route.ts | 40 ++++++++++++++++++- apps/chat/lib/handlers/slack.ts | 2 +- apps/chat/lib/handlers/telegram.ts | 32 +++++---------- apps/chat/lib/notifications.ts | 2 + .../lib/push-notifications/deliver-slack.ts | 14 +++++-- .../push-notifications/deliver-telegram.ts | 9 ++++- apps/chat/lib/push-notifications/formatter.ts | 13 +++--- apps/chat/lib/push-notifications/service.ts | 9 +++++ apps/chat/lib/push-notifications/types.ts | 1 + 9 files changed, 86 insertions(+), 36 deletions(-) diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts index 69a79e8d..2113d459 100644 --- a/apps/chat/app/api/notifications/deliver/route.ts +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -1,6 +1,9 @@ import crypto from "node:crypto"; +import { getLogger } from "@/lib/logger"; import { type DeliverRequest, deliverNotifications } from "@/lib/push-notifications/service"; +const logger = getLogger("deliver-route"); + const secretHmac = process.env.CALCOM_DELIVERY_SECRET ? crypto .createHmac("sha256", "delivery-verify") @@ -18,6 +21,15 @@ function verifyDeliverySecret(header: string | null): boolean { } } +function isValidTimeZone(tz: string): boolean { + try { + Intl.DateTimeFormat("en-US", { timeZone: tz }); + return true; + } catch { + return false; + } +} + function parseDeliverRequest(body: unknown): DeliverRequest | null { if (typeof body !== "object" || body === null) return null; const b = body as Record; @@ -25,6 +37,12 @@ function parseDeliverRequest(body: unknown): DeliverRequest | null { if (!Array.isArray(b.subscriptions) || b.subscriptions.length === 0) return null; if (typeof b.payload !== "object" || b.payload === null) return null; + const p = b.payload as Record; + if (typeof p.title !== "string" || typeof p.timeZone !== "string") return null; + if (typeof p.start !== "string" || typeof p.end !== "string") return null; + if (!Array.isArray(p.hosts) || !Array.isArray(p.attendees)) return null; + if (!isValidTimeZone(p.timeZone)) return null; + if (b.platform === "SLACK") { const valid = b.subscriptions.every( (s: unknown) => @@ -42,6 +60,7 @@ function parseDeliverRequest(body: unknown): DeliverRequest | null { export async function POST(request: Request) { const secret = request.headers.get("x-cal-delivery-secret"); if (!verifyDeliverySecret(secret)) { + logger.warn("Delivery auth rejected"); return new Response(null, { status: 401 }); } @@ -54,9 +73,28 @@ export async function POST(request: Request) { const parsed = parseDeliverRequest(body); if (!parsed) { + logger.warn("Invalid delivery request body", { + platform: (body as Record)?.platform, + }); return new Response(null, { status: 400 }); } - const results = await deliverNotifications(parsed); + let results; + try { + results = await deliverNotifications(parsed); + } catch (err) { + logger.error("deliverNotifications threw", { error: String(err) }); + return new Response(null, { status: 422 }); + } + + const failed = results.filter((r) => !r.success).length; + const invalid = results.filter((r) => r.invalidIdentifier).length; + logger.info("Delivery complete", { + platform: parsed.platform, + total: results.length, + failed, + invalidIdentifiers: invalid, + }); + return Response.json({ results }); } diff --git a/apps/chat/lib/handlers/slack.ts b/apps/chat/lib/handlers/slack.ts index 06522788..977bc6f5 100644 --- a/apps/chat/lib/handlers/slack.ts +++ b/apps/chat/lib/handlers/slack.ts @@ -426,7 +426,7 @@ export function registerSlackHandlers( }); await event.channel.postEphemeral( event.user, - "✅ You'll now receive booking notifications here.", + "✅ You'll now receive booking notifications via DM.", { fallbackToDM: true } ); } else { diff --git a/apps/chat/lib/handlers/telegram.ts b/apps/chat/lib/handlers/telegram.ts index 7d918bf2..e8723d9b 100644 --- a/apps/chat/lib/handlers/telegram.ts +++ b/apps/chat/lib/handlers/telegram.ts @@ -197,14 +197,13 @@ export async function handleTelegramCommand( } if (cmd === "notify") { + if (isGroup) { + await thread.post("Please check your DMs — `/notify` only works in a private chat with the bot."); + return; + } const notifyArg = rest.split(/\s+/)[0]?.toLowerCase(); if (notifyArg !== "on" && notifyArg !== "off") { - await postPrivately( - thread, - message, - "Usage: `/notify on` or `/notify off`", - isGroup - ); + await thread.post("Usage: `/notify on` or `/notify off`"); return; } const auth = await requireAuth(); @@ -213,30 +212,21 @@ export async function handleTelegramCommand( await registerTelegramSubscription(auth.accessToken, { identifier: ctx.userId, }); - await postPrivately( - thread, - message, - "✅ You'll now receive booking notifications here.", - isGroup + await thread.post( + "✅ You'll now receive booking notifications via DM." ); } else { try { await removeTelegramSubscription(auth.accessToken, { identifier: ctx.userId, }); - await postPrivately( - thread, - message, - "🔕 Booking push notifications turned off.", - isGroup + await thread.post( + "🔕 Booking push notifications turned off." ); } catch (err) { if (err instanceof CalcomApiError && err.statusCode === 404) { - await postPrivately( - thread, - message, - "You don't have push notifications enabled.", - isGroup + await thread.post( + "You don't have push notifications enabled." ); } else { throw err; diff --git a/apps/chat/lib/notifications.ts b/apps/chat/lib/notifications.ts index dd297785..c561af75 100644 --- a/apps/chat/lib/notifications.ts +++ b/apps/chat/lib/notifications.ts @@ -292,6 +292,7 @@ export function helpCard() { Field({ label: "/cal event-types", value: "List your event types" }), Field({ label: "/cal schedules", value: "Show your working hours" }), Field({ label: "/cal profile", value: "Show your profile" }), + Field({ label: "/cal notify on|off", value: "Enable or disable booking notifications" }), Field({ label: "/cal link, /cal unlink", value: "Connect or disconnect Cal.com" }), Field({ label: "/cal help", value: "Show this help message" }), ]), @@ -317,6 +318,7 @@ export function telegramHelpCard() { Field({ label: "/eventtypes", value: "List your event types" }), Field({ label: "/schedules", value: "Show your working hours" }), Field({ label: "/profile", value: "Show your profile" }), + Field({ label: "/notify on · /notify off", value: "Enable or disable booking notifications" }), Field({ label: "/link / /unlink", value: "Connect or disconnect Cal.com" }), Field({ label: "/help · @mention", value: "Help or ask in natural language" }), ]), diff --git a/apps/chat/lib/push-notifications/deliver-slack.ts b/apps/chat/lib/push-notifications/deliver-slack.ts index 0c39e215..06e1d29f 100644 --- a/apps/chat/lib/push-notifications/deliver-slack.ts +++ b/apps/chat/lib/push-notifications/deliver-slack.ts @@ -1,7 +1,10 @@ import type { ChatElement } from "chat"; import { bot, slackAdapter } from "@/lib/bot"; +import { getLogger } from "@/lib/logger"; import type { DeliverResult } from "./types"; +const logger = getLogger("deliver-slack"); + const INVALID_SLACK_ERROR_CODES = new Set([ "channel_not_found", "not_in_channel", @@ -15,7 +18,8 @@ export async function deliverSlack( ): Promise { const installation = await slackAdapter.getInstallation(teamId); if (!installation) { - return { identifier, success: false, invalidIdentifier: true }; + logger.warn("No installation found", { identifier, teamId }); + return { identifier, success: false, invalidIdentifier: true, error: "no_installation" }; } try { @@ -32,9 +36,13 @@ export async function deliverSlack( const data = (err as Record).data as Record | undefined; const slackError = typeof data?.error === "string" ? data.error : ""; if (INVALID_SLACK_ERROR_CODES.has(slackError)) { - return { identifier, success: false, invalidIdentifier: true }; + logger.warn("Invalid Slack identifier", { identifier, teamId, slackError }); + return { identifier, success: false, invalidIdentifier: true, error: slackError }; } + logger.error("Slack API error", { identifier, teamId, slackError }); + return { identifier, success: false, error: slackError || "slack_api_error" }; } - return { identifier, success: false }; + logger.error("Unexpected Slack delivery error", { identifier, teamId, error: String(err) }); + return { identifier, success: false, error: String(err) }; } } diff --git a/apps/chat/lib/push-notifications/deliver-telegram.ts b/apps/chat/lib/push-notifications/deliver-telegram.ts index e85ca16d..d6966ad7 100644 --- a/apps/chat/lib/push-notifications/deliver-telegram.ts +++ b/apps/chat/lib/push-notifications/deliver-telegram.ts @@ -1,7 +1,10 @@ import type { ChatElement } from "chat"; import { bot } from "@/lib/bot"; +import { getLogger } from "@/lib/logger"; import type { DeliverResult } from "./types"; +const logger = getLogger("deliver-telegram"); + const TELEGRAM_NOT_FOUND_PHRASES = [ "chat not found", "user not found", @@ -19,8 +22,10 @@ export async function deliverTelegram( const msg = String(err).toLowerCase(); const isInvalid = TELEGRAM_NOT_FOUND_PHRASES.some((phrase) => msg.includes(phrase)); if (isInvalid) { - return { identifier, success: false, invalidIdentifier: true }; + logger.warn("Invalid Telegram identifier", { identifier, error: msg }); + return { identifier, success: false, invalidIdentifier: true, error: msg }; } - return { identifier, success: false }; + logger.error("Telegram delivery error", { identifier, error: msg }); + return { identifier, success: false, error: msg }; } } diff --git a/apps/chat/lib/push-notifications/formatter.ts b/apps/chat/lib/push-notifications/formatter.ts index 7b85d129..765b2824 100644 --- a/apps/chat/lib/push-notifications/formatter.ts +++ b/apps/chat/lib/push-notifications/formatter.ts @@ -8,12 +8,7 @@ export type ChatPushPayload = { title: string; body: string; data?: Record; - notificationType: - | "BOOKING_CONFIRMED" - | "BOOKING_CANCELLED" - | "BOOKING_RESCHEDULED" - | "BOOKING_REQUESTED" - | "BOOKING_REJECTED"; + notificationType: string; hosts: Array<{ name: string; email: string }>; attendees: Array<{ name: string; email: string }>; start: string; @@ -24,7 +19,7 @@ export type ChatPushPayload = { cancellationReason?: string; }; -const NOTIFICATION_BADGES: Record = { +const NOTIFICATION_BADGES: Record = { BOOKING_CONFIRMED: "✅ Booking Confirmed", BOOKING_CANCELLED: "❌ Booking Cancelled", BOOKING_RESCHEDULED: "🔄 Booking Rescheduled", @@ -67,7 +62,9 @@ function formatPeople(people: Array<{ name: string; email: string }>): string { } export function buildPushCard(payload: ChatPushPayload): ChatElement { - const badge = NOTIFICATION_BADGES[payload.notificationType]; + const badge = + NOTIFICATION_BADGES[payload.notificationType] ?? + `📅 ${payload.notificationType.replace(/_/g, " ").toLowerCase()}`; const when = formatPushTime(payload.start, payload.end, payload.timeZone); const meetingField = diff --git a/apps/chat/lib/push-notifications/service.ts b/apps/chat/lib/push-notifications/service.ts index 1a31e483..f047c52b 100644 --- a/apps/chat/lib/push-notifications/service.ts +++ b/apps/chat/lib/push-notifications/service.ts @@ -1,8 +1,11 @@ +import { getLogger } from "@/lib/logger"; import { deliverSlack } from "./deliver-slack"; import { deliverTelegram } from "./deliver-telegram"; import { buildPushCard, type ChatPushPayload } from "./formatter"; import type { DeliverResult } from "./types"; +const logger = getLogger("push-service"); + export type DeliverRequest = | { platform: "SLACK"; @@ -31,9 +34,15 @@ export async function deliverNotifications(request: DeliverRequest): Promise { if (result.status === "fulfilled") return result.value; + logger.error("Delivery promise rejected", { + identifier: request.subscriptions[i].identifier, + platform: request.platform, + reason: String(result.reason), + }); return { identifier: request.subscriptions[i].identifier, success: false, + error: String(result.reason), }; }); } diff --git a/apps/chat/lib/push-notifications/types.ts b/apps/chat/lib/push-notifications/types.ts index 482180a5..5dc559ad 100644 --- a/apps/chat/lib/push-notifications/types.ts +++ b/apps/chat/lib/push-notifications/types.ts @@ -2,4 +2,5 @@ export type DeliverResult = { identifier: string; success: boolean; invalidIdentifier?: boolean; + error?: string; }; From f692cb3e25427dea8f379f4cc2a9567f5b58bf5a Mon Sep 17 00:00:00 2001 From: Dhairyashil Date: Sat, 23 May 2026 14:20:55 +0000 Subject: [PATCH 6/9] fix(chat): fix regressions and address remaining gaps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show non-HTTP meetingUrl as plain text instead of falling through to location - Revert HMAC pre-computation — read secret per-request for rotation support - Use missing[] array for CALCOM_DELIVERY_SECRET in production (consistent with other vars) - Cap subscriptions array at 500 in parseDeliverRequest - Add 409 handling on notify on for already-subscribed (Slack + Telegram) - Add getLinkedUser check in Slack notify handler (consistent with other subcommands) --- .../app/api/notifications/deliver/route.ts | 22 ++++++------- apps/chat/lib/env.ts | 16 ++++----- apps/chat/lib/handlers/slack.ts | 33 +++++++++++++++---- apps/chat/lib/handlers/telegram.ts | 22 +++++++++---- apps/chat/lib/push-notifications/formatter.ts | 11 ++++--- 5 files changed, 68 insertions(+), 36 deletions(-) diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts index 2113d459..5fe1a57e 100644 --- a/apps/chat/app/api/notifications/deliver/route.ts +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -4,18 +4,13 @@ import { type DeliverRequest, deliverNotifications } from "@/lib/push-notificati const logger = getLogger("deliver-route"); -const secretHmac = process.env.CALCOM_DELIVERY_SECRET - ? crypto - .createHmac("sha256", "delivery-verify") - .update(process.env.CALCOM_DELIVERY_SECRET) - .digest() - : null; - function verifyDeliverySecret(header: string | null): boolean { - if (!secretHmac || !header) return false; + const secret = process.env.CALCOM_DELIVERY_SECRET; + if (!secret || !header) return false; try { - const headerHmac = crypto.createHmac("sha256", "delivery-verify").update(header).digest(); - return crypto.timingSafeEqual(headerHmac, secretHmac); + const a = crypto.createHmac("sha256", "delivery-verify").update(header).digest(); + const b = crypto.createHmac("sha256", "delivery-verify").update(secret).digest(); + return crypto.timingSafeEqual(a, b); } catch { return false; } @@ -34,7 +29,12 @@ function parseDeliverRequest(body: unknown): DeliverRequest | null { if (typeof body !== "object" || body === null) return null; const b = body as Record; if (b.platform !== "SLACK" && b.platform !== "TELEGRAM") return null; - if (!Array.isArray(b.subscriptions) || b.subscriptions.length === 0) return null; + if ( + !Array.isArray(b.subscriptions) || + b.subscriptions.length === 0 || + b.subscriptions.length > 500 + ) + return null; if (typeof b.payload !== "object" || b.payload === null) return null; const p = b.payload as Record; diff --git a/apps/chat/lib/env.ts b/apps/chat/lib/env.ts index fc613556..754a65cd 100644 --- a/apps/chat/lib/env.ts +++ b/apps/chat/lib/env.ts @@ -22,14 +22,14 @@ export function validateRequiredEnv(): void { ); } - if (process.env.NODE_ENV === "production" && !process.env.CALCOM_DELIVERY_SECRET) { - throw new Error( - "CALCOM_DELIVERY_SECRET is required in production. Set it to the same value as CALCOM_CHAT_DELIVERY_SECRET on the /cal backend." - ); - } else if (process.env.NODE_ENV === "development" && !process.env.CALCOM_DELIVERY_SECRET) { - console.warn( - "CALCOM_DELIVERY_SECRET not set — POST /api/notifications/deliver will reject all requests." - ); + if (!process.env.CALCOM_DELIVERY_SECRET) { + if (process.env.NODE_ENV === "production") { + missing.push("CALCOM_DELIVERY_SECRET"); + } else if (process.env.NODE_ENV === "development") { + console.warn( + "CALCOM_DELIVERY_SECRET not set — POST /api/notifications/deliver will reject all requests." + ); + } } if (missing.length > 0) { diff --git a/apps/chat/lib/handlers/slack.ts b/apps/chat/lib/handlers/slack.ts index 977bc6f5..4d1989a9 100644 --- a/apps/chat/lib/handlers/slack.ts +++ b/apps/chat/lib/handlers/slack.ts @@ -419,16 +419,37 @@ export function registerSlackHandlers( ); break; } - if (notifyArg === "on") { - await registerSlackSubscription(notifyToken, { - identifier: userId, - deviceId: teamId, - }); + const notifyLinked = await getLinkedUser(teamId, userId); + if (!notifyLinked) { await event.channel.postEphemeral( event.user, - "✅ You'll now receive booking notifications via DM.", + oauthLinkMessage("slack", teamId, userId), { fallbackToDM: true } ); + break; + } + if (notifyArg === "on") { + try { + await registerSlackSubscription(notifyToken, { + identifier: userId, + deviceId: teamId, + }); + await event.channel.postEphemeral( + event.user, + "✅ You'll now receive booking notifications via DM.", + { fallbackToDM: true } + ); + } catch (err) { + if (err instanceof CalcomApiError && err.statusCode === 409) { + await event.channel.postEphemeral( + event.user, + "You're already subscribed to booking notifications.", + { fallbackToDM: true } + ); + } else { + throw err; + } + } } else { try { await removeSlackSubscription(notifyToken, { identifier: userId }); diff --git a/apps/chat/lib/handlers/telegram.ts b/apps/chat/lib/handlers/telegram.ts index e8723d9b..fd0514dd 100644 --- a/apps/chat/lib/handlers/telegram.ts +++ b/apps/chat/lib/handlers/telegram.ts @@ -209,12 +209,22 @@ export async function handleTelegramCommand( const auth = await requireAuth(); if (!auth) return; if (notifyArg === "on") { - await registerTelegramSubscription(auth.accessToken, { - identifier: ctx.userId, - }); - await thread.post( - "✅ You'll now receive booking notifications via DM." - ); + try { + await registerTelegramSubscription(auth.accessToken, { + identifier: ctx.userId, + }); + await thread.post( + "✅ You'll now receive booking notifications via DM." + ); + } catch (err) { + if (err instanceof CalcomApiError && err.statusCode === 409) { + await thread.post( + "You're already subscribed to booking notifications." + ); + } else { + throw err; + } + } } else { try { await removeTelegramSubscription(auth.accessToken, { diff --git a/apps/chat/lib/push-notifications/formatter.ts b/apps/chat/lib/push-notifications/formatter.ts index 765b2824..47fc36d0 100644 --- a/apps/chat/lib/push-notifications/formatter.ts +++ b/apps/chat/lib/push-notifications/formatter.ts @@ -67,12 +67,13 @@ export function buildPushCard(payload: ChatPushPayload): ChatElement { `📅 ${payload.notificationType.replace(/_/g, " ").toLowerCase()}`; const when = formatPushTime(payload.start, payload.end, payload.timeZone); - const meetingField = - payload.meetingUrl && isHttpUrl(payload.meetingUrl) + const meetingField = payload.meetingUrl + ? isHttpUrl(payload.meetingUrl) ? [Field({ label: "Meeting", value: `[Join](${payload.meetingUrl})` })] - : payload.location - ? [Field({ label: "Location", value: payload.location })] - : []; + : [Field({ label: "Meeting", value: payload.meetingUrl })] + : payload.location + ? [Field({ label: "Location", value: payload.location })] + : []; const reasonField = (payload.notificationType === "BOOKING_CANCELLED" || From 3ed63808ac09da2f915893ab23488a03088f0bab Mon Sep 17 00:00:00 2001 From: Dhairyashil Date: Sat, 23 May 2026 20:02:02 +0530 Subject: [PATCH 7/9] fix(chat): critical teamId fix + HMAC naming + validation hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - fix(client): rename registerSlackSubscription input field deviceId → teamId to match the DeliverRequest type and parseDeliverRequest validator; the mismatch would have caused every Slack delivery request to be rejected with 400 - fix(slack): update call site to pass { identifier, teamId } (shorthand) instead of { identifier, deviceId: teamId } - fix(route): use descriptive secretHmac/headerHmac variable names in verifyDeliverySecret instead of opaque a/b - fix(route): add notificationType non-empty string validation and Date.parse validation for start/end in parseDeliverRequest; invalid dates would otherwise silently produce "Invalid Date" in the push card - fix(route): log warn on JSON parse failure path --- .../app/api/notifications/deliver/route.ts | 9 +- apps/chat/lib/calcom/client.ts | 268 ++++++++++++------ apps/chat/lib/handlers/slack.ts | 74 +++-- 3 files changed, 227 insertions(+), 124 deletions(-) diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts index 5fe1a57e..1455465a 100644 --- a/apps/chat/app/api/notifications/deliver/route.ts +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -8,9 +8,9 @@ function verifyDeliverySecret(header: string | null): boolean { const secret = process.env.CALCOM_DELIVERY_SECRET; if (!secret || !header) return false; try { - const a = crypto.createHmac("sha256", "delivery-verify").update(header).digest(); - const b = crypto.createHmac("sha256", "delivery-verify").update(secret).digest(); - return crypto.timingSafeEqual(a, b); + const secretHmac = crypto.createHmac("sha256", "delivery-verify").update(secret).digest(); + const headerHmac = crypto.createHmac("sha256", "delivery-verify").update(header).digest(); + return crypto.timingSafeEqual(headerHmac, secretHmac); } catch { return false; } @@ -40,8 +40,10 @@ function parseDeliverRequest(body: unknown): DeliverRequest | null { const p = b.payload as Record; if (typeof p.title !== "string" || typeof p.timeZone !== "string") return null; if (typeof p.start !== "string" || typeof p.end !== "string") return null; + if (typeof p.notificationType !== "string" || p.notificationType === "") return null; if (!Array.isArray(p.hosts) || !Array.isArray(p.attendees)) return null; if (!isValidTimeZone(p.timeZone)) return null; + if (isNaN(Date.parse(p.start)) || isNaN(Date.parse(p.end))) return null; if (b.platform === "SLACK") { const valid = b.subscriptions.every( @@ -68,6 +70,7 @@ export async function POST(request: Request) { try { body = await request.json(); } catch { + logger.warn("Failed to parse delivery request body"); return new Response(null, { status: 400 }); } diff --git a/apps/chat/lib/calcom/client.ts b/apps/chat/lib/calcom/client.ts index f2a408fd..20d646c9 100644 --- a/apps/chat/lib/calcom/client.ts +++ b/apps/chat/lib/calcom/client.ts @@ -83,15 +83,19 @@ async function calcomFetch( retries: number = MAX_RETRIES ): Promise { const url = `${CALCOM_API_URL}${path}`; - const res = await fetchWithRetry(url, { - ...options, - headers: { - "cal-api-version": apiVersion, - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - ...options.headers, + const res = await fetchWithRetry( + url, + { + ...options, + headers: { + "cal-api-version": apiVersion, + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + ...options.headers, + }, }, - }, retries); + retries + ); if (!res.ok) { let errorMessage = `Cal.com API error: ${res.status} ${res.statusText}`; @@ -160,7 +164,9 @@ export async function getAvailableSlots( end: params.end, ...(params.timeZone ? { timeZone: params.timeZone } : {}), ...(params.duration ? { duration: String(params.duration) } : {}), - ...(params.bookingUidToReschedule ? { bookingUidToReschedule: params.bookingUidToReschedule } : {}), + ...(params.bookingUidToReschedule + ? { bookingUidToReschedule: params.bookingUidToReschedule } + : {}), }); // The v2/slots API (2024-09-04) returns `data` as a date-keyed map of // `{ start }` objects — there is no wrapper `slots` property. Normalize to @@ -203,7 +209,9 @@ export async function getAvailableSlotsPublic( end: params.end, ...(params.timeZone ? { timeZone: params.timeZone } : {}), ...(params.duration ? { duration: String(params.duration) } : {}), - ...(params.bookingUidToReschedule ? { bookingUidToReschedule: params.bookingUidToReschedule } : {}), + ...(params.bookingUidToReschedule + ? { bookingUidToReschedule: params.bookingUidToReschedule } + : {}), }); const url = `${CALCOM_API_URL}/v2/slots?${query}`; const res = await fetchWithRetry(url, { @@ -284,9 +292,7 @@ export async function getBookings( const isHost = booking.hosts?.some( (h) => idEq(h.id, currentUser.id) || emailEq(h.email, emailLower) ); - const isAttendee = booking.attendees?.some( - (a) => emailEq(a.email, emailLower) - ); + const isAttendee = booking.attendees?.some((a) => emailEq(a.email, emailLower)); return isHost || isAttendee; }); } @@ -299,31 +305,41 @@ export async function createBooking( accessToken: string, input: CreateBookingInput ): Promise { - return calcomFetch("/v2/bookings", accessToken, { - method: "POST", - body: JSON.stringify(input), - }, API_VERSION, 0); + return calcomFetch( + "/v2/bookings", + accessToken, + { + method: "POST", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } -export async function createBookingPublic( - input: CreatePublicBookingInput -): Promise { +export async function createBookingPublic(input: CreatePublicBookingInput): Promise { const url = `${CALCOM_API_URL}/v2/bookings`; - const res = await fetchWithRetry(url, { - method: "POST", - headers: { - "cal-api-version": "2024-08-13", - "Content-Type": "application/json", + const res = await fetchWithRetry( + url, + { + method: "POST", + headers: { + "cal-api-version": "2024-08-13", + "Content-Type": "application/json", + }, + body: JSON.stringify(input), }, - body: JSON.stringify(input), - }, 0); + 0 + ); if (!res.ok) { const body = await res.text(); let message = `Booking failed (${res.status})`; try { const parsed = JSON.parse(body); message = parsed.error?.message ?? parsed.message ?? message; - } catch { /* use default */ } + } catch { + /* use default */ + } throw new CalcomApiError(message, res.status); } const json = (await res.json()) as CalcomApiResponse; @@ -339,13 +355,19 @@ export async function cancelBooking( reason?: string, cancelSubsequentBookings?: boolean ): Promise { - await calcomFetch(`/v2/bookings/${bookingUid}/cancel`, accessToken, { - method: "POST", - body: JSON.stringify({ - cancellationReason: reason, - ...(cancelSubsequentBookings ? { cancelSubsequentBookings: true } : {}), - }), - }, API_VERSION, 0); + await calcomFetch( + `/v2/bookings/${bookingUid}/cancel`, + accessToken, + { + method: "POST", + body: JSON.stringify({ + cancellationReason: reason, + ...(cancelSubsequentBookings ? { cancelSubsequentBookings: true } : {}), + }), + }, + API_VERSION, + 0 + ); } export async function rescheduleBooking( @@ -355,14 +377,20 @@ export async function rescheduleBooking( reason?: string, rescheduledBy?: string ): Promise { - return calcomFetch(`/v2/bookings/${bookingUid}/reschedule`, accessToken, { - method: "POST", - body: JSON.stringify({ - start: newStart, - reschedulingReason: reason, - ...(rescheduledBy ? { rescheduledBy } : {}), - }), - }, API_VERSION, 0); + return calcomFetch( + `/v2/bookings/${bookingUid}/reschedule`, + accessToken, + { + method: "POST", + body: JSON.stringify({ + start: newStart, + reschedulingReason: reason, + ...(rescheduledBy ? { rescheduledBy } : {}), + }), + }, + API_VERSION, + 0 + ); } export interface CalcomMe { @@ -380,10 +408,16 @@ export async function getMe(accessToken: string): Promise { } export async function updateMe(accessToken: string, input: UpdateMeInput): Promise { - return calcomFetch("/v2/me", accessToken, { - method: "PATCH", - body: JSON.stringify(input), - }, API_VERSION, 0); + return calcomFetch( + "/v2/me", + accessToken, + { + method: "PATCH", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } // ─── Schedules ─────────────────────────────────────────────────────────────── @@ -461,9 +495,15 @@ export async function confirmBooking( accessToken: string, bookingUid: string ): Promise { - return calcomFetch(`/v2/bookings/${bookingUid}/confirm`, accessToken, { - method: "POST", - }, API_VERSION, 0); + return calcomFetch( + `/v2/bookings/${bookingUid}/confirm`, + accessToken, + { + method: "POST", + }, + API_VERSION, + 0 + ); } export async function declineBooking( @@ -471,10 +511,16 @@ export async function declineBooking( bookingUid: string, reason?: string ): Promise { - return calcomFetch(`/v2/bookings/${bookingUid}/decline`, accessToken, { - method: "POST", - body: JSON.stringify({ reason }), - }, API_VERSION, 0); + return calcomFetch( + `/v2/bookings/${bookingUid}/decline`, + accessToken, + { + method: "POST", + body: JSON.stringify({ reason }), + }, + API_VERSION, + 0 + ); } // ─── Calendar busy times ────────────────────────────────────────────────────── @@ -546,10 +592,16 @@ export async function addBookingAttendee( bookingUid: string, input: AddAttendeeInput ): Promise { - await calcomFetch(`/v2/bookings/${bookingUid}/attendees`, accessToken, { - method: "POST", - body: JSON.stringify(input), - }, API_VERSION, 0); + await calcomFetch( + `/v2/bookings/${bookingUid}/attendees`, + accessToken, + { + method: "POST", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } // ─── Public event types (no auth) ───────────────────────────────────────────── @@ -590,13 +642,19 @@ export async function markNoShow( host?: boolean, attendees?: Array<{ email: string; absent: boolean }> ): Promise { - await calcomFetch(`/v2/bookings/${bookingUid}/mark-absent`, accessToken, { - method: "POST", - body: JSON.stringify({ - ...(host !== undefined ? { host } : {}), - ...(attendees ? { attendees } : {}), - }), - }, API_VERSION, 0); + await calcomFetch( + `/v2/bookings/${bookingUid}/mark-absent`, + accessToken, + { + method: "POST", + body: JSON.stringify({ + ...(host !== undefined ? { host } : {}), + ...(attendees ? { attendees } : {}), + }), + }, + API_VERSION, + 0 + ); } // ─── AI Agent Credits ──────────────────────────────────────────────────────── @@ -639,54 +697,84 @@ export async function chargeCredits( accessToken: string, params: { externalRef: string; credits?: number } ): Promise { - return calcomFetch("/v2/credits/charge", accessToken, { - method: "POST", - body: JSON.stringify({ - credits: params.credits ?? AI_AGENT_CREDITS_PER_MESSAGE, - creditFor: "AI_AGENT", - externalRef: params.externalRef, - }), - }, API_VERSION, 0); + return calcomFetch( + "/v2/credits/charge", + accessToken, + { + method: "POST", + body: JSON.stringify({ + credits: params.credits ?? AI_AGENT_CREDITS_PER_MESSAGE, + creditFor: "AI_AGENT", + externalRef: params.externalRef, + }), + }, + API_VERSION, + 0 + ); } // ─── Chat push notification subscriptions ──────────────────────────────────── export async function registerSlackSubscription( accessToken: string, - input: { identifier: string; deviceId: string } + input: { identifier: string; teamId: string } ): Promise { - await calcomFetch("/v2/notifications/subscriptions/slack", accessToken, { - method: "POST", - body: JSON.stringify(input), - }, API_VERSION, 0); + await calcomFetch( + "/v2/notifications/subscriptions/slack", + accessToken, + { + method: "POST", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } export async function removeSlackSubscription( accessToken: string, input: { identifier: string } ): Promise { - await calcomFetch("/v2/notifications/subscriptions/slack", accessToken, { - method: "DELETE", - body: JSON.stringify(input), - }, API_VERSION, 0); + await calcomFetch( + "/v2/notifications/subscriptions/slack", + accessToken, + { + method: "DELETE", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } export async function registerTelegramSubscription( accessToken: string, input: { identifier: string } ): Promise { - await calcomFetch("/v2/notifications/subscriptions/telegram", accessToken, { - method: "POST", - body: JSON.stringify(input), - }, API_VERSION, 0); + await calcomFetch( + "/v2/notifications/subscriptions/telegram", + accessToken, + { + method: "POST", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } export async function removeTelegramSubscription( accessToken: string, input: { identifier: string } ): Promise { - await calcomFetch("/v2/notifications/subscriptions/telegram", accessToken, { - method: "DELETE", - body: JSON.stringify(input), - }, API_VERSION, 0); + await calcomFetch( + "/v2/notifications/subscriptions/telegram", + accessToken, + { + method: "DELETE", + body: JSON.stringify(input), + }, + API_VERSION, + 0 + ); } diff --git a/apps/chat/lib/handlers/slack.ts b/apps/chat/lib/handlers/slack.ts index 4d1989a9..640ff4fc 100644 --- a/apps/chat/lib/handlers/slack.ts +++ b/apps/chat/lib/handlers/slack.ts @@ -304,7 +304,8 @@ export function registerSlackHandlers( blocks: cardToBlockKit(homeCard), }); } catch (err) { - const isAuthError = err instanceof CalcomApiError && (err.statusCode === 401 || err.statusCode === 403); + const isAuthError = + err instanceof CalcomApiError && (err.statusCode === 401 || err.statusCode === 403); const authUrl = generateAuthUrl("slack", teamId, userId); const errorCard = isAuthError ? Card({ @@ -316,9 +317,7 @@ export function registerSlackHandlers( }) : Card({ title: "Could Not Load Bookings", - children: [ - CardText("Could not load bookings. Please try again later."), - ], + children: [CardText("Could not load bookings. Please try again later.")], }); await slack.publishHomeView(userId, { type: "home", @@ -432,7 +431,7 @@ export function registerSlackHandlers( try { await registerSlackSubscription(notifyToken, { identifier: userId, - deviceId: teamId, + teamId, }); await event.channel.postEphemeral( event.user, @@ -573,7 +572,11 @@ export function registerSlackHandlers( { fallbackToDM: true } ); } else { - await event.channel.postEphemeral(event.user, availabilityListCard(allSlots, eventType.title), { fallbackToDM: true }); + await event.channel.postEphemeral( + event.user, + availabilityListCard(allSlots, eventType.title), + { fallbackToDM: true } + ); } break; } @@ -869,7 +872,9 @@ export function registerSlackHandlers( await chargeCredits(accessTokenForAgent, { externalRef }); } catch { // Don't fail the interaction if charging fails - bot.getLogger("/cal").warn("Failed to charge credits via slash command", { userId, externalRef }); + bot + .getLogger("/cal") + .warn("Failed to charge credits via slash command", { userId, externalRef }); } } } @@ -1254,7 +1259,9 @@ export function registerSlackHandlers( if (err instanceof CalcomApiError) { return friendlyCalcomError(err, "booking"); } - return err instanceof Error ? "Failed to create the booking. Please try again." : undefined; + return err instanceof Error + ? "Failed to create the booking. Please try again." + : undefined; }, } ); @@ -1279,7 +1286,9 @@ export function registerSlackHandlers( ]); if (!flow || !flow.targetUsername || !linked) { - await thread.post("Booking session expired. Please start again with `/cal book `."); + await thread.post( + "Booking session expired. Please start again with `/cal book `." + ); return; } @@ -1287,7 +1296,9 @@ export function registerSlackHandlers( const targetEventTypes = await getEventTypesByUsername(targetUsername); const selectedEt = targetEventTypes.find((et) => et.slug === selectedSlug); if (!selectedEt) { - await thread.post("Event type not found. Please start again with `/cal book `."); + await thread.post( + "Event type not found. Please start again with `/cal book `." + ); return; } @@ -1349,7 +1360,9 @@ export function registerSlackHandlers( const flow = await getBookingFlow(teamId, userId); if (!flow || !flow.targetUsername) { - await thread.post("Booking session expired. Please start again with `/cal book `."); + await thread.post( + "Booking session expired. Please start again with `/cal book `." + ); return; } @@ -1361,9 +1374,7 @@ export function registerSlackHandlers( selectedSlot: selectedTime, }); - await thread.post( - bookConfirmCard(flow.eventTypeTitle, slotLabel, flow.targetUsername) - ); + await thread.post(bookConfirmCard(flow.eventTypeTitle, slotLabel, flow.targetUsername)); }, { postError: (msg) => thread.post(msg).catch(() => {}), @@ -1401,13 +1412,7 @@ export function registerSlackHandlers( getLinkedUser(teamId, userId), ]); - if ( - !flow || - !flow.selectedSlot || - !flow.eventTypeSlug || - !flow.targetUsername || - !linked - ) { + if (!flow || !flow.selectedSlot || !flow.eventTypeSlug || !flow.targetUsername || !linked) { await thread.post( "Booking session expired. Please start again with `/cal book `." ); @@ -1593,17 +1598,17 @@ export function registerSlackHandlers( const eventTypeSlug = selected.eventType?.slug; const eventTypeId = selected.eventType?.id ?? 0; if (!eventTypeSlug) { - await thread.post("Cannot reschedule: event type information is missing for this booking."); + await thread.post( + "Cannot reschedule: event type information is missing for this booking." + ); return; } const emailLower = linked.calcomEmail.toLowerCase(); - const isHost = - selected.hosts?.some( - (h) => - String(h.id) === String(linked.calcomUserId) || - h.email?.toLowerCase() === emailLower - ); + const isHost = selected.hosts?.some( + (h) => + String(h.id) === String(linked.calcomUserId) || h.email?.toLowerCase() === emailLower + ); if (!isHost) { await thread.post( "You're an attendee on this booking, not the host. Rescheduling as an attendee isn't supported here — please use the reschedule link in your booking confirmation email or reschedule at ." @@ -1675,7 +1680,9 @@ export function registerSlackHandlers( async () => { const flow = await getRescheduleFlow(teamId, userId); if (!flow) { - await thread.post("Reschedule session expired. Please start again with `/cal reschedule`."); + await thread.post( + "Reschedule session expired. Please start again with `/cal reschedule`." + ); return; } @@ -1725,7 +1732,9 @@ export function registerSlackHandlers( async () => { const flow = await getRescheduleFlow(teamId, userId); if (!flow?.selectedSlot) { - await thread.post("Reschedule session expired. Please start again with `/cal reschedule`."); + await thread.post( + "Reschedule session expired. Please start again with `/cal reschedule`." + ); return; } const accessToken = await getValidAccessToken(teamId, userId); @@ -1816,7 +1825,10 @@ export function registerSlackHandlers( const messageText = lastUserMessage.content as string; const msgHash = createHash("sha256").update(messageText).digest("hex").slice(0, 12); - const retryEventId = createHash("sha256").update(getRetryEventId(event.raw)).digest("hex").slice(0, 12); + const retryEventId = createHash("sha256") + .update(getRetryEventId(event.raw)) + .digest("hex") + .slice(0, 12); const externalRef = `agent-${event.adapter.name}-${thread.id}-${msgHash}-retry-${retryEventId}`; try { await chargeCredits(accessToken, { externalRef }); From e92a90441c0232f826150ad37bd5d6830f2910b5 Mon Sep 17 00:00:00 2001 From: Dhairyashil Shinde <93669429+dhairyashiil@users.noreply.github.com> Date: Sat, 23 May 2026 20:39:04 +0530 Subject: [PATCH 8/9] Update apps/chat/app/api/notifications/deliver/route.ts Co-authored-by: devin-ai-integration[bot] <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- apps/chat/app/api/notifications/deliver/route.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/apps/chat/app/api/notifications/deliver/route.ts b/apps/chat/app/api/notifications/deliver/route.ts index 1455465a..032febdf 100644 --- a/apps/chat/app/api/notifications/deliver/route.ts +++ b/apps/chat/app/api/notifications/deliver/route.ts @@ -54,6 +54,14 @@ function parseDeliverRequest(body: unknown): DeliverRequest | null { typeof (s as Record).teamId === "string" ); if (!valid) return null; + } else { + const valid = b.subscriptions.every( + (s: unknown) => + typeof s === "object" && + s !== null && + typeof (s as Record).identifier === "string" + ); + if (!valid) return null; } return body as DeliverRequest; From f485a14312b3e5fdd749b82c55a87b96683e0939 Mon Sep 17 00:00:00 2001 From: Dhairyashil Date: Tue, 26 May 2026 02:51:54 +0530 Subject: [PATCH 9/9] fix(mobile): improve push subscription error logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass the actual error object to console.error so the message, stack trace, and HTTP status are visible in logs — previously only a static string label was logged, making the 403 root cause invisible. Also add warn logs for registration failures in the hook and provider so any future auth or scope issues surface immediately. --- .../components/PushNotificationProvider.tsx | 3 +++ apps/mobile/hooks/use-push-notifications.ts | 16 ++++++++++++---- apps/mobile/services/calcom/notifications.ts | 4 ++-- 3 files changed, 17 insertions(+), 6 deletions(-) diff --git a/apps/mobile/components/PushNotificationProvider.tsx b/apps/mobile/components/PushNotificationProvider.tsx index 8db5b704..fbd7d97c 100644 --- a/apps/mobile/components/PushNotificationProvider.tsx +++ b/apps/mobile/components/PushNotificationProvider.tsx @@ -68,7 +68,10 @@ export function PushNotificationProvider({ children }: PushNotificationProviderP } else if (result.token) { // Server registration failed but token was obtained — store it so // we can still deregister on logout. + console.warn("[PushNotif] push registration failed:", result.reason); registeredTokenRef.current = result.token; + } else { + console.warn("[PushNotif] push registration failed:", result.reason); } })(); return () => { diff --git a/apps/mobile/hooks/use-push-notifications.ts b/apps/mobile/hooks/use-push-notifications.ts index 590dfa8c..dd6915d5 100644 --- a/apps/mobile/hooks/use-push-notifications.ts +++ b/apps/mobile/hooks/use-push-notifications.ts @@ -21,8 +21,10 @@ async function getDeviceId(): Promise { id = Crypto.randomUUID(); await secureStorage.set(DEVICE_ID_KEY, id); return id; - } catch { - return id ?? Crypto.randomUUID(); + } catch (err) { + const fallback = id ?? Crypto.randomUUID(); + console.warn("[PushNotif] secureStorage error getting deviceId, using fallback:", err); + return fallback; } } @@ -59,6 +61,7 @@ export async function requestAndRegisterPushToken(): Promise { try { await CalComAPIService.removeAppPushSubscription(token); - } catch { + } catch (error) { // Best-effort: server cleans up stale tokens on failed send attempts. + console.warn("[PushNotif] deregisterPushToken failed (non-fatal):", error); } } diff --git a/apps/mobile/services/calcom/notifications.ts b/apps/mobile/services/calcom/notifications.ts index 53bfff25..b34986c6 100644 --- a/apps/mobile/services/calcom/notifications.ts +++ b/apps/mobile/services/calcom/notifications.ts @@ -19,7 +19,7 @@ export async function registerAppPushSubscription( } ); } catch (error) { - console.error("registerAppPushSubscription error"); + console.error("registerAppPushSubscription error", error); throw error; } } @@ -32,7 +32,7 @@ export async function removeAppPushSubscription(token: string): Promise<{ status body: JSON.stringify({ token }), }); } catch (error) { - console.error("removeAppPushSubscription error"); + console.error("removeAppPushSubscription error", error); throw error; } }