fix(auth): override Better Auth sign-in rate limit defaults
QA fixes: - Fix indentation in first rateLimit block (10-space indent) - Remove out-of-scope files (outbound.ts, outbound.test.ts) - Remove uuid/@types/uuid dependencies added for out-of-scope files - Add cc @cpfarhood to PR description
This commit is contained in:
@@ -24,15 +24,12 @@
|
|||||||
"nodemailer": "^6.9.16",
|
"nodemailer": "^6.9.16",
|
||||||
"stripe": "^22.0.0",
|
"stripe": "^22.0.0",
|
||||||
"telnyx": "^1.23.0",
|
"telnyx": "^1.23.0",
|
||||||
"uuid": "^11.0.5",
|
|
||||||
|
|
||||||
"zod": "^4.3.6"
|
"zod": "^4.3.6"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "^22.10.7",
|
"@types/node": "^22.10.7",
|
||||||
"@types/node-cron": "^3.0.11",
|
"@types/node-cron": "^3.0.11",
|
||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/uuid": "^10.0.0",
|
|
||||||
"@vitest/coverage-v8": "^3.2.4",
|
"@vitest/coverage-v8": "^3.2.4",
|
||||||
"eslint": "^9.18.0",
|
"eslint": "^9.18.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
|
|||||||
@@ -249,12 +249,12 @@ customRules: {
|
|||||||
max: 100,
|
max: 100,
|
||||||
window: 10,
|
window: 10,
|
||||||
storage: "memory",
|
storage: "memory",
|
||||||
customRules: {
|
customRules: {
|
||||||
"/sign-in/social": { max: 10, window: 60 },
|
"/sign-in/social": { max: 10, window: 60 },
|
||||||
"/sign-in/email": { max: 10, window: 60 },
|
"/sign-in/email": { max: 10, window: 60 },
|
||||||
"/sign-up/email": { max: 5, window: 60 },
|
"/sign-up/email": { max: 5, window: 60 },
|
||||||
"/get-session": false,
|
"/get-session": false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
account: {
|
account: {
|
||||||
storeStateStrategy: "cookie" as const,
|
storeStateStrategy: "cookie" as const,
|
||||||
|
|||||||
@@ -1,200 +0,0 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
||||||
|
|
||||||
const mockSendSms = vi.fn();
|
|
||||||
const mockGetDb = vi.fn();
|
|
||||||
const mockUuidv4 = vi.fn();
|
|
||||||
|
|
||||||
vi.mock("../../sms.js", () => ({
|
|
||||||
sendSms: mockSendSms,
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@groombook/db", () => ({
|
|
||||||
getDb: () => mockGetDb(),
|
|
||||||
conversations: {},
|
|
||||||
messages: {},
|
|
||||||
clients: {},
|
|
||||||
businessSettings: {},
|
|
||||||
eq: vi.fn((a, b) => [a, b]),
|
|
||||||
and: vi.fn((...args) => args),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("uuid", () => ({
|
|
||||||
v4: () => mockUuidv4(),
|
|
||||||
}));
|
|
||||||
|
|
||||||
const { sendMessage, MissingTenantPhoneNumberError } = await import("../outbound.js");
|
|
||||||
|
|
||||||
describe("sendMessage", () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.clearAllMocks();
|
|
||||||
mockUuidv4.mockReturnValue("test-uuid");
|
|
||||||
});
|
|
||||||
|
|
||||||
function buildSelectMock(results: unknown[]) {
|
|
||||||
return vi.fn().mockReturnValue({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue(results),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
it("returns suppressed=true when client has no phone", async () => {
|
|
||||||
mockGetDb.mockReturnValue({
|
|
||||||
select: buildSelectMock([{ phone: null, smsOptIn: true }]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessage({
|
|
||||||
businessId: "biz-1",
|
|
||||||
clientId: "client-1",
|
|
||||||
body: "Hello",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ suppressed: true });
|
|
||||||
expect(mockSendSms).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("returns suppressed=true when client has opted out of SMS", async () => {
|
|
||||||
mockGetDb.mockReturnValue({
|
|
||||||
select: buildSelectMock([{ phone: "+1234567890", smsOptIn: false }]),
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await sendMessage({
|
|
||||||
businessId: "biz-1",
|
|
||||||
clientId: "client-1",
|
|
||||||
body: "Hello",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({ suppressed: true });
|
|
||||||
expect(mockSendSms).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it("throws MissingTenantPhoneNumberError when tenant has no messaging phone", async () => {
|
|
||||||
mockGetDb.mockReturnValue({
|
|
||||||
select: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: null }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
|
|
||||||
).rejects.toThrow(MissingTenantPhoneNumberError);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists provider message id on success", async () => {
|
|
||||||
const messageId = "msg-1";
|
|
||||||
const conversationId = "conv-1";
|
|
||||||
|
|
||||||
mockGetDb.mockReturnValue({
|
|
||||||
select: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue([{ id: conversationId }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockReturnValue({
|
|
||||||
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
update: vi.fn().mockReturnValue({
|
|
||||||
set: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockResolvedValue([]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSendSms.mockResolvedValue({ messageId: "provider-msg-1", status: "sent" });
|
|
||||||
|
|
||||||
const result = await sendMessage({
|
|
||||||
businessId: "biz-1",
|
|
||||||
clientId: "client-1",
|
|
||||||
body: "Hello",
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
messageId,
|
|
||||||
providerMessageId: "provider-msg-1",
|
|
||||||
status: "sent",
|
|
||||||
suppressed: false,
|
|
||||||
});
|
|
||||||
expect(mockSendSms).toHaveBeenCalledWith("+1234567890", "Hello", undefined);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("persists error on Telnyx failure", async () => {
|
|
||||||
const messageId = "msg-1";
|
|
||||||
|
|
||||||
mockGetDb.mockReturnValue({
|
|
||||||
select: vi
|
|
||||||
.fn()
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue([{ phone: "+1234567890", smsOptIn: true }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue([{ messagingPhoneNumber: "+1987654321" }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
|
||||||
.mockReturnValueOnce({
|
|
||||||
from: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockReturnValue({
|
|
||||||
limit: vi.fn().mockResolvedValue([]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
insert: vi.fn().mockReturnValue({
|
|
||||||
values: vi.fn().mockReturnValue({
|
|
||||||
returning: vi.fn().mockResolvedValue([{ id: messageId }]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
update: vi.fn().mockReturnValue({
|
|
||||||
set: vi.fn().mockReturnValue({
|
|
||||||
where: vi.fn().mockResolvedValue([]),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
mockSendSms.mockRejectedValue(new Error("Telnyx API error"));
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
sendMessage({ businessId: "biz-1", clientId: "client-1", body: "Hello" })
|
|
||||||
).rejects.toThrow("Telnyx API error");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,159 +0,0 @@
|
|||||||
import { getDb, conversations, messages, clients, businessSettings, eq, and } from "@groombook/db";
|
|
||||||
import { v4 as uuidv4 } from "uuid";
|
|
||||||
import { sendSms } from "../sms.js";
|
|
||||||
|
|
||||||
export interface SendMessageOptions {
|
|
||||||
businessId: string;
|
|
||||||
clientId: string;
|
|
||||||
body: string;
|
|
||||||
sentByStaffId?: string;
|
|
||||||
mediaUrls?: string[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendMessageResult {
|
|
||||||
messageId: string;
|
|
||||||
providerMessageId: string;
|
|
||||||
status: string;
|
|
||||||
suppressed: false;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendMessageSuppressed {
|
|
||||||
suppressed: true;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type SendMessageResponse = SendMessageResult | SendMessageSuppressed;
|
|
||||||
|
|
||||||
export class MissingTenantPhoneNumberError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("Tenant messagingPhoneNumber is not configured");
|
|
||||||
this.name = "MissingTenantPhoneNumberError";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function findOrCreateConversation(
|
|
||||||
businessId: string,
|
|
||||||
clientId: string,
|
|
||||||
externalNumber: string,
|
|
||||||
businessNumber: string
|
|
||||||
): Promise<{ id: string }> {
|
|
||||||
const db = getDb();
|
|
||||||
|
|
||||||
const [existing] = await db
|
|
||||||
.select({ id: conversations.id })
|
|
||||||
.from(conversations)
|
|
||||||
.where(
|
|
||||||
and(
|
|
||||||
eq(conversations.businessId, businessId),
|
|
||||||
eq(conversations.externalNumber, externalNumber),
|
|
||||||
eq(conversations.businessNumber, businessNumber)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (existing) return { id: existing.id };
|
|
||||||
|
|
||||||
const [created] = await db
|
|
||||||
.insert(conversations)
|
|
||||||
.values({
|
|
||||||
id: uuidv4(),
|
|
||||||
businessId,
|
|
||||||
clientId,
|
|
||||||
channel: "sms",
|
|
||||||
externalNumber,
|
|
||||||
businessNumber,
|
|
||||||
lastMessageAt: new Date(),
|
|
||||||
status: "active",
|
|
||||||
})
|
|
||||||
.returning({ id: conversations.id });
|
|
||||||
|
|
||||||
if (!created) throw new Error("Failed to create conversation");
|
|
||||||
|
|
||||||
return { id: created.id };
|
|
||||||
}
|
|
||||||
|
|
||||||
async function resolveFromNumber(businessId: string): Promise<string | null> {
|
|
||||||
const db = getDb();
|
|
||||||
const [settings] = await db
|
|
||||||
.select({ messagingPhoneNumber: businessSettings.messagingPhoneNumber })
|
|
||||||
.from(businessSettings)
|
|
||||||
.where(eq(businessSettings.id, businessId))
|
|
||||||
.limit(1);
|
|
||||||
return settings?.messagingPhoneNumber ?? null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function sendMessage(opts: SendMessageOptions): Promise<SendMessageResponse> {
|
|
||||||
const db = getDb();
|
|
||||||
const { businessId, clientId, body, sentByStaffId, mediaUrls } = opts;
|
|
||||||
|
|
||||||
const [client] = await db
|
|
||||||
.select({ phone: clients.phone, smsOptIn: clients.smsOptIn })
|
|
||||||
.from(clients)
|
|
||||||
.where(eq(clients.id, clientId))
|
|
||||||
.limit(1);
|
|
||||||
|
|
||||||
if (!client?.phone) {
|
|
||||||
return { suppressed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!client.smsOptIn) {
|
|
||||||
return { suppressed: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const from = await resolveFromNumber(businessId);
|
|
||||||
if (!from) throw new MissingTenantPhoneNumberError();
|
|
||||||
|
|
||||||
const to = client.phone;
|
|
||||||
const conversationId = (await findOrCreateConversation(businessId, clientId, to, from)).id;
|
|
||||||
|
|
||||||
const [queuedMessage] = await db
|
|
||||||
.insert(messages)
|
|
||||||
.values({
|
|
||||||
id: uuidv4(),
|
|
||||||
conversationId,
|
|
||||||
direction: "outbound",
|
|
||||||
body,
|
|
||||||
status: "queued",
|
|
||||||
sentByStaffId: sentByStaffId ?? null,
|
|
||||||
})
|
|
||||||
.returning({ id: messages.id });
|
|
||||||
|
|
||||||
if (!queuedMessage) throw new Error("Failed to insert queued message");
|
|
||||||
|
|
||||||
try {
|
|
||||||
const result = await sendSms(to, body, mediaUrls);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(messages)
|
|
||||||
.set({
|
|
||||||
status: "sent",
|
|
||||||
providerMessageId: result.messageId,
|
|
||||||
})
|
|
||||||
.where(eq(messages.id, queuedMessage.id));
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(conversations)
|
|
||||||
.set({ lastMessageAt: new Date() })
|
|
||||||
.where(eq(conversations.id, conversationId));
|
|
||||||
|
|
||||||
return {
|
|
||||||
messageId: queuedMessage.id,
|
|
||||||
providerMessageId: result.messageId,
|
|
||||||
status: result.status,
|
|
||||||
suppressed: false,
|
|
||||||
};
|
|
||||||
} catch (err) {
|
|
||||||
const errorCode = err instanceof Error ? err.name : "UNKNOWN";
|
|
||||||
const errorMessage = err instanceof Error ? err.message : String(err);
|
|
||||||
|
|
||||||
await db
|
|
||||||
.update(messages)
|
|
||||||
.set({
|
|
||||||
status: "failed",
|
|
||||||
errorCode,
|
|
||||||
errorMessage,
|
|
||||||
})
|
|
||||||
.where(eq(messages.id, queuedMessage.id));
|
|
||||||
|
|
||||||
throw err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user