feat: direct messages
1:1 conversations (userAId < userBId dedup pair), messages, per-participant read tracking (conversation_reads). Block relationship is enforced on every send. UI: /messages list + /messages/[id] thread (5s poll), MessageButton on profiles, unread-badged nav icon. This completes the social-feature backlog: notifications, rate limiting, blocking, reporting, search/discovery, mentions, DMs, plus fixes for the recipe-visibility 404, follow race, and 2-level comment thread cap found during the earlier audit. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,44 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, conversations, users, eq } from "@epicure/db";
|
||||
import { ConversationsList } from "@/components/social/conversations-list";
|
||||
import { MessageThread } from "@/components/social/message-thread";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { isParticipant, otherParticipantId } from "@/lib/messaging";
|
||||
|
||||
type Params = { params: Promise<{ id: string }> };
|
||||
|
||||
export default async function ConversationPage({ params }: Params) {
|
||||
const { id } = await params;
|
||||
const session = await auth.api.getSession({ headers: await headers() });
|
||||
if (!session) return null;
|
||||
|
||||
const conversation = await db.query.conversations.findFirst({ where: eq(conversations.id, id) });
|
||||
if (!conversation || !isParticipant(conversation, session.user.id)) notFound();
|
||||
|
||||
const otherId = otherParticipantId(conversation, session.user.id);
|
||||
const other = await db.query.users.findFirst({ where: eq(users.id, otherId) });
|
||||
if (!other) notFound();
|
||||
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto h-[calc(100vh-8rem)] border rounded-xl overflow-hidden flex">
|
||||
<div className="hidden sm:block w-80 border-r shrink-0 overflow-y-auto">
|
||||
<ConversationsList />
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col min-w-0">
|
||||
<div className="border-b p-3 flex items-center gap-3">
|
||||
<Link href={`/u/${other.username}`} className="flex items-center gap-3">
|
||||
<Avatar className="h-8 w-8">
|
||||
{other.avatarUrl && <AvatarImage src={other.avatarUrl} />}
|
||||
<AvatarFallback className="text-xs">{other.name.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<span className="font-medium text-sm hover:underline">{other.name}</span>
|
||||
</Link>
|
||||
</div>
|
||||
<MessageThread conversationId={id} currentUserId={session.user.id} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Metadata } from "next";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { ConversationsList } from "@/components/social/conversations-list";
|
||||
|
||||
export const metadata: Metadata = { title: "Messages — Epicure" };
|
||||
|
||||
export default function MessagesPage() {
|
||||
return (
|
||||
<div className="max-w-5xl mx-auto h-[calc(100vh-8rem)] border rounded-xl overflow-hidden flex">
|
||||
<div className="w-full sm:w-80 border-r shrink-0 overflow-y-auto">
|
||||
<ConversationsList />
|
||||
</div>
|
||||
<div className="hidden sm:flex flex-1 items-center justify-center text-muted-foreground">
|
||||
<div className="text-center space-y-2">
|
||||
<MessageCircle className="h-10 w-10 mx-auto opacity-50" />
|
||||
<p className="text-sm">Select a conversation</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,6 +17,7 @@ import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { FollowButton } from "@/components/social/follow-button";
|
||||
import { BlockButton } from "@/components/social/block-button";
|
||||
import { MessageButton } from "@/components/social/message-button";
|
||||
import { getPublicUrl } from "@/lib/storage";
|
||||
|
||||
type Params = { params: Promise<{ username: string }> };
|
||||
@@ -113,6 +114,7 @@ export default async function UserProfilePage({ params }: Params) {
|
||||
targetUsername={user.username!}
|
||||
initialFollowing={isFollowing}
|
||||
/>
|
||||
{!isBlocked && <MessageButton targetUsername={user.username!} />}
|
||||
<BlockButton targetUsername={user.username!} initialBlocked={isBlocked} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, conversations, conversationReads, messages, eq, asc } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { isParticipant, otherParticipantId } from "@/lib/messaging";
|
||||
import { isBlockedEitherWay } from "@/lib/blocks";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
interface RouteContext {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
const Schema = z.object({ content: z.string().min(1).max(4000) });
|
||||
|
||||
export async function GET(_req: NextRequest, { params }: RouteContext) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const conversation = await db.query.conversations.findFirst({ where: eq(conversations.id, id) });
|
||||
if (!conversation || !isParticipant(conversation, session!.user.id)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(messages)
|
||||
.where(eq(messages.conversationId, id))
|
||||
.orderBy(asc(messages.createdAt))
|
||||
.limit(200);
|
||||
|
||||
await db
|
||||
.insert(conversationReads)
|
||||
.values({ conversationId: id, userId: session!.user.id, lastReadAt: new Date() })
|
||||
.onConflictDoUpdate({
|
||||
target: [conversationReads.conversationId, conversationReads.userId],
|
||||
set: { lastReadAt: new Date() },
|
||||
});
|
||||
|
||||
return NextResponse.json({ messages: rows });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest, { params }: RouteContext) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
const { id } = await params;
|
||||
|
||||
const limited = await applyRateLimit(`rl:message:${session!.user.id}`, 30, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const conversation = await db.query.conversations.findFirst({ where: eq(conversations.id, id) });
|
||||
if (!conversation || !isParticipant(conversation, session!.user.id)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const otherId = otherParticipantId(conversation, session!.user.id);
|
||||
if (await isBlockedEitherWay(session!.user.id, otherId)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = Schema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
const messageId = randomUUID();
|
||||
await db.insert(messages).values({
|
||||
id: messageId,
|
||||
conversationId: id,
|
||||
senderId: session!.user.id,
|
||||
content: parsed.data.content,
|
||||
});
|
||||
await db.update(conversations).set({ lastMessageAt: new Date() }).where(eq(conversations.id, id));
|
||||
|
||||
return NextResponse.json({ id: messageId }, { status: 201 });
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { db, conversations, conversationReads, messages, users, eq, or, desc, and, gt, sql } from "@epicure/db";
|
||||
import { requireSession } from "@/lib/api-auth";
|
||||
import { applyRateLimit } from "@/lib/rate-limit";
|
||||
import { findOrCreateConversation, otherParticipantId } from "@/lib/messaging";
|
||||
import { isBlockedEitherWay } from "@/lib/blocks";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
const CreateSchema = z.object({
|
||||
username: z.string().min(1),
|
||||
content: z.string().min(1).max(4000).optional(),
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const rows = await db
|
||||
.select()
|
||||
.from(conversations)
|
||||
.where(or(eq(conversations.userAId, session!.user.id), eq(conversations.userBId, session!.user.id)))
|
||||
.orderBy(desc(conversations.lastMessageAt))
|
||||
.limit(50);
|
||||
|
||||
const result = await Promise.all(
|
||||
rows.map(async (conv) => {
|
||||
const otherId = otherParticipantId(conv, session!.user.id);
|
||||
const [other, lastMessage, readRow] = await Promise.all([
|
||||
db.query.users.findFirst({
|
||||
where: eq(users.id, otherId),
|
||||
columns: { id: true, name: true, username: true, avatarUrl: true },
|
||||
}),
|
||||
db.query.messages.findFirst({
|
||||
where: eq(messages.conversationId, conv.id),
|
||||
orderBy: (m, { desc: d }) => [d(m.createdAt)],
|
||||
}),
|
||||
db.query.conversationReads.findFirst({
|
||||
where: and(eq(conversationReads.conversationId, conv.id), eq(conversationReads.userId, session!.user.id)),
|
||||
}),
|
||||
]);
|
||||
|
||||
const unreadRow = await db
|
||||
.select({ total: sql<string>`count(*)` })
|
||||
.from(messages)
|
||||
.where(
|
||||
and(
|
||||
eq(messages.conversationId, conv.id),
|
||||
readRow ? gt(messages.createdAt, readRow.lastReadAt) : sql`true`,
|
||||
sql`${messages.senderId} != ${session!.user.id}`
|
||||
)
|
||||
);
|
||||
|
||||
return {
|
||||
id: conv.id,
|
||||
otherUser: other,
|
||||
lastMessage: lastMessage?.content ?? null,
|
||||
lastMessageAt: conv.lastMessageAt,
|
||||
unreadCount: Number(unreadRow[0]?.total ?? 0),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ conversations: result });
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const { session, response } = await requireSession();
|
||||
if (response) return response;
|
||||
|
||||
const limited = await applyRateLimit(`rl:message:${session!.user.id}`, 30, 60);
|
||||
if (limited) return limited;
|
||||
|
||||
const body = await req.json() as unknown;
|
||||
const parsed = CreateSchema.safeParse(body);
|
||||
if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 });
|
||||
|
||||
const target = await db.query.users.findFirst({ where: eq(users.username, parsed.data.username) });
|
||||
if (!target) return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
if (target.id === session!.user.id) return NextResponse.json({ error: "Cannot message yourself" }, { status: 400 });
|
||||
|
||||
if (await isBlockedEitherWay(session!.user.id, target.id)) {
|
||||
return NextResponse.json({ error: "Not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
const conversation = await findOrCreateConversation(session!.user.id, target.id);
|
||||
|
||||
if (parsed.data.content) {
|
||||
await db.insert(messages).values({
|
||||
id: randomUUID(),
|
||||
conversationId: conversation.id,
|
||||
senderId: session!.user.id,
|
||||
content: parsed.data.content,
|
||||
});
|
||||
await db.update(conversations).set({ lastMessageAt: new Date() }).where(eq(conversations.id, conversation.id));
|
||||
}
|
||||
|
||||
return NextResponse.json({ conversationId: conversation.id }, { status: 201 });
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
} from "@/components/ui/sheet";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { NotificationBell } from "@/components/social/notification-bell";
|
||||
import { MessagesNavLink } from "@/components/social/messages-nav-link";
|
||||
import { authClient } from "@/lib/auth/client";
|
||||
import { useTranslations } from "next-intl";
|
||||
|
||||
@@ -101,6 +102,7 @@ export function Nav() {
|
||||
))}
|
||||
</nav>
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<MessagesNavLink />
|
||||
<NotificationBell />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger className="rounded-full outline-none focus-visible:ring-2 focus-visible:ring-ring">
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConversationSummary = {
|
||||
id: string;
|
||||
otherUser: { id: string; name: string; username: string | null; avatarUrl: string | null } | null;
|
||||
lastMessage: string | null;
|
||||
lastMessageAt: string;
|
||||
unreadCount: number;
|
||||
};
|
||||
|
||||
export function ConversationsList() {
|
||||
const pathname = usePathname();
|
||||
const [conversations, setConversations] = useState<ConversationSummary[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
const res = await fetch("/api/v1/conversations");
|
||||
if (res.ok && !cancelled) {
|
||||
const data = (await res.json()) as { conversations: ConversationSummary[] };
|
||||
setConversations(data.conversations);
|
||||
}
|
||||
if (!cancelled) setLoading(false);
|
||||
}
|
||||
void load();
|
||||
const interval = setInterval(load, 10000);
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
if (loading) return <p className="text-sm text-muted-foreground p-4">Loading…</p>;
|
||||
if (conversations.length === 0) {
|
||||
return <p className="text-sm text-muted-foreground p-4">No conversations yet. Visit a profile to say hi.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="divide-y">
|
||||
{conversations.map((c) => (
|
||||
<Link
|
||||
key={c.id}
|
||||
href={`/messages/${c.id}`}
|
||||
className={cn(
|
||||
"flex items-center gap-3 p-3 hover:bg-accent transition-colors",
|
||||
pathname === `/messages/${c.id}` && "bg-accent"
|
||||
)}
|
||||
>
|
||||
<Avatar className="h-10 w-10 shrink-0">
|
||||
{c.otherUser?.avatarUrl && <AvatarImage src={c.otherUser.avatarUrl} />}
|
||||
<AvatarFallback>{(c.otherUser?.name ?? "?").slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className={cn("text-sm truncate", c.unreadCount > 0 && "font-semibold")}>
|
||||
{c.otherUser?.name ?? "Unknown"}
|
||||
</p>
|
||||
{c.unreadCount > 0 && (
|
||||
<Badge variant="destructive" className="h-4 min-w-4 px-1 text-[10px] shrink-0">
|
||||
{c.unreadCount > 9 ? "9+" : c.unreadCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<p className={cn("text-xs truncate", c.unreadCount > 0 ? "text-foreground" : "text-muted-foreground")}>
|
||||
{c.lastMessage ?? "No messages yet"}
|
||||
</p>
|
||||
</div>
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { toast } from "sonner";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
|
||||
export function MessageButton({ targetUsername }: { targetUsername: string }) {
|
||||
const router = useRouter();
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function startConversation() {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/conversations", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username: targetUsername }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json() as { error?: string };
|
||||
toast.error(err.error ?? "Failed to start conversation");
|
||||
return;
|
||||
}
|
||||
const { conversationId } = await res.json() as { conversationId: string };
|
||||
router.push(`/messages/${conversationId}`);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button variant="outline" size="sm" onClick={() => { void startConversation(); }} disabled={loading}>
|
||||
<MessageCircle className="h-3.5 w-3.5" />
|
||||
Message
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { Send } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type Message = {
|
||||
id: string;
|
||||
content: string;
|
||||
senderId: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export function MessageThread({
|
||||
conversationId,
|
||||
currentUserId,
|
||||
}: {
|
||||
conversationId: string;
|
||||
currentUserId: string;
|
||||
}) {
|
||||
const [messages, setMessages] = useState<Message[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [content, setContent] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const bottomRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
const res = await fetch(`/api/v1/conversations/${conversationId}/messages`);
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { messages: Message[] };
|
||||
setMessages(data.messages);
|
||||
}
|
||||
setLoading(false);
|
||||
}, [conversationId]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
const interval = setInterval(() => { void load(); }, 5000);
|
||||
return () => clearInterval(interval);
|
||||
}, [load]);
|
||||
|
||||
useEffect(() => {
|
||||
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
|
||||
}, [messages.length]);
|
||||
|
||||
async function send() {
|
||||
if (!content.trim()) return;
|
||||
setSending(true);
|
||||
try {
|
||||
const res = await fetch(`/api/v1/conversations/${conversationId}/messages`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ content: content.trim() }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({})) as { error?: string };
|
||||
toast.error(err.error ?? "Failed to send");
|
||||
return;
|
||||
}
|
||||
setContent("");
|
||||
await load();
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex-1 overflow-y-auto space-y-3 p-4">
|
||||
{loading ? (
|
||||
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||
) : messages.length === 0 ? (
|
||||
<p className="text-sm text-muted-foreground text-center py-8">No messages yet. Say hi!</p>
|
||||
) : (
|
||||
messages.map((m) => {
|
||||
const isOwn = m.senderId === currentUserId;
|
||||
return (
|
||||
<div key={m.id} className={cn("flex", isOwn ? "justify-end" : "justify-start")}>
|
||||
<div
|
||||
className={cn(
|
||||
"max-w-[70%] rounded-2xl px-4 py-2 text-sm whitespace-pre-wrap",
|
||||
isOwn ? "bg-primary text-primary-foreground" : "bg-muted"
|
||||
)}
|
||||
>
|
||||
{m.content}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
<div className="border-t p-3 flex gap-2 items-end">
|
||||
<Textarea
|
||||
value={content}
|
||||
onChange={(e) => setContent(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
void send();
|
||||
}
|
||||
}}
|
||||
placeholder="Type a message…"
|
||||
rows={1}
|
||||
className="resize-none"
|
||||
/>
|
||||
<Button size="icon" onClick={() => { void send(); }} disabled={!content.trim() || sending}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { MessageCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
|
||||
export function MessagesNavLink() {
|
||||
const [unreadTotal, setUnreadTotal] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch("/api/v1/conversations");
|
||||
if (!res.ok || cancelled) return;
|
||||
const data = (await res.json()) as { conversations: { unreadCount: number }[] };
|
||||
setUnreadTotal(data.conversations.reduce((sum, c) => sum + c.unreadCount, 0));
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
void load();
|
||||
const interval = setInterval(load, 30_000);
|
||||
return () => { cancelled = true; clearInterval(interval); };
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Button variant="ghost" size="icon" className="relative" nativeButton={false} render={<Link href="/messages" />}>
|
||||
<MessageCircle className="h-4 w-4" />
|
||||
{unreadTotal > 0 && (
|
||||
<Badge
|
||||
variant="destructive"
|
||||
className="absolute -top-1 -right-1 h-4 min-w-4 px-1 text-[10px] leading-none flex items-center justify-center"
|
||||
>
|
||||
{unreadTotal > 9 ? "9+" : unreadTotal}
|
||||
</Badge>
|
||||
)}
|
||||
<span className="sr-only">Messages</span>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import { db, conversations, eq, and } from "@epicure/db";
|
||||
import { randomUUID } from "crypto";
|
||||
|
||||
/** Normalizes a pair so (userAId, userBId) is order-independent. */
|
||||
function orderPair(idA: string, idB: string): [string, string] {
|
||||
return idA < idB ? [idA, idB] : [idB, idA];
|
||||
}
|
||||
|
||||
export async function findOrCreateConversation(userId1: string, userId2: string) {
|
||||
const [userAId, userBId] = orderPair(userId1, userId2);
|
||||
|
||||
const existing = await db.query.conversations.findFirst({
|
||||
where: and(eq(conversations.userAId, userAId), eq(conversations.userBId, userBId)),
|
||||
});
|
||||
if (existing) return existing;
|
||||
|
||||
const [created] = await db
|
||||
.insert(conversations)
|
||||
.values({ id: randomUUID(), userAId, userBId })
|
||||
.onConflictDoNothing()
|
||||
.returning();
|
||||
|
||||
if (created) return created;
|
||||
|
||||
// Lost a race with a concurrent insert — fetch what the winner created.
|
||||
const row = await db.query.conversations.findFirst({
|
||||
where: and(eq(conversations.userAId, userAId), eq(conversations.userBId, userBId)),
|
||||
});
|
||||
if (!row) throw new Error("Failed to create conversation");
|
||||
return row;
|
||||
}
|
||||
|
||||
export function isParticipant(
|
||||
conversation: { userAId: string; userBId: string },
|
||||
userId: string
|
||||
): boolean {
|
||||
return conversation.userAId === userId || conversation.userBId === userId;
|
||||
}
|
||||
|
||||
export function otherParticipantId(
|
||||
conversation: { userAId: string; userBId: string },
|
||||
userId: string
|
||||
): string {
|
||||
return conversation.userAId === userId ? conversation.userBId : conversation.userAId;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
CREATE TABLE "conversation_reads" (
|
||||
"conversation_id" text NOT NULL,
|
||||
"user_id" text NOT NULL,
|
||||
"last_read_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "conversation_reads_conversation_id_user_id_pk" PRIMARY KEY("conversation_id","user_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "conversations" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"user_a_id" text NOT NULL,
|
||||
"user_b_id" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL,
|
||||
"last_message_at" timestamp DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "conversations_pair_uniq" UNIQUE("user_a_id","user_b_id")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "messages" (
|
||||
"id" text PRIMARY KEY NOT NULL,
|
||||
"conversation_id" text NOT NULL,
|
||||
"sender_id" text NOT NULL,
|
||||
"content" text NOT NULL,
|
||||
"created_at" timestamp DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "conversation_reads" ADD CONSTRAINT "conversation_reads_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "conversation_reads" ADD CONSTRAINT "conversation_reads_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_user_a_id_users_id_fk" FOREIGN KEY ("user_a_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "conversations" ADD CONSTRAINT "conversations_user_b_id_users_id_fk" FOREIGN KEY ("user_b_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_conversation_id_conversations_id_fk" FOREIGN KEY ("conversation_id") REFERENCES "public"."conversations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "messages" ADD CONSTRAINT "messages_sender_id_users_id_fk" FOREIGN KEY ("sender_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "conversations_last_message_idx" ON "conversations" USING btree ("last_message_at");--> statement-breakpoint
|
||||
CREATE INDEX "messages_conversation_idx" ON "messages" USING btree ("conversation_id","created_at");
|
||||
File diff suppressed because it is too large
Load Diff
@@ -141,6 +141,13 @@
|
||||
"when": 1783108786252,
|
||||
"tag": "0019_clumsy_leader",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 20,
|
||||
"version": "7",
|
||||
"when": 1783109825851,
|
||||
"tag": "0020_acoustic_exiles",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export * from "./social";
|
||||
export * from "./meal-planning";
|
||||
export * from "./tiers";
|
||||
export * from "./webhooks";
|
||||
export * from "./messaging";
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import {
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
index,
|
||||
unique,
|
||||
primaryKey,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { relations } from "drizzle-orm";
|
||||
import { users } from "./users";
|
||||
|
||||
// 1:1 conversations only. userAId is always the lexicographically smaller
|
||||
// of the two participant ids, so (userAId, userBId) uniquely identifies a
|
||||
// pair regardless of who messaged first.
|
||||
export const conversations = pgTable("conversations", {
|
||||
id: text("id").primaryKey(),
|
||||
userAId: text("user_a_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
userBId: text("user_b_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
lastMessageAt: timestamp("last_message_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
unique("conversations_pair_uniq").on(t.userAId, t.userBId),
|
||||
index("conversations_last_message_idx").on(t.lastMessageAt),
|
||||
]);
|
||||
|
||||
export const messages = pgTable("messages", {
|
||||
id: text("id").primaryKey(),
|
||||
conversationId: text("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
|
||||
senderId: text("sender_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
content: text("content").notNull(),
|
||||
createdAt: timestamp("created_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
index("messages_conversation_idx").on(t.conversationId, t.createdAt),
|
||||
]);
|
||||
|
||||
export const conversationReads = pgTable("conversation_reads", {
|
||||
conversationId: text("conversation_id").notNull().references(() => conversations.id, { onDelete: "cascade" }),
|
||||
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
|
||||
lastReadAt: timestamp("last_read_at").notNull().defaultNow(),
|
||||
}, (t) => [
|
||||
primaryKey({ columns: [t.conversationId, t.userId] }),
|
||||
]);
|
||||
|
||||
export const conversationsRelations = relations(conversations, ({ one, many }) => ({
|
||||
userA: one(users, { fields: [conversations.userAId], references: [users.id], relationName: "userA" }),
|
||||
userB: one(users, { fields: [conversations.userBId], references: [users.id], relationName: "userB" }),
|
||||
messages: many(messages),
|
||||
}));
|
||||
|
||||
export const messagesRelations = relations(messages, ({ one }) => ({
|
||||
conversation: one(conversations, { fields: [messages.conversationId], references: [conversations.id] }),
|
||||
sender: one(users, { fields: [messages.senderId], references: [users.id] }),
|
||||
}));
|
||||
Reference in New Issue
Block a user