Skip to content

Commit 4025958

Browse files
authored
Add agency multi-account workspaces (#25)
1 parent 7954a77 commit 4025958

38 files changed

Lines changed: 1753 additions & 178 deletions

File tree

README.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ Most tools in this market are broad chatbot platforms. CampaignCue is intentiona
3737
- Public campaign template library.
3838
- Tracked redirect links with click, CTR, and keyword analytics.
3939
- Shareable read-only client report pages.
40+
- Agency-ready multi-account workspaces with member roles and invite links.
4041
- Production deployment docs for Vercel, Railway, Postgres, and Redis.
4142

4243
## Demo
@@ -157,7 +158,7 @@ The public launch roadmap is tracked in GitHub issues:
157158
- [#9 Public campaign templates](https://github.com/im-anishraj/instagram-comment-to-dm/issues/9) - done
158159
- [#10 Tracked links and analytics](https://github.com/im-anishraj/instagram-comment-to-dm/issues/10) - done
159160
- [#11 Shareable client reports](https://github.com/im-anishraj/instagram-comment-to-dm/issues/11) - done
160-
- [#12 Agency multi-account support](https://github.com/im-anishraj/instagram-comment-to-dm/issues/12)
161+
- [#12 Agency multi-account support](https://github.com/im-anishraj/instagram-comment-to-dm/issues/12) - done
161162
- [#13 Founding agency offer and referrals](https://github.com/im-anishraj/instagram-comment-to-dm/issues/13)
162163
- [#14 SEO landing pages](https://github.com/im-anishraj/instagram-comment-to-dm/issues/14)
163164

ROADMAP.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ This roadmap is optimized for three goals:
88

99
## Phase 0: Foundation
1010

11-
Status: merged through issue [#11](https://github.com/im-anishraj/instagram-comment-to-dm/issues/11).
11+
Status: merged through issue [#12](https://github.com/im-anishraj/instagram-comment-to-dm/issues/12).
1212

1313
- Email magic-link auth.
1414
- Workspace tenancy.
@@ -70,7 +70,7 @@ Planned work:
7070

7171
Issues:
7272

73-
- [#12 Agency multi-account support](https://github.com/im-anishraj/instagram-comment-to-dm/issues/12)
73+
- [#12 Agency multi-account support](https://github.com/im-anishraj/instagram-comment-to-dm/issues/12) - done
7474
- [#13 Founding agency offer and referrals](https://github.com/im-anishraj/instagram-comment-to-dm/issues/13)
7575

7676
Planned work:
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
import { beforeEach, describe, expect, it, vi } from "vitest";
2+
3+
const { mockPrisma } = vi.hoisted(() => ({
4+
mockPrisma: {
5+
instagramAccount: {
6+
count: vi.fn(),
7+
findUnique: vi.fn(),
8+
findFirst: vi.fn(),
9+
},
10+
},
11+
}));
12+
13+
vi.mock("@/lib/db/client", () => ({
14+
prisma: mockPrisma,
15+
}));
16+
17+
import {
18+
canConnectInstagramAccount,
19+
getInstagramAccountLimit,
20+
getWorkspaceInstagramAccount,
21+
} from "../lib/instagram-accounts";
22+
import {
23+
buildInvitationUrl,
24+
normalizeInvitationEmail,
25+
} from "../lib/workspace-invitations";
26+
27+
beforeEach(() => {
28+
vi.clearAllMocks();
29+
});
30+
31+
describe("agency workspace helpers", () => {
32+
it("applies account limits from the effective plan", () => {
33+
expect(getInstagramAccountLimit("FREE", "NONE")).toBe(1);
34+
expect(getInstagramAccountLimit("AGENCY", "ACTIVE")).toBe(10);
35+
expect(getInstagramAccountLimit("AGENCY", "PAST_DUE")).toBe(1);
36+
});
37+
38+
it("allows reconnecting an account already owned by the workspace", async () => {
39+
mockPrisma.instagramAccount.findUnique.mockResolvedValue({
40+
workspaceId: "workspace_123",
41+
});
42+
43+
await expect(
44+
canConnectInstagramAccount({
45+
workspaceId: "workspace_123",
46+
plan: "FREE",
47+
subscriptionStatus: "NONE",
48+
instagramId: "ig_123",
49+
})
50+
).resolves.toMatchObject({ allowed: true, reason: null, limit: 1 });
51+
expect(mockPrisma.instagramAccount.count).not.toHaveBeenCalled();
52+
});
53+
54+
it("blocks accounts already connected to another workspace", async () => {
55+
mockPrisma.instagramAccount.findUnique.mockResolvedValue({
56+
workspaceId: "workspace_other",
57+
});
58+
59+
await expect(
60+
canConnectInstagramAccount({
61+
workspaceId: "workspace_123",
62+
plan: "AGENCY",
63+
subscriptionStatus: "ACTIVE",
64+
instagramId: "ig_123",
65+
})
66+
).resolves.toMatchObject({
67+
allowed: false,
68+
reason: "already_connected",
69+
limit: 10,
70+
});
71+
});
72+
73+
it("blocks new account connections when the plan account limit is reached", async () => {
74+
mockPrisma.instagramAccount.findUnique.mockResolvedValue(null);
75+
mockPrisma.instagramAccount.count.mockResolvedValue(1);
76+
77+
await expect(
78+
canConnectInstagramAccount({
79+
workspaceId: "workspace_123",
80+
plan: "PRO",
81+
subscriptionStatus: "ACTIVE",
82+
instagramId: "ig_123",
83+
})
84+
).resolves.toMatchObject({
85+
allowed: false,
86+
reason: "plan_limit",
87+
limit: 1,
88+
});
89+
});
90+
91+
it("selects a requested workspace account or falls back to the latest account", async () => {
92+
mockPrisma.instagramAccount.findFirst.mockResolvedValue({ id: "account_1" });
93+
94+
await getWorkspaceInstagramAccount("workspace_123", "account_1");
95+
expect(mockPrisma.instagramAccount.findFirst).toHaveBeenCalledWith({
96+
where: { id: "account_1", workspaceId: "workspace_123" },
97+
});
98+
99+
await getWorkspaceInstagramAccount("workspace_123", "all");
100+
expect(mockPrisma.instagramAccount.findFirst).toHaveBeenLastCalledWith({
101+
where: { workspaceId: "workspace_123" },
102+
orderBy: { connectedAt: "desc" },
103+
});
104+
});
105+
106+
it("normalizes invitation emails and builds invite URLs", () => {
107+
expect(normalizeInvitationEmail(" Team@Agency.COM ")).toBe(
108+
"team@agency.com"
109+
);
110+
expect(buildInvitationUrl("token_123", "https://campaigncue.com/")).toBe(
111+
"https://campaigncue.com/invite/token_123"
112+
);
113+
});
114+
});
115+

__tests__/billing.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,13 @@ describe("billing plan helpers", () => {
2929
expect(PLAN_LIMITS.FREE).toEqual({
3030
maxAutomations: 1,
3131
maxDMsPerMonth: 100,
32+
maxInstagramAccounts: 1,
33+
maxWorkspaceMembers: 1,
3234
});
3335
expect(PLAN_LIMITS.PRO.maxAutomations).toBe(10);
36+
expect(PLAN_LIMITS.PRO.maxInstagramAccounts).toBe(1);
37+
expect(PLAN_LIMITS.AGENCY.maxInstagramAccounts).toBe(10);
38+
expect(PLAN_LIMITS.AGENCY.maxWorkspaceMembers).toBe(10);
3439
expect(PLAN_LIMITS.AGENCY.maxDMsPerMonth).toBe(10000);
3540
});
3641
});

app/(dashboard)/campaigns/new/page.tsx

Lines changed: 37 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { useEffect, useState } from "react";
1010
import { useRouter, useSearchParams } from "next/navigation";
11+
import AccountSelect, { type AccountOption } from "@/components/account-select";
1112
import KeywordInput from "@/components/keyword-input";
1213
import PostPicker from "@/components/post-picker";
1314
import { getCampaignTemplate } from "@/lib/templates/campaign-templates";
@@ -28,7 +29,8 @@ export default function NewCampaignPage() {
2829

2930
const [name, setName] = useState(selectedTemplate?.title ?? "");
3031
const [goal, setGoal] = useState(selectedTemplate?.goal ?? "");
31-
const [accountUsername, setAccountUsername] = useState<string | null>(null);
32+
const [accounts, setAccounts] = useState<AccountOption[]>([]);
33+
const [selectedAccountId, setSelectedAccountId] = useState("");
3234
const [postId, setPostId] = useState<string | null>(null);
3335
const [postUrl, setPostUrl] = useState<string | undefined>();
3436
const [keywords, setKeywords] = useState<string[]>(
@@ -53,12 +55,27 @@ export default function NewCampaignPage() {
5355
.then((res) => res.json())
5456
.then((payload) => {
5557
if (payload.success) {
56-
setAccountUsername(payload.data.instagramAccount?.username ?? null);
58+
const nextAccounts = payload.data.instagramAccounts ?? [];
59+
setAccounts(nextAccounts);
60+
setSelectedAccountId(
61+
payload.data.selectedInstagramAccountId ??
62+
nextAccounts[0]?.id ??
63+
""
64+
);
5765
}
5866
})
59-
.catch(() => setAccountUsername(null));
67+
.catch(() => {
68+
setAccounts([]);
69+
setSelectedAccountId("");
70+
});
6071
}, []);
6172

73+
function handleAccountChange(accountId: string) {
74+
setSelectedAccountId(accountId);
75+
setPostId(null);
76+
setPostUrl(undefined);
77+
}
78+
6279
async function handleSubmit(e: React.FormEvent) {
6380
e.preventDefault();
6481
if (!name || !goal || !postId || keywords.length === 0 || !dmMessage) {
@@ -76,6 +93,7 @@ export default function NewCampaignPage() {
7693
body: JSON.stringify({
7794
name,
7895
goal,
96+
instagramAccountId: selectedAccountId,
7997
postId,
8098
postUrl: postUrl ?? null,
8199
keywords,
@@ -159,12 +177,22 @@ export default function NewCampaignPage() {
159177

160178
{/* Instagram Account */}
161179
<div className="space-y-2">
162-
<label className="block text-sm font-medium text-foreground">
180+
<p className="block text-sm font-medium text-foreground">
163181
Instagram Account <span className="text-error">*</span>
164-
</label>
165-
<div className="rounded-xl border border-border bg-surface px-4 py-3 text-sm text-foreground">
166-
{accountUsername ? `@${accountUsername}` : "Connect Instagram before launching a campaign"}
167-
</div>
182+
</p>
183+
{accounts.length > 0 ? (
184+
<AccountSelect
185+
accounts={accounts}
186+
value={selectedAccountId}
187+
onChange={handleAccountChange}
188+
includeAll={false}
189+
label="Connected profile"
190+
/>
191+
) : (
192+
<div className="rounded-xl border border-border bg-surface px-4 py-3 text-sm text-foreground">
193+
Connect Instagram before launching a campaign
194+
</div>
195+
)}
168196
</div>
169197

170198
{/* Post Picker */}
@@ -178,6 +206,7 @@ export default function NewCampaignPage() {
178206
<div className="glass rounded-xl p-4">
179207
<PostPicker
180208
selectedPostId={postId}
209+
instagramAccountId={selectedAccountId}
181210
onSelect={(id, url) => {
182211
setPostId(id);
183212
setPostUrl(url);

app/(dashboard)/campaigns/page.tsx

Lines changed: 48 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import { useCallback, useEffect, useState } from "react";
1010
import Link from "next/link";
11+
import AccountSelect, { type AccountOption } from "@/components/account-select";
1112

1213
interface Campaign {
1314
id: string;
@@ -19,6 +20,10 @@ interface Campaign {
1920
dmMessage: string;
2021
isActive: boolean;
2122
wholeWordMatch: boolean;
23+
instagramAccount: {
24+
username: string;
25+
instagramId: string;
26+
};
2227
reportShareSlug: string | null;
2328
reportShareEnabled: boolean;
2429
reportUrl: string | null;
@@ -43,18 +48,33 @@ interface Campaign {
4348

4449
export default function CampaignsPage() {
4550
const [automations, setAutomations] = useState<Campaign[]>([]);
51+
const [accounts, setAccounts] = useState<AccountOption[]>([]);
52+
const [selectedAccountId, setSelectedAccountId] = useState("all");
4653
const [loading, setLoading] = useState(true);
4754

4855
const fetchAutomations = useCallback(async () => {
4956
try {
50-
const res = await fetch("/api/automations");
57+
const params = new URLSearchParams();
58+
if (selectedAccountId !== "all") {
59+
params.set("instagramAccountId", selectedAccountId);
60+
}
61+
const res = await fetch(`/api/automations${params.size ? `?${params}` : ""}`);
5162
const data = await res.json();
5263
if (data.success) setAutomations(data.data);
5364
} catch (err) {
5465
console.error("Failed to fetch campaigns:", err);
5566
} finally {
5667
setLoading(false);
5768
}
69+
}, [selectedAccountId]);
70+
71+
useEffect(() => {
72+
fetch("/api/dashboard/stats")
73+
.then((res) => res.json())
74+
.then((payload) => {
75+
if (payload.success) setAccounts(payload.data.instagramAccounts ?? []);
76+
})
77+
.catch(console.error);
5878
}, []);
5979

6080
useEffect(() => {
@@ -64,6 +84,11 @@ export default function CampaignsPage() {
6484
return () => window.clearTimeout(timer);
6585
}, [fetchAutomations]);
6686

87+
function handleAccountChange(accountId: string) {
88+
setLoading(true);
89+
setSelectedAccountId(accountId);
90+
}
91+
6792
async function toggleActive(id: string, isActive: boolean) {
6893
try {
6994
await fetch(`/api/automations?id=${id}`, {
@@ -121,21 +146,30 @@ export default function CampaignsPage() {
121146
return (
122147
<div className="space-y-6">
123148
{/* Header */}
124-
<div className="flex items-center justify-between">
149+
<div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
125150
<div>
126151
<p className="text-sm text-muted">
127152
{automations.length} campaign{automations.length !== 1 ? "s" : ""}
128153
</p>
129154
</div>
130-
<Link
131-
href="/campaigns/new"
132-
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-sm font-semibold text-white shadow-lg shadow-indigo-500/20 hover:shadow-xl hover:shadow-indigo-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all"
133-
>
134-
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
135-
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
136-
</svg>
137-
New Campaign
138-
</Link>
155+
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
156+
{accounts.length > 1 && (
157+
<AccountSelect
158+
accounts={accounts}
159+
value={selectedAccountId}
160+
onChange={handleAccountChange}
161+
/>
162+
)}
163+
<Link
164+
href="/campaigns/new"
165+
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl bg-gradient-to-r from-indigo-500 to-violet-500 text-sm font-semibold text-white shadow-lg shadow-indigo-500/20 hover:shadow-xl hover:shadow-indigo-500/30 hover:scale-[1.02] active:scale-[0.98] transition-all"
166+
>
167+
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" strokeWidth={2} stroke="currentColor">
168+
<path strokeLinecap="round" strokeLinejoin="round" d="M12 4.5v15m7.5-7.5h-15" />
169+
</svg>
170+
New Campaign
171+
</Link>
172+
</div>
139173
</div>
140174

141175
{/* Empty state */}
@@ -167,6 +201,9 @@ export default function CampaignsPage() {
167201
<div className="flex-1 min-w-0">
168202
<div className="flex items-center gap-3 mb-3">
169203
<h3 className="text-base font-semibold truncate">{auto.name}</h3>
204+
<span className="shrink-0 rounded-full border border-border px-2 py-0.5 text-xs text-muted">
205+
@{auto.instagramAccount.username}
206+
</span>
170207
<span
171208
className={`text-xs px-2 py-0.5 rounded-full font-medium ${
172209
auto.isActive

0 commit comments

Comments
 (0)