-
Notifications
You must be signed in to change notification settings - Fork 46
feat(applications): message all applicants in one inbox thread #481
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,232 @@ | ||
| import { NextRequest, NextResponse } from "next/server"; | ||
| import { getAuthContext, createServiceClient } from "@/lib/auth/get-user"; | ||
| import { sendEmail, newMessageEmail } from "@/lib/email"; | ||
| import { dispatchWebhookAsync } from "@/lib/webhooks/dispatch"; | ||
| import { isEmailNotificationEnabled } from "@/lib/notification-settings"; | ||
| import { z } from "zod"; | ||
|
|
||
| const bodySchema = z.object({ | ||
| content: z | ||
| .string() | ||
| .trim() | ||
| .min(1, "Message content is required") | ||
| .max(2000, "Message must be at most 2000 characters"), | ||
| // Optional status filter; when omitted, every applicant is messaged. | ||
| statuses: z | ||
| .array( | ||
| z.enum([ | ||
| "pending", | ||
| "reviewing", | ||
| "shortlisted", | ||
| "accepted", | ||
| "rejected", | ||
| "withdrawn", | ||
| ]) | ||
| ) | ||
| .optional(), | ||
| }); | ||
|
|
||
| // POST /api/gigs/[id]/applications/message-all | ||
| // Sends a single broadcast message to every applicant of a gig. Reuses one | ||
| // group conversation (poster + all applicants) so the poster gets one inbox | ||
| // thread instead of one per applicant. Notifies each recipient in-app, by | ||
| // email, and via webhook. | ||
| export async function POST( | ||
| request: NextRequest, | ||
| { params }: { params: Promise<{ id: string }> } | ||
| ) { | ||
| try { | ||
| const { id: gigId } = await params; | ||
| const auth = await getAuthContext(request); | ||
| if (!auth) return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); | ||
| const { user } = auth; | ||
|
|
||
| const body = await request.json().catch(() => null); | ||
| const parsed = bodySchema.safeParse(body); | ||
| if (!parsed.success) { | ||
| return NextResponse.json( | ||
| { error: parsed.error.issues[0].message }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
| const { content, statuses } = parsed.data; | ||
|
|
||
| const svc = createServiceClient(); | ||
|
|
||
| // Verify the caller is the gig poster | ||
| const { data: gig } = await svc | ||
| .from("gigs") | ||
| .select("id, title, poster_id") | ||
| .eq("id", gigId) | ||
| .single(); | ||
|
|
||
| if (!gig) return NextResponse.json({ error: "Gig not found" }, { status: 404 }); | ||
| if (gig.poster_id !== user.id) { | ||
| return NextResponse.json({ error: "Forbidden" }, { status: 403 }); | ||
| } | ||
|
|
||
| // Collect the distinct applicants to message | ||
| let appsQuery = svc | ||
| .from("applications") | ||
| .select("applicant_id") | ||
| .eq("gig_id", gigId); | ||
| if (statuses && statuses.length > 0) { | ||
| appsQuery = appsQuery.in("status", statuses); | ||
| } | ||
| const { data: applications, error: appsError } = await appsQuery; | ||
| if (appsError) { | ||
| return NextResponse.json({ error: appsError.message }, { status: 400 }); | ||
| } | ||
|
|
||
| const applicantIds = Array.from( | ||
| new Set( | ||
| (applications ?? []) | ||
| .map((a) => a.applicant_id as string) | ||
| .filter((id): id is string => !!id && id !== user.id) | ||
| ) | ||
| ); | ||
|
|
||
| if (applicantIds.length === 0) { | ||
| return NextResponse.json( | ||
| { error: "No applicants to message" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| const participantIds = [user.id, ...applicantIds].sort(); | ||
|
|
||
| // Find an existing gig-scoped broadcast conversation with exactly this set | ||
| // of participants; reuse it so repeated broadcasts stay in one thread. | ||
| const { data: candidates } = await svc | ||
| .from("conversations") | ||
| .select("id, participant_ids") | ||
| .eq("gig_id", gigId) | ||
| .contains("participant_ids", participantIds); | ||
|
|
||
| const existing = (candidates ?? []).find( | ||
| (c) => | ||
| Array.isArray(c.participant_ids) && | ||
| c.participant_ids.length === participantIds.length | ||
| ); | ||
|
|
||
| let conversationId: string; | ||
| if (existing) { | ||
| conversationId = existing.id; | ||
| } else { | ||
| const { data: created, error: convError } = await svc | ||
| .from("conversations") | ||
| .insert({ participant_ids: participantIds, gig_id: gigId }) | ||
| .select("id") | ||
| .single(); | ||
|
|
||
| if (convError || !created) { | ||
| return NextResponse.json( | ||
| { error: convError?.message || "Failed to create conversation" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
| conversationId = created.id; | ||
| } | ||
|
|
||
| // Insert the broadcast message (poster has read their own message) | ||
| const { data: message, error: messageError } = await svc | ||
| .from("messages") | ||
| .insert({ | ||
| conversation_id: conversationId, | ||
| sender_id: user.id, | ||
| content, | ||
| read_by: [user.id], | ||
| }) | ||
| .select("id") | ||
| .single(); | ||
|
|
||
| if (messageError || !message) { | ||
| return NextResponse.json( | ||
| { error: messageError?.message || "Failed to send message" }, | ||
| { status: 400 } | ||
| ); | ||
| } | ||
|
|
||
| await svc | ||
| .from("conversations") | ||
| .update({ last_message_at: new Date().toISOString() }) | ||
| .eq("id", conversationId); | ||
|
|
||
| // Sender display name for notifications/emails | ||
| const { data: senderProfile } = await svc | ||
| .from("profiles") | ||
| .select("full_name, username") | ||
| .eq("id", user.id) | ||
| .single(); | ||
| const senderName = | ||
| senderProfile?.full_name || senderProfile?.username || "Someone"; | ||
|
|
||
| const preview = content.slice(0, 100) + (content.length > 100 ? "..." : ""); | ||
|
|
||
| // In-app notifications (bulk insert) | ||
| await svc.from("notifications").insert( | ||
| applicantIds.map((recipientId) => ({ | ||
| user_id: recipientId, | ||
| type: "new_message" as const, | ||
| title: `New message from ${senderName}`, | ||
| body: preview, | ||
| data: { | ||
| conversation_id: conversationId, | ||
| message_id: message.id, | ||
| sender_id: user.id, | ||
| }, | ||
| })) | ||
| ); | ||
|
|
||
| // Email + webhook per recipient. This is a deliberate broadcast, so we send | ||
| // email regardless of conversation throttling but still honor the user's | ||
| // email_new_message preference. | ||
| for (const recipientId of applicantIds) { | ||
| dispatchWebhookAsync(recipientId, "message.new", { | ||
| message_id: message.id, | ||
| conversation_id: conversationId, | ||
| sender_id: user.id, | ||
| content_preview: content.slice(0, 200), | ||
| }); | ||
|
|
||
| const emailEnabled = await isEmailNotificationEnabled( | ||
| svc, | ||
| recipientId, | ||
| "email_new_message" | ||
| ); | ||
| if (!emailEnabled) continue; | ||
|
|
||
| const { data: recipientProfile } = await svc | ||
| .from("profiles") | ||
| .select("full_name, username") | ||
| .eq("id", recipientId) | ||
| .single(); | ||
|
|
||
| const { | ||
| data: { user: recipientUser }, | ||
| } = await svc.auth.admin.getUserById(recipientId); | ||
| const recipientEmail = recipientUser?.email; | ||
| if (!recipientEmail) continue; | ||
|
|
||
| const emailContent = newMessageEmail({ | ||
| recipientName: | ||
| recipientProfile?.full_name || recipientProfile?.username || "there", | ||
| senderName, | ||
| messagePreview: content, | ||
| conversationId, | ||
| gigTitle: gig.title, | ||
| }); | ||
|
|
||
| sendEmail({ to: recipientEmail, ...emailContent }).catch((err) => | ||
| console.error("Failed to send broadcast message email:", err) | ||
| ); | ||
| } | ||
|
Comment on lines
+184
to
+223
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The loop awaits The fix is to batch the three lookups before the loop: one query for all notification settings ( |
||
|
|
||
| return NextResponse.json({ | ||
| conversation_id: conversationId, | ||
| recipients: applicantIds.length, | ||
| }); | ||
| } catch { | ||
| return NextResponse.json({ error: "Unexpected error" }, { status: 500 }); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -11,6 +11,7 @@ import { formatRelativeTime } from "@/lib/utils"; | |
| import { ApplicationActions } from "@/components/applications/ApplicationActions"; | ||
| import { ExpandableApplicationCard } from "@/components/applications/ExpandableApplicationCard"; | ||
| import { StartConversationButton } from "@/components/messages/StartConversationButton"; | ||
| import { MessageAllApplicantsButton } from "@/components/applications/MessageAllApplicantsButton"; | ||
| import { MarkdownContent } from "@/components/ui/MarkdownContent"; | ||
| import { EscrowPaymentButton } from "@/components/gigs/EscrowPaymentButton"; | ||
| import { InvoiceButton } from "@/components/gigs/InvoiceButton"; | ||
|
|
@@ -142,9 +143,27 @@ export default async function ApplicationsPage({ params }: ApplicationsPageProps | |
| Back to gig | ||
| </Link> | ||
|
|
||
| <div className="mb-6"> | ||
| <h1 className="text-3xl font-bold mb-2">Applications</h1> | ||
| <p className="text-muted-foreground">{gig.title}</p> | ||
| <div className="mb-6 flex items-start justify-between gap-4"> | ||
| <div> | ||
| <h1 className="text-3xl font-bold mb-2">Applications</h1> | ||
| <p className="text-muted-foreground">{gig.title}</p> | ||
| </div> | ||
| {applications && applications.length > 0 && ( | ||
| <MessageAllApplicantsButton | ||
| gigId={gig.id} | ||
| applicantCount={ | ||
| new Set( | ||
| applications | ||
| .map((a) => | ||
| Array.isArray(a.applicant) | ||
| ? a.applicant[0]?.id | ||
| : a.applicant?.id | ||
| ) | ||
| .filter(Boolean) | ||
| ).size | ||
| } | ||
| /> | ||
| )} | ||
|
Comment on lines
+151
to
+166
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The Set deduplication iterates over all applications regardless of |
||
| </div> | ||
|
|
||
| {/* Stats */} | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
await svc.from("notifications").insert(...)result is not destructured, so any database error (constraint violation, row-level security rejection, schema mismatch) is discarded without logging or surfacing. If the insert fails, applicants receive no in-app notification and neither the poster nor any monitoring system knows. Consider destructuring{ error: notifError }and at minimum logging it so silent failures don't go undetected in production.