diff --git a/deploy/apply-chat-migration.py b/deploy/apply-chat-migration.py new file mode 100644 index 0000000..5d8c4e6 --- /dev/null +++ b/deploy/apply-chat-migration.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Apply ExposedGays chat migration (RLS + DM RPC).""" +import paramiko + +HOST = "10.10.0.11" +USER = "localadministrator" +PASS = "Bbt9115xty9176!" + +c = paramiko.SSHClient() +c.set_missing_host_key_policy(paramiko.AutoAddPolicy()) +c.connect(HOST, username=USER, password=PASS, timeout=20, allow_agent=False, look_for_keys=False) + +def run(cmd, t=120): + print("$", cmd[:140]) + _, o, e = c.exec_command(cmd, timeout=t) + code = o.channel.recv_exit_status() + out = (o.read() + e.read()).decode(errors="replace") + print(out[-3000:]) + print("exit", code, "\n") + return code + +with open( + r"C:\Users\Local Administrator\exposedgays\supabase\migration_v7_chat.sql", + "r", + encoding="utf-8", +) as f: + sql = f.read() + +sftp = c.open_sftp() +with sftp.file("/tmp/eg_chat_migration.sql", "w") as remote: + remote.write(sql) +sftp.close() + +run("cat /tmp/eg_chat_migration.sql | docker exec -i supabase-db psql -U postgres -d postgres 2>&1 | tail -30") + +c.close() +print("Done.") \ No newline at end of file diff --git a/src/app/auth/reset-password/page.tsx b/src/app/auth/reset-password/page.tsx new file mode 100644 index 0000000..d6077bd --- /dev/null +++ b/src/app/auth/reset-password/page.tsx @@ -0,0 +1,113 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { useAuth } from "@/lib/auth/context"; +import { Lock } from "lucide-react"; + +export default function ResetPasswordPage() { + const { updatePassword, session, loading } = useAuth(); + const router = useRouter(); + const [password, setPassword] = useState(""); + const [confirm, setConfirm] = useState(""); + const [error, setError] = useState(""); + const [submitting, setSubmitting] = useState(false); + + const handleSubmit = async () => { + setError(""); + if (password.length < 8) { + setError("Password must be at least 8 characters."); + return; + } + if (password !== confirm) { + setError("Passwords do not match."); + return; + } + + setSubmitting(true); + const { error: err } = await updatePassword(password); + setSubmitting(false); + + if (err) { + setError(err); + return; + } + + router.push("/profile?password=updated"); + }; + + if (loading) { + return ( +
+ Loading... +
+ ); + } + + if (!session) { + return ( +
+

Reset link expired

+

+ Open the app, tap Log In, and request a new password reset link. +

+ +
+ ); + } + + return ( +
+
+

Set a new password

+

+ Choose a new password for your ExposedGays account. +

+
+ +
+
+ +
+ + setPassword(e.target.value)} + placeholder="8+ characters" + className="pl-10" + minLength={8} + /> +
+
+
+ +
+ + setConfirm(e.target.value)} + className="pl-10" + minLength={8} + /> +
+
+ {error &&

{error}

} + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/AuthModal.tsx b/src/components/AuthModal.tsx index 53fcbd8..9f04687 100644 --- a/src/components/AuthModal.tsx +++ b/src/components/AuthModal.tsx @@ -12,7 +12,7 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs"; import { useAuth } from "@/lib/auth/context"; -import { Ghost, Mail, Lock } from "lucide-react"; +import { Ghost, Mail, Lock, ArrowLeft } from "lucide-react"; interface AuthModalProps { open: boolean; @@ -20,12 +20,13 @@ interface AuthModalProps { } export function AuthModal({ open, onClose }: AuthModalProps) { - const { signUp, signIn, signInAnonymously } = useAuth(); + const { signUp, signIn, signInAnonymously, resetPassword } = useAuth(); const [email, setEmail] = useState(""); const [password, setPassword] = useState(""); const [error, setError] = useState(""); const [success, setSuccess] = useState(""); const [loading, setLoading] = useState(false); + const [showForgot, setShowForgot] = useState(false); const handleSignUp = async () => { setLoading(true); @@ -57,20 +58,46 @@ export function AuthModal({ open, onClose }: AuthModalProps) { else onClose(); }; + const handleResetPassword = async () => { + setLoading(true); + setError(""); + setSuccess(""); + const { error: err } = await resetPassword(email); + setLoading(false); + if (err) setError(err); + else { + setSuccess("Password reset link sent. Check your email."); + } + }; + + const resetForgotState = () => { + setShowForgot(false); + setError(""); + setSuccess(""); + }; + return ( - !v && onClose()}> + { + if (!v) { + resetForgotState(); + onClose(); + } + }} + > - Join ExposedGays — Free Forever + + {showForgot ? "Reset Password" : "Join ExposedGays — Free Forever"} + - - - Sign Up - Log In - - - + {showForgot ? ( +
+

+ Enter your account email. We'll send a link to set a new password. +

@@ -84,86 +111,142 @@ export function AuthModal({ open, onClose }: AuthModalProps) { />
-
- -
- - setPassword(e.target.value)} - placeholder="8+ characters" - className="pl-10" - minLength={8} - /> -
-
{error &&

{error}

} {success &&

{success}

} - - - -
- - setEmail(e.target.value)} - placeholder="you@example.com" - className="mt-1" - /> -
-
- - setPassword(e.target.value)} - className="mt-1" - /> -
- {error &&

{error}

} -
- - -
-
-
-
- or -
-
+ ) : ( + <> + + + Sign Up + Log In + - + +
+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="pl-10" + /> +
+
+
+ +
+ + setPassword(e.target.value)} + placeholder="8+ characters" + className="pl-10" + minLength={8} + /> +
+
+ {error &&

{error}

} + {success &&

{success}

} + +
-

- No credit card. No paid tier. Ever. -

+ +
+ + setEmail(e.target.value)} + placeholder="you@example.com" + className="mt-1" + /> +
+
+ + setPassword(e.target.value)} + className="mt-1" + /> +
+
+ +
+ {error &&

{error}

} + +
+
+ +
+
+ +
+
+ or +
+
+ + + +

+ No credit card. No paid tier. Ever. +

+ + )}
); diff --git a/src/components/ChatPanel.tsx b/src/components/ChatPanel.tsx index 657b008..8faf9a4 100644 --- a/src/components/ChatPanel.tsx +++ b/src/components/ChatPanel.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useEffect, useRef } from "react"; +import { useState, useEffect, useRef, useMemo } from "react"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Badge } from "@/components/ui/badge"; @@ -18,8 +18,19 @@ import { canViewerMessageTarget, guestViewerProfile } from "@/lib/visibility"; import { loadHideFromRules } from "@/lib/privacy-rules"; import { loadBlockList } from "@/lib/block-list"; import { loadPrivacySettings, messageBlockReason } from "@/lib/privacy-settings"; +import { useChatMessages } from "@/lib/supabase/hooks"; +import { + ensureRoomMember, + fetchRoomMemberCount, + getOrCreateCityRoom, + getOrCreateDmRoom, + getOrCreateSpotRoom, + senderDisplayName, +} from "@/lib/chat-store"; +import { createClient } from "@/lib/supabase/client"; +import type { Profile, ChatMessage } from "@/types"; -interface Message { +interface DisplayMessage { id: string; sender: string; content: string; @@ -30,7 +41,7 @@ interface Message { const CITY = process.env.NEXT_PUBLIC_DEFAULT_CITY || "Fort Lauderdale"; -const MOCK_CITY_MESSAGES: Message[] = [ +const MOCK_CITY_MESSAGES: DisplayMessage[] = [ { id: "1", sender: "Mike_Top", @@ -57,7 +68,7 @@ const MOCK_CITY_MESSAGES: Message[] = [ }, ]; -const MOCK_DM_MESSAGES: Message[] = [ +const MOCK_DM_MESSAGES: DisplayMessage[] = [ { id: "dm1", sender: "You", @@ -73,6 +84,17 @@ const MOCK_DM_MESSAGES: Message[] = [ }, ]; +function mapChatMessage(msg: ChatMessage, currentUserId: string): DisplayMessage { + return { + id: msg.id, + sender: msg.sender_id === currentUserId ? "You" : senderDisplayName(msg.sender), + content: msg.content, + image_url: msg.image_url ?? undefined, + created_at: msg.created_at, + isMe: msg.sender_id === currentUserId, + }; +} + interface ChatPanelProps { dmUserId?: string | null; spotId?: string | null; @@ -83,45 +105,132 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) { const { capabilities } = useAccess(); const { user, profile: authProfile } = useAuth(); const { blockList } = usePrivacy(); - const [cityMessages, setCityMessages] = useState(MOCK_CITY_MESSAGES); - const [dmMessages, setDmMessages] = useState(MOCK_DM_MESSAGES); const [input, setInput] = useState(""); const [activeTab, setActiveTab] = useState( dmUserId ? "dm" : spotId ? "city" : "city" ); const [showQuickReplies, setShowQuickReplies] = useState(false); + const [cityRoomId, setCityRoomId] = useState(null); + const [spotRoomId, setSpotRoomId] = useState(null); + const [dmRoomId, setDmRoomId] = useState(null); + const [memberCount, setMemberCount] = useState(0); + const [dmTarget, setDmTarget] = useState(null); + const [sending, setSending] = useState(false); const bottomRef = useRef(null); + const isLive = !!user && capabilities.canSendMessages; + const publicRoomId = spotId && spotRoomId ? spotRoomId : cityRoomId; + + const { messages: livePublicMessages, sendMessage: sendPublicMessage } = useChatMessages( + publicRoomId, + isLive + ); + const { messages: liveDmMessages, sendMessage: sendDmMessage } = useChatMessages( + dmRoomId, + isLive && !!dmUserId + ); + + useEffect(() => { + if (!user) { + setCityRoomId(null); + setSpotRoomId(null); + setDmRoomId(null); + return; + } + + let cancelled = false; + + async function resolveRooms() { + const cityId = await getOrCreateCityRoom(CITY); + if (cancelled) return; + if (cityId) { + setCityRoomId(cityId); + await ensureRoomMember(cityId, user.id); + const count = await fetchRoomMemberCount(cityId); + if (!cancelled) setMemberCount(count); + } + + if (spotId) { + const spotName = spotRoomName + ? decodeURIComponent(spotRoomName) + : "Spot Chat"; + const sId = await getOrCreateSpotRoom(spotId, spotName); + if (!cancelled && sId) { + setSpotRoomId(sId); + await ensureRoomMember(sId, user.id); + } + } + + if (dmUserId) { + const dId = await getOrCreateDmRoom(user.id, dmUserId); + if (!cancelled && dId) { + setDmRoomId(dId); + await ensureRoomMember(dId, user.id); + } + } + } + + resolveRooms(); + return () => { + cancelled = true; + }; + }, [user, spotId, spotRoomName, dmUserId]); + + useEffect(() => { + if (!dmUserId) { + setDmTarget(null); + return; + } + + const mock = MOCK_PROFILES.find((p) => p.id === dmUserId); + if (mock) { + setDmTarget(mock); + return; + } + + const supabase = createClient(); + supabase + .from("profiles") + .select("*") + .eq("id", dmUserId) + .maybeSingle() + .then(({ data }) => { + if (data) setDmTarget(data as Profile); + }); + }, [dmUserId]); + useEffect(() => { bottomRef.current?.scrollIntoView({ behavior: "smooth" }); - }, [cityMessages, dmMessages, activeTab]); + }, [livePublicMessages, liveDmMessages, activeTab, isLive]); - const sendMessage = (text?: string) => { - if (!capabilities.canSendMessages) return; + const cityMessages = useMemo(() => { + if (!isLive) return MOCK_CITY_MESSAGES; + return livePublicMessages.map((m) => mapChatMessage(m, user!.id)); + }, [isLive, livePublicMessages, user]); + + const dmMessages = useMemo(() => { + if (!isLive || !dmUserId) return MOCK_DM_MESSAGES; + return liveDmMessages.map((m) => mapChatMessage(m, user!.id)); + }, [isLive, liveDmMessages, dmUserId, user]); + + const sendMessage = async (text?: string) => { + if (!capabilities.canSendMessages || !user) return; const content = (text ?? input).trim(); - if (!content) return; - const msg: Message = { - id: Date.now().toString(), - sender: "You", - content, - created_at: new Date().toISOString(), - isMe: true, - }; + if (!content || sending) return; + setSending(true); if (activeTab === "city") { - setCityMessages((prev) => [...prev, msg]); - } else { - setDmMessages((prev) => [...prev, msg]); + await sendPublicMessage(content, user.id); + } else if (activeTab === "dm" && dmRoomId) { + await sendDmMessage(content, user.id); } + setSending(false); setInput(""); setShowQuickReplies(false); }; const messages = activeTab === "city" ? cityMessages : dmMessages; - const dmTarget = dmUserId - ? MOCK_PROFILES.find((p) => p.id === dmUserId) ?? null - : null; const viewer = authProfile ?? guestViewerProfile(); const viewerId = user?.id ?? "guest"; const dmAllowed = @@ -142,13 +251,17 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) { (activeTab === "city" || activeTab === "dm") && (activeTab !== "dm" || dmAllowed); + const roomBadgeCount = isLive + ? Math.max(memberCount, cityMessages.length) + : cityMessages.length + 12; + return (
- {CITY} + {spotId && spotRoomName ? "Spot" : CITY} @@ -162,12 +275,14 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) {
- {cityMessages.length + 12} in room + {roomBadgeCount} in room{isLive ? "" : " (preview)"}

{spotId && spotRoomName - ? `Spot chat — ${decodeURIComponent(spotRoomName)}. Also connected to ${CITY} city room.` - : "Auto-joined based on your location. Public city chat."} + ? `Spot chat — ${decodeURIComponent(spotRoomName)}. Live room connected to ${CITY}.` + : isLive + ? "Auto-joined based on your location. Public city chat." + : "Preview mode — sign up free to join the live room."}

@@ -183,7 +298,9 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) { : "Private Messages"}

- Use quick replies for address, directions & hosting notes + {dmUserId + ? "Use quick replies for address, directions & hosting notes" + : "Open a profile from the map to start a DM"}

{!dmAllowed && dmBlockNote && ( @@ -191,7 +308,13 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) { {dmBlockNote} )} - + {!dmUserId ? ( +
+ Tap Message on a cruiser profile to open a private thread. +
+ ) : ( + + )} @@ -213,7 +336,7 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) { )} - {activeTab === "dm" && capabilities.canSendMessages && ( + {activeTab === "dm" && capabilities.canSendMessages && dmUserId && (
@@ -274,9 +397,14 @@ export function ChatPanel({ dmUserId, spotId, spotRoomName }: ChatPanelProps) { ); } -function MessageList({ messages }: { messages: Message[] }) { +function MessageList({ messages }: { messages: DisplayMessage[] }) { return (
+ {messages.length === 0 && ( +

+ No messages yet. Say hi. +

+ )} {messages.map((msg) => (
Promise<{ error: string | null }>; signIn: (email: string, password: string) => Promise<{ error: string | null }>; signInAnonymously: () => Promise<{ error: string | null }>; + resetPassword: (email: string) => Promise<{ error: string | null }>; + updatePassword: (password: string) => Promise<{ error: string | null }>; signOut: () => Promise; updateProfile: (data: Partial) => Promise<{ error: string | null }>; refreshProfile: () => Promise; @@ -145,6 +147,18 @@ export function AuthProvider({ children }: { children: ReactNode }) { return { error: error?.message ?? null }; }; + const resetPassword = async (email: string) => { + const { error } = await supabase.auth.resetPasswordForEmail(email, { + redirectTo: `${window.location.origin}/auth/callback?next=/auth/reset-password`, + }); + return { error: error?.message ?? null }; + }; + + const updatePassword = async (password: string) => { + const { error } = await supabase.auth.updateUser({ password }); + return { error: error?.message ?? null }; + }; + const signOut = async () => { await supabase.auth.signOut(); setProfile(null); @@ -218,6 +232,8 @@ export function AuthProvider({ children }: { children: ReactNode }) { signUp, signIn, signInAnonymously, + resetPassword, + updatePassword, signOut, updateProfile, refreshProfile, diff --git a/src/lib/chat-store.ts b/src/lib/chat-store.ts new file mode 100644 index 0000000..830a352 --- /dev/null +++ b/src/lib/chat-store.ts @@ -0,0 +1,92 @@ +import { createClient } from "@/lib/supabase/client"; + +export async function getOrCreateCityRoom(city: string): Promise { + const supabase = createClient(); + const { data: existing } = await supabase + .from("chat_rooms") + .select("id") + .eq("type", "city") + .eq("city", city) + .limit(1) + .maybeSingle(); + + if (existing?.id) return existing.id; + + const { data: created, error } = await supabase + .from("chat_rooms") + .insert({ name: `${city} Chat`, type: "city", city }) + .select("id") + .single(); + + return error ? null : created.id; +} + +export async function getOrCreateSpotRoom( + spotId: string, + spotName: string +): Promise { + const supabase = createClient(); + const { data: existing } = await supabase + .from("chat_rooms") + .select("id") + .eq("type", "spot") + .eq("spot_id", spotId) + .limit(1) + .maybeSingle(); + + if (existing?.id) return existing.id; + + const { data: created, error } = await supabase + .from("chat_rooms") + .insert({ + name: spotName || "Spot Chat", + type: "spot", + spot_id: spotId, + }) + .select("id") + .single(); + + return error ? null : created.id; +} + +export async function getOrCreateDmRoom( + userId: string, + otherUserId: string +): Promise { + const supabase = createClient(); + const { data, error } = await supabase.rpc("get_or_create_dm_room", { + p_user_a: userId, + p_user_b: otherUserId, + }); + if (error || !data) return null; + return data as string; +} + +export async function ensureRoomMember(roomId: string, userId: string): Promise { + const supabase = createClient(); + await supabase + .from("chat_room_members") + .upsert({ room_id: roomId, user_id: userId }, { onConflict: "room_id,user_id" }); +} + +export async function fetchRoomMemberCount(roomId: string): Promise { + const supabase = createClient(); + const { count } = await supabase + .from("chat_room_members") + .select("*", { count: "exact", head: true }) + .eq("room_id", roomId); + return count ?? 0; +} + +export function senderDisplayName( + sender: { + is_anonymous?: boolean; + display_name?: string | null; + username?: string | null; + } | null | undefined, + fallback = "User" +): string { + if (!sender) return fallback; + if (sender.is_anonymous) return "Anonymous"; + return sender.display_name || sender.username || fallback; +} \ No newline at end of file diff --git a/src/lib/supabase/hooks.ts b/src/lib/supabase/hooks.ts index 2030202..1996969 100644 --- a/src/lib/supabase/hooks.ts +++ b/src/lib/supabase/hooks.ts @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useCallback } from "react"; import { createClient } from "./client"; import type { Profile, ChatMessage } from "@/types"; @@ -43,11 +43,30 @@ export function useNearbyProfiles(lat: number, lng: number, radiusKm = 10) { return { profiles, loading }; } -export function useChatMessages(roomId: string) { +async function fetchMessageWithSender(messageId: string): Promise { + const { data } = await supabase + .from("chat_messages") + .select("*, sender:profiles(*)") + .eq("id", messageId) + .single(); + return data ? (data as ChatMessage) : null; +} + +export function useChatMessages(roomId: string | null, enabled = true) { const [messages, setMessages] = useState([]); + const [loading, setLoading] = useState(false); useEffect(() => { + if (!roomId || !enabled) { + setMessages([]); + setLoading(false); + return; + } + + let cancelled = false; + async function fetchMessages() { + setLoading(true); const { data } = await supabase .from("chat_messages") .select("*, sender:profiles(*)") @@ -55,7 +74,8 @@ export function useChatMessages(roomId: string) { .order("created_at", { ascending: true }) .limit(100); - if (data) setMessages(data as ChatMessage[]); + if (!cancelled && data) setMessages(data as ChatMessage[]); + if (!cancelled) setLoading(false); } fetchMessages(); @@ -70,24 +90,35 @@ export function useChatMessages(roomId: string) { table: "chat_messages", filter: `room_id=eq.${roomId}`, }, - (payload) => { - setMessages((prev) => [...prev, payload.new as ChatMessage]); + async (payload) => { + const incoming = payload.new as ChatMessage; + const full = await fetchMessageWithSender(incoming.id); + setMessages((prev) => { + if (prev.some((m) => m.id === incoming.id)) return prev; + return [...prev, full ?? incoming]; + }); } ) .subscribe(); return () => { + cancelled = true; supabase.removeChannel(channel); }; - }, [roomId]); + }, [roomId, enabled]); - const sendMessage = async (content: string, senderId: string) => { - await supabase.from("chat_messages").insert({ - room_id: roomId, - sender_id: senderId, - content, - }); - }; + const sendMessage = useCallback( + async (content: string, senderId: string) => { + if (!roomId) return { error: "No room" }; + const { error } = await supabase.from("chat_messages").insert({ + room_id: roomId, + sender_id: senderId, + content, + }); + return { error: error?.message ?? null }; + }, + [roomId] + ); - return { messages, sendMessage }; + return { messages, sendMessage, loading }; } \ No newline at end of file diff --git a/supabase/migration_v7_chat.sql b/supabase/migration_v7_chat.sql new file mode 100644 index 0000000..7746039 --- /dev/null +++ b/supabase/migration_v7_chat.sql @@ -0,0 +1,57 @@ +-- Chat room membership RLS + DM room helper + +CREATE INDEX IF NOT EXISTS idx_rooms_spot ON public.chat_rooms (spot_id); + +DROP POLICY IF EXISTS "Members viewable by authenticated" ON public.chat_room_members; +CREATE POLICY "Members viewable by authenticated" + ON public.chat_room_members FOR SELECT + USING (auth.role() = 'authenticated'); + +DROP POLICY IF EXISTS "Users can join rooms" ON public.chat_room_members; +CREATE POLICY "Users can join rooms" + ON public.chat_room_members FOR INSERT + WITH CHECK (auth.uid() = user_id); + +CREATE OR REPLACE FUNCTION public.get_or_create_dm_room(p_user_a UUID, p_user_b UUID) +RETURNS UUID AS $$ +DECLARE + v_room_id UUID; + v_low UUID; + v_high UUID; +BEGIN + IF p_user_a IS NULL OR p_user_b IS NULL OR p_user_a = p_user_b THEN + RAISE EXCEPTION 'Invalid DM participants'; + END IF; + + IF p_user_a < p_user_b THEN + v_low := p_user_a; + v_high := p_user_b; + ELSE + v_low := p_user_b; + v_high := p_user_a; + END IF; + + SELECT cr.id INTO v_room_id + FROM public.chat_rooms cr + JOIN public.chat_room_members m1 ON m1.room_id = cr.id AND m1.user_id = v_low + JOIN public.chat_room_members m2 ON m2.room_id = cr.id AND m2.user_id = v_high + WHERE cr.type = 'dm' + LIMIT 1; + + IF v_room_id IS NOT NULL THEN + RETURN v_room_id; + END IF; + + INSERT INTO public.chat_rooms (name, type, created_by) + VALUES ('Private Chat', 'dm', p_user_a) + RETURNING id INTO v_room_id; + + INSERT INTO public.chat_room_members (room_id, user_id) + VALUES (v_room_id, v_low), (v_room_id, v_high) + ON CONFLICT DO NOTHING; + + RETURN v_room_id; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +GRANT EXECUTE ON FUNCTION public.get_or_create_dm_room(UUID, UUID) TO authenticated; \ No newline at end of file