Skip to content

Commit 870e35a

Browse files
feat(messenger): let workspace admins clone templates to their pages (#1123)
* style(broadcast): narrow the status sidebar * feat(messenger): let workspace admins clone templates to their pages --------- Co-authored-by: Hung Phan Viet <hungphan@ahachat.com>
1 parent 1ed43a4 commit 870e35a

9 files changed

Lines changed: 408 additions & 93 deletions

File tree

apps/builder/__tests__/messenger-clone-template-components.test.ts

Lines changed: 128 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -2,33 +2,35 @@
22
import { beforeEach, describe, expect, test, vi } from "vitest"
33

44
const resumableUploadImage = vi.fn(async () => "new-handle")
5+
const createPageMessageTemplate = vi.fn()
6+
const syncTemplates = vi.fn()
7+
const findByIdForWorkspace = vi.fn()
8+
const listCloneTargetsForUser = vi.fn()
9+
const findByIdForIntegration = vi.fn()
10+
11+
// Captures the handler the safe-action chain wraps so the action's
12+
// authorization can be exercised directly.
13+
let cloneHandler: ((props: unknown) => Promise<unknown>) | null = null
514

615
vi.mock("@chatbotx.io/integration-messenger/apis/upload", () => ({
716
resumableUploadImage,
817
}))
918

10-
vi.mock("@chatbotx.io/database/client", () => ({
11-
db: {},
12-
inArray: vi.fn(),
13-
}))
14-
15-
vi.mock("@chatbotx.io/database/schema", () => ({
16-
integrationMessengerModel: {},
17-
messengerMessageTemplateModel: {},
18-
}))
19-
2019
vi.mock("@chatbotx.io/business", () => ({
2120
messengerIntegrationService: {
22-
findByIdForWorkspace: vi.fn(),
23-
findByIds: vi.fn(),
21+
findByIdForWorkspace: (...args: unknown[]) => findByIdForWorkspace(...args),
22+
listCloneTargetsForUser: (...args: unknown[]) =>
23+
listCloneTargetsForUser(...args),
2424
},
2525
messengerMessageTemplateService: {
26-
findByIdForIntegration: vi.fn(),
26+
findByIdForIntegration: (...args: unknown[]) =>
27+
findByIdForIntegration(...args),
2728
},
2829
}))
2930

3031
vi.mock("@chatbotx.io/integration-messenger/apis/message-templates", () => ({
31-
createPageMessageTemplate: vi.fn(),
32+
createPageMessageTemplate: (...args: unknown[]) =>
33+
createPageMessageTemplate(...args),
3234
}))
3335

3436
vi.mock("@chatbotx.io/redis", () => ({
@@ -38,21 +40,21 @@ vi.mock("@chatbotx.io/redis", () => ({
3840
vi.mock(
3941
"@/features/integration-messenger/message-templates/actions/sync-message-templates",
4042
() => ({
41-
syncMessengerMessageTemplatesForIntegration: vi.fn(),
43+
syncMessengerMessageTemplatesForIntegration: (...args: unknown[]) =>
44+
syncTemplates(...args),
4245
}),
4346
)
4447

45-
vi.mock("@/features/workspace-members/queries", () => ({
46-
getAllWorkspaceMembers: vi.fn(),
47-
}))
48-
4948
vi.mock("@/lib/safe-action", () => ({
5049
workspaceActionClient: {
51-
bindArgsSchemas: vi.fn(() => ({
52-
schema: vi.fn(() => ({
53-
action: vi.fn(),
54-
})),
55-
})),
50+
bindArgsSchemas: () => ({
51+
schema: () => ({
52+
action: (handler: (props: unknown) => Promise<unknown>) => {
53+
cloneHandler = handler
54+
return handler
55+
},
56+
}),
57+
}),
5658
},
5759
}))
5860

@@ -148,3 +150,105 @@ describe("prepareComponentsForClone", () => {
148150
})
149151
})
150152
})
153+
154+
type CloneResult = {
155+
succeeded: { channel: string }[]
156+
failed: { channel: string; error: string }[]
157+
}
158+
159+
const sourceTemplate = {
160+
id: "tpl-src",
161+
integrationMessengerId: "im-source",
162+
name: "promo",
163+
language: "vi",
164+
category: "MARKETING",
165+
parameterFormat: "POSITIONAL",
166+
components: [{ type: "BODY", text: "Hi" }],
167+
}
168+
const target = (id: string, workspaceId = "ws-other") => ({
169+
id,
170+
name: `Page ${id}`,
171+
workspaceId,
172+
pageId: `page-${id}`,
173+
auth: { accessToken: "token" },
174+
})
175+
176+
const run = (targetIntegrationMessengerIds: string[]) => {
177+
if (!cloneHandler) {
178+
throw new Error("clone action handler was not captured")
179+
}
180+
return cloneHandler({
181+
bindArgsParsedInputs: ["ws-source", "im-source", "tpl-src"],
182+
parsedInput: { targetIntegrationMessengerIds },
183+
ctx: { user: { id: "user-1" } },
184+
}) as Promise<CloneResult>
185+
}
186+
187+
describe("cloneMessengerMessageTemplateAction authorization", () => {
188+
beforeEach(() => {
189+
findByIdForIntegration.mockReset().mockResolvedValue(sourceTemplate)
190+
findByIdForWorkspace
191+
.mockReset()
192+
.mockResolvedValue({ id: "im-source", pageId: "page-source" })
193+
listCloneTargetsForUser.mockReset()
194+
createPageMessageTemplate.mockReset().mockResolvedValue({
195+
id: "meta-new",
196+
status: "APPROVED",
197+
})
198+
syncTemplates.mockReset().mockResolvedValue(undefined)
199+
resumableUploadImage.mockClear()
200+
})
201+
202+
test("clones onto the requested pages the user administers, read uncached", async () => {
203+
listCloneTargetsForUser.mockResolvedValue([
204+
target("im-admin"),
205+
target("im-owner", "ws-owner"),
206+
])
207+
208+
const result = await run(["im-admin", "im-owner"])
209+
210+
expect(listCloneTargetsForUser).toHaveBeenCalledWith({
211+
userId: "user-1",
212+
excludePageId: "page-source",
213+
authoritative: true,
214+
})
215+
expect(createPageMessageTemplate).toHaveBeenCalledTimes(2)
216+
expect(result.succeeded).toEqual([
217+
{ channel: "Page im-admin" },
218+
{ channel: "Page im-owner" },
219+
])
220+
expect(result.failed).toEqual([])
221+
})
222+
223+
test("drops requested pages outside the user's admin workspaces", async () => {
224+
listCloneTargetsForUser.mockResolvedValue([target("im-admin")])
225+
226+
const result = await run(["im-admin", "im-foreign"])
227+
228+
expect(createPageMessageTemplate).toHaveBeenCalledTimes(1)
229+
expect(result.succeeded).toEqual([{ channel: "Page im-admin" }])
230+
})
231+
232+
test("fails when none of the requested pages is authorized", async () => {
233+
listCloneTargetsForUser.mockResolvedValue([target("im-admin")])
234+
235+
await expect(run(["im-foreign"])).rejects.toThrow(
236+
"No authorized target channels found",
237+
)
238+
expect(createPageMessageTemplate).not.toHaveBeenCalled()
239+
})
240+
241+
test("reports a page whose Meta create fails without blocking the others", async () => {
242+
listCloneTargetsForUser.mockResolvedValue([target("im-a"), target("im-b")])
243+
createPageMessageTemplate
244+
.mockRejectedValueOnce(new Error("rate limited"))
245+
.mockResolvedValueOnce({ id: "meta-b", status: "APPROVED" })
246+
247+
const result = await run(["im-a", "im-b"])
248+
249+
expect(result.failed).toEqual([
250+
{ channel: "Page im-a", error: "rate limited" },
251+
])
252+
expect(result.succeeded).toEqual([{ channel: "Page im-b" }])
253+
})
254+
})

apps/builder/src/app/space/[workspaceId]/messengers/[id]/message-templates/page.tsx

Lines changed: 13 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,10 @@
1-
import { db, inArray } from "@chatbotx.io/database/client"
2-
import { integrationMessengerModel } from "@chatbotx.io/database/schema"
1+
import { messengerIntegrationService } from "@chatbotx.io/business"
32
import { notFound } from "next/navigation"
43
import { Suspense } from "react"
54
import { MessengerMessageTemplatesTable } from "@/features/integration-messenger/message-templates/message-templates-table"
65
import { messengerMessageTemplateService } from "@/features/integration-messenger/message-templates/queries"
76
import { listMessengerMessageTemplatesSearchParamsCache } from "@/features/integration-messenger/message-templates/schema/query"
87
import { findIntegrationMessenger } from "@/features/integration-messenger/queries"
9-
import { getAllWorkspaceMembers } from "@/features/workspace-members/queries"
108
import { withWorkspaceIdAndIdSchema } from "@/features/workspaces/schema/resource"
119
import { getCurrentUserId } from "@/lib/auth/utils"
1210

@@ -32,36 +30,19 @@ export default async function MessengerMessageTemplatesPage(props: {
3230
}
3331

3432
// Clone targets = every Messenger channel across all workspaces where the
35-
// current user is an owner ("admin"), excluding the current channel.
33+
// current user is an admin (owner or superAdmin), excluding the current
34+
// Facebook Page. The clone action authorizes against the same list.
3635
const userId = await getCurrentUserId()
37-
let channels: { id: string; name: string }[] = []
38-
if (userId) {
39-
const { workspaceMembers } = await getAllWorkspaceMembers(userId)
40-
const ownerWorkspaceIds = Array.from(
41-
new Set(
42-
workspaceMembers
43-
.filter((member) => member.role === "owner")
44-
.map((member) => member.workspaceId),
45-
),
46-
)
47-
if (ownerWorkspaceIds.length > 0) {
48-
const rows = await db
49-
.select({
50-
id: integrationMessengerModel.id,
51-
name: integrationMessengerModel.name,
52-
pageId: integrationMessengerModel.pageId,
53-
})
54-
.from(integrationMessengerModel)
55-
.where(
56-
inArray(integrationMessengerModel.workspaceId, ownerWorkspaceIds),
57-
)
58-
// Exclude the current Facebook Page (by pageId) — it may be connected in
59-
// more than one workspace, so filtering by integration id alone is not enough.
60-
channels = rows
61-
.filter((channel) => channel.pageId !== integrationMessenger.pageId)
62-
.map((channel) => ({ id: channel.id, name: channel.name }))
63-
}
64-
}
36+
const cloneTargets = userId
37+
? await messengerIntegrationService.listCloneTargetsForUser({
38+
userId,
39+
excludePageId: integrationMessenger.pageId,
40+
})
41+
: []
42+
const channels = cloneTargets.map((channel) => ({
43+
id: channel.id,
44+
name: channel.name,
45+
}))
6546

6647
const promises = messengerMessageTemplateService.listPaginated({
6748
where: {

apps/builder/src/features/broadcasts/components/broadcast-status-panel.tsx

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -53,9 +53,9 @@ export function BroadcastStatusPanel({
5353
}
5454

5555
return (
56-
<aside className="flex w-[280px] shrink-0 flex-col border-e bg-sidebar">
57-
<div className="flex h-16 items-center justify-between ps-6 pe-4">
58-
<h2 className="font-bold text-xl">{t("broadcasts.title")}</h2>
56+
<aside className="flex w-[220px] shrink-0 flex-col border-e bg-sidebar">
57+
<div className="flex h-14 items-center justify-between ps-4 pe-2">
58+
<h2 className="font-bold text-lg">{t("broadcasts.title")}</h2>
5959
<Button
6060
aria-label={t("broadcasts.panel.collapse")}
6161
onClick={() => onOpenChange(false)}
@@ -65,14 +65,14 @@ export function BroadcastStatusPanel({
6565
<PanelLeftCloseIcon aria-hidden="true" />
6666
</Button>
6767
</div>
68-
<nav className="flex flex-col gap-1 px-4 py-2">
68+
<nav className="flex flex-col gap-0.5 px-2 py-1">
6969
{PANEL_ITEMS.map((item) => {
7070
const isActive = status === item.value
7171
return (
7272
<button
7373
aria-pressed={isActive}
7474
className={cn(
75-
"flex h-11 items-center gap-3.5 rounded-md px-4 text-[15px] transition-colors",
75+
"flex h-9 items-center gap-2.5 rounded-md px-3 text-sm transition-colors",
7676
isActive
7777
? "bg-primary/10 font-medium text-foreground"
7878
: "text-muted-foreground hover:bg-accent hover:text-foreground",
@@ -88,7 +88,7 @@ export function BroadcastStatusPanel({
8888
<span
8989
aria-hidden="true"
9090
className={cn(
91-
"size-3 shrink-0 rounded-full",
91+
"size-2.5 shrink-0 rounded-full",
9292
item.dotClassName,
9393
)}
9494
/>

apps/builder/src/features/integration-messenger/message-templates/actions/clone-message-templates.ts

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,6 @@ import { SdkException } from "@chatbotx.io/sdk"
1212
import { zodBigintAsString } from "@chatbotx.io/utils"
1313
import { chunk } from "remeda"
1414
import { z } from "zod"
15-
import { getAllWorkspaceMembers } from "@/features/workspace-members/queries"
1615
import { workspaceActionClient } from "@/lib/safe-action"
1716
import { syncMessengerMessageTemplatesForIntegration } from "./sync-message-templates"
1817

@@ -159,24 +158,18 @@ export const cloneMessengerMessageTemplateAction = workspaceActionClient
159158
workspaceId,
160159
})
161160

162-
// Resolve target rows by id (targets may live in OTHER workspaces).
163-
const candidateTargets = await messengerIntegrationService.findByIds(
164-
targetIntegrationMessengerIds,
165-
)
166-
167-
// Authorize per target: the user must be an owner of the target's workspace,
168-
// and the target must not be the source's own Facebook Page.
169-
const { workspaceMembers } = await getAllWorkspaceMembers(user.id)
170-
const ownerWorkspaceIds = new Set(
171-
workspaceMembers
172-
.filter((member) => member.role === "owner")
173-
.map((member) => member.workspaceId),
174-
)
175-
const targets = candidateTargets.filter(
176-
(target) =>
177-
ownerWorkspaceIds.has(target.workspaceId) &&
178-
target.pageId !== sourceIntegration?.pageId,
179-
)
161+
// Authorize per target: the user must be an admin (owner or superAdmin)
162+
// of the target's workspace, and the target must not be the source's own
163+
// Facebook Page. Memberships are read uncached so a just-revoked admin
164+
// cannot clone across a workspace boundary.
165+
const requested = new Set(targetIntegrationMessengerIds)
166+
const cloneTargets =
167+
await messengerIntegrationService.listCloneTargetsForUser({
168+
userId: user.id,
169+
excludePageId: sourceIntegration?.pageId,
170+
authoritative: true,
171+
})
172+
const targets = cloneTargets.filter((target) => requested.has(target.id))
180173

181174
if (targets.length === 0) {
182175
throw new Error("No authorized target channels found")

0 commit comments

Comments
 (0)