Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 17 additions & 2 deletions apps/builder/src/features/inboxes/api/private.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { listInboxesRequest, listInboxesResponse } from "@chatbotx.io/business"
import {
listAllConnectedInboxesRequest,
listAllConnectedInboxesResponse,
listInboxesRequest,
listInboxesResponse,
} from "@chatbotx.io/business"
import { workspaceAuthorizedMidddleware } from "@/middlewares/auth"
import { authorizedAPI } from "@/orpc"
import { listInboxes } from "../queries"
import { listAllConnectedInboxes, listInboxes } from "../queries"

export const inboxesAuthenticatedAPI = {
listInboxesAuthenticatedAPI: authorizedAPI
Expand All @@ -15,4 +20,14 @@ export const inboxesAuthenticatedAPI = {
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
.output(listInboxesResponse)
.handler(async ({ input }) => await listInboxes(input)),

// RPC-only (no `.route()`): the builder inbox store needs the complete
// connected set, which the paginated `list` caps at 50. Kept off the
// documented OpenAPI surface so "return everything" is not offered as a
// public endpoint — the public token API stays paginated.
listAllInboxesAuthenticatedAPI: authorizedAPI
.input(listAllConnectedInboxesRequest)
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
.output(listAllConnectedInboxesResponse)
.handler(async ({ input }) => await listAllConnectedInboxes(input)),
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,37 +2,36 @@ import { ORPCError } from "@orpc/client"
import { beforeEach, describe, expect, test, vi } from "vitest"

const mocks = vi.hoisted(() => ({
listInboxesAuthenticatedAPI: vi.fn(),
listAllInboxesAuthenticatedAPI: vi.fn(),
}))

vi.mock("@/lib/orpc/orpc", () => ({
client: {
inboxesAPI: {
listInboxesAuthenticatedAPI: mocks.listInboxesAuthenticatedAPI,
listAllInboxesAuthenticatedAPI: mocks.listAllInboxesAuthenticatedAPI,
},
},
}))

const { createInboxStore } = await import("../inbox-store")

beforeEach(() => {
mocks.listInboxesAuthenticatedAPI.mockReset()
mocks.listAllInboxesAuthenticatedAPI.mockReset()
})

describe("getAllInboxes", () => {
test("fetches inboxes for the workspace with includes and maxPerPage", async () => {
mocks.listInboxesAuthenticatedAPI.mockResolvedValueOnce({
test("fetches every inbox for the workspace with integrations, unpaginated", async () => {
mocks.listAllInboxesAuthenticatedAPI.mockResolvedValueOnce({
data: [{ id: "inbox-1", name: "Support" }],
})

const store = createInboxStore({ workspaceId: "workspace-1" })

await store.getState().getAllInboxes()

expect(mocks.listInboxesAuthenticatedAPI).toHaveBeenCalledWith({
expect(mocks.listAllInboxesAuthenticatedAPI).toHaveBeenCalledWith({
workspaceId: "workspace-1",
includes: ["integration"],
perPage: 999_999_999,
})
expect(store.getState().inboxes).toEqual([
{ id: "inbox-1", name: "Support" },
Expand All @@ -44,29 +43,29 @@ describe("getAllInboxes", () => {

await store.getState().getAllInboxes()

expect(mocks.listInboxesAuthenticatedAPI).not.toHaveBeenCalled()
expect(mocks.listAllInboxesAuthenticatedAPI).not.toHaveBeenCalled()
})

test("is a no-op while a fetch is already in flight", async () => {
let resolveFetch!: (value: { data: unknown[] }) => void
const pending = new Promise<{ data: unknown[] }>((resolve) => {
resolveFetch = resolve
})
mocks.listInboxesAuthenticatedAPI.mockReturnValueOnce(pending)
mocks.listAllInboxesAuthenticatedAPI.mockReturnValueOnce(pending)

const store = createInboxStore({ workspaceId: "workspace-1" })

const first = store.getState().getAllInboxes()
await store.getState().getAllInboxes()

expect(mocks.listInboxesAuthenticatedAPI).toHaveBeenCalledTimes(1)
expect(mocks.listAllInboxesAuthenticatedAPI).toHaveBeenCalledTimes(1)

resolveFetch({ data: [] })
await first
})

test("sets the ORPCError message on a rejected request", async () => {
mocks.listInboxesAuthenticatedAPI.mockRejectedValueOnce(
mocks.listAllInboxesAuthenticatedAPI.mockRejectedValueOnce(
new ORPCError("INTERNAL_SERVER_ERROR", { message: "HTTP 500" }),
)

Expand All @@ -79,7 +78,7 @@ describe("getAllInboxes", () => {
})

test("falls back to a generic message for a non-ORPCError rejection", async () => {
mocks.listInboxesAuthenticatedAPI.mockRejectedValueOnce(
mocks.listAllInboxesAuthenticatedAPI.mockRejectedValueOnce(
new Error("network down"),
)

Expand All @@ -93,30 +92,30 @@ describe("getAllInboxes", () => {

describe("initialize", () => {
test("calls getAllInboxes once and marks the store initialized", async () => {
mocks.listInboxesAuthenticatedAPI.mockResolvedValueOnce({
mocks.listAllInboxesAuthenticatedAPI.mockResolvedValueOnce({
data: [{ id: "inbox-1", name: "Support" }],
})

const store = createInboxStore({ workspaceId: "workspace-1" })

await store.getState().initialize()

expect(mocks.listInboxesAuthenticatedAPI).toHaveBeenCalledTimes(1)
expect(mocks.listAllInboxesAuthenticatedAPI).toHaveBeenCalledTimes(1)
expect(store.getState().inboxes).toEqual([
{ id: "inbox-1", name: "Support" },
])
expect(store.getState().initialized).toBe(true)
})

test("does not fetch again once already initialized", async () => {
mocks.listInboxesAuthenticatedAPI.mockResolvedValue({ data: [] })
mocks.listAllInboxesAuthenticatedAPI.mockResolvedValue({ data: [] })

const store = createInboxStore({ workspaceId: "workspace-1" })

await store.getState().initialize()
await store.getState().initialize()

expect(mocks.listInboxesAuthenticatedAPI).toHaveBeenCalledTimes(1)
expect(mocks.listAllInboxesAuthenticatedAPI).toHaveBeenCalledTimes(1)
})

test("still marks the store initialized when getAllInboxes fails", async () => {
Expand All @@ -125,7 +124,7 @@ describe("initialize", () => {
// failure directly — but its `finally` unconditionally marks
// `initialized: true` regardless, and getAllInboxes's error is still
// visible on the shared `error` field.
mocks.listInboxesAuthenticatedAPI.mockRejectedValueOnce(
mocks.listAllInboxesAuthenticatedAPI.mockRejectedValueOnce(
new ORPCError("INTERNAL_SERVER_ERROR", { message: "HTTP 500" }),
)

Expand Down
19 changes: 11 additions & 8 deletions apps/builder/src/features/inboxes/provider/inbox-store.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { ListInboxesResponse } from "@chatbotx.io/business"
import type { ListAllConnectedInboxesResponse } from "@chatbotx.io/business"
import { createStore } from "zustand/vanilla"
import { getClientErrorMessage } from "@/lib/orpc/client-error"
import { client } from "@/lib/orpc/orpc"
import { maxPerPage } from "@/lib/shared-request"

export type InboxState = {
error: string | null
Expand All @@ -11,7 +10,7 @@ export type InboxState = {
workspaceId: string

loadingInboxes: boolean
inboxes: ListInboxesResponse["data"]
inboxes: ListAllConnectedInboxesResponse["data"]
}

export type InboxActions = {
Expand Down Expand Up @@ -59,11 +58,15 @@ export const createInboxStore = (props: Partial<InboxState>) =>
}
set({ loadingInboxes: true, error: null })
try {
const { data } = await client.inboxesAPI.listInboxesAuthenticatedAPI({
workspaceId,
includes: ["integration"],
perPage: maxPerPage,
})
// The unpaginated endpoint: the paginated list caps at 50 rows, which
// would hide inboxes from every store consumer (broadcast page
// picker, inbox/contacts filters) in a workspace with more than 50.
const { data } = await client.inboxesAPI.listAllInboxesAuthenticatedAPI(
{
workspaceId,
includes: ["integration"],
},
)

set({ inboxes: data })
} catch (error: unknown) {
Expand Down
12 changes: 11 additions & 1 deletion apps/builder/src/features/inboxes/queries/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,18 @@
import type { ListInboxesResponse } from "@chatbotx.io/business"
import type {
ListAllConnectedInboxesRequest,
ListAllConnectedInboxesResponse,
ListInboxesResponse,
} from "@chatbotx.io/business"
import { inboxService, type ListInboxesRequest } from "@chatbotx.io/business"

export async function listInboxes(
input: ListInboxesRequest,
): Promise<ListInboxesResponse> {
return await inboxService.list(input)
}

export async function listAllConnectedInboxes(
input: ListAllConnectedInboxesRequest,
): Promise<ListAllConnectedInboxesResponse> {
return await inboxService.listAllConnectedByWorkspace(input)
}
42 changes: 42 additions & 0 deletions packages/business/src/inbox/__tests__/service.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -322,3 +322,45 @@ describe("InboxService.list", () => {
expect(mocks.count).toHaveBeenCalledTimes(1)
})
})

describe("InboxService.listAllConnectedByWorkspace", () => {
test("returns every connected inbox with no page limit and no count query", async () => {
mocks.inboxFindMany.mockResolvedValue([
{ id: "inbox-1" },
{ id: "inbox-2" },
])

const result = await inboxService.listAllConnectedByWorkspace({
workspaceId: "workspace-1",
})

expect(result).toEqual({ data: [{ id: "inbox-1" }, { id: "inbox-2" }] })
expect(mocks.inboxFindMany).toHaveBeenCalledWith({
where: {
workspaceId: "workspace-1",
status: "connected",
},
with: undefined,
})
// No pagination: the 50-row `maxLimit` that caps `list` must not apply.
const call = mocks.inboxFindMany.mock.calls[0][0]
expect(call).not.toHaveProperty("limit")
expect(call).not.toHaveProperty("offset")
expect(mocks.count).not.toHaveBeenCalled()
})

test("eager-loads integrations when includes asks for them", async () => {
mocks.inboxFindMany.mockResolvedValue([])

await inboxService.listAllConnectedByWorkspace({
workspaceId: "workspace-1",
includes: ["integration"],
})

expect(mocks.inboxFindMany).toHaveBeenCalledWith(
expect.objectContaining({
with: expect.objectContaining({ integrationMessenger: true }),
}),
)
})
})
45 changes: 34 additions & 11 deletions packages/business/src/inbox/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,18 +36,41 @@ export const inboxResource = createSelectSchema(inboxModel, {
id: zodBigintAsString(),
workspaceId: zodBigintAsString(),
})
export const inboxWithIntegrationsResource = inboxResource.extend({
integrationWhatsapp: integrationWhatsappResource.nullish(),
integrationWebchat: integrationWebchatResource.nullish(),
integrationMessenger: integrationMessengerResource.nullish(),
integrationZalo: integrationZaloResource.nullish(),
integrationTelegram: integrationTelegramResource.nullish(),
integrationInstagram: integrationInstagramResource.nullish(),
integrationSmtp: integrationSmtpResource.nullish(),
})

export const listInboxesResponse = z.object({
data: z.array(
inboxResource.extend({
integrationWhatsapp: integrationWhatsappResource.nullish(),
integrationWebchat: integrationWebchatResource.nullish(),
integrationMessenger: integrationMessengerResource.nullish(),
integrationZalo: integrationZaloResource.nullish(),
integrationTelegram: integrationTelegramResource.nullish(),
integrationInstagram: integrationInstagramResource.nullish(),
integrationSmtp: integrationSmtpResource.nullish(),
}),
),
data: z.array(inboxWithIntegrationsResource),
pageCount: z.number(),
})
export type ListInboxesResponse = z.infer<typeof listInboxesResponse>

/**
* Unpaginated companion to `listInboxesRequest`. The paginated `list` is
* capped at `maxLimit` (50) rows, which silently truncates a workspace with
* more connected inboxes than that — so any client that needs the complete
* set (e.g. the builder's inbox store, which the broadcast page picker reads
* from) uses this instead. No `page`/`perPage`: the whole connected set is
* always returned.
*/
export const listAllConnectedInboxesRequest = listInboxesRequest.pick({
workspaceId: true,
includes: true,
})
export type ListAllConnectedInboxesRequest = z.infer<
typeof listAllConnectedInboxesRequest
>

export const listAllConnectedInboxesResponse = z.object({
data: z.array(inboxWithIntegrationsResource),
})
export type ListAllConnectedInboxesResponse = z.infer<
typeof listAllConnectedInboxesResponse
>
Loading
Loading