Skip to content

Commit bf2faee

Browse files
ralyodioclaude
andauthored
feat(applications): message all applicants in one inbox thread (#481)
Adds a "Message all applicants" button to the gig applications page that sends a single broadcast message to every applicant via one shared group conversation (poster + all applicants) instead of one thread per applicant. Recipients are notified in-app, by email (honoring email_new_message preference), and via the message.new webhook — reusing the existing messaging machinery. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b435532 commit bf2faee

3 files changed

Lines changed: 390 additions & 3 deletions

File tree

Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { NextRequest, NextResponse } from "next/server";
2+
import { getAuthContext, createServiceClient } from "@/lib/auth/get-user";
3+
import { sendEmail, newMessageEmail } from "@/lib/email";
4+
import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch";
5+
import { isEmailNotificationEnabled } from "@/lib/notification-settings";
6+
import { z } from "zod";
7+
8+
const bodySchema = z.object({
9+
content: z
10+
.string()
11+
.trim()
12+
.min(1, "Message content is required")
13+
.max(2000, "Message must be at most 2000 characters"),
14+
// Optional status filter; when omitted, every applicant is messaged.
15+
statuses: z
16+
.array(
17+
z.enum([
18+
"pending",
19+
"reviewing",
20+
"shortlisted",
21+
"accepted",
22+
"rejected",
23+
"withdrawn",
24+
])
25+
)
26+
.optional(),
27+
});
28+
29+
// POST /api/gigs/[id]/applications/message-all
30+
// Sends a single broadcast message to every applicant of a gig. Reuses one
31+
// group conversation (poster + all applicants) so the poster gets one inbox
32+
// thread instead of one per applicant. Notifies each recipient in-app, by
33+
// email, and via webhook.
34+
export async function POST(
35+
request: NextRequest,
36+
{ params }: { params: Promise<{ id: string }> }
37+
) {
38+
try {
39+
const { id: gigId } = await params;
40+
const auth = await getAuthContext(request);
41+
if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
42+
const { user } = auth;
43+
44+
const body = await request.json().catch(() => null);
45+
const parsed = bodySchema.safeParse(body);
46+
if (!parsed.success) {
47+
return NextResponse.json(
48+
{ error: parsed.error.issues[0].message },
49+
{ status: 400 }
50+
);
51+
}
52+
const { content, statuses } = parsed.data;
53+
54+
const svc = createServiceClient();
55+
56+
// Verify the caller is the gig poster
57+
const { data: gig } = await svc
58+
.from("gigs")
59+
.select("id, title, poster_id")
60+
.eq("id", gigId)
61+
.single();
62+
63+
if (!gig) return NextResponse.json({ error: "Gig not found" }, { status: 404 });
64+
if (gig.poster_id !== user.id) {
65+
return NextResponse.json({ error: "Forbidden" }, { status: 403 });
66+
}
67+
68+
// Collect the distinct applicants to message
69+
let appsQuery = svc
70+
.from("applications")
71+
.select("applicant_id")
72+
.eq("gig_id", gigId);
73+
if (statuses && statuses.length > 0) {
74+
appsQuery = appsQuery.in("status", statuses);
75+
}
76+
const { data: applications, error: appsError } = await appsQuery;
77+
if (appsError) {
78+
return NextResponse.json({ error: appsError.message }, { status: 400 });
79+
}
80+
81+
const applicantIds = Array.from(
82+
new Set(
83+
(applications ?? [])
84+
.map((a) => a.applicant_id as string)
85+
.filter((id): id is string => !!id && id !== user.id)
86+
)
87+
);
88+
89+
if (applicantIds.length === 0) {
90+
return NextResponse.json(
91+
{ error: "No applicants to message" },
92+
{ status: 400 }
93+
);
94+
}
95+
96+
const participantIds = [user.id, ...applicantIds].sort();
97+
98+
// Find an existing gig-scoped broadcast conversation with exactly this set
99+
// of participants; reuse it so repeated broadcasts stay in one thread.
100+
const { data: candidates } = await svc
101+
.from("conversations")
102+
.select("id, participant_ids")
103+
.eq("gig_id", gigId)
104+
.contains("participant_ids", participantIds);
105+
106+
const existing = (candidates ?? []).find(
107+
(c) =>
108+
Array.isArray(c.participant_ids) &&
109+
c.participant_ids.length === participantIds.length
110+
);
111+
112+
let conversationId: string;
113+
if (existing) {
114+
conversationId = existing.id;
115+
} else {
116+
const { data: created, error: convError } = await svc
117+
.from("conversations")
118+
.insert({ participant_ids: participantIds, gig_id: gigId })
119+
.select("id")
120+
.single();
121+
122+
if (convError || !created) {
123+
return NextResponse.json(
124+
{ error: convError?.message || "Failed to create conversation" },
125+
{ status: 400 }
126+
);
127+
}
128+
conversationId = created.id;
129+
}
130+
131+
// Insert the broadcast message (poster has read their own message)
132+
const { data: message, error: messageError } = await svc
133+
.from("messages")
134+
.insert({
135+
conversation_id: conversationId,
136+
sender_id: user.id,
137+
content,
138+
read_by: [user.id],
139+
})
140+
.select("id")
141+
.single();
142+
143+
if (messageError || !message) {
144+
return NextResponse.json(
145+
{ error: messageError?.message || "Failed to send message" },
146+
{ status: 400 }
147+
);
148+
}
149+
150+
await svc
151+
.from("conversations")
152+
.update({ last_message_at: new Date().toISOString() })
153+
.eq("id", conversationId);
154+
155+
// Sender display name for notifications/emails
156+
const { data: senderProfile } = await svc
157+
.from("profiles")
158+
.select("full_name, username")
159+
.eq("id", user.id)
160+
.single();
161+
const senderName =
162+
senderProfile?.full_name || senderProfile?.username || "Someone";
163+
164+
const preview = content.slice(0, 100) + (content.length > 100 ? "..." : "");
165+
166+
// In-app notifications (bulk insert)
167+
await svc.from("notifications").insert(
168+
applicantIds.map((recipientId) => ({
169+
user_id: recipientId,
170+
type: "new_message" as const,
171+
title: `New message from ${senderName}`,
172+
body: preview,
173+
data: {
174+
conversation_id: conversationId,
175+
message_id: message.id,
176+
sender_id: user.id,
177+
},
178+
}))
179+
);
180+
181+
// Email + webhook per recipient. This is a deliberate broadcast, so we send
182+
// email regardless of conversation throttling but still honor the user's
183+
// email_new_message preference.
184+
for (const recipientId of applicantIds) {
185+
dispatchWebhookAsync(recipientId, "message.new", {
186+
message_id: message.id,
187+
conversation_id: conversationId,
188+
sender_id: user.id,
189+
content_preview: content.slice(0, 200),
190+
});
191+
192+
const emailEnabled = await isEmailNotificationEnabled(
193+
svc,
194+
recipientId,
195+
"email_new_message"
196+
);
197+
if (!emailEnabled) continue;
198+
199+
const { data: recipientProfile } = await svc
200+
.from("profiles")
201+
.select("full_name, username")
202+
.eq("id", recipientId)
203+
.single();
204+
205+
const {
206+
data: { user: recipientUser },
207+
} = await svc.auth.admin.getUserById(recipientId);
208+
const recipientEmail = recipientUser?.email;
209+
if (!recipientEmail) continue;
210+
211+
const emailContent = newMessageEmail({
212+
recipientName:
213+
recipientProfile?.full_name || recipientProfile?.username || "there",
214+
senderName,
215+
messagePreview: content,
216+
conversationId,
217+
gigTitle: gig.title,
218+
});
219+
220+
sendEmail({ to: recipientEmail, ...emailContent }).catch((err) =>
221+
console.error("Failed to send broadcast message email:", err)
222+
);
223+
}
224+
225+
return NextResponse.json({
226+
conversation_id: conversationId,
227+
recipients: applicantIds.length,
228+
});
229+
} catch {
230+
return NextResponse.json({ error: "Unexpected error" }, { status: 500 });
231+
}
232+
}

src/app/gigs/[id]/applications/page.tsx

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { formatRelativeTime } from "@/lib/utils";
1111
import { ApplicationActions } from "@/components/applications/ApplicationActions";
1212
import { ExpandableApplicationCard } from "@/components/applications/ExpandableApplicationCard";
1313
import { StartConversationButton } from "@/components/messages/StartConversationButton";
14+
import { MessageAllApplicantsButton } from "@/components/applications/MessageAllApplicantsButton";
1415
import { MarkdownContent } from "@/components/ui/MarkdownContent";
1516
import { EscrowPaymentButton } from "@/components/gigs/EscrowPaymentButton";
1617
import { InvoiceButton } from "@/components/gigs/InvoiceButton";
@@ -142,9 +143,27 @@ export default async function ApplicationsPage({ params }: ApplicationsPageProps
142143
Back to gig
143144
</Link>
144145

145-
<div className="mb-6">
146-
<h1 className="text-3xl font-bold mb-2">Applications</h1>
147-
<p className="text-muted-foreground">{gig.title}</p>
146+
<div className="mb-6 flex items-start justify-between gap-4">
147+
<div>
148+
<h1 className="text-3xl font-bold mb-2">Applications</h1>
149+
<p className="text-muted-foreground">{gig.title}</p>
150+
</div>
151+
{applications && applications.length > 0 && (
152+
<MessageAllApplicantsButton
153+
gigId={gig.id}
154+
applicantCount={
155+
new Set(
156+
applications
157+
.map((a) =>
158+
Array.isArray(a.applicant)
159+
? a.applicant[0]?.id
160+
: a.applicant?.id
161+
)
162+
.filter(Boolean)
163+
).size
164+
}
165+
/>
166+
)}
148167
</div>
149168

150169
{/* Stats */}

0 commit comments

Comments
 (0)