From b405716e6e6359fde5390094eca90505fd4c30f9 Mon Sep 17 00:00:00 2001 From: Jay Patrick Cano <0x3ef8@gmail.com> Date: Tue, 7 Apr 2026 19:08:30 +0800 Subject: [PATCH] chore: align upstream branch with local snapshot state --- .env.example | 2 +- .gitignore | 2 + README.md | 36 +- app/components/Chat.tsx | 45 +- .../chat/hooks/useActiveConversationStream.ts | 177 +++- .../chat/hooks/useChatConversationActions.ts | 126 ++- .../hooks/useChatConversationsRealtime.ts | 33 +- .../chat/hooks/useChatInputBehavior.ts | 5 +- .../chat/hooks/useChatMessageComposer.ts | 63 +- .../chat/hooks/useChatUserPicker.ts | 21 +- app/components/dashboard/Navbar.tsx | 6 +- app/supabase-types.ts | 48 +- package-lock.json | 54 +- .../20260320234731_add_enforcement_checks.sql | 16 - .../20260320234758_add_conversations.sql | 49 -- ...260323033205_add_type_to_conversations.sql | 1 - .../20260323033729_add_global_chat.sql | 20 - ...0323075413_add_attachments_to_messages.sql | 9 - .../20260325044133_add_user_flexes.sql | 43 - ...60325054413_add_expires_at_user_flexes.sql | 2 - .../20260325072343_add_role_to_profiles.sql | 6 - .../20260407120000_baseline_fresh_setup.sql | 772 ++++++++++++++++++ .../20260320234600_add_user_stats.sql | 0 .../20260320234643_add_top_user_stats.sql | 0 .../20260320234653_add_profiles.sql | 6 +- .../20260320234714_add_leaderboards.sql | 22 +- .../20260320234731_add_enforcement_checks.sql | 61 ++ .../20260320234742_add_user_projects.sql | 0 .../20260320234758_add_conversations.sql | 96 +++ ...40532_add_categories_to_top_user_stats.sql | 0 ...260323033205_add_type_to_conversations.sql | 1 + .../20260323033729_add_global_chat.sql | 18 + ..._add_type_to_conversation_participants.sql | 2 +- .../20260323041543_add_trigger_to_type.sql | 2 + ...0323075413_add_attachments_to_messages.sql | 21 + .../20260325044133_add_user_flexes.sql | 88 ++ ...60325054413_add_expires_at_user_flexes.sql | 11 + .../20260325072343_add_role_to_profiles.sql | 17 + ...0327100000_cascade_conversation_delete.sql | 0 ...329120000_add_user_dashboard_snapshots.sql | 0 ...00_add_chat_presence_and_read_tracking.sql | 0 ...03000_enable_chat_realtime_publication.sql | 0 ...60330104500_fix_global_chat_membership.sql | 0 43 files changed, 1601 insertions(+), 280 deletions(-) delete mode 100644 supabase/migrations/20260320234731_add_enforcement_checks.sql delete mode 100644 supabase/migrations/20260320234758_add_conversations.sql delete mode 100644 supabase/migrations/20260323033205_add_type_to_conversations.sql delete mode 100644 supabase/migrations/20260323033729_add_global_chat.sql delete mode 100644 supabase/migrations/20260323075413_add_attachments_to_messages.sql delete mode 100644 supabase/migrations/20260325044133_add_user_flexes.sql delete mode 100644 supabase/migrations/20260325054413_add_expires_at_user_flexes.sql delete mode 100644 supabase/migrations/20260325072343_add_role_to_profiles.sql create mode 100644 supabase/migrations/20260407120000_baseline_fresh_setup.sql rename supabase/{migrations => migrations_archive}/20260320234600_add_user_stats.sql (100%) rename supabase/{migrations => migrations_archive}/20260320234643_add_top_user_stats.sql (100%) rename supabase/{migrations => migrations_archive}/20260320234653_add_profiles.sql (87%) rename supabase/{migrations => migrations_archive}/20260320234714_add_leaderboards.sql (82%) create mode 100644 supabase/migrations_archive/20260320234731_add_enforcement_checks.sql rename supabase/{migrations => migrations_archive}/20260320234742_add_user_projects.sql (100%) create mode 100644 supabase/migrations_archive/20260320234758_add_conversations.sql rename supabase/{migrations => migrations_archive}/20260321140532_add_categories_to_top_user_stats.sql (100%) create mode 100644 supabase/migrations_archive/20260323033205_add_type_to_conversations.sql create mode 100644 supabase/migrations_archive/20260323033729_add_global_chat.sql rename supabase/{migrations => migrations_archive}/20260323041448_add_type_to_conversation_participants.sql (71%) rename supabase/{migrations => migrations_archive}/20260323041543_add_trigger_to_type.sql (81%) create mode 100644 supabase/migrations_archive/20260323075413_add_attachments_to_messages.sql create mode 100644 supabase/migrations_archive/20260325044133_add_user_flexes.sql create mode 100644 supabase/migrations_archive/20260325054413_add_expires_at_user_flexes.sql create mode 100644 supabase/migrations_archive/20260325072343_add_role_to_profiles.sql rename supabase/{migrations => migrations_archive}/20260327100000_cascade_conversation_delete.sql (100%) rename supabase/{migrations => migrations_archive}/20260329120000_add_user_dashboard_snapshots.sql (100%) rename supabase/{migrations => migrations_archive}/20260329133000_add_chat_presence_and_read_tracking.sql (100%) rename supabase/{migrations => migrations_archive}/20260330103000_enable_chat_realtime_publication.sql (100%) rename supabase/{migrations => migrations_archive}/20260330104500_fix_global_chat_membership.sql (100%) diff --git a/.env.example b/.env.example index 00ecde0..9dd84a7 100644 --- a/.env.example +++ b/.env.example @@ -15,4 +15,4 @@ SENTRY_DNS= NEXT_PUBLIC_NORTON_SAFEWEB_SITE_VERIFICATION= SUPABASE_ACCESS_TOKEN= -SUPABASE_PROJECT_ID= +SUPABASE_PROJECT_ID=vswabkwgipyweqsabzwv diff --git a/.gitignore b/.gitignore index 87dccdb..5b60097 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,8 @@ next-env.d.ts # supabase supabase/* !supabase/migrations +!supabase/migrations_archive +!supabase/migrations_archive/** yarn.lock diff --git a/README.md b/README.md index a0e070c..685e640 100644 --- a/README.md +++ b/README.md @@ -48,38 +48,62 @@ Open [http://localhost:3000](http://localhost:3000) with your browser to see the ## Database Migrations -Login first (if you haven't): +For a brand-new Supabase project, use this flow from the repo root. + +This repository now uses a squashed baseline migration for fresh installs: + +- Active baseline: `supabase/migrations/20260407120000_baseline_fresh_setup.sql` + +- Historical migrations archive: `supabase/migrations_archive/` + +1. Login to Supabase CLI: ```bash npx supabase login ``` -Link the cloud project to this local one: +2. Initialize local Supabase config (only if missing): + +```bash +npx supabase init +``` + +3. Link this repo to your cloud project: ```bash npx supabase link ``` -It'll show the list of project you have select your project. +You can select from the project list, or run `npx supabase link --project-ref `. -Push migrations to cloud: +4. Push all migrations to the new project: ```bash npx supabase db push ``` -Pull migrations from cloud: +On a fresh project this applies only the single baseline migration. + +5. (Optional) Pull remote schema changes into migrations: ```bash npx supabase db pull ``` -Updating types (if you ever changed migrations): +6. Regenerate Supabase TypeScript types after schema changes: ```bash npx supabase gen types typescript --project-id --schema public > app/supabase-types.ts ``` +If you want to re-run the full migration chain on local development: + +```bash +npx supabase db reset +``` + +If you need to inspect migration history, use the files in `supabase/migrations_archive/`. + ## Learn More diff --git a/app/components/Chat.tsx b/app/components/Chat.tsx index e39f4da..325e88f 100644 --- a/app/components/Chat.tsx +++ b/app/components/Chat.tsx @@ -169,6 +169,7 @@ export default function Chat({ user }: { user: User }) { supabase, userId: user.id, showModal, + globalConversationId: GLOBAL_CONVERSATION_ID, }); const { badgesByUserId } = useChatBadges({ @@ -197,7 +198,6 @@ export default function Chat({ user }: { user: User }) { setConversations, setParticipantMetaByConversationId, setLastSeenByUserId, - setUnreadCountByConversationId, fetchUnreadCountsForConversations, markConversationAsRead, }); @@ -236,9 +236,10 @@ export default function Chat({ user }: { user: User }) { const bucketName = process.env.NEXT_PUBLIC_SUPABASE_BUCKET_NAME || ""; - const { sendMessage } = useChatMessageComposer({ + const { sendMessage, isSendingMessage } = useChatMessageComposer({ supabase, userId: user.id, + channelRef, conversationId, input, attachments, @@ -255,6 +256,7 @@ export default function Chat({ user }: { user: User }) { const { textareaRef, handleInputChange, handleInputKeyDown } = useChatInputBehavior({ input, + isSendingMessage, conversationId, attachmentsCount: attachments.length, setInput, @@ -263,10 +265,8 @@ export default function Chat({ user }: { user: User }) { maxChars: 1000, }); - const totalUnreadCount = useMemo( - () => Object.values(unreadCountByConversationId).reduce((sum, count) => sum + count, 0), - [unreadCountByConversationId], - ); + const canSendMessage = + !isSendingMessage && (input.trim().length > 0 || attachments.length > 0); const globalConversations = conversations.filter((c) => c.type === "global"); const privateConversations = conversations @@ -334,6 +334,15 @@ export default function Chat({ user }: { user: User }) { .reverse(); }, [messages]); + // Performance optimization: Memoize the filtered messages to avoid O(N) string manipulation on every keystroke + // when typing a message (which triggers a re-render of Chat.tsx). This significantly improves typing latency + // in conversations with many messages. + const filteredMessages = useMemo(() => { + if (!messageSearch) return messages; + const lowerSearch = messageSearch.toLowerCase(); + return messages.filter((m) => (m.text || "").toLowerCase().includes(lowerSearch)); + }, [messages, messageSearch]); + return ( <>
-
-

Message category

- {totalUnreadCount > 0 && ( - - {totalUnreadCount > 99 ? "99+" : totalUnreadCount} - - )} -
+

Message category

diff --git a/app/components/chat/hooks/useActiveConversationStream.ts b/app/components/chat/hooks/useActiveConversationStream.ts index c78f7bc..c673ddc 100644 --- a/app/components/chat/hooks/useActiveConversationStream.ts +++ b/app/components/chat/hooks/useActiveConversationStream.ts @@ -39,6 +39,63 @@ const getAttachmentFingerprint = (attachments: Message["attachments"] = []) => }) .join("::"); +const EPHEMERAL_RECONCILE_WINDOW_MS = 15_000; +const BROADCAST_DUPLICATE_WINDOW_MS = 1_500; + +const isEphemeralMessageId = (messageId: string) => + messageId.startsWith("temp-") || messageId.startsWith("live-"); + +const isCreatedWithinWindow = ( + candidateCreatedAt: string, + incomingCreatedAt: string, + windowMs = EPHEMERAL_RECONCILE_WINDOW_MS, +) => { + const candidateTimestamp = Date.parse(candidateCreatedAt); + const incomingTimestamp = Date.parse(incomingCreatedAt); + + if (!Number.isFinite(candidateTimestamp) || !Number.isFinite(incomingTimestamp)) { + return true; + } + + return Math.abs(incomingTimestamp - candidateTimestamp) <= windowMs; +}; + +const normalizeAttachments = (attachments: unknown): Message["attachments"] => { + if (!Array.isArray(attachments)) return []; + + return attachments + .map((rawAttachment) => { + if (!rawAttachment || typeof rawAttachment !== "object") return null; + + const attachment = rawAttachment as Record; + const filename = + typeof attachment.filename === "string" ? attachment.filename : ""; + const mimetype = + typeof attachment.mimetype === "string" ? attachment.mimetype : ""; + const publicUrl = + typeof attachment.public_url === "string" ? attachment.public_url : ""; + const rawFilesize = attachment.filesize; + const filesize = + typeof rawFilesize === "number" + ? rawFilesize + : typeof rawFilesize === "string" + ? Number(rawFilesize) + : 0; + + return { + filename, + mimetype, + filesize: Number.isFinite(filesize) ? filesize : 0, + public_url: publicUrl, + }; + }) + .filter( + ( + attachment, + ): attachment is Message["attachments"][number] => attachment !== null, + ); +}; + export function useActiveConversationStream({ supabase, conversationId, @@ -91,6 +148,114 @@ export function useActiveConversationStream({ setRemoteTypingState(conversationId, null); }, ) + .on( + "broadcast", + { + event: "message", + }, + ({ payload }) => { + const messagePayload = payload as { + conversation_id?: string; + sender_id?: string; + text?: string; + attachments?: unknown; + created_at?: string; + client_message_id?: string; + }; + + if (messagePayload.conversation_id !== conversationId) return; + if (!messagePayload.sender_id || messagePayload.sender_id === userId) { + return; + } + const senderId = messagePayload.sender_id; + + const incomingCreatedAt = + typeof messagePayload.created_at === "string" + ? messagePayload.created_at + : new Date().toISOString(); + const incomingAttachments = normalizeAttachments( + messagePayload.attachments, + ); + const clientMessageId = + typeof messagePayload.client_message_id === "string" && + messagePayload.client_message_id.length > 0 + ? messagePayload.client_message_id + : `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; + const liveMessageId = `live-${clientMessageId}`; + + setMessages((prev) => { + if (prev.some((message) => message.id === liveMessageId)) { + return prev; + } + + const incomingText = messagePayload.text ?? ""; + const incomingFingerprint = getAttachmentFingerprint(incomingAttachments); + + const hasMatchingMessage = prev.some((message) => { + if (isEphemeralMessageId(message.id)) return false; + if (message.sender_id !== senderId) return false; + if (message.conversation_id !== conversationId) return false; + if (message.text !== incomingText) return false; + if ( + getAttachmentFingerprint(message.attachments) !== incomingFingerprint + ) { + return false; + } + + return isCreatedWithinWindow( + message.created_at, + incomingCreatedAt, + BROADCAST_DUPLICATE_WINDOW_MS, + ); + }); + + if (hasMatchingMessage) { + return prev; + } + + return [ + ...prev, + { + id: liveMessageId, + conversation_id: conversationId, + sender_id: senderId, + text: incomingText, + attachments: incomingAttachments, + created_at: incomingCreatedAt, + }, + ]; + }); + + void markConversationAsRead(conversationId); + + window.setTimeout(() => { + bottomRef.current?.scrollIntoView({ behavior: "smooth" }); + }, 100); + }, + ) + .on( + "broadcast", + { + event: "message_retract", + }, + ({ payload }) => { + const retractPayload = payload as { + conversation_id?: string; + sender_id?: string; + client_message_id?: string; + }; + + if (retractPayload.conversation_id !== conversationId) return; + if (retractPayload.sender_id === userId) return; + if (!retractPayload.client_message_id) return; + + const liveMessageId = `live-${retractPayload.client_message_id}`; + + setMessages((prev) => + prev.filter((message) => message.id !== liveMessageId), + ); + }, + ) .on( "postgres_changes", { @@ -105,7 +270,7 @@ export function useActiveConversationStream({ conversation_id: payload.new.conversation_id, sender_id: payload.new.sender_id, text: payload.new.text, - attachments: payload.new.attachments ?? [], + attachments: normalizeAttachments(payload.new.attachments), created_at: payload.new.created_at, }; @@ -119,12 +284,20 @@ export function useActiveConversationStream({ ); const optimisticMessageIndex = prev.findIndex((message) => { - if (!message.id.startsWith("temp-")) return false; + if (!isEphemeralMessageId(message.id)) return false; if (message.sender_id !== incomingMessage.sender_id) return false; if (message.conversation_id !== incomingMessage.conversation_id) { return false; } if (message.text !== incomingMessage.text) return false; + if ( + !isCreatedWithinWindow( + message.created_at, + incomingMessage.created_at, + ) + ) { + return false; + } return ( getAttachmentFingerprint(message.attachments) === diff --git a/app/components/chat/hooks/useChatConversationActions.ts b/app/components/chat/hooks/useChatConversationActions.ts index 35c0872..18e94fd 100644 --- a/app/components/chat/hooks/useChatConversationActions.ts +++ b/app/components/chat/hooks/useChatConversationActions.ts @@ -34,6 +34,44 @@ type UseChatConversationActionsParams = { >; }; +function getErrorMessage(error: unknown): string { + if (!error) return ""; + + if (error instanceof Error && error.message) { + return error.message; + } + + if (typeof error === "object") { + const candidate = error as { + message?: string; + details?: string; + hint?: string; + code?: string; + error_description?: string; + }; + + const composed = [ + candidate.message, + candidate.details, + candidate.hint, + candidate.error_description, + candidate.code ? `code: ${candidate.code}` : undefined, + ] + .filter(Boolean) + .join(" | "); + + if (composed) return composed; + + try { + return JSON.stringify(error); + } catch { + return ""; + } + } + + return String(error); +} + export function useChatConversationActions({ supabase, userId, @@ -72,46 +110,68 @@ export function useChatConversationActions({ return; } - const { data: conversationData } = await supabase + const createdConversationId = crypto.randomUUID(); + const timestamp = new Date().toISOString(); + + const { error: conversationError } = await supabase .from("conversations") - .insert({ type: "private" }) - .select("*") - .single(); + .insert({ + id: createdConversationId, + type: "private", + created_at: timestamp, + }); - if (!conversationData) return; + if (conversationError) { + throw conversationError; + } - const createdConversationId = conversationData.id; - const timestamp = new Date().toISOString(); + const normalizedSelfEmail = + userEmail && userEmail.trim().length > 0 + ? userEmail + : `${userId}@user.local`; - await supabase.from("conversation_participants").upsert( - [ - { - conversation_id: createdConversationId, - user_id: userId, - email: userEmail, - last_seen_at: timestamp, - last_read_at: timestamp, - }, - { - conversation_id: createdConversationId, - user_id: otherUser.user_id, - email: otherUser.email, - last_seen_at: unseenPresenceIso, - last_read_at: unseenPresenceIso, - }, - ], - { - onConflict: "conversation_id,user_id", - ignoreDuplicates: true, - }, - ); + const { error: selfParticipantError } = await supabase + .from("conversation_participants") + .insert({ + conversation_id: createdConversationId, + user_id: userId, + email: normalizedSelfEmail, + last_seen_at: timestamp, + last_read_at: timestamp, + }); + + if (selfParticipantError && selfParticipantError.code !== "23505") { + await supabase + .from("conversations") + .delete() + .eq("id", createdConversationId); + throw selfParticipantError; + } + + const { error: otherParticipantError } = await supabase + .from("conversation_participants") + .insert({ + conversation_id: createdConversationId, + user_id: otherUser.user_id, + email: otherUser.email, + last_seen_at: unseenPresenceIso, + last_read_at: unseenPresenceIso, + }); + + if (otherParticipantError && otherParticipantError.code !== "23505") { + await supabase + .from("conversations") + .delete() + .eq("id", createdConversationId); + throw otherParticipantError; + } setConversationId(createdConversationId); setConversations((prev) => [ ...prev, { id: createdConversationId, - created_at: conversationData.created_at, + created_at: timestamp, users: [ { id: userId, email: userEmail ?? "" }, { id: otherUser.user_id, email: otherUser.email ?? "" }, @@ -132,6 +192,12 @@ export function useChatConversationActions({ })); setShowModal(false); + } catch (error) { + const errorMessage = getErrorMessage(error); + console.error("Failed to create conversation:", errorMessage, error); + toast.error( + errorMessage || "Could not start a direct message. Please try again.", + ); } finally { creatingRef.current = false; } diff --git a/app/components/chat/hooks/useChatConversationsRealtime.ts b/app/components/chat/hooks/useChatConversationsRealtime.ts index 3e045c5..71ad33c 100644 --- a/app/components/chat/hooks/useChatConversationsRealtime.ts +++ b/app/components/chat/hooks/useChatConversationsRealtime.ts @@ -45,7 +45,6 @@ type UseChatConversationsRealtimeParams = { SetStateAction> >; setLastSeenByUserId: Dispatch>>; - setUnreadCountByConversationId: Dispatch>>; fetchUnreadCountsForConversations: ( targetConversationIds: string[], readMap: Record, @@ -86,10 +85,29 @@ export function useChatConversationsRealtime({ setConversations, setParticipantMetaByConversationId, setLastSeenByUserId, - setUnreadCountByConversationId, fetchUnreadCountsForConversations, markConversationAsRead, }: UseChatConversationsRealtimeParams) { + const refreshUnreadForConversation = useCallback( + async (targetConversationId: string) => { + const { data: participant } = await supabase + .from("conversation_participants") + .select("last_read_at") + .eq("conversation_id", targetConversationId) + .eq("user_id", userId) + .single(); + + await fetchUnreadCountsForConversations( + [targetConversationId], + { + [targetConversationId]: participant?.last_read_at ?? null, + }, + "merge", + ); + }, + [fetchUnreadCountsForConversations, supabase, userId], + ); + const ensureGlobalConversationMembership = useCallback(async () => { if (!userId) return; @@ -359,6 +377,8 @@ export function useChatConversationsRealtime({ }, })); + void refreshUnreadForConversation(row.conversation_id); + return; } @@ -372,7 +392,7 @@ export function useChatConversationsRealtime({ return () => { channel.unsubscribe(); }; - }, [conversationIdsRef, setLastSeenByUserId, setParticipantMetaByConversationId, supabase, userId]); + }, [conversationIdsRef, refreshUnreadForConversation, setLastSeenByUserId, setParticipantMetaByConversationId, supabase, userId]); useEffect(() => { if (!userId) return; @@ -397,10 +417,7 @@ export function useChatConversationsRealtime({ return; } - setUnreadCountByConversationId((prev) => ({ - ...prev, - [message.conversation_id]: (prev[message.conversation_id] ?? 0) + 1, - })); + void refreshUnreadForConversation(message.conversation_id); }, ) .subscribe(); @@ -411,8 +428,8 @@ export function useChatConversationsRealtime({ }, [ activeConversationIdRef, conversationIdsRef, + refreshUnreadForConversation, markConversationAsRead, - setUnreadCountByConversationId, supabase, userId, ]); diff --git a/app/components/chat/hooks/useChatInputBehavior.ts b/app/components/chat/hooks/useChatInputBehavior.ts index 348e6e8..c646e09 100644 --- a/app/components/chat/hooks/useChatInputBehavior.ts +++ b/app/components/chat/hooks/useChatInputBehavior.ts @@ -12,6 +12,7 @@ import { type UseChatInputBehaviorParams = { input: string; + isSendingMessage: boolean; conversationId: string | null; attachmentsCount: number; setInput: Dispatch>; @@ -22,6 +23,7 @@ type UseChatInputBehaviorParams = { export function useChatInputBehavior({ input, + isSendingMessage, conversationId, attachmentsCount, setInput, @@ -59,6 +61,7 @@ export function useChatInputBehavior({ const handleInputKeyDown = useCallback( (event: KeyboardEvent) => { if (event.key !== "Enter" || event.shiftKey) return; + if (isSendingMessage) return; event.preventDefault(); @@ -66,7 +69,7 @@ export function useChatInputBehavior({ sendMessage(); } }, - [attachmentsCount, input, sendMessage], + [attachmentsCount, input, isSendingMessage, sendMessage], ); return { diff --git a/app/components/chat/hooks/useChatMessageComposer.ts b/app/components/chat/hooks/useChatMessageComposer.ts index 9a9b884..e0e5be3 100644 --- a/app/components/chat/hooks/useChatMessageComposer.ts +++ b/app/components/chat/hooks/useChatMessageComposer.ts @@ -2,11 +2,14 @@ import { useCallback, + useRef, + useState, type Dispatch, + type MutableRefObject, type RefObject, type SetStateAction, } from "react"; -import type { SupabaseClient } from "@supabase/supabase-js"; +import type { RealtimeChannel, SupabaseClient } from "@supabase/supabase-js"; import { toast } from "react-toastify"; import type { Database } from "@/app/supabase-types"; import type { Message } from "@/app/components/Chat"; @@ -15,6 +18,7 @@ import { sanitizeTextWithBlocklist } from "@/app/utils/moderation"; type UseChatMessageComposerParams = { supabase: SupabaseClient; userId: string; + channelRef: MutableRefObject; conversationId: string | null; input: string; attachments: File[]; @@ -31,6 +35,7 @@ type UseChatMessageComposerParams = { export function useChatMessageComposer({ supabase, userId, + channelRef, conversationId, input, attachments, @@ -43,11 +48,27 @@ export function useChatMessageComposer({ stopTyping, markConversationAsRead, }: UseChatMessageComposerParams) { + const sendingMessageRef = useRef(false); + const [isSendingMessage, setIsSendingMessage] = useState(false); + + const createClientMessageId = useCallback(() => { + if (typeof crypto !== "undefined" && typeof crypto.randomUUID === "function") { + return crypto.randomUUID(); + } + + return `${Date.now()}-${Math.random().toString(36).slice(2, 10)}`; + }, []); + const sendMessage = useCallback(async () => { + if (sendingMessageRef.current) return; if ((!input.trim() && attachments.length === 0) || !conversationId) return; + sendingMessageRef.current = true; + setIsSendingMessage(true); + const targetConversationId = conversationId; const originalText = input; + const originalAttachments = attachments; const outgoingText = sanitizeTextWithBlocklist( input.slice(0, 1000), badWords, @@ -99,6 +120,8 @@ export function useChatMessageComposer({ return; } + const clientMessageId = createClientMessageId(); + const optimisticCreatedAt = new Date().toISOString(); const optimisticMessageId = `temp-${Date.now()}-${Math.random() .toString(36) .slice(2, 8)}`; @@ -111,11 +134,27 @@ export function useChatMessageComposer({ sender_id: userId, text: outgoingText, attachments: validAttachments, - created_at: new Date().toISOString(), + created_at: optimisticCreatedAt, optimistic: true, }, ]); + const activeChannel = channelRef.current; + if (activeChannel) { + void activeChannel.send({ + type: "broadcast", + event: "message", + payload: { + client_message_id: clientMessageId, + conversation_id: targetConversationId, + sender_id: userId, + text: outgoingText, + attachments: validAttachments, + created_at: optimisticCreatedAt, + }, + }); + } + setInput(""); setAttachments([]); stopTyping(targetConversationId); @@ -132,11 +171,23 @@ export function useChatMessageComposer({ }); if (insertError) { + if (activeChannel) { + void activeChannel.send({ + type: "broadcast", + event: "message_retract", + payload: { + client_message_id: clientMessageId, + conversation_id: targetConversationId, + sender_id: userId, + }, + }); + } + setMessages((prev) => prev.filter((message) => message.id !== optimisticMessageId), ); setInput(originalText); - setAttachments(attachments); + setAttachments(originalAttachments); throw insertError; } @@ -144,13 +195,18 @@ export function useChatMessageComposer({ } catch (error) { console.error("Send message error:", error); toast.error("Failed to send message. Please try again."); + } finally { + sendingMessageRef.current = false; + setIsSendingMessage(false); } }, [ attachments, badWords, bottomRef, bucketName, + channelRef, conversationId, + createClientMessageId, input, markConversationAsRead, setAttachments, @@ -163,5 +219,6 @@ export function useChatMessageComposer({ return { sendMessage, + isSendingMessage, }; } \ No newline at end of file diff --git a/app/components/chat/hooks/useChatUserPicker.ts b/app/components/chat/hooks/useChatUserPicker.ts index f7f7438..42eadac 100644 --- a/app/components/chat/hooks/useChatUserPicker.ts +++ b/app/components/chat/hooks/useChatUserPicker.ts @@ -9,12 +9,14 @@ type UseChatUserPickerParams = { supabase: SupabaseClient; userId: string; showModal: boolean; + globalConversationId: string; }; export function useChatUserPicker({ supabase, userId, showModal, + globalConversationId, }: UseChatUserPickerParams) { const [search, setSearch] = useState(""); const [allUsers, setAllUsers] = useState([]); @@ -23,23 +25,32 @@ export function useChatUserPicker({ if (!showModal) return; const fetchUsers = async () => { - const { data } = await supabase - .from("top_user_stats") + const { data, error } = await supabase + .from("conversation_participants") .select("user_id, email") + .eq("conversation_id", globalConversationId) .neq("user_id", userId); + if (error) { + console.error("Failed to load chat users:", error); + setAllUsers([]); + return; + } + if (!data) return; - const users: ChatUser[] = data.filter( + const users: ChatUser[] = data + .filter( (user): user is { user_id: string; email: string } => user.user_id !== null && user.email !== null, - ); + ) + .sort((a, b) => a.email.localeCompare(b.email)); setAllUsers(users); }; void fetchUsers(); - }, [showModal, supabase, userId]); + }, [globalConversationId, showModal, supabase, userId]); const filteredUsers = useMemo( () => diff --git a/app/components/dashboard/Navbar.tsx b/app/components/dashboard/Navbar.tsx index 9292875..671afa8 100644 --- a/app/components/dashboard/Navbar.tsx +++ b/app/components/dashboard/Navbar.tsx @@ -180,7 +180,7 @@ function Sidebar({ role }: { role: string }) { if (!conversationIdsRef.current.has(row.conversation_id)) return; if (row.sender_id === userId) return; - setChatUnreadCount((prev) => prev + 1); + void refreshChatUnreadCount(userId); }, ) .on( @@ -358,7 +358,7 @@ export default function DashboardLayout({ role: string; children: React.ReactNode; }) { - const [collapsed, setCollapsed] = useState(false); + const [collapsed, setCollapsed] = useState(true); const [mobileHidden, setMobileHidden] = useState(false); const [isMobile, setIsMobile] = useState(false); @@ -397,7 +397,7 @@ export default function DashboardLayout({ setCollapsed(true); setMobileHidden(true); } else { - setCollapsed(false); + setCollapsed(true); setMobileHidden(false); } }; diff --git a/app/supabase-types.ts b/app/supabase-types.ts index 55f5788..ba68795 100644 --- a/app/supabase-types.ts +++ b/app/supabase-types.ts @@ -1,4 +1,4 @@ -export type Json = +export type Json = | string | number | boolean @@ -10,14 +10,14 @@ export type Database = { // Allows to automatically instantiate createClient with right options // instead of createClient(URL, KEY) __InternalSupabase: { - PostgrestVersion: "14.4" + PostgrestVersion: "14.5" } public: { Tables: { conversation_participants: { Row: { conversation_id: string - email: string | null + email: string last_read_at: string last_seen_at: string type: string @@ -25,7 +25,7 @@ export type Database = { } Insert: { conversation_id: string - email?: string | null + email: string last_read_at?: string last_seen_at?: string type?: string @@ -33,7 +33,7 @@ export type Database = { } Update: { conversation_id?: string - email?: string | null + email?: string last_read_at?: string last_seen_at?: string type?: string @@ -51,17 +51,17 @@ export type Database = { } conversations: { Row: { - created_at: string + created_at: string | null id: string type: string } Insert: { - created_at?: string + created_at?: string | null id?: string type?: string } Update: { - created_at?: string + created_at?: string | null id?: string type?: string } @@ -104,7 +104,7 @@ export type Database = { created_at: string description: string | null id: string - is_public: boolean | null + is_public: boolean join_code: string name: string owner_id: string @@ -114,7 +114,7 @@ export type Database = { created_at?: string description?: string | null id?: string - is_public?: boolean | null + is_public?: boolean join_code: string name: string owner_id: string @@ -124,7 +124,7 @@ export type Database = { created_at?: string description?: string | null id?: string - is_public?: boolean | null + is_public?: boolean join_code?: string name?: string owner_id?: string @@ -245,7 +245,7 @@ export type Database = { user_flexes: { Row: { created_at: string - expires_at: string | null + expires_at: string id: string is_open_source: boolean open_source_url: string @@ -254,11 +254,11 @@ export type Database = { project_time: string project_url: string user_email: string - user_id: string | null + user_id: string } Insert: { created_at?: string - expires_at?: string | null + expires_at?: string id?: string is_open_source?: boolean open_source_url?: string @@ -267,11 +267,11 @@ export type Database = { project_time: string project_url: string user_email: string - user_id?: string | null + user_id: string } Update: { created_at?: string - expires_at?: string | null + expires_at?: string id?: string is_open_source?: boolean open_source_url?: string @@ -280,24 +280,24 @@ export type Database = { project_time?: string project_url?: string user_email?: string - user_id?: string | null + user_id?: string } Relationships: [] } user_projects: { Row: { last_fetched_at: string - projects: Json + projects: Json | null user_id: string } Insert: { last_fetched_at?: string - projects: Json + projects?: Json | null user_id: string } Update: { last_fetched_at?: string - projects?: Json + projects?: Json | null user_id?: string } Relationships: [] @@ -324,11 +324,11 @@ export type Database = { daily_stats?: Json dependencies?: Json editors?: Json - languages: Json + languages?: Json last_fetched_at?: string machines?: Json operating_systems?: Json - total_seconds: number + total_seconds?: number user_id: string } Update: { @@ -386,6 +386,10 @@ export type Database = { Args: { p_project: Json; p_user_id: string } Returns: string } + is_conversation_member: { + Args: { target_conversation_id: string; target_user_id: string } + Returns: boolean + } } Enums: { [_ in never]: never diff --git a/package-lock.json b/package-lock.json index bc847e3..9c86dcf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -529,9 +529,9 @@ } }, "node_modules/@fastify/otel/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -2047,9 +2047,9 @@ } }, "node_modules/@rollup/plugin-commonjs/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "peer": true, "engines": { @@ -2082,9 +2082,9 @@ } }, "node_modules/@rollup/pluginutils/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "license": "MIT", "engines": { "node": ">=12" @@ -3742,9 +3742,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4670,9 +4670,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -6309,9 +6309,9 @@ } }, "node_modules/flatted": { - "version": "3.3.3", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.3.3.tgz", - "integrity": "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", "dev": true, "license": "ISC" }, @@ -6548,9 +6548,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz", - "integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==", + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.5.tgz", + "integrity": "sha512-VZznLgtwhn+Mact9tfiwx64fA9erHH/MCXEUfB/0bX/6Fz6ny5EGTXYltMocqg4xFAQZtnO3DHWWXi8RiuN7cQ==", "license": "MIT", "dependencies": { "balanced-match": "^4.0.2" @@ -9267,9 +9267,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, "license": "MIT", "engines": { @@ -10682,9 +10682,9 @@ } }, "node_modules/tinyglobby/node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", "dev": true, "license": "MIT", "peer": true, diff --git a/supabase/migrations/20260320234731_add_enforcement_checks.sql b/supabase/migrations/20260320234731_add_enforcement_checks.sql deleted file mode 100644 index 84b3f89..0000000 --- a/supabase/migrations/20260320234731_add_enforcement_checks.sql +++ /dev/null @@ -1,16 +0,0 @@ -alter table public.leaderboards -add constraint leaderboards_name_length -check (length(trim(name)) between 3 and 50); - -alter table public.leaderboards -add constraint leaderboards_description_length -check (description is null or length(description) <= 150); - -alter table public.leaderboards -add constraint leaderboards_join_code_format -check (join_code ~ '^[A-Za-z0-9]{1,8}$'); - -create policy "Owner can delete leaderboard" -on public.leaderboards -for delete -using (auth.uid() = owner_id); diff --git a/supabase/migrations/20260320234758_add_conversations.sql b/supabase/migrations/20260320234758_add_conversations.sql deleted file mode 100644 index b5af241..0000000 --- a/supabase/migrations/20260320234758_add_conversations.sql +++ /dev/null @@ -1,49 +0,0 @@ -/* ---- Conversations ----- */ -CREATE TABLE conversations ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - created_at timestamptz DEFAULT now() -); - -/* ---- Participants ----- */ -CREATE TABLE conversation_participants ( - conversation_id uuid NOT NULL REFERENCES conversations(id), - user_id uuid NOT NULL REFERENCES auth.users(id), - email text NOT NULL, - PRIMARY KEY(conversation_id, user_id) -); - -/* ---- Messages ----- */ -CREATE TABLE messages ( - id uuid PRIMARY KEY DEFAULT gen_random_uuid(), - conversation_id uuid NOT NULL REFERENCES conversations(id), - sender_id uuid NOT NULL REFERENCES auth.users(id), - text text NOT NULL, - created_at timestamptz NOT NULL DEFAULT now() -); - -ALTER TABLE messages ENABLE ROW LEVEL SECURITY; - -/* ---- RLS Policies ----- */ -CREATE POLICY "participants can read messages" -ON messages -FOR SELECT -USING ( - EXISTS ( - SELECT 1 - FROM conversation_participants - WHERE conversation_id = messages.conversation_id - AND user_id = auth.uid() - ) -); - -CREATE POLICY "participants can send messages" -ON messages -FOR INSERT -WITH CHECK ( - EXISTS ( - SELECT 1 - FROM conversation_participants - WHERE conversation_id = messages.conversation_id - AND user_id = auth.uid() - ) -); diff --git a/supabase/migrations/20260323033205_add_type_to_conversations.sql b/supabase/migrations/20260323033205_add_type_to_conversations.sql deleted file mode 100644 index 15e105c..0000000 --- a/supabase/migrations/20260323033205_add_type_to_conversations.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE conversations ADD COLUMN type text NOT NULL DEFAULT 'private'; diff --git a/supabase/migrations/20260323033729_add_global_chat.sql b/supabase/migrations/20260323033729_add_global_chat.sql deleted file mode 100644 index 4e33458..0000000 --- a/supabase/migrations/20260323033729_add_global_chat.sql +++ /dev/null @@ -1,20 +0,0 @@ --- Add a global conversation -INSERT INTO conversations ( - id, - type -) -VALUES ( - '00000000-0000-0000-0000-000000000001', - 'global' -); - -INSERT INTO conversation_participants ( - conversation_id, - user_id, - email -) -SELECT - '00000000-0000-0000-0000-000000000001', - id, - email -FROM auth.users; diff --git a/supabase/migrations/20260323075413_add_attachments_to_messages.sql b/supabase/migrations/20260323075413_add_attachments_to_messages.sql deleted file mode 100644 index e059b9b..0000000 --- a/supabase/migrations/20260323075413_add_attachments_to_messages.sql +++ /dev/null @@ -1,9 +0,0 @@ -ALTER TABLE messages -ADD COLUMN attachments JSONB NOT NULL DEFAULT '[]'::jsonb; - --- allow authenticated users to upload -CREATE POLICY "Allow uploads by owner" -ON storage.objects -FOR INSERT -TO authenticated -WITH CHECK (auth.uid() = owner); diff --git a/supabase/migrations/20260325044133_add_user_flexes.sql b/supabase/migrations/20260325044133_add_user_flexes.sql deleted file mode 100644 index 6d570d3..0000000 --- a/supabase/migrations/20260325044133_add_user_flexes.sql +++ /dev/null @@ -1,43 +0,0 @@ -create table public.user_flexes ( - id uuid primary key default gen_random_uuid(), - user_id uuid references auth.users(id) on delete cascade, - user_email text NOT NULL, - project_name text NOT NULL, - project_description text NOT NULL, - project_url text NOT NULL, - project_time text NOT NULL, - is_open_source boolean NOT NULL default false, - open_source_url text NOT NULL default '', - created_at timestamp with time zone NOT NULL default now() -); - -alter table public.user_flexes enable row level security; - -/* ---- RLS Policy ----- */ -create policy "Public Access" on public.user_flexes -for select -using (true); - -create policy "User Flexes" on public.user_flexes -for all -to authenticated -using (user_id = auth.uid()) -with check (user_id = auth.uid()); - -create or replace function flex_project(p_user_id uuid, p_project jsonb) -returns text as $$ -begin - if exists ( - select 1 - from public.user_flexes - where user_id = p_user_id - and flexed_at > now() - interval '24 hours' - ) then - return 'You can only flex once every 24 hours'; - else - insert into public.user_flexes(user_id, project) - values (p_user_id, p_project); - return 'Project flexed successfully!'; - end if; -end; -$$ language plpgsql; diff --git a/supabase/migrations/20260325054413_add_expires_at_user_flexes.sql b/supabase/migrations/20260325054413_add_expires_at_user_flexes.sql deleted file mode 100644 index 736fc9c..0000000 --- a/supabase/migrations/20260325054413_add_expires_at_user_flexes.sql +++ /dev/null @@ -1,2 +0,0 @@ -alter table public.user_flexes -add column expires_at timestamp with time zone default (now() + interval '24 hours'); diff --git a/supabase/migrations/20260325072343_add_role_to_profiles.sql b/supabase/migrations/20260325072343_add_role_to_profiles.sql deleted file mode 100644 index 603fc89..0000000 --- a/supabase/migrations/20260325072343_add_role_to_profiles.sql +++ /dev/null @@ -1,6 +0,0 @@ -ALTER TABLE public.profiles -ADD COLUMN role text NOT NULL DEFAULT 'user'; - -ALTER TABLE public.profiles -ADD CONSTRAINT profiles_role_check -CHECK (role IN ('user', 'admin')); diff --git a/supabase/migrations/20260407120000_baseline_fresh_setup.sql b/supabase/migrations/20260407120000_baseline_fresh_setup.sql new file mode 100644 index 0000000..0820fba --- /dev/null +++ b/supabase/migrations/20260407120000_baseline_fresh_setup.sql @@ -0,0 +1,772 @@ +-- Baseline migration for fresh Supabase projects. +-- Generated by consolidating historical migrations in chronological order. +-- Date: 2026-04-07 + +-- >>> BEGIN 20260320234600_add_user_stats.sql +/* ---- User Stats ----- */ +create table public.user_stats ( + user_id uuid primary key references auth.users(id) on delete cascade, + total_seconds bigint NOT NULL default 0, + daily_average bigint NOT NULL default 0, + languages jsonb NOT NULL default '[]'::jsonb, + operating_systems jsonb NOT NULL default '[]'::jsonb, + editors jsonb NOT NULL default '[]'::jsonb, + machines jsonb NOT NULL default '[]'::jsonb, + categories jsonb NOT NULL default '[]'::jsonb, + dependencies jsonb NOT NULL default '[]'::jsonb, + best_day jsonb NOT NULL default '{}'::jsonb, + daily_stats jsonb NOT NULL default '[]'::jsonb, + last_fetched_at timestamp with time zone NOT NULL default now() +); + +alter table public.user_stats enable row level security; + +/* ---- RLS Policy ----- */ +create policy "Users can insert their stats" +on public.user_stats +for insert +with check (auth.uid() = user_id); + +create policy "Users can update their stats" +on public.user_stats +for update +using (auth.uid() = user_id); + +create policy "Authenticated users can view stats" +on public.user_stats +for select +using (auth.role() = 'authenticated'); +-- <<< END 20260320234600_add_user_stats.sql + +-- >>> BEGIN 20260320234643_add_top_user_stats.sql +CREATE OR REPLACE VIEW top_user_stats AS +SELECT + us.user_id, + us.total_seconds, + a.email +FROM user_stats us +JOIN auth.users a ON us.user_id = a.id; +-- <<< END 20260320234643_add_top_user_stats.sql + +-- >>> BEGIN 20260320234653_add_profiles.sql +/* ---- Profile ----- */ +create table public.profiles ( + id uuid primary key references auth.users(id) on delete cascade, + email text not null, + wakatime_api_key text default null unique, + created_at timestamp with time zone NOT NULL default now() +); + +/* ---- RLS Policies ----- */ +alter table public.profiles enable row level security; + +create policy "Users can view their own profile" +on public.profiles +for select +using (auth.uid() = id); + +create policy "Users can update their own profile" +on public.profiles +for update +using (auth.uid() = id); + +create policy "Users can insert their own profile" +on public.profiles +for insert +with check (auth.uid() = id); + +/* ---- Auto Create Profile on Signup Trigger ----- */ +create or replace function public.handle_new_user() +returns trigger as $$ +begin + insert into public.profiles (id, email) + values (new.id, new.email) + on conflict (id) do update + set email = excluded.email; + return new; +end; +$$ language plpgsql security definer; + +drop trigger if exists on_auth_user_created on auth.users; + +create trigger on_auth_user_created +after insert on auth.users +for each row execute procedure public.handle_new_user(); +-- <<< END 20260320234653_add_profiles.sql + +-- >>> BEGIN 20260320234714_add_leaderboards.sql +/* ---- Leaderboards ----- */ +create table public.leaderboards ( + id uuid primary key default gen_random_uuid(), + name text not null, + slug text not null unique, + description text, + is_public boolean NOT NULL default true, + owner_id uuid NOT NULL references auth.users(id) on delete cascade, + join_code text not null unique, + created_at timestamptz NOT NULL default now() +); + +alter table public.leaderboards enable row level security; + +/* ---- Leaderboards Members ----- */ +create table public.leaderboard_members ( + id uuid primary key default gen_random_uuid(), + leaderboard_id uuid NOT NULL references public.leaderboards(id) on delete cascade, + user_id uuid NOT NULL references auth.users(id) on delete cascade, + role text NOT NULL default 'member', -- owner | member + joined_at timestamptz NOT NULL default now(), + unique (leaderboard_id, user_id) +); + +alter table public.leaderboard_members enable row level security; + +/* ---- Leaderboards Members View ----- */ +create view public.leaderboard_members_view as +select + lm.id, + lm.leaderboard_id, + lm.user_id, + lm.role, + u.email, + us.total_seconds, + us.languages, + us.operating_systems, + us.editors +from public.leaderboard_members lm +join auth.users u on lm.user_id = u.id +left join public.user_stats us on lm.user_id = us.user_id; + +/* ---- RLS Policies ----- */ +create policy "Public leaderboards are viewable" +on public.leaderboards +for select +using (is_public = true OR owner_id = auth.uid()); + +create policy "Users can create leaderboards" +on public.leaderboards +for insert +with check (auth.uid() = owner_id); + +create policy "Owner can update leaderboard" +on public.leaderboards +for update +using (auth.uid() = owner_id); + +/* ---- Members Policies ----- */ +create policy "Users can see memberships for visible leaderboards" +on public.leaderboard_members +for select +using ( + exists ( + select 1 + from public.leaderboards l + where l.id = leaderboard_members.leaderboard_id + and ( + l.is_public = true + or l.owner_id = auth.uid() + or exists ( + select 1 + from public.leaderboard_members lm + where lm.leaderboard_id = l.id + and lm.user_id = auth.uid() + ) + ) + ) +); + +create policy "Users can join leaderboard" +on public.leaderboard_members +for insert +with check (auth.uid() = user_id); + +create policy "Members can leave leaderboard" +on public.leaderboard_members +for delete +using (user_id = auth.uid()); + +create policy "Owner can manage members" +on public.leaderboard_members +for delete +using ( + leaderboard_id IN ( + select id + from public.leaderboards + where owner_id = auth.uid() + ) +); +-- <<< END 20260320234714_add_leaderboards.sql + +-- >>> BEGIN 20260320234731_add_enforcement_checks.sql +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'leaderboards_name_length' + AND conrelid = 'public.leaderboards'::regclass + ) THEN + alter table public.leaderboards + add constraint leaderboards_name_length + check (length(trim(name)) between 3 and 50); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'leaderboards_description_length' + AND conrelid = 'public.leaderboards'::regclass + ) THEN + alter table public.leaderboards + add constraint leaderboards_description_length + check (description is null or length(description) <= 150); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'leaderboards_join_code_format' + AND conrelid = 'public.leaderboards'::regclass + ) THEN + alter table public.leaderboards + add constraint leaderboards_join_code_format + check (join_code ~ '^[A-Za-z0-9]{1,8}$'); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'leaderboards' + AND policyname = 'Owner can delete leaderboard' + ) THEN + create policy "Owner can delete leaderboard" + on public.leaderboards + for delete + using (auth.uid() = owner_id); + END IF; +END +$$; +-- <<< END 20260320234731_add_enforcement_checks.sql + +-- >>> BEGIN 20260320234742_add_user_projects.sql +/* ---- User Projects Stats ----- */ +create table public.user_projects ( + user_id uuid primary key references auth.users(id) on delete cascade, + projects jsonb default '[]'::jsonb, + last_fetched_at timestamp with time zone NOT NULL default now() +); + +alter table public.user_projects enable row level security; + +create policy "Users can view their own projects" +on public.user_projects +for select +using (auth.uid() = user_id); + +create policy "Users can insert their own projects" +on public.user_projects +for insert +with check (auth.uid() = user_id); + +create policy "Users can update their own projects" +on public.user_projects +for update +using (auth.uid() = user_id); +-- <<< END 20260320234742_add_user_projects.sql + +-- >>> BEGIN 20260320234758_add_conversations.sql +/* ---- Conversations ----- */ +CREATE TABLE conversations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz DEFAULT now() +); + +/* ---- Participants ----- */ +CREATE TABLE conversation_participants ( + conversation_id uuid NOT NULL REFERENCES conversations(id), + user_id uuid NOT NULL REFERENCES auth.users(id), + email text NOT NULL, + PRIMARY KEY(conversation_id, user_id) +); + +/* ---- Messages ----- */ +CREATE TABLE messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id uuid NOT NULL REFERENCES conversations(id), + sender_id uuid NOT NULL REFERENCES auth.users(id), + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE OR REPLACE FUNCTION public.is_conversation_member( + target_conversation_id uuid, + target_user_id uuid +) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.conversation_participants cp + WHERE cp.conversation_id = target_conversation_id + AND cp.user_id = target_user_id + ); +$$; + +ALTER TABLE conversations ENABLE ROW LEVEL SECURITY; +ALTER TABLE conversation_participants ENABLE ROW LEVEL SECURITY; +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; + +/* ---- RLS Policies ----- */ +CREATE POLICY "participants can read conversations" +ON conversations +FOR SELECT +USING (public.is_conversation_member(id, auth.uid())); + +CREATE POLICY "authenticated users can create conversations" +ON conversations +FOR INSERT +WITH CHECK (auth.role() = 'authenticated'); + +CREATE POLICY "participants can update conversations" +ON conversations +FOR UPDATE +USING (public.is_conversation_member(id, auth.uid())); + +CREATE POLICY "participants can delete conversations" +ON conversations +FOR DELETE +USING (public.is_conversation_member(id, auth.uid())); + +CREATE POLICY "participants can read participant list" +ON conversation_participants +FOR SELECT +USING (public.is_conversation_member(conversation_id, auth.uid())); + +CREATE POLICY "users can add self participant row" +ON conversation_participants +FOR INSERT +WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "participants can add other participants" +ON conversation_participants +FOR INSERT +WITH CHECK (public.is_conversation_member(conversation_id, auth.uid())); + +CREATE POLICY "users can update their own participant row" +ON conversation_participants +FOR UPDATE +USING (auth.uid() = user_id) +WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "users can remove their own participant row" +ON conversation_participants +FOR DELETE +USING (auth.uid() = user_id); + +CREATE POLICY "participants can read messages" +ON messages +FOR SELECT +USING (public.is_conversation_member(conversation_id, auth.uid())); + +CREATE POLICY "participants can send messages" +ON messages +FOR INSERT +WITH CHECK (public.is_conversation_member(conversation_id, auth.uid())); +-- <<< END 20260320234758_add_conversations.sql + +-- >>> BEGIN 20260321140532_add_categories_to_top_user_stats.sql +CREATE OR REPLACE VIEW top_user_stats AS +SELECT + us.user_id, + us.total_seconds, + a.email, + us.categories -- new column +FROM user_stats us +JOIN auth.users a ON us.user_id = a.id; +-- <<< END 20260321140532_add_categories_to_top_user_stats.sql + +-- >>> BEGIN 20260323033205_add_type_to_conversations.sql +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'private'; +-- <<< END 20260323033205_add_type_to_conversations.sql + +-- >>> BEGIN 20260323033729_add_global_chat.sql +-- Add a global conversation +INSERT INTO public.conversations (id, type) +VALUES ('00000000-0000-0000-0000-000000000001', 'global') +ON CONFLICT (id) DO UPDATE +SET type = EXCLUDED.type; + +INSERT INTO public.conversation_participants ( + conversation_id, + user_id, + email +) +SELECT + '00000000-0000-0000-0000-000000000001', + u.id, + COALESCE(u.email, CONCAT(u.id::text, '@user.local')) +FROM auth.users u +ON CONFLICT (conversation_id, user_id) DO UPDATE +SET email = EXCLUDED.email; +-- <<< END 20260323033729_add_global_chat.sql + +-- >>> BEGIN 20260323041448_add_type_to_conversation_participants.sql +ALTER TABLE conversation_participants +ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'private'; + +UPDATE conversation_participants +SET type = 'global' +WHERE conversation_id = '00000000-0000-0000-0000-000000000001'; +-- <<< END 20260323041448_add_type_to_conversation_participants.sql + +-- >>> BEGIN 20260323041543_add_trigger_to_type.sql +CREATE OR REPLACE FUNCTION public.sync_conversation_type() +RETURNS TRIGGER +LANGUAGE plpgsql +SECURITY DEFINER +SET search_path = public +AS $$ +BEGIN + NEW.type := ( + SELECT c.type + FROM public.conversations c + WHERE c.id = NEW.conversation_id + ); + + RETURN NEW; +END; +$$; + +DROP TRIGGER IF EXISTS conversation_type_trigger ON conversation_participants; + +CREATE TRIGGER conversation_type_trigger +BEFORE INSERT ON conversation_participants +FOR EACH ROW +EXECUTE FUNCTION sync_conversation_type(); +-- <<< END 20260323041543_add_trigger_to_type.sql + +-- >>> BEGIN 20260323075413_add_attachments_to_messages.sql +ALTER TABLE messages +ADD COLUMN IF NOT EXISTS attachments JSONB NOT NULL DEFAULT '[]'::jsonb; + +-- allow authenticated users to upload +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'storage' + AND tablename = 'objects' + AND policyname = 'Allow uploads by owner' + ) THEN + CREATE POLICY "Allow uploads by owner" + ON storage.objects + FOR INSERT + TO authenticated + WITH CHECK (auth.uid() = owner); + END IF; +END +$$; +-- <<< END 20260323075413_add_attachments_to_messages.sql + +-- >>> BEGIN 20260325044133_add_user_flexes.sql +create table public.user_flexes ( + id uuid primary key default gen_random_uuid(), + user_id uuid NOT NULL references auth.users(id) on delete cascade, + user_email text NOT NULL, + project_name text NOT NULL, + project_description text NOT NULL, + project_url text NOT NULL, + project_time text NOT NULL, + is_open_source boolean NOT NULL default false, + open_source_url text NOT NULL default '', + created_at timestamp with time zone NOT NULL default now() +); + +alter table public.user_flexes enable row level security; + +/* ---- RLS Policy ----- */ +create policy "Public Access" on public.user_flexes +for select +using (true); + +create policy "User Flexes" on public.user_flexes +for all +to authenticated +using (user_id = auth.uid()) +with check (user_id = auth.uid()); + +create or replace function flex_project(p_user_id uuid, p_project jsonb) +returns text as $$ +declare + v_project_name text; + v_project_description text; + v_project_url text; + v_project_time text; + v_is_open_source boolean; + v_open_source_url text; + v_user_email text; +begin + v_project_name := coalesce( + nullif(trim(p_project->>'project_name'), ''), + nullif(trim(p_project->>'name'), '') + ); + v_project_description := coalesce(nullif(trim(p_project->>'project_description'), ''), ''); + v_project_url := coalesce(nullif(trim(p_project->>'project_url'), ''), ''); + v_project_time := coalesce( + nullif(trim(p_project->>'project_time'), ''), + nullif(trim(p_project->>'text'), ''), + '' + ); + v_is_open_source := lower(coalesce(p_project->>'is_open_source', 'false')) in ('true', '1', 't', 'yes', 'y'); + v_open_source_url := coalesce(nullif(trim(p_project->>'open_source_url'), ''), ''); + v_user_email := coalesce(nullif(trim(p_project->>'user_email'), ''), concat(p_user_id::text, '@user.local')); + + if v_project_name is null then + return 'Project name is required'; + end if; + + if exists ( + select 1 + from public.user_flexes + where user_id = p_user_id + and created_at > now() - interval '24 hours' + ) then + return 'You can only flex once every 24 hours'; + else + insert into public.user_flexes( + user_id, + user_email, + project_name, + project_description, + project_url, + project_time, + is_open_source, + open_source_url + ) + values ( + p_user_id, + v_user_email, + v_project_name, + v_project_description, + v_project_url, + v_project_time, + v_is_open_source, + v_open_source_url + ); + return 'Project flexed successfully!'; + end if; +end; +$$ language plpgsql security definer set search_path = public; +-- <<< END 20260325044133_add_user_flexes.sql + +-- >>> BEGIN 20260325054413_add_expires_at_user_flexes.sql +alter table public.user_flexes +add column if not exists expires_at timestamp with time zone; + +update public.user_flexes +set expires_at = coalesce(expires_at, created_at + interval '24 hours'); + +alter table public.user_flexes +alter column expires_at set default (now() + interval '24 hours'); + +alter table public.user_flexes +alter column expires_at set not null; +-- <<< END 20260325054413_add_expires_at_user_flexes.sql + +-- >>> BEGIN 20260325072343_add_role_to_profiles.sql +ALTER TABLE public.profiles +ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'profiles_role_check' + AND conrelid = 'public.profiles'::regclass + ) THEN + ALTER TABLE public.profiles + ADD CONSTRAINT profiles_role_check + CHECK (role IN ('user', 'admin')); + END IF; +END +$$; +-- <<< END 20260325072343_add_role_to_profiles.sql + +-- >>> BEGIN 20260327100000_cascade_conversation_delete.sql +DO $$ +BEGIN + -- Drop existing constraints if they exist + ALTER TABLE conversation_participants DROP CONSTRAINT IF EXISTS conversation_participants_conversation_id_fkey; + ALTER TABLE messages DROP CONSTRAINT IF EXISTS messages_conversation_id_fkey; + + -- Add constraints with cascade + ALTER TABLE conversation_participants ADD CONSTRAINT conversation_participants_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE; + ALTER TABLE messages ADD CONSTRAINT messages_conversation_id_fkey FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE; +END $$; +-- <<< END 20260327100000_cascade_conversation_delete.sql + +-- >>> BEGIN 20260329120000_add_user_dashboard_snapshots.sql +/* ---- User Dashboard Snapshots ----- */ +create table public.user_dashboard_snapshots ( + id bigint generated by default as identity primary key, + user_id uuid not null references auth.users(id) on delete cascade, + snapshot_date date not null, + total_seconds_7d bigint not null default 0, + active_days_7d integer not null default 0, + consistency_percent integer not null default 0, + current_streak integer not null default 0, + best_streak integer not null default 0, + peak_day date, + peak_day_seconds bigint not null default 0, + top_language text, + top_language_percent numeric(5,2), + created_at timestamp with time zone not null default now(), + updated_at timestamp with time zone not null default now(), + unique (user_id, snapshot_date), + check (active_days_7d between 0 and 7), + check (consistency_percent between 0 and 100), + check (peak_day_seconds >= 0), + check ( + top_language_percent is null + or (top_language_percent >= 0 and top_language_percent <= 100) + ) +); + +alter table public.user_dashboard_snapshots enable row level security; + +create policy "Users can view their own dashboard snapshots" +on public.user_dashboard_snapshots +for select +using (auth.uid() = user_id); + +create policy "Users can insert their own dashboard snapshots" +on public.user_dashboard_snapshots +for insert +with check (auth.uid() = user_id); + +create policy "Users can update their own dashboard snapshots" +on public.user_dashboard_snapshots +for update +using (auth.uid() = user_id); +-- <<< END 20260329120000_add_user_dashboard_snapshots.sql + +-- >>> BEGIN 20260329133000_add_chat_presence_and_read_tracking.sql +ALTER TABLE conversation_participants +ADD COLUMN IF NOT EXISTS last_seen_at timestamptz NOT NULL DEFAULT to_timestamp(0), +ADD COLUMN IF NOT EXISTS last_read_at timestamptz NOT NULL DEFAULT now(); + +UPDATE conversation_participants +SET + last_seen_at = COALESCE(last_seen_at, to_timestamp(0)), + last_read_at = COALESCE(last_read_at, now()); + +CREATE INDEX IF NOT EXISTS idx_conversation_participants_user_last_seen_at +ON conversation_participants (user_id, last_seen_at DESC); + +CREATE INDEX IF NOT EXISTS idx_messages_conversation_created_sender +ON messages (conversation_id, created_at DESC, sender_id); +-- <<< END 20260329133000_add_chat_presence_and_read_tracking.sql + +-- >>> BEGIN 20260330103000_enable_chat_realtime_publication.sql +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'conversations' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE public.conversations; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'conversation_participants' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE public.conversation_participants; + END IF; + + IF NOT EXISTS ( + SELECT 1 + FROM pg_publication_tables + WHERE pubname = 'supabase_realtime' + AND schemaname = 'public' + AND tablename = 'messages' + ) THEN + ALTER PUBLICATION supabase_realtime ADD TABLE public.messages; + END IF; +END +$$; +-- <<< END 20260330103000_enable_chat_realtime_publication.sql + +-- >>> BEGIN 20260330104500_fix_global_chat_membership.sql +-- Ensure the global conversation row exists and has the expected type. +INSERT INTO public.conversations (id, type) +VALUES ('00000000-0000-0000-0000-000000000001', 'global') +ON CONFLICT (id) DO UPDATE +SET type = EXCLUDED.type; + +-- Backfill global chat membership for all existing auth users. +INSERT INTO public.conversation_participants (conversation_id, user_id, email) +SELECT + '00000000-0000-0000-0000-000000000001', + u.id, + COALESCE(u.email, CONCAT(u.id::text, '@user.local')) +FROM auth.users u +ON CONFLICT (conversation_id, user_id) DO UPDATE +SET email = EXCLUDED.email; + +-- Keep future auth users automatically enrolled in global chat. +CREATE OR REPLACE FUNCTION public.ensure_user_in_global_chat() +RETURNS TRIGGER AS $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM public.conversations + WHERE id = '00000000-0000-0000-0000-000000000001' + ) THEN + INSERT INTO public.conversation_participants (conversation_id, user_id, email) + VALUES ( + '00000000-0000-0000-0000-000000000001', + NEW.id, + COALESCE(NEW.email, CONCAT(NEW.id::text, '@user.local')) + ) + ON CONFLICT (conversation_id, user_id) DO UPDATE + SET email = EXCLUDED.email; + END IF; + + RETURN NEW; +END; +$$ LANGUAGE plpgsql SECURITY DEFINER SET search_path = public; + +DROP TRIGGER IF EXISTS on_auth_user_join_global_chat ON auth.users; + +CREATE TRIGGER on_auth_user_join_global_chat +AFTER INSERT ON auth.users +FOR EACH ROW +EXECUTE FUNCTION public.ensure_user_in_global_chat(); +-- <<< END 20260330104500_fix_global_chat_membership.sql + diff --git a/supabase/migrations/20260320234600_add_user_stats.sql b/supabase/migrations_archive/20260320234600_add_user_stats.sql similarity index 100% rename from supabase/migrations/20260320234600_add_user_stats.sql rename to supabase/migrations_archive/20260320234600_add_user_stats.sql diff --git a/supabase/migrations/20260320234643_add_top_user_stats.sql b/supabase/migrations_archive/20260320234643_add_top_user_stats.sql similarity index 100% rename from supabase/migrations/20260320234643_add_top_user_stats.sql rename to supabase/migrations_archive/20260320234643_add_top_user_stats.sql diff --git a/supabase/migrations/20260320234653_add_profiles.sql b/supabase/migrations_archive/20260320234653_add_profiles.sql similarity index 87% rename from supabase/migrations/20260320234653_add_profiles.sql rename to supabase/migrations_archive/20260320234653_add_profiles.sql index 630eded..e2dccf2 100644 --- a/supabase/migrations/20260320234653_add_profiles.sql +++ b/supabase/migrations_archive/20260320234653_add_profiles.sql @@ -29,11 +29,15 @@ create or replace function public.handle_new_user() returns trigger as $$ begin insert into public.profiles (id, email) - values (new.id, new.email); + values (new.id, new.email) + on conflict (id) do update + set email = excluded.email; return new; end; $$ language plpgsql security definer; +drop trigger if exists on_auth_user_created on auth.users; + create trigger on_auth_user_created after insert on auth.users for each row execute procedure public.handle_new_user(); diff --git a/supabase/migrations/20260320234714_add_leaderboards.sql b/supabase/migrations_archive/20260320234714_add_leaderboards.sql similarity index 82% rename from supabase/migrations/20260320234714_add_leaderboards.sql rename to supabase/migrations_archive/20260320234714_add_leaderboards.sql index 35155e7..f9c6d08 100644 --- a/supabase/migrations/20260320234714_add_leaderboards.sql +++ b/supabase/migrations_archive/20260320234714_add_leaderboards.sql @@ -57,10 +57,26 @@ for update using (auth.uid() = owner_id); /* ---- Members Policies ----- */ -create policy "Users can see their own membership" +create policy "Users can see memberships for visible leaderboards" on public.leaderboard_members for select -using (user_id = auth.uid()); +using ( + exists ( + select 1 + from public.leaderboards l + where l.id = leaderboard_members.leaderboard_id + and ( + l.is_public = true + or l.owner_id = auth.uid() + or exists ( + select 1 + from public.leaderboard_members lm + where lm.leaderboard_id = l.id + and lm.user_id = auth.uid() + ) + ) + ) +); create policy "Users can join leaderboard" on public.leaderboard_members @@ -77,7 +93,7 @@ on public.leaderboard_members for delete using ( leaderboard_id IN ( - select leaderboard_id + select id from public.leaderboards where owner_id = auth.uid() ) diff --git a/supabase/migrations_archive/20260320234731_add_enforcement_checks.sql b/supabase/migrations_archive/20260320234731_add_enforcement_checks.sql new file mode 100644 index 0000000..1b85f3a --- /dev/null +++ b/supabase/migrations_archive/20260320234731_add_enforcement_checks.sql @@ -0,0 +1,61 @@ +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'leaderboards_name_length' + AND conrelid = 'public.leaderboards'::regclass + ) THEN + alter table public.leaderboards + add constraint leaderboards_name_length + check (length(trim(name)) between 3 and 50); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'leaderboards_description_length' + AND conrelid = 'public.leaderboards'::regclass + ) THEN + alter table public.leaderboards + add constraint leaderboards_description_length + check (description is null or length(description) <= 150); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'leaderboards_join_code_format' + AND conrelid = 'public.leaderboards'::regclass + ) THEN + alter table public.leaderboards + add constraint leaderboards_join_code_format + check (join_code ~ '^[A-Za-z0-9]{1,8}$'); + END IF; +END +$$; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'public' + AND tablename = 'leaderboards' + AND policyname = 'Owner can delete leaderboard' + ) THEN + create policy "Owner can delete leaderboard" + on public.leaderboards + for delete + using (auth.uid() = owner_id); + END IF; +END +$$; diff --git a/supabase/migrations/20260320234742_add_user_projects.sql b/supabase/migrations_archive/20260320234742_add_user_projects.sql similarity index 100% rename from supabase/migrations/20260320234742_add_user_projects.sql rename to supabase/migrations_archive/20260320234742_add_user_projects.sql diff --git a/supabase/migrations_archive/20260320234758_add_conversations.sql b/supabase/migrations_archive/20260320234758_add_conversations.sql new file mode 100644 index 0000000..ca51746 --- /dev/null +++ b/supabase/migrations_archive/20260320234758_add_conversations.sql @@ -0,0 +1,96 @@ +/* ---- Conversations ----- */ +CREATE TABLE conversations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + created_at timestamptz DEFAULT now() +); + +/* ---- Participants ----- */ +CREATE TABLE conversation_participants ( + conversation_id uuid NOT NULL REFERENCES conversations(id), + user_id uuid NOT NULL REFERENCES auth.users(id), + email text NOT NULL, + PRIMARY KEY(conversation_id, user_id) +); + +/* ---- Messages ----- */ +CREATE TABLE messages ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + conversation_id uuid NOT NULL REFERENCES conversations(id), + sender_id uuid NOT NULL REFERENCES auth.users(id), + text text NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE OR REPLACE FUNCTION public.is_conversation_member( + target_conversation_id uuid, + target_user_id uuid +) +RETURNS boolean +LANGUAGE sql +STABLE +SECURITY DEFINER +SET search_path = public +AS $$ + SELECT EXISTS ( + SELECT 1 + FROM public.conversation_participants cp + WHERE cp.conversation_id = target_conversation_id + AND cp.user_id = target_user_id + ); +$$; + +ALTER TABLE conversations ENABLE ROW LEVEL SECURITY; +ALTER TABLE conversation_participants ENABLE ROW LEVEL SECURITY; +ALTER TABLE messages ENABLE ROW LEVEL SECURITY; + +/* ---- RLS Policies ----- */ +CREATE POLICY "participants can read conversations" +ON conversations +FOR SELECT +USING (public.is_conversation_member(id, auth.uid())); + +CREATE POLICY "authenticated users can create conversations" +ON conversations +FOR INSERT +WITH CHECK (auth.role() = 'authenticated'); + +CREATE POLICY "participants can update conversations" +ON conversations +FOR UPDATE +USING (public.is_conversation_member(id, auth.uid())); + +CREATE POLICY "participants can delete conversations" +ON conversations +FOR DELETE +USING (public.is_conversation_member(id, auth.uid())); + +CREATE POLICY "participants can read participant list" +ON conversation_participants +FOR SELECT +USING (public.is_conversation_member(conversation_id, auth.uid())); + +CREATE POLICY "authenticated users can add participants" +ON conversation_participants +FOR INSERT +WITH CHECK (auth.role() = 'authenticated'); + +CREATE POLICY "users can update their own participant row" +ON conversation_participants +FOR UPDATE +USING (auth.uid() = user_id) +WITH CHECK (auth.uid() = user_id); + +CREATE POLICY "users can remove their own participant row" +ON conversation_participants +FOR DELETE +USING (auth.uid() = user_id); + +CREATE POLICY "participants can read messages" +ON messages +FOR SELECT +USING (public.is_conversation_member(conversation_id, auth.uid())); + +CREATE POLICY "participants can send messages" +ON messages +FOR INSERT +WITH CHECK (public.is_conversation_member(conversation_id, auth.uid())); diff --git a/supabase/migrations/20260321140532_add_categories_to_top_user_stats.sql b/supabase/migrations_archive/20260321140532_add_categories_to_top_user_stats.sql similarity index 100% rename from supabase/migrations/20260321140532_add_categories_to_top_user_stats.sql rename to supabase/migrations_archive/20260321140532_add_categories_to_top_user_stats.sql diff --git a/supabase/migrations_archive/20260323033205_add_type_to_conversations.sql b/supabase/migrations_archive/20260323033205_add_type_to_conversations.sql new file mode 100644 index 0000000..1408ba7 --- /dev/null +++ b/supabase/migrations_archive/20260323033205_add_type_to_conversations.sql @@ -0,0 +1 @@ +ALTER TABLE conversations ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'private'; diff --git a/supabase/migrations_archive/20260323033729_add_global_chat.sql b/supabase/migrations_archive/20260323033729_add_global_chat.sql new file mode 100644 index 0000000..cce8476 --- /dev/null +++ b/supabase/migrations_archive/20260323033729_add_global_chat.sql @@ -0,0 +1,18 @@ +-- Add a global conversation +INSERT INTO public.conversations (id, type) +VALUES ('00000000-0000-0000-0000-000000000001', 'global') +ON CONFLICT (id) DO UPDATE +SET type = EXCLUDED.type; + +INSERT INTO public.conversation_participants ( + conversation_id, + user_id, + email +) +SELECT + '00000000-0000-0000-0000-000000000001', + u.id, + COALESCE(u.email, CONCAT(u.id::text, '@user.local')) +FROM auth.users u +ON CONFLICT (conversation_id, user_id) DO UPDATE +SET email = EXCLUDED.email; diff --git a/supabase/migrations/20260323041448_add_type_to_conversation_participants.sql b/supabase/migrations_archive/20260323041448_add_type_to_conversation_participants.sql similarity index 71% rename from supabase/migrations/20260323041448_add_type_to_conversation_participants.sql rename to supabase/migrations_archive/20260323041448_add_type_to_conversation_participants.sql index 629c317..b240c51 100644 --- a/supabase/migrations/20260323041448_add_type_to_conversation_participants.sql +++ b/supabase/migrations_archive/20260323041448_add_type_to_conversation_participants.sql @@ -1,5 +1,5 @@ ALTER TABLE conversation_participants -ADD COLUMN type text NOT NULL DEFAULT 'private'; +ADD COLUMN IF NOT EXISTS type text NOT NULL DEFAULT 'private'; UPDATE conversation_participants SET type = 'global' diff --git a/supabase/migrations/20260323041543_add_trigger_to_type.sql b/supabase/migrations_archive/20260323041543_add_trigger_to_type.sql similarity index 81% rename from supabase/migrations/20260323041543_add_trigger_to_type.sql rename to supabase/migrations_archive/20260323041543_add_trigger_to_type.sql index 57e50a5..2a25867 100644 --- a/supabase/migrations/20260323041543_add_trigger_to_type.sql +++ b/supabase/migrations_archive/20260323041543_add_trigger_to_type.sql @@ -10,6 +10,8 @@ BEGIN END; $$ LANGUAGE plpgsql; +DROP TRIGGER IF EXISTS conversation_type_trigger ON conversation_participants; + CREATE TRIGGER conversation_type_trigger BEFORE INSERT ON conversation_participants FOR EACH ROW diff --git a/supabase/migrations_archive/20260323075413_add_attachments_to_messages.sql b/supabase/migrations_archive/20260323075413_add_attachments_to_messages.sql new file mode 100644 index 0000000..77c3134 --- /dev/null +++ b/supabase/migrations_archive/20260323075413_add_attachments_to_messages.sql @@ -0,0 +1,21 @@ +ALTER TABLE messages +ADD COLUMN IF NOT EXISTS attachments JSONB NOT NULL DEFAULT '[]'::jsonb; + +-- allow authenticated users to upload +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_policies + WHERE schemaname = 'storage' + AND tablename = 'objects' + AND policyname = 'Allow uploads by owner' + ) THEN + CREATE POLICY "Allow uploads by owner" + ON storage.objects + FOR INSERT + TO authenticated + WITH CHECK (auth.uid() = owner); + END IF; +END +$$; diff --git a/supabase/migrations_archive/20260325044133_add_user_flexes.sql b/supabase/migrations_archive/20260325044133_add_user_flexes.sql new file mode 100644 index 0000000..bd17a25 --- /dev/null +++ b/supabase/migrations_archive/20260325044133_add_user_flexes.sql @@ -0,0 +1,88 @@ +create table public.user_flexes ( + id uuid primary key default gen_random_uuid(), + user_id uuid NOT NULL references auth.users(id) on delete cascade, + user_email text NOT NULL, + project_name text NOT NULL, + project_description text NOT NULL, + project_url text NOT NULL, + project_time text NOT NULL, + is_open_source boolean NOT NULL default false, + open_source_url text NOT NULL default '', + created_at timestamp with time zone NOT NULL default now() +); + +alter table public.user_flexes enable row level security; + +/* ---- RLS Policy ----- */ +create policy "Public Access" on public.user_flexes +for select +using (true); + +create policy "User Flexes" on public.user_flexes +for all +to authenticated +using (user_id = auth.uid()) +with check (user_id = auth.uid()); + +create or replace function flex_project(p_user_id uuid, p_project jsonb) +returns text as $$ +declare + v_project_name text; + v_project_description text; + v_project_url text; + v_project_time text; + v_is_open_source boolean; + v_open_source_url text; + v_user_email text; +begin + v_project_name := coalesce( + nullif(trim(p_project->>'project_name'), ''), + nullif(trim(p_project->>'name'), '') + ); + v_project_description := coalesce(nullif(trim(p_project->>'project_description'), ''), ''); + v_project_url := coalesce(nullif(trim(p_project->>'project_url'), ''), ''); + v_project_time := coalesce( + nullif(trim(p_project->>'project_time'), ''), + nullif(trim(p_project->>'text'), ''), + '' + ); + v_is_open_source := lower(coalesce(p_project->>'is_open_source', 'false')) in ('true', '1', 't', 'yes', 'y'); + v_open_source_url := coalesce(nullif(trim(p_project->>'open_source_url'), ''), ''); + v_user_email := coalesce(nullif(trim(p_project->>'user_email'), ''), concat(p_user_id::text, '@user.local')); + + if v_project_name is null then + return 'Project name is required'; + end if; + + if exists ( + select 1 + from public.user_flexes + where user_id = p_user_id + and created_at > now() - interval '24 hours' + ) then + return 'You can only flex once every 24 hours'; + else + insert into public.user_flexes( + user_id, + user_email, + project_name, + project_description, + project_url, + project_time, + is_open_source, + open_source_url + ) + values ( + p_user_id, + v_user_email, + v_project_name, + v_project_description, + v_project_url, + v_project_time, + v_is_open_source, + v_open_source_url + ); + return 'Project flexed successfully!'; + end if; +end; +$$ language plpgsql security definer set search_path = public; diff --git a/supabase/migrations_archive/20260325054413_add_expires_at_user_flexes.sql b/supabase/migrations_archive/20260325054413_add_expires_at_user_flexes.sql new file mode 100644 index 0000000..e1239f8 --- /dev/null +++ b/supabase/migrations_archive/20260325054413_add_expires_at_user_flexes.sql @@ -0,0 +1,11 @@ +alter table public.user_flexes +add column if not exists expires_at timestamp with time zone; + +update public.user_flexes +set expires_at = coalesce(expires_at, created_at + interval '24 hours'); + +alter table public.user_flexes +alter column expires_at set default (now() + interval '24 hours'); + +alter table public.user_flexes +alter column expires_at set not null; diff --git a/supabase/migrations_archive/20260325072343_add_role_to_profiles.sql b/supabase/migrations_archive/20260325072343_add_role_to_profiles.sql new file mode 100644 index 0000000..dfd1eee --- /dev/null +++ b/supabase/migrations_archive/20260325072343_add_role_to_profiles.sql @@ -0,0 +1,17 @@ +ALTER TABLE public.profiles +ADD COLUMN IF NOT EXISTS role text NOT NULL DEFAULT 'user'; + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM pg_constraint + WHERE conname = 'profiles_role_check' + AND conrelid = 'public.profiles'::regclass + ) THEN + ALTER TABLE public.profiles + ADD CONSTRAINT profiles_role_check + CHECK (role IN ('user', 'admin')); + END IF; +END +$$; diff --git a/supabase/migrations/20260327100000_cascade_conversation_delete.sql b/supabase/migrations_archive/20260327100000_cascade_conversation_delete.sql similarity index 100% rename from supabase/migrations/20260327100000_cascade_conversation_delete.sql rename to supabase/migrations_archive/20260327100000_cascade_conversation_delete.sql diff --git a/supabase/migrations/20260329120000_add_user_dashboard_snapshots.sql b/supabase/migrations_archive/20260329120000_add_user_dashboard_snapshots.sql similarity index 100% rename from supabase/migrations/20260329120000_add_user_dashboard_snapshots.sql rename to supabase/migrations_archive/20260329120000_add_user_dashboard_snapshots.sql diff --git a/supabase/migrations/20260329133000_add_chat_presence_and_read_tracking.sql b/supabase/migrations_archive/20260329133000_add_chat_presence_and_read_tracking.sql similarity index 100% rename from supabase/migrations/20260329133000_add_chat_presence_and_read_tracking.sql rename to supabase/migrations_archive/20260329133000_add_chat_presence_and_read_tracking.sql diff --git a/supabase/migrations/20260330103000_enable_chat_realtime_publication.sql b/supabase/migrations_archive/20260330103000_enable_chat_realtime_publication.sql similarity index 100% rename from supabase/migrations/20260330103000_enable_chat_realtime_publication.sql rename to supabase/migrations_archive/20260330103000_enable_chat_realtime_publication.sql diff --git a/supabase/migrations/20260330104500_fix_global_chat_membership.sql b/supabase/migrations_archive/20260330104500_fix_global_chat_membership.sql similarity index 100% rename from supabase/migrations/20260330104500_fix_global_chat_membership.sql rename to supabase/migrations_archive/20260330104500_fix_global_chat_membership.sql