feat(06): Phase 6 — Direct Messages

- Schema: DmPolicy enum (EVERYONE/FOLLOWERS_ONLY), dmPolicy on Pet, Conversation + Message models
- conversationsRouter: list (inbox), getOrCreate (with DmPolicy + block check), unreadCount
- messagesRouter: list (3s poll, auto mark-read), send (+ MESSAGE notification), markRead
- /messages inbox page with unread badges + last message preview
- /messages/[conversationId] thread with bubble UI, day dividers, Enter to send
- Message button on pet profiles (ProfileActions) → getOrCreate → navigate to thread
- Sidebar: Messages link activated
- Pet edit page: DM Privacy setting (Everyone / Only pets I follow)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Sonnet 4.6 committed 2026-06-14 09:45:45 +02:00
1 parent 1fc69add4d
commit e2e932804b
10 files changed
+843 -3

No files matched your search

+45
View File
@@ -41,6 +41,7 @@ model Pet {
bio String?
adoptionStory String?
avatarKey String? // Supabase Storage object key, NOT a full URL
dmPolicy DmPolicy @default(EVERYONE) // who can send this pet DMs
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -63,9 +64,21 @@ model Pet {
receivedNotifications Notification[] @relation("ReceivedNotifications")
sentNotifications Notification[] @relation("SentNotifications")
// Phase 6 back-relations
conversationsAsA Conversation[] @relation("ConvPetA")
conversationsAsB Conversation[] @relation("ConvPetB")
sentMessages Message[] @relation("MessageSender")
@@index([ownerId])
}
// ─── Phase 6 Enums ───────────────────────────────────────────────────────────
enum DmPolicy {
EVERYONE // anyone can send a DM
FOLLOWERS_ONLY // only pets that this pet follows can send a DM
}
// ─── Phase 2 Enums ───────────────────────────────────────────────────────────
enum PostType {
@@ -296,6 +309,38 @@ model VideoPost {
@@index([muxAssetId])
}
// ─── Phase 6 Models ──────────────────────────────────────────────────────────
model Conversation {
id String @id @default(cuid())
petA Pet @relation("ConvPetA", fields: [petAId], references: [id], onDelete: Cascade)
petAId String
petB Pet @relation("ConvPetB", fields: [petBId], references: [id], onDelete: Cascade)
petBId String
messages Message[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt // updated on every new message for inbox ordering
@@unique([petAId, petBId]) // petAId is always < petBId (sorted on create) — deduplication
@@index([petAId])
@@index([petBId])
}
model Message {
id String @id @default(cuid())
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
conversationId String
senderPet Pet @relation("MessageSender", fields: [senderPetId], references: [id])
senderPetId String
body String @db.VarChar(1000)
readAt DateTime? // null = unread by recipient
createdAt DateTime @default(now())
@@index([conversationId, createdAt(sort: Asc)])
@@index([senderPetId])
}
// ─── Phase 4 Enums ───────────────────────────────────────────────────────────
enum NotificationType {
@@ -0,0 +1,46 @@
"use client";
import { useParams } from "next/navigation";
import { useQuery } from "@tanstack/react-query";
import { Loader2 } from "lucide-react";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { MessageThread } from "@/components/messages/MessageThread";
export default function ConversationPage() {
const params = useParams<{ conversationId: string }>();
const conversationId = params.conversationId;
const trpc = useTRPC();
const { activePetId } = useActivePet();
// Load conversations to get the other pet's info
const { data: convList, isLoading } = useQuery({
...trpc.conversations.list.queryOptions({ petId: activePetId ?? "" }),
enabled: !!activePetId,
});
const conversation = convList?.find((c) => c.id === conversationId);
if (isLoading || !activePetId) {
return (
<div className="flex items-center justify-center h-[100dvh]">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (!conversation) {
return (
<div className="flex items-center justify-center h-[100dvh] text-muted-foreground text-sm">
Conversation not found.
</div>
);
}
return (
<MessageThread
conversationId={conversationId}
otherPet={conversation.otherPet}
/>
);
}
+116
View File
@@ -0,0 +1,116 @@
"use client";
import { useRouter } from "next/navigation";
import { MessageCircle, Loader2 } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { getAvatarUrl } from "@/lib/avatar-url";
function formatRelativeTime(date: Date | string): string {
const diffMs = Date.now() - new Date(date).getTime();
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "now";
if (diffMins < 60) return `${diffMins}m`;
if (diffHours < 24) return `${diffHours}h`;
if (diffDays < 7) return `${diffDays}d`;
return new Date(date).toLocaleDateString();
}
export default function MessagesPage() {
const trpc = useTRPC();
const router = useRouter();
const { activePetId } = useActivePet();
const { data, isLoading } = useQuery({
...trpc.conversations.list.queryOptions({ petId: activePetId ?? "" }),
enabled: !!activePetId,
refetchInterval: 10_000,
});
const conversations = data ?? [];
return (
<div className="max-w-[640px] mx-auto">
<div className="px-4 py-5 border-b">
<h1 className="text-xl font-semibold flex items-center gap-2">
<MessageCircle className="h-5 w-5 text-orange-500" aria-hidden="true" />
Messages
</h1>
</div>
{isLoading && (
<div className="flex justify-center py-16">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && conversations.length === 0 && (
<div className="flex flex-col items-center gap-3 py-24 text-muted-foreground">
<MessageCircle className="h-12 w-12 opacity-25" />
<p className="text-sm font-medium">No messages yet</p>
<p className="text-xs text-center max-w-[220px]">
Visit a pet&apos;s profile and tap <strong>Message</strong> to start a conversation.
</p>
</div>
)}
{!isLoading && conversations.length > 0 && (
<ul>
{conversations.map((conv) => {
const avatarUrl = getAvatarUrl(conv.otherPet.avatarKey);
const hasUnread = conv.unreadCount > 0;
return (
<li key={conv.id}>
<button
type="button"
onClick={() => router.push(`/messages/${conv.id}`)}
className="w-full flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors text-left"
>
{/* Avatar */}
<Avatar className="w-12 h-12 shrink-0">
{avatarUrl && <AvatarImage src={avatarUrl} alt={conv.otherPet.name} />}
<AvatarFallback className="text-sm bg-muted">
{conv.otherPet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
{/* Content */}
<div className="flex-1 min-w-0">
<div className="flex items-baseline justify-between gap-2">
<p className={`text-sm truncate ${hasUnread ? "font-semibold" : "font-medium"}`}>
{conv.otherPet.name}
</p>
<span className="text-xs text-muted-foreground shrink-0">
{formatRelativeTime(conv.updatedAt)}
</span>
</div>
<div className="flex items-center justify-between gap-2 mt-0.5">
<p className={`text-xs truncate ${hasUnread ? "text-foreground" : "text-muted-foreground"}`}>
{conv.lastMessage
? conv.lastMessage.senderPetId === activePetId
? `You: ${conv.lastMessage.body}`
: conv.lastMessage.body
: "Start the conversation"}
</p>
{hasUnread && (
<Badge className="shrink-0 bg-orange-500 text-white text-xs px-1.5 min-w-[20px] h-5 flex items-center justify-center rounded-full">
{conv.unreadCount > 99 ? "99+" : conv.unreadCount}
</Badge>
)}
</div>
</div>
</button>
</li>
);
})}
</ul>
)}
</div>
);
}
+38 -2
View File
@@ -1,8 +1,12 @@
"use client";
import { useState } from "react";
import { MoreHorizontal } from "lucide-react";
import { useRouter } from "next/navigation";
import { MoreHorizontal, MessageCircle, Loader2 } from "lucide-react";
import { useMutation } from "@tanstack/react-query";
import { toast } from "sonner";
import { useActivePet } from "@/context/ActivePetContext";
import { useTRPC } from "@/trpc/client";
import { FollowButton } from "@/components/social/FollowButton";
import { BlockDialog } from "@/components/safety/BlockDialog";
import { ReportSheet } from "@/components/safety/ReportSheet";
@@ -37,9 +41,18 @@ interface ProfileActionsProps {
export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps) {
const { activePetId } = useActivePet();
const router = useRouter();
const trpc = useTRPC();
const [blockOpen, setBlockOpen] = useState(false);
const [reportOpen, setReportOpen] = useState(false);
const getOrCreateMutation = useMutation(
trpc.conversations.getOrCreate.mutationOptions({
onSuccess: (conv) => router.push(`/messages/${conv.id}`),
onError: (err) => toast.error(err.message ?? "Cannot start conversation."),
})
);
// activePetId may be null during SSR hydration — skip rendering interactive elements
if (!activePetId) return null;
@@ -47,7 +60,7 @@ export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps)
return (
<div className="flex items-center justify-center gap-3 mb-6">
{/* FollowButton — hidden on own pet (FollowButton also guards this internally) */}
{/* FollowButton — hidden on own pet */}
{!isOwnPet && (
<FollowButton
followerPetId={activePetId}
@@ -56,6 +69,29 @@ export function ProfileActions({ petId, petName, ownerId }: ProfileActionsProps)
/>
)}
{/* Message button — opens or creates a DM conversation */}
{!isOwnPet && (
<Button
variant="outline"
size="sm"
onClick={() =>
getOrCreateMutation.mutate({
senderPetId: activePetId,
recipientPetId: petId,
})
}
disabled={getOrCreateMutation.isPending}
className="flex items-center gap-1.5"
>
{getOrCreateMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MessageCircle className="h-4 w-4" />
)}
Message
</Button>
)}
{/* Options menu — only show for other pets' profiles */}
{!isOwnPet && (
<>
+58
View File
@@ -10,6 +10,14 @@ import { PetForm } from "@/components/pet/PetForm";
import { Separator } from "@/components/ui/separator";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import {
Dialog,
DialogContent,
@@ -39,6 +47,7 @@ export default function EditPetPage() {
const queryClient = useQueryClient();
const { activePetId, setActivePet } = useActivePet();
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [dmPolicy, setDmPolicy] = useState<"EVERYONE" | "FOLLOWERS_ONLY">("EVERYONE");
const {
data: pet,
@@ -48,6 +57,11 @@ export default function EditPetPage() {
...trpc.pets.byId.queryOptions({ petId }),
});
// Sync dmPolicy from fetched pet
useEffect(() => {
if (pet?.dmPolicy) setDmPolicy(pet.dmPolicy as "EVERYONE" | "FOLLOWERS_ONLY");
}, [pet?.dmPolicy]);
// Gracefully handle ownership violation — redirect instead of showing error screen
useEffect(() => {
if (isError) {
@@ -55,6 +69,12 @@ export default function EditPetPage() {
}
}, [isError, router]);
const updateDmPolicyMutation = useMutation({
...trpc.pets.update.mutationOptions(),
onSuccess: () => toast.success("Privacy setting saved"),
onError: () => toast.error("Failed to save privacy setting"),
});
const deleteMutation = useMutation({
...trpc.pets.delete.mutationOptions(),
onSuccess: async () => {
@@ -101,6 +121,44 @@ export default function EditPetPage() {
onSuccess={() => router.push(`/pets/${pet.id}`)}
/>
{/* Privacy settings */}
<Separator className="my-8" />
<div className="space-y-4">
<h2 className="text-sm font-medium">Privacy</h2>
<div className="space-y-2">
<Label htmlFor="dm-policy">Who can message {pet.name}?</Label>
<div className="flex items-center gap-3">
<Select
value={dmPolicy}
onValueChange={(v) => setDmPolicy(v as "EVERYONE" | "FOLLOWERS_ONLY")}
>
<SelectTrigger id="dm-policy" className="w-[220px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="EVERYONE">Everyone</SelectItem>
<SelectItem value="FOLLOWERS_ONLY">Only pets I follow</SelectItem>
</SelectContent>
</Select>
<Button
variant="outline"
size="sm"
disabled={updateDmPolicyMutation.isPending || dmPolicy === pet.dmPolicy}
onClick={() =>
updateDmPolicyMutation.mutate({ petId: pet.id, dmPolicy })
}
>
Save
</Button>
</div>
<p className="text-xs text-muted-foreground">
{dmPolicy === "FOLLOWERS_ONLY"
? "Only pets that you follow can send you direct messages."
: "Any pet on PawFeed can send you direct messages."}
</p>
</div>
</div>
{/* Destructive zone */}
<Separator className="my-8" />
<div className="flex flex-col gap-2">
+1 -1
View File
@@ -46,7 +46,7 @@ const navLinks = [
{ href: "/explore", label: "Explore", icon: Compass, active: true, special: false },
{ href: "/search", label: "Search", icon: Search, active: true, special: false },
{ href: "/notifications", label: "Notifications", icon: Bell, active: true, special: true },
{ href: "/messages", label: "Messages", icon: MessageCircle, active: false, special: false },
{ href: "/messages", label: "Messages", icon: MessageCircle, active: true, special: false },
];
/**
+250
View File
@@ -0,0 +1,250 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useRouter } from "next/navigation";
import { ArrowLeft, Send, Loader2 } from "lucide-react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTRPC } from "@/trpc/client";
import { useActivePet } from "@/context/ActivePetContext";
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { getAvatarUrl } from "@/lib/avatar-url";
import Link from "next/link";
function formatTime(date: Date | string) {
return new Date(date).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function formatDay(date: Date | string) {
const d = new Date(date);
const today = new Date();
const yesterday = new Date();
yesterday.setDate(today.getDate() - 1);
if (d.toDateString() === today.toDateString()) return "Today";
if (d.toDateString() === yesterday.toDateString()) return "Yesterday";
return d.toLocaleDateString([], { weekday: "long", month: "short", day: "numeric" });
}
interface MessageThreadProps {
conversationId: string;
otherPet: { id: string; name: string; avatarKey: string | null };
}
export function MessageThread({ conversationId, otherPet }: MessageThreadProps) {
const trpc = useTRPC();
const router = useRouter();
const queryClient = useQueryClient();
const { activePetId } = useActivePet();
const bottomRef = useRef<HTMLDivElement>(null);
const [body, setBody] = useState("");
const { data, isLoading } = useQuery({
...trpc.messages.list.queryOptions({
conversationId,
petId: activePetId ?? "",
limit: 50,
}),
enabled: !!activePetId,
refetchInterval: 3000, // poll every 3s for new messages
});
const sendMutation = useMutation(
trpc.messages.send.mutationOptions({
onSuccess: () => {
setBody("");
void queryClient.invalidateQueries(
trpc.messages.list.queryOptions({ conversationId, petId: activePetId ?? "", limit: 50 })
);
void queryClient.invalidateQueries(
trpc.conversations.list.queryOptions({ petId: activePetId ?? "" })
);
void queryClient.invalidateQueries(
trpc.conversations.unreadCount.queryOptions({ petId: activePetId ?? "" })
);
},
onError: () => toast.error("Failed to send message. Try again."),
})
);
// Scroll to bottom on new messages
useEffect(() => {
bottomRef.current?.scrollIntoView({ behavior: "smooth" });
}, [data?.messages]);
const handleSend = useCallback(() => {
if (!body.trim() || !activePetId) return;
sendMutation.mutate({
conversationId,
senderPetId: activePetId,
body: body.trim(),
});
}, [body, activePetId, conversationId, sendMutation]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
},
[handleSend]
);
const messages = data?.messages ?? [];
const otherAvatarUrl = getAvatarUrl(otherPet.avatarKey);
// Group messages by day
const grouped: Array<{ day: string; messages: typeof messages }> = [];
for (const msg of messages) {
const day = formatDay(msg.createdAt);
const last = grouped[grouped.length - 1];
if (last && last.day === day) {
last.messages.push(msg);
} else {
grouped.push({ day, messages: [msg] });
}
}
return (
<div className="flex flex-col h-[100dvh] max-h-[100dvh]">
{/* Header */}
<div className="flex items-center gap-3 px-4 py-3 border-b bg-card shrink-0">
<button
type="button"
onClick={() => router.back()}
className="p-2 -ml-2 rounded-full hover:bg-muted transition-colors"
aria-label="Back"
>
<ArrowLeft className="h-5 w-5" aria-hidden="true" />
</button>
<Link href={`/pets/${otherPet.id}`} className="flex items-center gap-2 hover:opacity-80">
<Avatar className="w-8 h-8">
{otherAvatarUrl && <AvatarImage src={otherAvatarUrl} alt={otherPet.name} />}
<AvatarFallback className="text-xs bg-muted">
{otherPet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<span className="text-sm font-semibold">{otherPet.name}</span>
</Link>
</div>
{/* Message list */}
<div className="flex-1 overflow-y-auto px-4 py-4 space-y-4">
{isLoading && (
<div className="flex justify-center py-10">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
)}
{!isLoading && messages.length === 0 && (
<div className="flex flex-col items-center gap-2 py-20 text-muted-foreground">
<Avatar className="w-16 h-16">
{otherAvatarUrl && <AvatarImage src={otherAvatarUrl} alt={otherPet.name} />}
<AvatarFallback className="text-xl bg-muted">
{otherPet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
<p className="text-sm font-medium mt-2">{otherPet.name}</p>
<p className="text-xs">Say hello 👋</p>
</div>
)}
{grouped.map(({ day, messages: dayMsgs }) => (
<div key={day}>
{/* Day divider */}
<div className="flex items-center gap-2 my-3">
<div className="flex-1 h-px bg-border" />
<span className="text-xs text-muted-foreground shrink-0">{day}</span>
<div className="flex-1 h-px bg-border" />
</div>
<div className="space-y-1.5">
{dayMsgs.map((msg, i) => {
const isOwn = msg.senderPetId === activePetId;
const prevMsg = dayMsgs[i - 1];
const showAvatar = !isOwn && (!prevMsg || prevMsg.senderPetId !== msg.senderPetId);
return (
<div
key={msg.id}
className={`flex items-end gap-2 ${isOwn ? "justify-end" : "justify-start"}`}
>
{/* Other pet avatar — only shown at first bubble in a run */}
{!isOwn && (
<div className="w-7 h-7 shrink-0">
{showAvatar && (
<Avatar className="w-7 h-7">
{otherAvatarUrl && (
<AvatarImage src={otherAvatarUrl} alt={otherPet.name} />
)}
<AvatarFallback className="text-xs bg-muted">
{otherPet.name[0]?.toUpperCase() ?? "?"}
</AvatarFallback>
</Avatar>
)}
</div>
)}
<div className={`max-w-[72%] ${isOwn ? "items-end" : "items-start"} flex flex-col`}>
<div
className={`px-3 py-2 rounded-2xl text-sm leading-snug whitespace-pre-wrap break-words ${
isOwn
? "bg-orange-500 text-white rounded-br-sm"
: "bg-muted text-foreground rounded-bl-sm"
}`}
>
{msg.body}
</div>
<span className="text-[10px] text-muted-foreground mt-0.5 px-1">
{formatTime(msg.createdAt)}
</span>
</div>
</div>
);
})}
</div>
</div>
))}
<div ref={bottomRef} />
</div>
{/* Composer */}
<div className="shrink-0 border-t bg-card px-4 py-3">
<div className="flex items-end gap-2">
<Textarea
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Message…"
maxLength={1000}
rows={1}
className="resize-none text-sm min-h-[40px] max-h-[120px] flex-1"
disabled={sendMutation.isPending}
style={{ overflowY: "auto" }}
/>
<Button
size="icon"
onClick={handleSend}
disabled={!body.trim() || sendMutation.isPending}
className="h-10 w-10 shrink-0 bg-orange-500 hover:bg-orange-600 text-white rounded-full"
aria-label="Send message"
>
{sendMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</Button>
</div>
{body.length > 900 && (
<p className="text-xs text-muted-foreground text-right mt-1">
{1000 - body.length} characters left
</p>
)}
</div>
</div>
);
}
+3
View File
@@ -15,6 +15,7 @@ import { exploreRouter } from "@/trpc/routers/explore";
import { searchRouter } from "@/trpc/routers/search";
import { notificationsRouter } from "@/trpc/routers/notifications";
import { videosRouter } from "@/trpc/routers/videos";
import { conversationsRouter, messagesRouter } from "@/trpc/routers/messages";
export const appRouter = router({
pets: petsRouter,
@@ -33,6 +34,8 @@ export const appRouter = router({
search: searchRouter,
notifications: notificationsRouter,
videos: videosRouter,
conversations: conversationsRouter,
messages: messagesRouter,
});
// Export the type for use in the client
+284
View File
@@ -0,0 +1,284 @@
import "server-only";
import { z } from "zod";
import { TRPCError } from "@trpc/server";
import { router, protectedProcedure } from "@/trpc/init";
import type { PrismaClient } from "@prisma/client";
/**
* Messages tRPC router — Phase 6 Direct Messages.
*
* Two sub-routers exported:
* conversationsRouter — list, getOrCreate, unreadCount
* messagesRouter — list, send, markRead
*
* Security:
* - All procedures verify pet ownership via ctx.userId before accessing data
* - DmPolicy check: FOLLOWERS_ONLY blocks DMs from non-followers
* - Block check: blocked pets cannot initiate or receive DMs
* - Conversation IDs are sorted (petAId < petBId) to prevent duplicates
*/
async function assertPetOwnership(prisma: PrismaClient, petId: string, userId: string) {
const pet = await prisma.pet.findUnique({ where: { id: petId } });
if (!pet || pet.ownerId !== userId) throw new TRPCError({ code: "FORBIDDEN" });
return pet;
}
/** Verify that petId is a participant of the conversation and belongs to ctx.userId */
async function assertParticipant(
prisma: PrismaClient,
conversationId: string,
petId: string,
userId: string
) {
const [conv] = await Promise.all([
prisma.conversation.findUnique({ where: { id: conversationId } }),
assertPetOwnership(prisma, petId, userId),
]);
if (!conv || (conv.petAId !== petId && conv.petBId !== petId)) {
throw new TRPCError({ code: "FORBIDDEN" });
}
return conv;
}
export const conversationsRouter = router({
/**
* List all conversations for a pet, ordered by most recent message.
* Returns the other pet's info, last message preview, and unread count.
*/
list: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const convs = await ctx.prisma.conversation.findMany({
where: { OR: [{ petAId: input.petId }, { petBId: input.petId }] },
include: {
petA: { select: { id: true, name: true, avatarKey: true } },
petB: { select: { id: true, name: true, avatarKey: true } },
messages: {
orderBy: { createdAt: "desc" },
take: 1,
},
_count: {
select: {
messages: {
where: { senderPetId: { not: input.petId }, readAt: null },
},
},
},
},
orderBy: { updatedAt: "desc" },
});
return convs.map((c) => ({
id: c.id,
otherPet: c.petAId === input.petId ? c.petB : c.petA,
lastMessage: c.messages[0] ?? null,
unreadCount: c._count.messages,
updatedAt: c.updatedAt,
}));
}),
/**
* Find or create a conversation between two pets.
* Enforces DmPolicy and block checks before creating.
* Pet IDs are sorted alphabetically to ensure uniqueness.
*/
getOrCreate: protectedProcedure
.input(
z.object({
senderPetId: z.string().min(1),
recipientPetId: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.senderPetId, ctx.userId);
if (input.senderPetId === input.recipientPetId) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Cannot message yourself." });
}
const [recipient, block] = await Promise.all([
ctx.prisma.pet.findUnique({
where: { id: input.recipientPetId },
select: { id: true, dmPolicy: true },
}),
ctx.prisma.block.findFirst({
where: {
OR: [
{ blockerPetId: input.senderPetId, blockedPetId: input.recipientPetId },
{ blockerPetId: input.recipientPetId, blockedPetId: input.senderPetId },
],
},
}),
]);
if (!recipient) throw new TRPCError({ code: "NOT_FOUND" });
if (block) throw new TRPCError({ code: "FORBIDDEN", message: "Cannot send messages to this pet." });
// DmPolicy check
if (recipient.dmPolicy === "FOLLOWERS_ONLY") {
const follow = await ctx.prisma.follow.findUnique({
where: {
followerPetId_followeePetId: {
followerPetId: input.recipientPetId,
followeePetId: input.senderPetId,
},
},
});
if (!follow) {
throw new TRPCError({
code: "FORBIDDEN",
message: "This pet only accepts messages from pets they follow.",
});
}
}
// Sort IDs to ensure uniqueness constraint ([petAId, petBId])
const [petAId, petBId] = [input.senderPetId, input.recipientPetId].sort();
const conversation = await ctx.prisma.conversation.upsert({
where: { petAId_petBId: { petAId, petBId } },
update: {},
create: { petAId, petBId },
});
return conversation;
}),
/** Total unread message count across all conversations — used for inbox badge. */
unreadCount: protectedProcedure
.input(z.object({ petId: z.string().min(1) }))
.query(async ({ ctx, input }) => {
await assertPetOwnership(ctx.prisma, input.petId, ctx.userId);
const count = await ctx.prisma.message.count({
where: {
conversation: {
OR: [{ petAId: input.petId }, { petBId: input.petId }],
},
senderPetId: { not: input.petId },
readAt: null,
},
});
return { count };
}),
});
export const messagesRouter = router({
/**
* Paginated messages in a conversation, oldest-first (cursor on createdAt).
* Marks messages sent by the other pet as read on load.
*/
list: protectedProcedure
.input(
z.object({
conversationId: z.string().min(1),
petId: z.string().min(1),
cursor: z.string().nullish(), // ISO createdAt for pagination
limit: z.number().min(1).max(50).default(30),
})
)
.query(async ({ ctx, input }) => {
await assertParticipant(ctx.prisma, input.conversationId, input.petId, ctx.userId);
const messages = await ctx.prisma.message.findMany({
where: {
conversationId: input.conversationId,
...(input.cursor ? { createdAt: { gt: new Date(input.cursor) } } : {}),
},
orderBy: { createdAt: "asc" },
take: input.limit + 1,
include: {
senderPet: { select: { id: true, name: true, avatarKey: true } },
},
});
const hasNextPage = messages.length > input.limit;
const items = hasNextPage ? messages.slice(0, input.limit) : messages;
const nextCursor = hasNextPage ? items[items.length - 1].createdAt.toISOString() : null;
// Mark received messages as read (fire-and-forget)
ctx.prisma.message.updateMany({
where: {
conversationId: input.conversationId,
senderPetId: { not: input.petId },
readAt: null,
},
data: { readAt: new Date() },
}).catch(() => {});
return { messages: items, nextCursor };
}),
/** Send a message. Fires MESSAGE notification fire-and-forget. */
send: protectedProcedure
.input(
z.object({
conversationId: z.string().min(1),
senderPetId: z.string().min(1),
body: z.string().min(1).max(1000),
})
)
.mutation(async ({ ctx, input }) => {
const conv = await assertParticipant(
ctx.prisma,
input.conversationId,
input.senderPetId,
ctx.userId
);
const recipientPetId =
conv.petAId === input.senderPetId ? conv.petBId : conv.petAId;
const [message] = await ctx.prisma.$transaction([
ctx.prisma.message.create({
data: {
conversationId: input.conversationId,
senderPetId: input.senderPetId,
body: input.body,
},
include: { senderPet: { select: { id: true, name: true, avatarKey: true } } },
}),
// Touch conversation updatedAt so inbox re-orders correctly
ctx.prisma.conversation.update({
where: { id: input.conversationId },
data: { updatedAt: new Date() },
}),
]);
// MESSAGE notification — fire-and-forget
ctx.prisma.notification.create({
data: {
recipientPetId,
type: "MESSAGE",
actorPetId: input.senderPetId,
},
}).catch(() => {});
return message;
}),
/** Mark all received messages in a conversation as read. */
markRead: protectedProcedure
.input(
z.object({
conversationId: z.string().min(1),
petId: z.string().min(1),
})
)
.mutation(async ({ ctx, input }) => {
await assertParticipant(ctx.prisma, input.conversationId, input.petId, ctx.userId);
await ctx.prisma.message.updateMany({
where: {
conversationId: input.conversationId,
senderPetId: { not: input.petId },
readAt: null,
},
data: { readAt: new Date() },
});
}),
});
+2
View File
@@ -117,6 +117,7 @@ export const petsRouter = router({
bio: z.string().max(150).optional().nullable(),
adoptionStory: z.string().max(500).optional().nullable(),
breedId: z.string().optional().nullable(),
dmPolicy: z.enum(["EVERYONE", "FOLLOWERS_ONLY"]).optional(),
})
)
.mutation(async ({ ctx, input }) => {
@@ -131,6 +132,7 @@ export const petsRouter = router({
if (data.adoptionStory !== undefined)
updateData.adoptionStory = data.adoptionStory;
if (data.breedId !== undefined) updateData.breedId = data.breedId;
if (data.dmPolicy !== undefined) updateData.dmPolicy = data.dmPolicy;
return ctx.prisma.pet.update({
where: { id: petId },