Skip to content

Commit b90ec3a

Browse files
authored
Merge pull request #101 from Recoupable-com/test
Test
2 parents f75b30c + 6d79478 commit b90ec3a

File tree

8 files changed

+331
-14
lines changed

8 files changed

+331
-14
lines changed

CLAUDE.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,11 @@ pnpm format:check # Check formatting
5151
- `lib/trigger/` - Trigger.dev task triggers
5252
- `lib/x402/` - Payment middleware utilities
5353

54-
## Key Patterns
54+
## Code Principles
5555

56+
- **SRP (Single Responsibility Principle)**: One exported function per file. Each file should do one thing well.
57+
- **DRY (Don't Repeat Yourself)**: Extract shared logic into reusable utilities.
58+
- **KISS (Keep It Simple)**: Prefer simple solutions over clever ones.
5659
- All API routes should have JSDoc comments
5760
- Run `pnpm lint` before committing
5861

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { describe, it, expect } from "vitest";
2+
import { extractRoomIdFromText } from "../extractRoomIdFromText";
3+
4+
describe("extractRoomIdFromText", () => {
5+
describe("valid chat links", () => {
6+
it("extracts roomId from a valid Recoup chat link", () => {
7+
const text =
8+
"Check out this chat: https://chat.recoupable.com/chat/550e8400-e29b-41d4-a716-446655440000";
9+
10+
const result = extractRoomIdFromText(text);
11+
12+
expect(result).toBe("550e8400-e29b-41d4-a716-446655440000");
13+
});
14+
15+
it("extracts roomId from chat link embedded in longer text", () => {
16+
const text = `
17+
Hey there,
18+
19+
I wanted to follow up on our conversation.
20+
Here's the link: https://chat.recoupable.com/chat/a1b2c3d4-e5f6-7890-abcd-ef1234567890
21+
22+
Let me know if you have questions.
23+
`;
24+
25+
const result = extractRoomIdFromText(text);
26+
27+
expect(result).toBe("a1b2c3d4-e5f6-7890-abcd-ef1234567890");
28+
});
29+
30+
it("handles case-insensitive domain matching", () => {
31+
const text = "Visit HTTPS://CHAT.RECOUPABLE.COM/CHAT/12345678-1234-1234-1234-123456789abc";
32+
33+
const result = extractRoomIdFromText(text);
34+
35+
expect(result).toBe("12345678-1234-1234-1234-123456789abc");
36+
});
37+
38+
it("extracts first roomId when multiple links present", () => {
39+
const text = `
40+
First link: https://chat.recoupable.com/chat/aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee
41+
Second link: https://chat.recoupable.com/chat/11111111-2222-3333-4444-555555555555
42+
`;
43+
44+
const result = extractRoomIdFromText(text);
45+
46+
expect(result).toBe("aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee");
47+
});
48+
});
49+
50+
describe("invalid inputs", () => {
51+
it("returns undefined for undefined input", () => {
52+
const result = extractRoomIdFromText(undefined);
53+
54+
expect(result).toBeUndefined();
55+
});
56+
57+
it("returns undefined for empty string", () => {
58+
const result = extractRoomIdFromText("");
59+
60+
expect(result).toBeUndefined();
61+
});
62+
63+
it("returns undefined when no chat link present", () => {
64+
const text = "This email has no Recoup chat link.";
65+
66+
const result = extractRoomIdFromText(text);
67+
68+
expect(result).toBeUndefined();
69+
});
70+
71+
it("returns undefined for invalid UUID format in link", () => {
72+
const text = "https://chat.recoupable.com/chat/not-a-valid-uuid";
73+
74+
const result = extractRoomIdFromText(text);
75+
76+
expect(result).toBeUndefined();
77+
});
78+
79+
it("returns undefined for partial UUID", () => {
80+
const text = "https://chat.recoupable.com/chat/550e8400-e29b-41d4";
81+
82+
const result = extractRoomIdFromText(text);
83+
84+
expect(result).toBeUndefined();
85+
});
86+
87+
it("returns undefined for wrong domain", () => {
88+
const text = "https://chat.otherdomain.com/chat/550e8400-e29b-41d4-a716-446655440000";
89+
90+
const result = extractRoomIdFromText(text);
91+
92+
expect(result).toBeUndefined();
93+
});
94+
95+
it("returns undefined for wrong path structure", () => {
96+
const text = "https://chat.recoupable.com/room/550e8400-e29b-41d4-a716-446655440000";
97+
98+
const result = extractRoomIdFromText(text);
99+
100+
expect(result).toBeUndefined();
101+
});
102+
});
103+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import { describe, it, expect, vi, beforeEach } from "vitest";
2+
import { getEmailRoomId } from "../getEmailRoomId";
3+
import type { GetReceivingEmailResponseSuccess } from "resend";
4+
5+
import selectMemoryEmails from "@/lib/supabase/memory_emails/selectMemoryEmails";
6+
7+
vi.mock("@/lib/supabase/memory_emails/selectMemoryEmails", () => ({
8+
default: vi.fn(),
9+
}));
10+
11+
const mockSelectMemoryEmails = vi.mocked(selectMemoryEmails);
12+
13+
describe("getEmailRoomId", () => {
14+
beforeEach(() => {
15+
vi.clearAllMocks();
16+
});
17+
18+
describe("primary: extracting from email text", () => {
19+
it("returns roomId when chat link found in email text", async () => {
20+
const emailContent = {
21+
text: "Check out this chat: https://chat.recoupable.com/chat/550e8400-e29b-41d4-a716-446655440000",
22+
headers: { references: "<old-message-id@example.com>" },
23+
} as GetReceivingEmailResponseSuccess;
24+
25+
const result = await getEmailRoomId(emailContent);
26+
27+
expect(result).toBe("550e8400-e29b-41d4-a716-446655440000");
28+
expect(mockSelectMemoryEmails).not.toHaveBeenCalled();
29+
});
30+
31+
it("prioritizes chat link over references header", async () => {
32+
mockSelectMemoryEmails.mockResolvedValue([
33+
{ memories: { room_id: "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" } },
34+
] as Awaited<ReturnType<typeof selectMemoryEmails>>);
35+
36+
const emailContent = {
37+
text: "Link: https://chat.recoupable.com/chat/11111111-2222-3333-4444-555555555555",
38+
headers: { references: "<message-id@example.com>" },
39+
} as GetReceivingEmailResponseSuccess;
40+
41+
const result = await getEmailRoomId(emailContent);
42+
43+
expect(result).toBe("11111111-2222-3333-4444-555555555555");
44+
expect(mockSelectMemoryEmails).not.toHaveBeenCalled();
45+
});
46+
});
47+
48+
describe("fallback: checking references header", () => {
49+
it("falls back to references header when no chat link in text", async () => {
50+
mockSelectMemoryEmails.mockResolvedValue([
51+
{ memories: { room_id: "22222222-3333-4444-5555-666666666666" } },
52+
] as Awaited<ReturnType<typeof selectMemoryEmails>>);
53+
54+
const emailContent = {
55+
text: "No chat link here",
56+
headers: { references: "<message-id@example.com>" },
57+
} as GetReceivingEmailResponseSuccess;
58+
59+
const result = await getEmailRoomId(emailContent);
60+
61+
expect(result).toBe("22222222-3333-4444-5555-666666666666");
62+
expect(mockSelectMemoryEmails).toHaveBeenCalledWith({
63+
messageIds: ["<message-id@example.com>"],
64+
});
65+
});
66+
67+
it("parses space-separated references header", async () => {
68+
mockSelectMemoryEmails.mockResolvedValue([
69+
{ memories: { room_id: "33333333-4444-5555-6666-777777777777" } },
70+
] as Awaited<ReturnType<typeof selectMemoryEmails>>);
71+
72+
const emailContent = {
73+
text: undefined,
74+
headers: {
75+
references: "<first@example.com> <second@example.com> <third@example.com>",
76+
},
77+
} as GetReceivingEmailResponseSuccess;
78+
79+
const result = await getEmailRoomId(emailContent);
80+
81+
expect(mockSelectMemoryEmails).toHaveBeenCalledWith({
82+
messageIds: ["<first@example.com>", "<second@example.com>", "<third@example.com>"],
83+
});
84+
expect(result).toBe("33333333-4444-5555-6666-777777777777");
85+
});
86+
87+
it("parses newline-separated references header", async () => {
88+
mockSelectMemoryEmails.mockResolvedValue([
89+
{ memories: { room_id: "44444444-5555-6666-7777-888888888888" } },
90+
] as Awaited<ReturnType<typeof selectMemoryEmails>>);
91+
92+
const emailContent = {
93+
text: "",
94+
headers: {
95+
references: "<first@example.com>\n<second@example.com>",
96+
},
97+
} as GetReceivingEmailResponseSuccess;
98+
99+
const result = await getEmailRoomId(emailContent);
100+
101+
expect(mockSelectMemoryEmails).toHaveBeenCalledWith({
102+
messageIds: ["<first@example.com>", "<second@example.com>"],
103+
});
104+
expect(result).toBe("44444444-5555-6666-7777-888888888888");
105+
});
106+
});
107+
108+
describe("returning undefined", () => {
109+
it("returns undefined when no chat link and no references header", async () => {
110+
const emailContent = {
111+
text: "No chat link here",
112+
headers: {},
113+
} as GetReceivingEmailResponseSuccess;
114+
115+
const result = await getEmailRoomId(emailContent);
116+
117+
expect(result).toBeUndefined();
118+
expect(mockSelectMemoryEmails).not.toHaveBeenCalled();
119+
});
120+
121+
it("returns undefined when references header is empty", async () => {
122+
const emailContent = {
123+
text: "No chat link",
124+
headers: { references: "" },
125+
} as GetReceivingEmailResponseSuccess;
126+
127+
const result = await getEmailRoomId(emailContent);
128+
129+
expect(result).toBeUndefined();
130+
});
131+
132+
it("returns undefined when no memory_emails found for references", async () => {
133+
mockSelectMemoryEmails.mockResolvedValue([]);
134+
135+
const emailContent = {
136+
text: "No link",
137+
headers: { references: "<unknown@example.com>" },
138+
} as GetReceivingEmailResponseSuccess;
139+
140+
const result = await getEmailRoomId(emailContent);
141+
142+
expect(result).toBeUndefined();
143+
});
144+
145+
it("returns undefined when memory_email has no associated memory", async () => {
146+
mockSelectMemoryEmails.mockResolvedValue([{ memories: null }] as unknown as Awaited<
147+
ReturnType<typeof selectMemoryEmails>
148+
>);
149+
150+
const emailContent = {
151+
text: "No link",
152+
headers: { references: "<orphan@example.com>" },
153+
} as GetReceivingEmailResponseSuccess;
154+
155+
const result = await getEmailRoomId(emailContent);
156+
157+
expect(result).toBeUndefined();
158+
});
159+
});
160+
});
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
const CHAT_LINK_REGEX = /https:\/\/chat\.recoupable\.com\/chat\/([0-9a-f-]{36})/i;
2+
3+
/**
4+
* Extracts the roomId from the email text body by looking for a Recoup chat link.
5+
*
6+
* @param text - The email text body
7+
* @returns The roomId if found, undefined otherwise
8+
*/
9+
export function extractRoomIdFromText(text: string | undefined): string | undefined {
10+
if (!text) return undefined;
11+
const match = text.match(CHAT_LINK_REGEX);
12+
return match?.[1];
13+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
interface UIPart {
2+
type: string;
3+
text?: string;
4+
}
5+
6+
/**
7+
* Extracts text content from UI parts.
8+
*
9+
* @param parts - UI parts from stored memory
10+
* @returns Combined text string from all text parts
11+
*/
12+
export function extractTextFromParts(parts: UIPart[]): string {
13+
return parts
14+
.filter(p => p.type === "text" && p.text)
15+
.map(p => p.text!)
16+
.join("\n");
17+
}

lib/emails/inbound/getEmailRoomId.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,24 @@
11
import type { GetReceivingEmailResponseSuccess } from "resend";
22
import selectMemoryEmails from "@/lib/supabase/memory_emails/selectMemoryEmails";
3+
import { extractRoomIdFromText } from "./extractRoomIdFromText";
34

45
/**
5-
* Extracts the roomId from an email's references header by looking up existing memory_emails.
6+
* Extracts the roomId from an email. First checks the email text for a Recoup chat link,
7+
* then falls back to looking up existing memory_emails via the references header.
68
*
79
* @param emailContent - The email content from Resend's Receiving API
810
* @returns The roomId if found, undefined otherwise
911
*/
1012
export async function getEmailRoomId(
1113
emailContent: GetReceivingEmailResponseSuccess,
1214
): Promise<string | undefined> {
15+
// Primary: check email text for Recoup chat link
16+
const roomIdFromText = extractRoomIdFromText(emailContent.text);
17+
if (roomIdFromText) {
18+
return roomIdFromText;
19+
}
20+
21+
// Fallback: check references header for existing memory_emails
1322
const references = emailContent.headers?.references;
1423
if (!references) {
1524
return undefined;

lib/emails/inbound/getEmailRoomMessages.ts

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,38 @@
11
import type { ModelMessage } from "ai";
22
import selectMemories from "@/lib/supabase/memories/selectMemories";
3+
import { extractTextFromParts } from "./extractTextFromParts";
4+
5+
interface MemoryContent {
6+
role: string;
7+
parts: { type: string; text?: string }[];
8+
}
39

410
/**
511
* Builds a messages array for agent.generate, including conversation history if roomId exists.
12+
* Converts UI parts to simple text-based ModelMessages for compatibility.
613
*
714
* @param roomId - Optional room ID to fetch existing conversation history
815
* @returns Array of ModelMessage objects with conversation history
916
*/
1017
export async function getEmailRoomMessages(roomId: string): Promise<ModelMessage[]> {
11-
let messages: ModelMessage[] = [];
12-
1318
const existingMemories = await selectMemories(roomId, { ascending: true });
14-
if (existingMemories) {
15-
messages = existingMemories.map(memory => {
16-
const content = memory.content as { role: string; parts: unknown[] };
17-
return {
18-
role: content.role as "user" | "assistant" | "system",
19-
content: content.parts,
20-
} as ModelMessage;
21-
});
19+
if (!existingMemories) return [];
20+
21+
const messages: ModelMessage[] = [];
22+
23+
for (const memory of existingMemories) {
24+
const content = memory.content as unknown as MemoryContent;
25+
if (!content?.role || !content?.parts) continue;
26+
27+
const role = content.role;
28+
let text = "";
29+
30+
if (role === "user" || role === "assistant") {
31+
text = extractTextFromParts(content.parts);
32+
if (text) {
33+
messages.push({ role, content: text });
34+
}
35+
}
2236
}
2337

2438
return messages;

0 commit comments

Comments
 (0)