diff --git a/src/app/api/gigs/[id]/applications/message-all/route.ts b/src/app/api/gigs/[id]/applications/message-all/route.ts new file mode 100644 index 00000000..c86573d0 --- /dev/null +++ b/src/app/api/gigs/[id]/applications/message-all/route.ts @@ -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) + ); + } + + return NextResponse.json({ + conversation_id: conversationId, + recipients: applicantIds.length, + }); + } catch { + return NextResponse.json({ error: "Unexpected error" }, { status: 500 }); + } +} diff --git a/src/app/gigs/[id]/applications/page.tsx b/src/app/gigs/[id]/applications/page.tsx index 215f2a8c..87a59442 100644 --- a/src/app/gigs/[id]/applications/page.tsx +++ b/src/app/gigs/[id]/applications/page.tsx @@ -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 -
-

Applications

-

{gig.title}

+
+
+

Applications

+

{gig.title}

+
+ {applications && applications.length > 0 && ( + + Array.isArray(a.applicant) + ? a.applicant[0]?.id + : a.applicant?.id + ) + .filter(Boolean) + ).size + } + /> + )}
{/* Stats */} diff --git a/src/components/applications/MessageAllApplicantsButton.tsx b/src/components/applications/MessageAllApplicantsButton.tsx new file mode 100644 index 00000000..44f6b0dd --- /dev/null +++ b/src/components/applications/MessageAllApplicantsButton.tsx @@ -0,0 +1,136 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Megaphone, Loader2, X } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +interface MessageAllApplicantsButtonProps { + gigId: string; + applicantCount: number; +} + +export function MessageAllApplicantsButton({ + gigId, + applicantCount, +}: MessageAllApplicantsButtonProps) { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [content, setContent] = useState(""); + const [isSending, setIsSending] = useState(false); + const [error, setError] = useState(null); + + const close = () => { + if (isSending) return; + setOpen(false); + setError(null); + }; + + const handleSend = async () => { + if (!content.trim()) { + setError("Message content is required"); + return; + } + setIsSending(true); + setError(null); + try { + const res = await fetch( + `/api/gigs/${gigId}/applications/message-all`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ content }), + } + ); + const data = await res.json(); + if (!res.ok) { + setError(data.error || "Failed to send message"); + return; + } + router.push(`/dashboard/messages/${data.conversation_id}`); + } catch { + setError("Failed to send message"); + } finally { + setIsSending(false); + } + }; + + return ( + <> + + + {open && ( +
+
e.stopPropagation()} + > +
+
+

Message all applicants

+

+ Sends one message to {applicantCount}{" "} + {applicantCount === 1 ? "applicant" : "applicants"} in a shared + inbox thread. They'll be notified by email and in-app. +

+
+ +
+ +