diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts index 2df3c5016..8af93ad7a 100644 --- a/app/api/chat/route.ts +++ b/app/api/chat/route.ts @@ -25,7 +25,7 @@ export async function OPTIONS() { * retained as a backward-compatible alias while consumers (chat, * open-agents, API-key callers) migrate to `/api/chat`. * - * Contract: https://developers.recoupable.com/api-reference/chat/workflow + * Contract: https://docs.recoupable.dev/api-reference/chat/workflow * * @param request - The incoming NextRequest. * @returns A streaming Response (200) or a NextResponse error. diff --git a/app/api/notifications/route.ts b/app/api/notifications/route.ts deleted file mode 100644 index 22597e8f0..000000000 --- a/app/api/notifications/route.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { createNotificationHandler } from "@/lib/notifications/createNotificationHandler"; - -/** - * OPTIONS handler for CORS preflight requests. - * - * @returns A NextResponse with CORS headers. - */ -export async function OPTIONS() { - return new NextResponse(null, { - status: 204, - headers: getCorsHeaders(), - }); -} - -/** - * POST /api/notifications - * - * Sends a notification email to the authenticated account's email address via Resend. - * The recipient is automatically resolved from the API key or Bearer token. - * Requires authentication via x-api-key header or Authorization bearer token. - * - * Body parameters: - * - subject (required): email subject line - * - text (optional): plain text / Markdown body - * - html (optional): raw HTML body (takes precedence over text) - * - cc (optional): array of CC email addresses - * - headers (optional): custom email headers - * - room_id (optional): room ID for chat link in footer - * - account_id (optional): UUID of the account to send to (org keys only) - * - * @param request - The request object. - * @returns A NextResponse with send result. - */ -export async function POST(request: NextRequest): Promise { - return createNotificationHandler(request); -} - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; -export const revalidate = 0; diff --git a/app/page.tsx b/app/page.tsx index a6a6b0508..139add5d1 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -1,4 +1,5 @@ import Image from "next/image"; +import { DOCS_BASE_URL } from "@/lib/const"; export default function Home() { return ( @@ -24,7 +25,7 @@ export default function Home() {
diff --git a/lib/agents/__tests__/buildTaskCard.test.ts b/lib/agents/__tests__/buildTaskCard.test.ts index b8c50525f..4b4a45ea7 100644 --- a/lib/agents/__tests__/buildTaskCard.test.ts +++ b/lib/agents/__tests__/buildTaskCard.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import { buildTaskCard } from "../buildTaskCard"; import { LinkButton } from "chat"; +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; vi.mock("chat", () => ({ Card: vi.fn(({ title, children }) => ({ type: "card", title, children })), @@ -28,7 +29,7 @@ describe("buildTaskCard", () => { buttons: [ { type: "linkButton", - url: "https://chat.recoupable.com/tasks/run-abc-123", + url: `${getFrontendBaseUrl()}/tasks/run-abc-123`, label: "View Task", }, ], @@ -42,7 +43,7 @@ describe("buildTaskCard", () => { buildTaskCard("Title", "Message", "my-run-id"); expect(LinkButton).toHaveBeenCalledWith({ - url: "https://chat.recoupable.com/tasks/my-run-id", + url: `${getFrontendBaseUrl()}/tasks/my-run-id`, label: "View Task", }); }); diff --git a/lib/agents/buildTaskCard.ts b/lib/agents/buildTaskCard.ts index 27869c76d..de4ae0fc1 100644 --- a/lib/agents/buildTaskCard.ts +++ b/lib/agents/buildTaskCard.ts @@ -1,4 +1,5 @@ import { Card, CardText, Actions, LinkButton } from "chat"; +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; /** * Builds a Card with a message and a View Task button. @@ -13,9 +14,7 @@ export function buildTaskCard(title: string, message: string, runId: string) { title, children: [ CardText(message), - Actions([ - LinkButton({ url: `https://chat.recoupable.com/tasks/${runId}`, label: "View Task" }), - ]), + Actions([LinkButton({ url: `${getFrontendBaseUrl()}/tasks/${runId}`, label: "View Task" })]), ], }); } diff --git a/lib/chat/recoupApiSkillPrompt.ts b/lib/chat/recoupApiSkillPrompt.ts index 446c3cd54..2a0fb41c9 100644 --- a/lib/chat/recoupApiSkillPrompt.ts +++ b/lib/chat/recoupApiSkillPrompt.ts @@ -12,4 +12,4 @@ * (recoupable/chat#1815). */ export const recoupApiSkillPrompt = - 'If you\'re asked to do anything involving their Recoup account — artists, socials, orgs, research, tasks, chats, pulses, subscriptions, **sending an email or delivering a report**, or any other resource/action at recoup-api.vercel.app / developers.recoupable.com — load the right skill first instead of guessing or assuming you lack a tool. For live data or actions against the API (socials, posts, metrics, research, tasks, and **sending email via `POST /api/emails`** — e.g. "email X to Y", scheduled-report output) load `recoup-platform-api-access`; when `RECOUP_ORG_ID` is set in the env, scope list endpoints to that org (`/api/organizations/$RECOUP_ORG_ID/...`, `--org $RECOUP_ORG_ID`) so you get the sandbox\'s org, not every org the user belongs to. For inventory questions about this sandbox ("what artists / orgs do I have", "list my artists", "what\'s in here") load `recoup-roster-list-artists` — the `artists/{artist-slug}/RECOUP.md` tree is authoritative for this sandbox (it is already org-scoped — its repo IS the org — so artists live at the top level, not under an `orgs/` directory) and the API is not. For create-artist intents ("create artist", "onboard X", "add an artist") load `recoup-roster-add-artist`; to operate inside one artist\'s folder load `recoup-roster-manage-artist`; to scaffold the folder tree load `recoup-platform-build-workspace`. Treat ambiguous account-data questions as Recoup questions by default, not repo-level TODOs.'; + 'If you\'re asked to do anything involving their Recoup account — artists, socials, orgs, research, tasks, chats, pulses, subscriptions, **sending an email or delivering a report**, or any other resource/action at recoup-api.vercel.app / docs.recoupable.dev — load the right skill first instead of guessing or assuming you lack a tool. For live data or actions against the API (socials, posts, metrics, research, tasks, and **sending email via `POST /api/emails`** — e.g. "email X to Y", scheduled-report output) load `recoup-platform-api-access`; when `RECOUP_ORG_ID` is set in the env, scope list endpoints to that org (`/api/organizations/$RECOUP_ORG_ID/...`, `--org $RECOUP_ORG_ID`) so you get the sandbox\'s org, not every org the user belongs to. For inventory questions about this sandbox ("what artists / orgs do I have", "list my artists", "what\'s in here") load `recoup-roster-list-artists` — the `artists/{artist-slug}/RECOUP.md` tree is authoritative for this sandbox (it is already org-scoped — its repo IS the org — so artists live at the top level, not under an `orgs/` directory) and the API is not. For create-artist intents ("create artist", "onboard X", "add an artist") load `recoup-roster-add-artist`; to operate inside one artist\'s folder load `recoup-roster-manage-artist`; to scaffold the folder tree load `recoup-platform-build-workspace`. Treat ambiguous account-data questions as Recoup questions by default, not repo-level TODOs.'; diff --git a/lib/coding-agent/handleGitHubWebhook.ts b/lib/coding-agent/handleGitHubWebhook.ts index fd6e5fc93..7153822d7 100644 --- a/lib/coding-agent/handleGitHubWebhook.ts +++ b/lib/coding-agent/handleGitHubWebhook.ts @@ -6,6 +6,7 @@ import { extractPRComment } from "./extractPRComment"; import { getCodingAgentPRState, setCodingAgentPRState } from "./prState"; import { triggerUpdatePR } from "@/lib/trigger/triggerUpdatePR"; import { postGitHubComment } from "./postGitHubComment"; +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; const BOT_MENTION = "@recoup-coding-agent"; @@ -97,7 +98,7 @@ export async function handleGitHubWebhook(request: Request): Promise { it("includes reply note in all cases", () => { @@ -21,14 +22,14 @@ describe("getEmailFooter", () => { it("includes chat link when roomId is provided", () => { const roomId = "test-room-123"; const footer = getEmailFooter(roomId); - expect(footer).toContain(`https://chat.recoupable.com/chat/${roomId}`); + expect(footer).toContain(`${getFrontendBaseUrl()}/chat/${roomId}`); expect(footer).toContain("Or continue the conversation on Recoup"); }); it("generates proper HTML with roomId", () => { const roomId = "my-room-id"; const footer = getEmailFooter(roomId); - expect(footer).toContain(`href="https://chat.recoupable.com/chat/${roomId}"`); + expect(footer).toContain(`href="${getFrontendBaseUrl()}/chat/${roomId}"`); expect(footer).toContain('target="_blank"'); expect(footer).toContain('rel="noopener noreferrer"'); }); diff --git a/lib/emails/getEmailFooter.ts b/lib/emails/getEmailFooter.ts index 6fe173d0b..3d211f798 100644 --- a/lib/emails/getEmailFooter.ts +++ b/lib/emails/getEmailFooter.ts @@ -1,3 +1,5 @@ +import { getFrontendBaseUrl } from "@/lib/composio/getFrontendBaseUrl"; + /** * Generates a standardized email footer HTML. * @@ -6,6 +8,7 @@ * @returns HTML string for the email footer. */ export function getEmailFooter(roomId?: string, artistName?: string): string { + const chatUrl = roomId ? `${getFrontendBaseUrl()}/chat/${roomId}` : ""; const artistLine = artistName ? `

@@ -22,8 +25,8 @@ export function getEmailFooter(roomId?: string, artistName?: string): string { ? `

Or continue the conversation on Recoup: - - https://chat.recoupable.com/chat/${roomId} + + ${chatUrl}

`.trim() : ""; diff --git a/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts b/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts index d7aebeea3..4a7231d81 100644 --- a/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts +++ b/lib/emails/inbound/__tests__/extractRoomIdFromHtml.test.ts @@ -60,6 +60,22 @@ describe("extractRoomIdFromHtml", () => { }); }); + describe(".dev domain (post-migration links)", () => { + it("extracts roomId from a chat.recoupable.dev link", () => { + const html = ` + + +

Continue the conversation: https://chat.recoupable.dev/chat/b2c3d4e5-f6a7-8901-bcde-f23456789012

+ + + `; + + const result = extractRoomIdFromHtml(html); + + expect(result).toBe("b2c3d4e5-f6a7-8901-bcde-f23456789012"); + }); + }); + describe("Gmail reply with proper threading", () => { it("extracts roomId from Gmail reply with quoted content", () => { const html = ` diff --git a/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts b/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts index 2db2eb66d..e817ae160 100644 --- a/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts +++ b/lib/emails/inbound/__tests__/extractRoomIdFromText.test.ts @@ -27,6 +27,15 @@ describe("extractRoomIdFromText", () => { expect(result).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890"); }); + it("extracts roomId from a chat.recoupable.dev link (post-migration)", () => { + const text = + "Check out this chat: https://chat.recoupable.dev/chat/b2c3d4e5-f6a7-8901-bcde-f23456789012"; + + const result = extractRoomIdFromText(text); + + expect(result).toBe("b2c3d4e5-f6a7-8901-bcde-f23456789012"); + }); + it("handles case-insensitive domain matching", () => { const text = "Visit HTTPS://CHAT.RECOUPABLE.COM/CHAT/12345678-1234-1234-1234-123456789abc"; diff --git a/lib/emails/inbound/extractRoomIdFromHtml.ts b/lib/emails/inbound/extractRoomIdFromHtml.ts index 6a48f954b..0b29745f9 100644 --- a/lib/emails/inbound/extractRoomIdFromHtml.ts +++ b/lib/emails/inbound/extractRoomIdFromHtml.ts @@ -1,16 +1,21 @@ const UUID_PATTERN = "[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}"; -// Matches chat.recoupable.com/chat/{uuid} in various formats: -// - Direct URL: https://chat.recoupable.com/chat/uuid -// - URL-encoded (in tracking redirects): chat.recoupable.com%2Fchat%2Fuuid +// Matches chat.recoupable.com or chat.recoupable.dev /chat/{uuid} in various formats. +// Both hosts are recognized so legacy .com links still in flight resolve alongside +// post-migration .dev links: +// - Direct URL: https://chat.recoupable.dev/chat/uuid +// - URL-encoded (in tracking redirects): chat.recoupable.dev%2Fchat%2Fuuid const CHAT_LINK_PATTERNS = [ - new RegExp(`https?://chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, "i"), - new RegExp(`chat\\.recoupable\\.com%2Fchat%2F(${UUID_PATTERN})`, "i"), + new RegExp(`https?://chat\\.recoupable\\.(com|dev)/chat/(${UUID_PATTERN})`, "i"), + new RegExp(`chat\\.recoupable\\.(com|dev)%2Fchat%2F(${UUID_PATTERN})`, "i"), ]; // Pattern to find UUID after /chat/ or %2Fchat%2F in link text that may contain tags -// The link text version: "https:///chat.recoupable.com/chat/uuid" -const WBR_STRIPPED_PATTERN = new RegExp(`chat\\.recoupable\\.com/chat/(${UUID_PATTERN})`, "i"); +// The link text version: "https:///chat.recoupable.dev/chat/uuid" +const WBR_STRIPPED_PATTERN = new RegExp( + `chat\\.recoupable\\.(com|dev)/chat/(${UUID_PATTERN})`, + "i", +); /** * Extracts the roomId from email HTML by looking for a Recoup chat link. @@ -25,11 +30,12 @@ const WBR_STRIPPED_PATTERN = new RegExp(`chat\\.recoupable\\.com/chat/(${UUID_PA export function extractRoomIdFromHtml(html: string | undefined): string | undefined { if (!html) return undefined; - // Try direct URL patterns first (most common case) + // Try direct URL patterns first (most common case). + // Group 1 is the host suffix (com|dev); group 2 is the UUID. for (const pattern of CHAT_LINK_PATTERNS) { const match = html.match(pattern); - if (match?.[1]) { - return match[1]; + if (match?.[2]) { + return match[2]; } } @@ -37,8 +43,8 @@ export function extractRoomIdFromHtml(html: string | undefined): string | undefi // This handles Superhuman's link text formatting: "https://chat...." const strippedHtml = html.replace(//gi, ""); const strippedMatch = strippedHtml.match(WBR_STRIPPED_PATTERN); - if (strippedMatch?.[1]) { - return strippedMatch[1]; + if (strippedMatch?.[2]) { + return strippedMatch[2]; } return undefined; diff --git a/lib/emails/inbound/extractRoomIdFromText.ts b/lib/emails/inbound/extractRoomIdFromText.ts index 446bdbbef..b6a0aba3b 100644 --- a/lib/emails/inbound/extractRoomIdFromText.ts +++ b/lib/emails/inbound/extractRoomIdFromText.ts @@ -1,4 +1,6 @@ -const CHAT_LINK_REGEX = /https:\/\/chat\.recoupable\.com\/chat\/([0-9a-f-]{36})/i; +// Recognizes both the legacy .com host (links still in flight) and the +// post-migration .dev host. (?:com|dev) is non-capturing so the UUID stays group 1. +const CHAT_LINK_REGEX = /https:\/\/chat\.recoupable\.(?:com|dev)\/chat\/([0-9a-f-]{36})/i; /** * Extracts the roomId from the email text body by looking for a Recoup chat link. diff --git a/lib/emails/processAndSendEmail.ts b/lib/emails/processAndSendEmail.ts index 934939ca6..16402cd5b 100644 --- a/lib/emails/processAndSendEmail.ts +++ b/lib/emails/processAndSendEmail.ts @@ -30,7 +30,7 @@ export type ProcessAndSendEmailResult = ProcessAndSendEmailSuccess | ProcessAndS /** * Shared email processing and sending logic used by both the - * POST /api/notifications handler and the send_email MCP tool. + * POST /api/emails handler and the send_email MCP tool. * * Handles room lookup, footer generation, markdown-to-HTML conversion, * and the Resend API call. diff --git a/lib/notifications/__tests__/createNotificationHandler.test.ts b/lib/notifications/__tests__/createNotificationHandler.test.ts deleted file mode 100644 index ca7fb677f..000000000 --- a/lib/notifications/__tests__/createNotificationHandler.test.ts +++ /dev/null @@ -1,194 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { createNotificationHandler } from "../createNotificationHandler"; - -const mockValidateAuthContext = vi.fn(); -const mockSelectAccountEmails = vi.fn(); -const mockProcessAndSendEmail = vi.fn(); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), -})); - -vi.mock("@/lib/supabase/account_emails/selectAccountEmails", () => ({ - default: (...args: unknown[]) => mockSelectAccountEmails(...args), -})); - -vi.mock("@/lib/emails/processAndSendEmail", () => ({ - processAndSendEmail: (...args: unknown[]) => mockProcessAndSendEmail(...args), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/networking/safeParseJson", () => ({ - safeParseJson: vi.fn(async (req: Request) => req.json()), -})); - -function createRequest(body: unknown): NextRequest { - return new NextRequest("https://recoup-api.vercel.app/api/notifications", { - method: "POST", - headers: { - "Content-Type": "application/json", - "x-api-key": "test-key", - }, - body: JSON.stringify(body), - }); -} - -describe("createNotificationHandler", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "test-key", - }); - mockSelectAccountEmails.mockResolvedValue([ - { id: "email-1", account_id: "account-123", email: "owner@example.com", updated_at: "" }, - ]); - }); - - it("returns 401 when authentication fails", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); - - const request = createRequest({ subject: "Test" }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(401); - }); - - it("returns 400 when body validation fails", async () => { - const request = createRequest({}); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.status).toBe("error"); - }); - - it("returns 400 when account has no email", async () => { - mockSelectAccountEmails.mockResolvedValue([]); - - const request = createRequest({ subject: "Test", text: "Hello" }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(400); - const data = await response.json(); - expect(data.error).toContain("No email address found"); - }); - - it("sends email to account owner with text body", async () => { - mockProcessAndSendEmail.mockResolvedValue({ - success: true, - message: - "Email sent successfully from Agent by Recoup to owner@example.com. CC: none.", - id: "email-123", - }); - - const request = createRequest({ - subject: "Test Subject", - text: "Hello world", - }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(200); - const data = await response.json(); - expect(data.success).toBe(true); - expect(data.id).toBe("email-123"); - expect(data.message).toContain("owner@example.com"); - expect(mockSelectAccountEmails).toHaveBeenCalledWith({ accountIds: "account-123" }); - expect(mockProcessAndSendEmail).toHaveBeenCalledWith({ - to: ["owner@example.com"], - cc: [], - subject: "Test Subject", - text: "Hello world", - html: "", - headers: {}, - room_id: undefined, - }); - }); - - it("passes CC and room_id through to processAndSendEmail", async () => { - mockProcessAndSendEmail.mockResolvedValue({ - success: true, - message: "Email sent successfully.", - id: "email-789", - }); - - const request = createRequest({ - cc: ["cc@example.com"], - subject: "Test", - text: "Hello", - room_id: "room-abc", - }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(200); - expect(mockProcessAndSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - cc: ["cc@example.com"], - room_id: "room-abc", - }), - ); - }); - - it("returns 502 when email delivery fails", async () => { - mockProcessAndSendEmail.mockResolvedValue({ - success: false, - error: "Rate limited", - }); - - const request = createRequest({ - subject: "Test", - text: "Hello", - }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(502); - const data = await response.json(); - expect(data.status).toBe("error"); - expect(data.error).toContain("Rate limited"); - }); - - it("resolves email from account_id override", async () => { - const overrideAccountId = "550e8400-e29b-41d4-a716-446655440000"; - - mockValidateAuthContext.mockResolvedValue({ - accountId: overrideAccountId, - orgId: "org-id", - authToken: "test-key", - }); - mockSelectAccountEmails.mockResolvedValue([ - { id: "email-2", account_id: overrideAccountId, email: "member@example.com", updated_at: "" }, - ]); - mockProcessAndSendEmail.mockResolvedValue({ - success: true, - message: "Email sent successfully to member@example.com.", - id: "email-override", - }); - - const request = createRequest({ - subject: "Override Test", - text: "Hello member", - account_id: overrideAccountId, - }); - const response = await createNotificationHandler(request); - - expect(response.status).toBe(200); - expect(mockValidateAuthContext).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ accountId: overrideAccountId }), - ); - expect(mockSelectAccountEmails).toHaveBeenCalledWith({ accountIds: overrideAccountId }); - expect(mockProcessAndSendEmail).toHaveBeenCalledWith( - expect.objectContaining({ - to: ["member@example.com"], - subject: "Override Test", - }), - ); - }); -}); diff --git a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts b/lib/notifications/__tests__/validateCreateNotificationBody.test.ts deleted file mode 100644 index 10390b15d..000000000 --- a/lib/notifications/__tests__/validateCreateNotificationBody.test.ts +++ /dev/null @@ -1,212 +0,0 @@ -import { describe, it, expect, vi, beforeEach } from "vitest"; -import { NextRequest, NextResponse } from "next/server"; -import { validateCreateNotificationBody } from "../validateCreateNotificationBody"; - -const mockValidateAuthContext = vi.fn(); - -vi.mock("@/lib/auth/validateAuthContext", () => ({ - validateAuthContext: (...args: unknown[]) => mockValidateAuthContext(...args), -})); - -vi.mock("@/lib/networking/getCorsHeaders", () => ({ - getCorsHeaders: vi.fn(() => ({ "Access-Control-Allow-Origin": "*" })), -})); - -vi.mock("@/lib/networking/safeParseJson", () => ({ - safeParseJson: vi.fn(async (req: Request) => req.json()), -})); - -function createRequest(body: unknown, headers: Record = {}): NextRequest { - const defaultHeaders: Record = { "Content-Type": "application/json" }; - return new NextRequest("http://localhost/api/notifications", { - method: "POST", - headers: { ...defaultHeaders, ...headers }, - body: JSON.stringify(body), - }); -} - -describe("validateCreateNotificationBody", () => { - beforeEach(() => { - vi.clearAllMocks(); - mockValidateAuthContext.mockResolvedValue({ - accountId: "account-123", - orgId: null, - authToken: "test-api-key", - }); - }); - - describe("successful validation", () => { - it("returns validated data with subject and text", async () => { - const request = createRequest( - { subject: "Test Subject", text: "Hello world" }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.subject).toBe("Test Subject"); - expect(result.text).toBe("Hello world"); - expect(result.accountId).toBe("account-123"); - } - }); - - it("returns validated data with all optional fields", async () => { - const request = createRequest( - { - cc: ["cc@example.com"], - subject: "Test Subject", - text: "Hello", - html: "

Hello

", - headers: { "X-Custom": "value" }, - room_id: "room-123", - }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.cc).toEqual(["cc@example.com"]); - expect(result.subject).toBe("Test Subject"); - expect(result.text).toBe("Hello"); - expect(result.html).toBe("

Hello

"); - expect(result.headers).toEqual({ "X-Custom": "value" }); - expect(result.room_id).toBe("room-123"); - expect(result.accountId).toBe("account-123"); - } - }); - - it("accepts subject-only body", async () => { - const request = createRequest({ subject: "Test Subject" }, { "x-api-key": "test-api-key" }); - const result = await validateCreateNotificationBody(request); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.subject).toBe("Test Subject"); - expect(result.accountId).toBe("account-123"); - } - }); - - it("uses account_id override for org API keys", async () => { - mockValidateAuthContext.mockResolvedValue({ - accountId: "550e8400-e29b-41d4-a716-446655440000", - orgId: "org-id", - authToken: "test-api-key", - }); - - const request = createRequest( - { subject: "Test", account_id: "550e8400-e29b-41d4-a716-446655440000" }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(mockValidateAuthContext).toHaveBeenCalledWith( - expect.anything(), - expect.objectContaining({ - accountId: "550e8400-e29b-41d4-a716-446655440000", - }), - ); - - expect(result).not.toBeInstanceOf(NextResponse); - if (!(result instanceof NextResponse)) { - expect(result.accountId).toBe("550e8400-e29b-41d4-a716-446655440000"); - } - }); - - it("passes undefined accountId to auth when account_id is omitted", async () => { - const request = createRequest({ subject: "Test" }, { "x-api-key": "test-api-key" }); - await validateCreateNotificationBody(request); - - expect(mockValidateAuthContext).toHaveBeenCalledWith(expect.anything(), { - accountId: undefined, - }); - }); - }); - - describe("schema validation errors", () => { - it("returns 400 when subject is missing", async () => { - const request = createRequest({ text: "Hello" }, { "x-api-key": "test-api-key" }); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when subject is empty", async () => { - const request = createRequest({ subject: "" }, { "x-api-key": "test-api-key" }); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when cc contains invalid email", async () => { - const request = createRequest( - { subject: "Test", cc: ["not-valid"] }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - - it("returns 400 when account_id is not a valid UUID", async () => { - const request = createRequest( - { subject: "Test", account_id: "invalid-uuid" }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(400); - } - }); - }); - - describe("auth errors", () => { - it("returns 401 when auth is missing", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json({ status: "error", error: "Unauthorized" }, { status: 401 }), - ); - - const request = createRequest({ subject: "Test" }); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(401); - } - }); - - it("returns 403 when org API key lacks access to account_id", async () => { - mockValidateAuthContext.mockResolvedValue( - NextResponse.json( - { status: "error", error: "Access denied to specified account_id" }, - { status: 403 }, - ), - ); - - const request = createRequest( - { subject: "Test", account_id: "550e8400-e29b-41d4-a716-446655440000" }, - { "x-api-key": "test-api-key" }, - ); - const result = await validateCreateNotificationBody(request); - - expect(result).toBeInstanceOf(NextResponse); - if (result instanceof NextResponse) { - expect(result.status).toBe(403); - const data = await result.json(); - expect(data.error).toBe("Access denied to specified account_id"); - } - }); - }); -}); diff --git a/lib/notifications/createNotificationHandler.ts b/lib/notifications/createNotificationHandler.ts deleted file mode 100644 index 35914a332..000000000 --- a/lib/notifications/createNotificationHandler.ts +++ /dev/null @@ -1,76 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateCreateNotificationBody } from "./validateCreateNotificationBody"; -import { processAndSendEmail } from "@/lib/emails/processAndSendEmail"; -import selectAccountEmails from "@/lib/supabase/account_emails/selectAccountEmails"; - -/** - * Handler for POST /api/notifications. - * Sends a notification email to the authenticated account's email address. - * The recipient is automatically resolved from the API key or Bearer token. - * Supports optional account_id override for org API keys. - * Requires authentication via x-api-key header or Authorization bearer token. - * - * @param request - The request object. - * @returns A NextResponse with the send result. - */ -export async function createNotificationHandler(request: NextRequest): Promise { - const validated = await validateCreateNotificationBody(request); - if (validated instanceof NextResponse) { - return validated; - } - - const { cc = [], subject, text, html = "", headers = {}, room_id, accountId } = validated; - - // Resolve recipient email from authenticated account - const accountEmails = await selectAccountEmails({ accountIds: accountId }); - const recipientEmail = accountEmails?.[0]?.email; - - if (!recipientEmail) { - return NextResponse.json( - { - status: "error", - error: "No email address found for the authenticated account.", - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - const result = await processAndSendEmail({ - to: [recipientEmail], - cc, - subject, - text, - html, - headers, - room_id, - }); - - if (result.success === false) { - return NextResponse.json( - { - status: "error", - error: result.error, - }, - { - status: 502, - headers: getCorsHeaders(), - }, - ); - } - - return NextResponse.json( - { - success: true, - message: result.message, - id: result.id, - }, - { - status: 200, - headers: getCorsHeaders(), - }, - ); -} diff --git a/lib/notifications/validateCreateNotificationBody.ts b/lib/notifications/validateCreateNotificationBody.ts deleted file mode 100644 index 33e3b6fad..000000000 --- a/lib/notifications/validateCreateNotificationBody.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { NextRequest, NextResponse } from "next/server"; -import { getCorsHeaders } from "@/lib/networking/getCorsHeaders"; -import { validateAuthContext } from "@/lib/auth/validateAuthContext"; -import { safeParseJson } from "@/lib/networking/safeParseJson"; -import { z } from "zod"; - -export const createNotificationBodySchema = z.object({ - cc: z.array(z.string().email("each 'cc' entry must be a valid email")).default([]).optional(), - subject: z.string({ message: "subject is required" }).min(1, "subject cannot be empty"), - text: z.string().optional(), - html: z.string().default("").optional(), - headers: z.record(z.string(), z.string()).default({}).optional(), - room_id: z.string().optional(), - account_id: z.string().uuid("account_id must be a valid UUID").optional(), -}); - -export type CreateNotificationBody = z.infer; - -export type ValidatedCreateNotificationRequest = { - cc?: string[]; - subject: string; - text?: string; - html?: string; - headers?: Record; - room_id?: string; - accountId: string; -}; - -/** - * Validates POST /api/notifications request including auth headers, body parsing, - * schema validation, and account access authorization. - * - * @param request - The NextRequest object - * @returns A NextResponse with an error if validation fails, or the validated request data. - */ -export async function validateCreateNotificationBody( - request: NextRequest, -): Promise { - const body = await safeParseJson(request); - const result = createNotificationBodySchema.safeParse(body); - - if (!result.success) { - const firstError = result.error.issues[0]; - return NextResponse.json( - { - status: "error", - missing_fields: firstError.path, - error: firstError.message, - }, - { - status: 400, - headers: getCorsHeaders(), - }, - ); - } - - const authContext = await validateAuthContext(request, { - accountId: result.data.account_id, - }); - - if (authContext instanceof NextResponse) { - return authContext; - } - - return { - cc: result.data.cc, - subject: result.data.subject, - text: result.data.text, - html: result.data.html, - headers: result.data.headers, - room_id: result.data.room_id, - accountId: authContext.accountId, - }; -}