diff --git a/app/admin/applications/leaderboard/page.tsx b/app/admin/applications/leaderboard/page.tsx index 8f5d608..fc06e04 100644 --- a/app/admin/applications/leaderboard/page.tsx +++ b/app/admin/applications/leaderboard/page.tsx @@ -6,6 +6,7 @@ import { UsersRoundIcon, } from "lucide-react"; import { getApplicationReviewLeaderboard } from "@/lib/queries/application-review"; +import { formatShortDateTime } from "@/lib/format/dates"; import type { ReviewLeaderboardRow } from "@/lib/types/application-reviews"; import { Card, @@ -22,16 +23,6 @@ import { Meter } from "../components/meter"; import { SummaryBar } from "../components/summary-bar"; import { AuditActivityFeed } from "./audit-activity-feed"; -function formatDate(value: string | null) { - if (!value) return "No activity yet"; - return new Date(value).toLocaleDateString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); -} - function rankIcon(index: number) { if (index === 0) { return ; @@ -87,7 +78,10 @@ function LeaderboardRow({

{row.reviewerEmail}

- Last completed {formatDate(row.lastActivityAt)} + Last completed{" "} + {row.lastActivityAt + ? formatShortDateTime(row.lastActivityAt) + : "No activity yet"}

diff --git a/app/admin/applications/review-event-timeline.tsx b/app/admin/applications/review-event-timeline.tsx index bdf70c1..e88b937 100644 --- a/app/admin/applications/review-event-timeline.tsx +++ b/app/admin/applications/review-event-timeline.tsx @@ -6,6 +6,14 @@ import type { ReviewAuditEventRecord, ReviewEventRecord, } from "@/lib/types/application-reviews"; +import { + applicationStatusBadgeClass, + reviewEventTypeBadgeClass, +} from "@/lib/utils/badge-classes"; +import { + formatShortDateTime, + formatTimelineTimestamp, +} from "@/lib/format/dates"; import { Badge } from "@/components/ui/badge"; import { ScrollArea } from "@/components/ui/scroll-area"; import { paginateSlice } from "@/lib/pagination"; @@ -31,56 +39,11 @@ function isAuditEvent(event: TimelineEvent): event is ReviewAuditEventRecord { return "applicationName" in event; } -function formatEventTimestamp(value: string, compact: true): string; -function formatEventTimestamp( - value: string, - compact?: false, -): { date: string; time: string }; -function formatEventTimestamp(value: string, compact = false) { - const date = new Date(value); - if (compact) { - return date.toLocaleString(undefined, { - month: "short", - day: "numeric", - hour: "numeric", - minute: "2-digit", - }); - } - - return { - date: date.toLocaleDateString(undefined, { - month: "short", - day: "numeric", - }), - time: date.toLocaleTimeString(undefined, { - hour: "numeric", - minute: "2-digit", - }), - }; -} - -function eventTypeBadgeClass(eventType: ReviewEventRecord["eventType"]) { - if (eventType === "review_completed") { - return "border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/50 dark:text-green-300"; - } - return "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300"; -} - function eventTypeLabel(eventType: ReviewEventRecord["eventType"]) { if (eventType === "review_completed") return "Completed"; return "Draft saved"; } -function statusBadgeClass(status: ReviewAuditEventRecord["applicationStatus"]) { - if (status === "reviewed") { - return "border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/50 dark:text-green-300"; - } - if (status === "flagged") { - return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-300"; - } - return "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300"; -} - function ChangeChip({ field, from, @@ -125,7 +88,7 @@ function ChangeChip({ function CompactReviewEventRow({ event }: { event: TimelineEvent }) { const changes = Object.entries(event.changes); - const timestamp = formatEventTimestamp(event.createdAt, true); + const timestamp = formatShortDateTime(event.createdAt); return (
@@ -133,14 +96,14 @@ function CompactReviewEventRow({ event }: { event: TimelineEvent }) {
{eventTypeLabel(event.eventType)} {isAuditEvent(event) ? ( {applicationStatusLabel(event.applicationStatus)} @@ -187,7 +150,7 @@ export function ReviewEventRow({ } const changes = Object.entries(event.changes); - const timestamp = formatEventTimestamp(event.createdAt); + const timestamp = formatTimelineTimestamp(event.createdAt); return (
@@ -220,14 +183,14 @@ export function ReviewEventRow({
{eventTypeLabel(event.eventType)} {isAuditEvent(event) ? ( {applicationStatusLabel(event.applicationStatus)} diff --git a/app/admin/applications/review-workspace.tsx b/app/admin/applications/review-workspace.tsx index 8318782..3194c1c 100644 --- a/app/admin/applications/review-workspace.tsx +++ b/app/admin/applications/review-workspace.tsx @@ -2,7 +2,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useMounted } from "@/hooks/use-mounted"; -import type { Session } from "@supabase/supabase-js"; +import { useCoalescedAsync } from "@/hooks/use-coalesced-async"; +import { useOrganizerRealtimeSession } from "@/hooks/use-organizer-realtime-session"; +import { usePrivateBroadcastChannel } from "@/hooks/use-private-broadcast-channel"; import { useDefaultLayout, type LayoutStorage } from "react-resizable-panels"; import { Controller, @@ -23,7 +25,6 @@ import { InboxIcon, ListFilterIcon, RefreshCwIcon, - SearchIcon, SmartphoneIcon, UserRoundIcon, type LucideIcon, @@ -35,10 +36,16 @@ import { markApplicationReviewed, } from "@/lib/actions/application-review.server.actions"; import { getResumeDownloadUrl } from "@/lib/actions/resume.server.actions"; +import { formatMonthDay, formatShortDate } from "@/lib/format/dates"; import { createClient } from "@/lib/supabase/client"; +import { isBenignRealtimeChannelError } from "@/lib/supabase/realtime-errors"; +import { sendPrivateBroadcast } from "@/lib/supabase/realtime-broadcast"; +import { applicationStatusBadgeClass } from "@/lib/utils/badge-classes"; import { reviewCompleteSchema, reviewDraftSchema, + REVIEW_SYNC_CHANNEL, + REVIEW_SYNC_EVENT, reviewSyncPayloadSchema, type ReviewCounts, type ReviewWorkspaceData, @@ -69,7 +76,6 @@ import { DrawerHeader, DrawerTitle, } from "@/components/ui/drawer"; -import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { ResizablePanel, ResizablePanelGroup } from "@/components/ui/resizable"; import { ScrollArea } from "@/components/ui/scroll-area"; @@ -80,6 +86,8 @@ import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; import { clampPageIndex, getPageCount, paginateSlice } from "@/lib/pagination"; import { cn } from "@/lib/utils"; import { AdminPageHeader } from "@/app/admin/components/admin-page-header"; +import { InlineWarningNotice } from "@/app/admin/components/warning-callout"; +import { SearchField } from "@/app/admin/components/search-field"; import { ApplicationDetailSkeleton, ResumePreviewSkeleton, @@ -92,8 +100,6 @@ import { } from "./display-formatters"; import { ReviewEventTimeline } from "./review-event-timeline"; -type Organizer = { id: string; email: string }; - type StatusFilter = "all" | "pending" | "reviewed" | "flagged"; type MobileView = "list" | "detail"; type SupabaseBrowserClient = ReturnType; @@ -134,17 +140,6 @@ function useIsPhoneLandscape() { return isPhoneLandscape; } -function isBenignRealtimeChannelError(error: unknown) { - if (!error) return true; - - const message = error instanceof Error ? error.message : String(error); - return ( - message.includes("socket closed: 1001") || - message.includes("socket closed") || - message.includes("Channel closed") - ); -} - type PresenceMeta = { userId: string; email: string; @@ -156,8 +151,6 @@ const REVIEW_WORKSPACE_PANEL_IDS = [ "application-detail", "scorecard", ] as const; -const REVIEW_SYNC_CHANNEL = "application-review:dashboard"; -const REVIEW_SYNC_EVENT = "review_updated"; const PANEL_LAYOUT_STORAGE: LayoutStorage = { getItem(key) { @@ -273,16 +266,6 @@ function applicantName(item: ReviewListSummaryItem | ReviewListItem) { return name || item.application.applicantEmail || "Unnamed applicant"; } -function statusClassName(status: ReviewListItem["application"]["status"]) { - if (status === "reviewed") { - return "border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/50 dark:text-green-300"; - } - if (status === "flagged") { - return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-300"; - } - return "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300"; -} - function ReviewBadge({ review }: { review: ReviewRecord | null }) { if (!review?.reviewedAt) return null; @@ -611,9 +594,8 @@ export default function ApplicationReviewWorkspace({ >(null); const [pendingApplicationSwitch, setPendingApplicationSwitch] = useState(null); - const [organizer, setOrganizer] = useState(null); - const [realtimeReady, setRealtimeReady] = useState(false); const supabase = useMemo(() => createClient(), []); + const { organizer, realtimeReady } = useOrganizerRealtimeSession(supabase); const selectedIdRef = useRef(selectedId); const serverUpdatedAt = useRef( initialSelectedDetail?.review?.updatedAt ?? @@ -666,69 +648,6 @@ export default function ApplicationReviewWorkspace({ storage: PANEL_LAYOUT_STORAGE, }); - useEffect(() => { - let cancelled = false; - - async function syncSession( - session: Session | null, - mode: "full" | "refresh", - ) { - if (!session?.access_token) { - await supabase.realtime.setAuth(null); - if (cancelled) return; - setOrganizer(null); - setRealtimeReady(false); - return; - } - - const { - data: { user }, - error, - } = await supabase.auth.getUser(); - if (cancelled || error || !user) { - await supabase.realtime.setAuth(null); - setOrganizer(null); - setRealtimeReady(false); - return; - } - - if (mode === "refresh") { - await supabase.realtime.setAuth(session.access_token); - return; - } - - setRealtimeReady(false); - await supabase.realtime.setAuth(session.access_token); - if (cancelled) return; - - setOrganizer({ id: user.id, email: user.email ?? "" }); - setRealtimeReady(true); - } - - const { - data: { subscription }, - } = supabase.auth.onAuthStateChange((event, session) => { - if (event === "INITIAL_SESSION" || event === "SIGNED_IN") { - void syncSession(session, "full"); - return; - } - - if (event === "TOKEN_REFRESHED") { - void syncSession(session, "refresh"); - return; - } - - if (event === "SIGNED_OUT") { - void syncSession(null, "full"); - } - }); - - return () => { - cancelled = true; - subscription.unsubscribe(); - }; - }, [supabase]); - useEffect(() => { selectedIdRef.current = selectedId; }, [selectedId]); @@ -845,21 +764,25 @@ export default function ApplicationReviewWorkspace({ const broadcastReviewUpdate = useCallback( async (applicationId: string) => { - await reviewSyncChannel.current?.send({ - type: "broadcast", - event: REVIEW_SYNC_EVENT, - payload: { - applicationId, - sourceUserId: organizer?.id ?? "", - }, + if (!organizer?.id) return; + + await sendPrivateBroadcast(reviewSyncChannel.current, REVIEW_SYNC_EVENT, { + applicationId, + sourceUserId: organizer.id, }); }, - [organizer?.id], + [organizer], ); const refreshReviewFromServer = useCallback( async (applicationId: string) => { - const detail = await getApplicationReviewDetail(applicationId); + const isSelected = selectedIdRef.current === applicationId; + const [detail, events] = await Promise.all([ + getApplicationReviewDetail(applicationId), + isSelected + ? getApplicationReviewEvents(applicationId) + : Promise.resolve(null), + ]); setItems((current) => current.map((item) => @@ -869,7 +792,7 @@ export default function ApplicationReviewWorkspace({ ), ); - if (selectedIdRef.current !== applicationId) return; + if (!isSelected || selectedIdRef.current !== applicationId) return; setSelectedDetail(detail); if (form.formState.isDirty) { @@ -878,14 +801,40 @@ export default function ApplicationReviewWorkspace({ applyReviewForm(detail); } - const events = await getApplicationReviewEvents(applicationId); - if (selectedIdRef.current === applicationId) { + if (events && selectedIdRef.current === applicationId) { setReviewEvents(events); } }, [applyReviewForm, form.formState.isDirty, markReviewConflict], ); + const scheduleRefreshReview = useCoalescedAsync( + async (applicationId: string) => { + try { + await refreshReviewFromServer(applicationId); + } catch (error) { + console.error( + "Unable to refresh application after review sync:", + error, + ); + } + }, + ); + + usePrivateBroadcastChannel({ + supabase, + channelName: REVIEW_SYNC_CHANNEL, + event: REVIEW_SYNC_EVENT, + payloadSchema: reviewSyncPayloadSchema, + organizerId: organizer?.id, + realtimeReady, + channelRef: reviewSyncChannel, + onRemoteMessage: (payload) => { + scheduleRefreshReview(payload.applicationId); + }, + logLabel: "review sync channel", + }); + function applyApplicationSwitch(item: ReviewListSummaryItem) { setSelectedId(item.application.id); setSelectedDetail(undefined); @@ -956,47 +905,6 @@ export default function ApplicationReviewWorkspace({ if (reviewEventsLoadedId !== null) setReviewEventsLoadedId(null); } - useEffect(() => { - if (!realtimeReady || !organizer) return; - - let active = true; - let channel: ReviewSyncChannel | null = null; - - channel = supabase.channel(REVIEW_SYNC_CHANNEL, { - config: { private: true }, - }); - if (!active) { - supabase.removeChannel(channel); - return; - } - reviewSyncChannel.current = channel; - - channel.on("broadcast", { event: REVIEW_SYNC_EVENT }, ({ payload }) => { - const parsed = reviewSyncPayloadSchema.safeParse(payload); - if (!parsed.success) return; - if (parsed.data.sourceUserId === organizer.id) return; - - void refreshReviewFromServer(parsed.data.applicationId).catch((error) => { - console.error( - "Unable to refresh application after review sync:", - error, - ); - }); - }); - - channel.subscribe((status, err) => { - if (!active || status !== "CHANNEL_ERROR") return; - if (isBenignRealtimeChannelError(err)) return; - console.error("Unable to subscribe to review sync channel:", err); - }); - - return () => { - active = false; - reviewSyncChannel.current = null; - if (channel) supabase.removeChannel(channel); - }; - }, [organizer, realtimeReady, refreshReviewFromServer, supabase]); - const clearResumeExpiryTimer = useCallback(() => { if (resumeExpiryTimer.current) { clearTimeout(resumeExpiryTimer.current); @@ -1279,15 +1187,11 @@ export default function ApplicationReviewWorkspace({ -
- - setQuery(event.target.value)} - placeholder="Search applications" - className="pl-8" - /> -
+ setQuery(event.target.value)} + />
{filteredItems.length === 0 ? ( @@ -1313,19 +1217,15 @@ export default function ApplicationReviewWorkspace({ {applicantName(item)}

- {new Date(item.application.createdAt).toLocaleDateString( - undefined, - { - month: "short", - day: "numeric", - }, - )} + {formatMonthDay(item.application.createdAt)}
{applicationStatusLabel(item.application.status)} @@ -1383,7 +1283,9 @@ export default function ApplicationReviewWorkspace({ {activeItem && ( {applicationStatusLabel(activeItem.application.status)} @@ -1406,7 +1308,7 @@ export default function ApplicationReviewWorkspace({ @@ -1417,9 +1319,7 @@ export default function ApplicationReviewWorkspace({ {selectedDetail.application.applicantEmail ?? "No applicant email"}{" "} · submitted{" "} - {new Date( - selectedDetail.application.createdAt, - ).toLocaleDateString()} + {formatShortDate(selectedDetail.application.createdAt)}

{activeReviewers.length > 0 && ( -
-
- - Currently viewing -
-

- {activeReviewers - .map((reviewer) => reviewer.email) - .join(", ")} -

-
+ + {activeReviewers.map((reviewer) => reviewer.email).join(", ")} + )}
@@ -1969,15 +1861,13 @@ function ScorecardForm({ /> {activeReviewers.length > 0 && ( -
-
- - Another organizer is here -
-

- {activeReviewers.map((reviewer) => reviewer.email).join(", ")} -

-
+ + {activeReviewers.map((reviewer) => reviewer.email).join(", ")} + )} ); } diff --git a/app/admin/components/warning-callout.tsx b/app/admin/components/warning-callout.tsx new file mode 100644 index 0000000..2f3df17 --- /dev/null +++ b/app/admin/components/warning-callout.tsx @@ -0,0 +1,58 @@ +"use client"; + +import type { LucideIcon } from "lucide-react"; +import type { ReactNode } from "react"; +import { AlertTriangleIcon } from "lucide-react"; +import { cn } from "@/lib/utils"; + +export const warningSurfaceClass = + "rounded-lg border border-amber-200 bg-amber-50 text-sm text-amber-800 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-200"; + +export function InlineWarningNotice({ + icon: Icon, + title, + children, + className, +}: { + icon: LucideIcon; + title: string; + children: ReactNode; + className?: string; +}) { + return ( +
+
+ + {title} +
+
{children}
+
+ ); +} + +export function WarningCallout({ + title, + children, + actions, +}: { + title: string; + children: ReactNode; + actions?: ReactNode; +}) { + return ( +
+
+ +
+

{title}

+
+ {children} +
+ {actions ? ( +
{actions}
+ ) : null} +
+
+
+ ); +} diff --git a/app/admin/team/loading.tsx b/app/admin/team/loading.tsx new file mode 100644 index 0000000..3e8c761 --- /dev/null +++ b/app/admin/team/loading.tsx @@ -0,0 +1,5 @@ +import { TeamManagementSkeleton } from "./team-management-skeleton"; + +export default function AdminTeamLoading() { + return ; +} diff --git a/app/admin/team/page.tsx b/app/admin/team/page.tsx new file mode 100644 index 0000000..3fd3d49 --- /dev/null +++ b/app/admin/team/page.tsx @@ -0,0 +1,9 @@ +import { listUserInvites } from "@/lib/queries/user-invitations"; +import { INVITE_PAGE_SIZE } from "@/lib/types/user-invitations"; +import TeamManagement from "./team-management"; + +export default async function AdminTeamPage() { + const invites = await listUserInvites(0, INVITE_PAGE_SIZE); + + return ; +} diff --git a/app/admin/team/team-management-skeleton.tsx b/app/admin/team/team-management-skeleton.tsx new file mode 100644 index 0000000..29b205f --- /dev/null +++ b/app/admin/team/team-management-skeleton.tsx @@ -0,0 +1,78 @@ +import { AdminPageHeaderSkeleton } from "@/app/admin/components/admin-page-header-skeleton"; +import { AdminPageShell } from "@/app/admin/components/admin-page-shell"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; + +function InviteRowSkeleton() { + return ( +
+
+
+ + + +
+ +
+ +
+ ); +} + +export function TeamManagementSkeleton() { + return ( + + + + + +
+ + +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + + +
+ + +
+ +
+ +
+ {Array.from({ length: 5 }).map((_, index) => ( + + ))} +
+
+ +
+ + + +
+
+
+
+
+ ); +} diff --git a/app/admin/team/team-management.tsx b/app/admin/team/team-management.tsx new file mode 100644 index 0000000..b7fab70 --- /dev/null +++ b/app/admin/team/team-management.tsx @@ -0,0 +1,507 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition, +} from "react"; +import { Trash2Icon, UsersRoundIcon } from "lucide-react"; +import { toast } from "sonner"; +import { + createUserInvite, + listUserInvites, + revokeUserInvite, +} from "@/lib/actions/user-invitations.server.actions"; +import type { UserRole } from "@/lib/db/schema/users"; +import { formatShortDate } from "@/lib/format/dates"; +import { + INVITE_PAGE_SIZE, + INVITE_SYNC_CHANNEL, + INVITE_SYNC_EVENT, + inviteStatus, + inviteSyncPayloadSchema, + normalizeInviteEmail, + USER_ROLE_LABELS, + type UserInviteListResult, + userInviteRoleSchema, +} from "@/lib/types/user-invitations"; +import { createClient } from "@/lib/supabase/client"; +import { sendPrivateBroadcast } from "@/lib/supabase/realtime-broadcast"; +import { inviteStatusBadgeClass } from "@/lib/utils/badge-classes"; +import { useOrganizerRealtimeSession } from "@/hooks/use-organizer-realtime-session"; +import { usePrivateBroadcastChannel } from "@/hooks/use-private-broadcast-channel"; +import { ListPagination } from "@/app/admin/applications/components/list-pagination"; +import { AdminPageHeader } from "@/app/admin/components/admin-page-header"; +import { AdminPageShell } from "@/app/admin/components/admin-page-shell"; +import { SearchField } from "@/app/admin/components/search-field"; +import { WarningCallout } from "@/app/admin/components/warning-callout"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; + +type InviteConfirmation = + | { + type: "pending-invite"; + email: string; + role: UserRole; + pendingRole: UserRole; + } + | { + type: "existing-user"; + email: string; + role: UserRole; + currentRole: UserRole; + }; + +type TeamManagementProps = { + initialInvites: UserInviteListResult; +}; + +function inviteMetadata(invite: UserInviteListResult["items"][number]) { + const status = inviteStatus(invite); + const parts = [ + `Invited by ${invite.invitedByEmail}`, + `Created ${formatShortDate(invite.createdAt)}`, + `Expires ${formatShortDate(invite.expiresAt)}`, + ]; + + if (status === "Accepted" && invite.acceptedAt) { + parts.push(`Accepted ${formatShortDate(invite.acceptedAt)}`); + } + + return parts.join(" · "); +} + +export default function TeamManagement({ + initialInvites, +}: TeamManagementProps) { + const [inviteData, setInviteData] = useState(initialInvites); + const [pageIndex, setPageIndex] = useState(0); + const [inviteEmail, setInviteEmail] = useState(""); + const [searchInput, setSearchInput] = useState(""); + const [role, setRole] = useState("organizer"); + const [isSendingInvite, startSendTransition] = useTransition(); + const [, startRefreshTransition] = useTransition(); + const [revokingInviteId, setRevokingInviteId] = useState(null); + const [inviteConfirmation, setInviteConfirmation] = + useState(null); + const skipSearchEffect = useRef(true); + const searchInputRef = useRef(searchInput); + const pageIndexRef = useRef(pageIndex); + const supabase = useMemo(() => createClient(), []); + const inviteSyncChannel = useRef | null>( + null, + ); + const { organizer, realtimeReady } = useOrganizerRealtimeSession(supabase); + + useEffect(() => { + searchInputRef.current = searchInput; + }, [searchInput]); + + useEffect(() => { + pageIndexRef.current = pageIndex; + }, [pageIndex]); + + const refreshInvites = useCallback( + async (nextPageIndex: number, query?: string) => { + const updatedInvites = await listUserInvites( + nextPageIndex, + INVITE_PAGE_SIZE, + query ?? searchInputRef.current.trim(), + ); + setInviteData(updatedInvites); + }, + [], + ); + + const broadcastInviteUpdate = useCallback(async () => { + if (!organizer?.id) return; + + await sendPrivateBroadcast(inviteSyncChannel.current, INVITE_SYNC_EVENT, { + sourceUserId: organizer.id, + }); + }, [organizer]); + + usePrivateBroadcastChannel({ + supabase, + channelName: INVITE_SYNC_CHANNEL, + event: INVITE_SYNC_EVENT, + payloadSchema: inviteSyncPayloadSchema, + organizerId: organizer?.id, + realtimeReady, + channelRef: inviteSyncChannel, + onRemoteMessage: () => { + void refreshInvites(pageIndexRef.current).catch((error) => { + console.error("Unable to refresh invites after realtime sync:", error); + }); + }, + logLabel: "invite sync channel", + }); + + useEffect(() => { + if (skipSearchEffect.current) { + skipSearchEffect.current = false; + return; + } + + const timeoutId = window.setTimeout(() => { + setPageIndex(0); + startRefreshTransition(async () => { + await refreshInvites(0, searchInput.trim()); + }); + }, 300); + + return () => window.clearTimeout(timeoutId); + }, [searchInput, refreshInvites]); + + function isOwnEmail(email: string) { + if (!organizer?.email) return false; + return ( + normalizeInviteEmail(email) === normalizeInviteEmail(organizer.email) + ); + } + + async function sendInvite( + email: string, + inviteRole: UserRole, + options?: { + replacePendingInvite?: boolean; + changeExistingUserRole?: boolean; + }, + ) { + if (isOwnEmail(email)) { + toast.error("You cannot change your own role."); + return; + } + + const result = await createUserInvite(email, inviteRole, options); + if ("ok" in result && result.ok) { + toast.success( + options?.changeExistingUserRole ? "Role updated." : "Invite sent.", + ); + setInviteEmail(""); + setPageIndex(0); + await refreshInvites(0); + void broadcastInviteUpdate(); + return; + } + + if ("existingUser" in result) { + setInviteConfirmation({ + type: "existing-user", + email, + role: inviteRole, + currentRole: result.existingUser.role, + }); + return; + } + + if ("pendingInvite" in result) { + setInviteConfirmation({ + type: "pending-invite", + email, + role: inviteRole, + pendingRole: result.pendingInvite.role, + }); + return; + } + + if ("error" in result) { + toast.error(result.error); + } + } + + function handleInviteSubmit(event: React.FormEvent) { + event.preventDefault(); + + startSendTransition(async () => { + await sendInvite(inviteEmail, role); + }); + } + + function handleConfirmExistingUserRoleChange() { + if (inviteConfirmation?.type !== "existing-user") return; + + const { email, role: inviteRole } = inviteConfirmation; + if (isOwnEmail(email)) { + setInviteConfirmation(null); + toast.error("You cannot change your own role."); + return; + } + setInviteConfirmation(null); + + startSendTransition(async () => { + await sendInvite(email, inviteRole, { changeExistingUserRole: true }); + }); + } + + function handleConfirmPendingInviteReplacement() { + if (inviteConfirmation?.type !== "pending-invite") return; + + const { email, role: inviteRole } = inviteConfirmation; + setInviteConfirmation(null); + + startSendTransition(async () => { + await sendInvite(email, inviteRole, { replacePendingInvite: true }); + }); + } + + function handlePageChange(nextPageIndex: number) { + setPageIndex(nextPageIndex); + startRefreshTransition(async () => { + await refreshInvites(nextPageIndex); + }); + } + + function handleRevokeInvite(inviteId: string) { + setRevokingInviteId(inviteId); + void (async () => { + const result = await revokeUserInvite(inviteId); + setRevokingInviteId(null); + if ("error" in result) { + toast.error(result.error); + return; + } + + toast.success("Invite revoked."); + await refreshInvites(pageIndex); + void broadcastInviteUpdate(); + })(); + } + + const isExistingUserConfirmation = + inviteConfirmation?.type === "existing-user"; + + return ( + + + + + + + + Send invite + + + Invited users sign in at{" "} + /login{" "} + with that email. Invites expire after 7 days. Pending invites apply + on first sign-in; existing accounts are prompted before their role + changes. + + + + {inviteConfirmation ? ( + + + + + } + > + {isExistingUserConfirmation ? ( + <> + + {inviteConfirmation.email} + {" "} + already has an account as{" "} + {USER_ROLE_LABELS[inviteConfirmation.currentRole]}. Change + their role to {USER_ROLE_LABELS[inviteConfirmation.role]}? + + ) : ( + <> + + {inviteConfirmation.email} + {" "} + already has a pending invite as{" "} + {USER_ROLE_LABELS[inviteConfirmation.pendingRole]}. Revoke + that invite and send a new one as{" "} + {USER_ROLE_LABELS[inviteConfirmation.role]}? + + )} + + ) : null} +
+
+ + setInviteEmail(event.target.value)} + required + /> +
+
+ + +
+
+ +
+
+
+
+ + + +
+ Invites + + Pending invites can be revoked before they are accepted or expire. + +
+ setSearchInput(event.target.value)} + aria-label="Search invites" + /> +
+ + {inviteData.totalCount === 0 ? ( +

+ {searchInput.trim() + ? "No invites match your search." + : "No invites yet."} +

+ ) : ( + <> +
+ {inviteData.items.map((invite) => { + const status = inviteStatus(invite); + + return ( +
+
+
+

+ {invite.email} +

+ + {USER_ROLE_LABELS[invite.role]} + + + {status} + +
+

+ {inviteMetadata(invite)} +

+
+ {inviteStatus(invite) === "Pending" ? ( + + ) : null} +
+ ); + })} +
+ + + )} +
+
+
+ ); +} diff --git a/docs/local-development.md b/docs/local-development.md index 9660d31..282fa48 100644 --- a/docs/local-development.md +++ b/docs/local-development.md @@ -26,13 +26,13 @@ touch the remote (e.g. `supabase config push`) — see `pnpm db:start` (= `supabase start` + env generation) boots a full Supabase stack in Docker: -| Service | URL | Purpose | -| -------- | ------------------------------- | ----------------------------------------- | -| Postgres | `127.0.0.1:54322` | the database (`DATABASE_URL` points here) | -| API/Auth | `127.0.0.1:54321` | what the auth clients talk to | -| Storage | `127.0.0.1:54321/storage/v1/s3` | S3-compatible API for resume uploads | -| Studio | `127.0.0.1:54323` | Supabase Studio — DB GUI | -| Mailpit | `127.0.0.1:54324` | catches outgoing email locally | +| Service | URL | Purpose | +| -------- | -------------------------------------- | ----------------------------------------- | +| Postgres | `127.0.0.1:54322` | the database (`DATABASE_URL` points here) | +| API/Auth | `127.0.0.1:54321` | what the auth clients talk to | +| Storage | `127.0.0.1:54321/storage/v1/s3` | S3-compatible API for resume uploads | +| Studio | `127.0.0.1:54323` | Supabase Studio — DB GUI | +| Mailpit | `127.0.0.1:54324` (UI), `54325` (SMTP) | catches outgoing email locally | ```bash pnpm db:start # boot Supabase in Docker + write .env.local @@ -82,6 +82,22 @@ and every `pnpm db:reset` (after migrations). No separate seed script is needed. To add another local bucket, add an `insert into storage.buckets` statement to `supabase/seed.sql`, run `pnpm db:reset`, and update `RESUMES_BUCKET` in `.env.local`. +### App email (local) + +Invite and role-change emails go through [`lib/aws/ses.ts`](../lib/aws/ses.ts) via +Nodemailer. The same code runs in every environment; `.env.local` sets `SMTP_HOST` so +the client uses Mailpit instead of AWS SES. Production uses `SES_ACCESS_KEY_ID` / +`SES_SECRET_ACCESS_KEY` — see [Remote development](./remote-development.md). + +**Env wiring** — [`scripts/gen-env-local.sh`](../scripts/gen-env-local.sh) writes: + +| Variable | Purpose | +| ----------- | ------------------------------- | +| `SMTP_HOST` | `127.0.0.1` — selects Mailpit | +| `SMTP_PORT` | `54325` — Supabase Mailpit SMTP | + +View captured emails at `http://127.0.0.1:54324`. + ## 2. Change the schema The schema lives in [`lib/db/schema.ts`](../lib/db/schema.ts) — Drizzle owns every diff --git a/hooks/use-coalesced-async.ts b/hooks/use-coalesced-async.ts new file mode 100644 index 0000000..15a004c --- /dev/null +++ b/hooks/use-coalesced-async.ts @@ -0,0 +1,40 @@ +"use client"; + +import { useCallback, useEffect, useRef } from "react"; + +export function useCoalescedAsync( + handler: (...args: TArgs) => Promise, +) { + const handlerRef = useRef(handler); + const stateRef = useRef({ + inFlight: false, + pendingArgs: null as TArgs | null, + }); + + useEffect(() => { + handlerRef.current = handler; + }, [handler]); + + return useCallback((...args: TArgs) => { + const run = async (runArgs: TArgs) => { + if (stateRef.current.inFlight) { + stateRef.current.pendingArgs = runArgs; + return; + } + + stateRef.current.inFlight = true; + try { + await handlerRef.current(...runArgs); + } finally { + stateRef.current.inFlight = false; + const pendingArgs = stateRef.current.pendingArgs; + if (pendingArgs) { + stateRef.current.pendingArgs = null; + void run(pendingArgs); + } + } + }; + + void run(args); + }, []); +} diff --git a/hooks/use-organizer-realtime-session.ts b/hooks/use-organizer-realtime-session.ts new file mode 100644 index 0000000..f40d6d0 --- /dev/null +++ b/hooks/use-organizer-realtime-session.ts @@ -0,0 +1,69 @@ +"use client"; + +import { useEffect, useState } from "react"; +import type { Session } from "@supabase/supabase-js"; +import { createClient } from "@/lib/supabase/client"; + +export type Organizer = { id: string; email: string }; +type SupabaseBrowserClient = ReturnType; + +export function useOrganizerRealtimeSession(supabase: SupabaseBrowserClient) { + const [organizer, setOrganizer] = useState(null); + const [realtimeReady, setRealtimeReady] = useState(false); + + useEffect(() => { + let cancelled = false; + + async function syncSession( + session: Session | null, + mode: "full" | "refresh", + ) { + if (!session?.access_token) { + await supabase.realtime.setAuth(null); + if (cancelled) return; + setOrganizer(null); + setRealtimeReady(false); + return; + } + + if (mode === "refresh") { + await supabase.realtime.setAuth(session.access_token); + return; + } + + await supabase.realtime.setAuth(session.access_token); + if (cancelled) return; + + setOrganizer({ + id: session.user.id, + email: session.user.email ?? "", + }); + setRealtimeReady(true); + } + + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange((event, session) => { + if (event === "INITIAL_SESSION" || event === "SIGNED_IN") { + void syncSession(session, "full"); + return; + } + + if (event === "TOKEN_REFRESHED") { + void syncSession(session, "refresh"); + return; + } + + if (event === "SIGNED_OUT") { + void syncSession(null, "full"); + } + }); + + return () => { + cancelled = true; + subscription.unsubscribe(); + }; + }, [supabase]); + + return { organizer, realtimeReady }; +} diff --git a/hooks/use-private-broadcast-channel.ts b/hooks/use-private-broadcast-channel.ts new file mode 100644 index 0000000..7a0da4a --- /dev/null +++ b/hooks/use-private-broadcast-channel.ts @@ -0,0 +1,80 @@ +"use client"; + +import { useEffect, useRef } from "react"; +import type { z } from "zod"; +import { createClient } from "@/lib/supabase/client"; +import { isBenignRealtimeChannelError } from "@/lib/supabase/realtime-errors"; + +type SupabaseBrowserClient = ReturnType; +type RealtimeChannel = ReturnType; + +type SyncPayload = { sourceUserId: string }; + +export function usePrivateBroadcastChannel< + TSchema extends z.ZodType, +>({ + supabase, + channelName, + event, + payloadSchema, + organizerId, + realtimeReady, + channelRef, + onRemoteMessage, + logLabel = channelName, +}: { + supabase: SupabaseBrowserClient; + channelName: string; + event: string; + payloadSchema: TSchema; + organizerId: string | undefined; + realtimeReady: boolean; + channelRef: React.MutableRefObject; + onRemoteMessage: (payload: z.infer) => void; + logLabel?: string; +}) { + const onRemoteMessageRef = useRef(onRemoteMessage); + + useEffect(() => { + onRemoteMessageRef.current = onRemoteMessage; + }, [onRemoteMessage]); + + useEffect(() => { + if (!realtimeReady || !organizerId) return; + + let active = true; + const channel = supabase.channel(channelName, { + config: { private: true }, + }); + channelRef.current = channel; + + channel.on("broadcast", { event }, ({ payload }) => { + const parsed = payloadSchema.safeParse(payload); + if (!parsed.success) return; + if (parsed.data.sourceUserId === organizerId) return; + onRemoteMessageRef.current(parsed.data); + }); + + channel.subscribe((status, err) => { + if (!active || status !== "CHANNEL_ERROR") return; + if (isBenignRealtimeChannelError(err)) return; + console.error(`Unable to subscribe to ${logLabel}:`, err); + }); + + return () => { + active = false; + channelRef.current = null; + supabase.removeChannel(channel); + }; + // channelRef is stable for the component lifetime. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ + channelName, + event, + logLabel, + organizerId, + payloadSchema, + realtimeReady, + supabase, + ]); +} diff --git a/lib/actions/auth.server.actions.ts b/lib/actions/auth.server.actions.ts index 4068fbe..5d00413 100644 --- a/lib/actions/auth.server.actions.ts +++ b/lib/actions/auth.server.actions.ts @@ -3,6 +3,7 @@ import { redirect } from "next/navigation"; import { destinationForRole } from "@/lib/auth/redirects"; import { getSessionUser } from "@/lib/auth/session"; +import { acceptPendingUserInvite } from "@/lib/queries/user-invitations"; import { createClient } from "@/lib/supabase/server"; import { getPostHogClient } from "@/lib/posthog-server"; @@ -57,6 +58,8 @@ export async function verifyOtp( if (error) return { error: error.message }; if (data.user) { + await acceptPendingUserInvite(data.user.id, email); + const posthog = getPostHogClient(); posthog.capture({ distinctId: data.user.id, diff --git a/lib/actions/user-invitations.server.actions.ts b/lib/actions/user-invitations.server.actions.ts new file mode 100644 index 0000000..5d2ec2d --- /dev/null +++ b/lib/actions/user-invitations.server.actions.ts @@ -0,0 +1,188 @@ +"use server"; + +import { eq, sql } from "drizzle-orm"; +import { z } from "zod"; +import { requireOrganizer } from "@/lib/auth/guards"; +import { db } from "@/lib/db"; +import { + pendingInviteForEmail, + userInvitations, +} from "@/lib/db/schema/user-invitations"; +import { users, type UserRole } from "@/lib/db/schema/users"; +import { + sendInviteEmail, + sendRoleChangeEmail, +} from "@/lib/email/send-invite-email"; +import { listUserInvites as listUserInvitesQuery } from "@/lib/queries/user-invitations"; +import { + type CreateUserInviteResult, + coerceInviteDate, + inviteExpiresAt, + normalizeInviteEmail, + userInviteEmailSchema, + userInviteRoleSchema, +} from "@/lib/types/user-invitations"; + +export async function listUserInvites( + pageIndex?: number, + pageSize?: number, + search?: string, +) { + return listUserInvitesQuery(pageIndex, pageSize, search); +} + +export async function createUserInvite( + email: string, + role: UserRole, + options?: { + replacePendingInvite?: boolean; + changeExistingUserRole?: boolean; + }, +): Promise { + const organizer = await requireOrganizer(); + const normalizedEmail = normalizeInviteEmail(email); + const parsedEmail = userInviteEmailSchema.safeParse(normalizedEmail); + const parsedRole = userInviteRoleSchema.safeParse(role); + if (!parsedEmail.success) { + return { error: "Please enter a valid email address." }; + } + if (!parsedRole.success) { + return { error: "Please choose a valid role." }; + } + + const inviteRole = parsedRole.data; + const expiresAt = inviteExpiresAt(); + const replacePendingInvite = options?.replacePendingInvite ?? false; + const changeExistingUserRole = options?.changeExistingUserRole ?? false; + + if (normalizeInviteEmail(organizer.email) === normalizedEmail) { + return { error: "You cannot change your own role." }; + } + + const [[existingUser], [pendingInvite]] = await Promise.all([ + db + .select({ id: users.id, role: users.role }) + .from(users) + .where(sql`lower(${users.email}) = ${normalizedEmail}`) + .limit(1), + db + .select({ + id: userInvitations.id, + role: userInvitations.role, + }) + .from(userInvitations) + .where(pendingInviteForEmail(normalizedEmail)) + .limit(1), + ]); + + if (existingUser?.role === inviteRole) { + return { error: `This user already has the ${inviteRole} role.` }; + } + + if (existingUser && !changeExistingUserRole) { + return { existingUser: { role: existingUser.role } }; + } + + if (!existingUser && pendingInvite && !replacePendingInvite) { + return { pendingInvite: { role: pendingInvite.role } }; + } + + if (existingUser) { + const acceptedAt = new Date(); + + await db.transaction(async (tx) => { + if (pendingInvite) { + await tx + .update(userInvitations) + .set({ revokedAt: new Date() }) + .where(eq(userInvitations.id, pendingInvite.id)); + } + + await tx + .update(users) + .set({ role: inviteRole }) + .where(eq(users.id, existingUser.id)); + + await tx.insert(userInvitations).values({ + email: normalizedEmail, + role: inviteRole, + invitedBy: organizer.id, + acceptedAt, + expiresAt: inviteExpiresAt(acceptedAt), + }); + }); + + void sendRoleChangeEmail(normalizedEmail, inviteRole).catch((error) => { + console.error("Failed to send role change email:", error); + }); + + return { ok: true }; + } + + if (pendingInvite) { + await db + .update(userInvitations) + .set({ revokedAt: new Date() }) + .where(eq(userInvitations.id, pendingInvite.id)); + } + + await db.insert(userInvitations).values({ + email: normalizedEmail, + role: inviteRole, + invitedBy: organizer.id, + expiresAt, + }); + + void sendInviteEmail(normalizedEmail, inviteRole, expiresAt).catch( + (error) => { + console.error("Failed to send invite email:", error); + }, + ); + + return { ok: true }; +} + +export async function revokeUserInvite( + inviteId: string, +): Promise<{ ok: true } | { error: string }> { + await requireOrganizer(); + + const parsedId = z.uuid().safeParse(inviteId); + if (!parsedId.success) { + return { error: "Invalid invite." }; + } + + const [invite] = await db + .select({ + id: userInvitations.id, + acceptedAt: userInvitations.acceptedAt, + revokedAt: userInvitations.revokedAt, + expiresAt: userInvitations.expiresAt, + }) + .from(userInvitations) + .where(eq(userInvitations.id, parsedId.data)) + .limit(1); + + if (!invite) { + return { error: "Invite not found." }; + } + + if (invite.acceptedAt) { + return { error: "Accepted invites cannot be revoked." }; + } + + if (invite.revokedAt) { + return { error: "Invite is already revoked." }; + } + + if (coerceInviteDate(invite.expiresAt).getTime() <= Date.now()) { + return { error: "Expired invites cannot be revoked." }; + } + + await db + .update(userInvitations) + .set({ revokedAt: new Date() }) + .where(eq(userInvitations.id, parsedId.data)); + + return { ok: true }; +} diff --git a/lib/admin/sections.ts b/lib/admin/sections.ts index 990f883..224b10b 100644 --- a/lib/admin/sections.ts +++ b/lib/admin/sections.ts @@ -2,6 +2,7 @@ import { BarChart3Icon, ClipboardCheckIcon, TrophyIcon, + UsersRoundIcon, type LucideIcon, } from "lucide-react"; @@ -45,4 +46,18 @@ export const ADMIN_AREAS: AdminArea[] = [ }, ], }, + { + title: "Team", + description: "Invite users and manage portal access.", + icon: UsersRoundIcon, + links: [ + { + href: "/admin/team", + title: "User invites", + description: + "Send email invitations and assign organizer or hacker roles.", + icon: UsersRoundIcon, + }, + ], + }, ]; diff --git a/lib/aws/ses.ts b/lib/aws/ses.ts new file mode 100644 index 0000000..9869971 --- /dev/null +++ b/lib/aws/ses.ts @@ -0,0 +1,64 @@ +import { SESv2Client, SendEmailCommand } from "@aws-sdk/client-sesv2"; +import nodemailer, { type Transporter } from "nodemailer"; + +const FROM_EMAIL = process.env.EMAIL_FROM ?? "hackathon@mhacks.org"; +const FROM_NAME = process.env.EMAIL_FROM_NAME ?? "MHacks Team"; + +let transporter: Transporter | null | undefined; + +function createTransporter(): Transporter | null { + const smtpHost = process.env.SMTP_HOST; + if (smtpHost) { + return nodemailer.createTransport({ + host: smtpHost, + port: Number(process.env.SMTP_PORT ?? 54325), + secure: false, + tls: { rejectUnauthorized: false }, + }); + } + + const accessKeyId = process.env.SES_ACCESS_KEY_ID; + const secretAccessKey = process.env.SES_SECRET_ACCESS_KEY; + if (!accessKeyId || !secretAccessKey) return null; + + const sesClient = new SESv2Client({ + region: process.env.SES_REGION ?? "us-east-2", + credentials: { accessKeyId, secretAccessKey }, + }); + + return nodemailer.createTransport({ + SES: { sesClient, SendEmailCommand }, + }); +} + +function getTransporter(): Transporter | null { + if (transporter === undefined) { + transporter = createTransporter(); + } + return transporter; +} + +export async function sendEmail({ + to, + subject, + text, + html, +}: { + to: string; + subject: string; + text: string; + html: string; +}) { + const mailTransporter = getTransporter(); + if (!mailTransporter) return false; + + await mailTransporter.sendMail({ + from: `${FROM_NAME} <${FROM_EMAIL}>`, + to, + subject, + text, + html, + }); + + return true; +} diff --git a/lib/db/index.ts b/lib/db/index.ts index 7b6955c..57dc4b2 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -1,6 +1,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as applicationsSchema from "./schema/applications"; +import * as userInvitationsSchema from "./schema/user-invitations"; import * as usersSchema from "./schema/users"; // Disable prefetch — prepared statements are not supported in Supabase's @@ -9,5 +10,9 @@ const client = postgres(process.env.DATABASE_URL ?? "", { prepare: false }); export const db = drizzle({ client, - schema: { ...applicationsSchema, ...usersSchema }, + schema: { + ...applicationsSchema, + ...usersSchema, + ...userInvitationsSchema, + }, }); diff --git a/lib/db/schema/realtime-policies.ts b/lib/db/schema/realtime-policies.ts index 15a4e26..bee7e70 100644 --- a/lib/db/schema/realtime-policies.ts +++ b/lib/db/schema/realtime-policies.ts @@ -12,6 +12,8 @@ const reviewRealtimeTopic = sql`( OR ${realtimeTopic} LIKE 'application-review:%' )`; +const inviteRealtimeTopic = sql`${realtimeTopic} = 'user-invites:dashboard'`; + export const organizersReceiveReviewRealtime = pgPolicy( "organizers_receive_review_realtime", { @@ -29,3 +31,21 @@ export const organizersSendReviewRealtime = pgPolicy( withCheck: sql`${isOrganizerFn} AND ${reviewRealtimeTopic}`, }, ).link(realtimeMessages); + +export const organizersReceiveInviteRealtime = pgPolicy( + "organizers_receive_invite_realtime", + { + for: "select", + to: authenticatedRole, + using: sql`${isOrganizerFn} AND ${inviteRealtimeTopic}`, + }, +).link(realtimeMessages); + +export const organizersSendInviteRealtime = pgPolicy( + "organizers_send_invite_realtime", + { + for: "insert", + to: authenticatedRole, + withCheck: sql`${isOrganizerFn} AND ${inviteRealtimeTopic}`, + }, +).link(realtimeMessages); diff --git a/lib/db/schema/user-invitations.ts b/lib/db/schema/user-invitations.ts new file mode 100644 index 0000000..93d67b0 --- /dev/null +++ b/lib/db/schema/user-invitations.ts @@ -0,0 +1,59 @@ +import { + pgTable, + pgPolicy, + uuid, + text, + timestamp, + index, +} from "drizzle-orm/pg-core"; +import { and, gt, isNull, sql } from "drizzle-orm"; +import { authenticatedRole } from "drizzle-orm/supabase"; +import { isOrganizer } from "./rls"; +import { userRole, users } from "./users"; + +export const userInvitations = pgTable( + "user_invitations", + { + id: uuid().primaryKey().defaultRandom().notNull(), + email: text().notNull(), + role: userRole().notNull(), + invitedBy: uuid("invited_by") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + acceptedAt: timestamp("accepted_at", { withTimezone: true }), + revokedAt: timestamp("revoked_at", { withTimezone: true }), + expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(), + createdAt: timestamp("created_at", { withTimezone: true }) + .defaultNow() + .notNull(), + }, + (table) => [ + index("user_invitations_email_lower_idx").on(sql`lower(${table.email})`), + index("user_invitations_created_at_idx").on(table.createdAt), + pgPolicy("user_invitations_organizer_select", { + for: "select", + to: authenticatedRole, + using: isOrganizer, + }), + pgPolicy("user_invitations_organizer_insert", { + for: "insert", + to: authenticatedRole, + withCheck: isOrganizer, + }), + pgPolicy("user_invitations_organizer_update", { + for: "update", + to: authenticatedRole, + using: isOrganizer, + withCheck: isOrganizer, + }), + ], +).enableRLS(); + +export function pendingInviteForEmail(normalizedEmail: string) { + return and( + sql`lower(${userInvitations.email}) = ${normalizedEmail}`, + isNull(userInvitations.acceptedAt), + isNull(userInvitations.revokedAt), + gt(userInvitations.expiresAt, new Date()), + ); +} diff --git a/lib/email/invite-template.ts b/lib/email/invite-template.ts new file mode 100644 index 0000000..f603680 --- /dev/null +++ b/lib/email/invite-template.ts @@ -0,0 +1,533 @@ +import type { UserRole } from "@/lib/db/schema/users"; +import { USER_ROLE_LABELS } from "@/lib/types/user-invitations"; + +const EMAIL_FONT = + "font-family: "Red Hat Display", Arial, sans-serif;"; + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderEmailSection( + content: string, + { + align, + padding = "0 40px 32px", + }: { align?: "center"; padding?: string } = {}, +) { + return ` + + ${content} + + `; +} + +function renderSignInButton(loginUrl: string, withLinkCopy = false) { + const safeLoginUrl = escapeHtml(loginUrl); + const linkCopy = withLinkCopy + ? `

+ Or copy this link into your browser:
+ + ${safeLoginUrl} + +

` + : ""; + + return ` + Sign in to MHacks + ${linkCopy}`; +} + +function renderWhatsNextSection(items: string[], withContact = false) { + const contact = withContact + ? `

+ Questions? Reach out to us anytime at + + hackathon@mhacks.org + . +

` + : ""; + + return renderEmailSection( + `

+ What's Next? +

+ +
    + ${renderListItems(items)} +
+ + ${contact} + +

+ — The MHacks Team +

`, + { padding: "0 40px 40px" }, + ); +} + +function renderEmailLayout(pageTitle: string, bodyRows: string) { + return ` + + + + + ${escapeHtml(pageTitle)} + + + + + + + + + + +
+ + + + + ${bodyRows} +
+ MHacks +
+
+ +`; +} + +export function formatInviteExpiration(expiresAt: Date) { + return expiresAt.toLocaleString(undefined, { + weekday: "long", + month: "long", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + timeZoneName: "short", + }); +} + +function roleDescription(role: UserRole) { + if (role === "organizer") { + return "You've been invited as an organizer for the MHacks review portal."; + } + + return "You've been invited to join the MHacks portal as a hacker."; +} + +function whatsNextItems(role: UserRole) { + if (role === "organizer") { + return [ + "Access the MHacks review portal", + "Review and manage hacker applications", + "Collaborate with the organizing team", + ]; + } + + return [ + "Access your MHacks dashboard", + "Apply for upcoming hackathons", + "Manage your profile and applications", + ]; +} + +function renderListItems(items: string[]) { + return items + .map( + (item, index) => + `
  • ${escapeHtml(item)}
  • `, + ) + .join(""); +} + +export function buildInviteEmail({ + role, + loginUrl, + expiresAt, +}: { + role: UserRole; + loginUrl: string; + expiresAt: Date; +}) { + const roleLabel = USER_ROLE_LABELS[role]; + const expiration = formatInviteExpiration(expiresAt); + const nextSteps = whatsNextItems(role); + + const subject = `You're invited to MHacks as ${roleLabel}`; + + const text = [ + roleDescription(role), + "", + `Role: ${roleLabel}`, + "", + "Sign in with this email address to accept your invite:", + loginUrl, + "", + "How to sign in:", + "1. Open the link above.", + "2. Confirm your email address.", + "3. Enter the 6-digit code we send you.", + "", + `This invite expires on ${expiration}.`, + "", + "Questions? Contact hackathon@mhacks.org.", + "", + "— The MHacks Team", + ].join("\n"); + + const html = renderEmailLayout( + "MHacks | You're Invited", + `${renderEmailSection( + `

    + You're Invited +

    + +

    + Join MHacks as ${escapeHtml(roleLabel)} +

    + +

    + ${escapeHtml(roleDescription(role))} +

    + +

    + Sign in with this email address to accept your invite and + access the portal. +

    `, + { padding: "0 40px" }, + )} + ${renderEmailSection( + ` + + + +
    +

    + Assigned role +

    +

    + ${escapeHtml(roleLabel)} +

    +
    `, + { align: "center", padding: "0 40px 12px" }, + )} + ${renderEmailSection(renderSignInButton(loginUrl, true), { + align: "center", + })} + ${renderEmailSection( + `

    + How to sign in +

    + +
      +
    1. + Open the sign-in link above. +
    2. +
    3. + Confirm your email address. +
    4. +
    5. + Enter the 6-digit code we email you to finish signing in. +
    6. +
    + +

    + This invite expires on + ${escapeHtml(expiration)}. +

    `, + { padding: "0 40px 40px" }, + )} + ${renderWhatsNextSection(nextSteps, true)}`, + ); + + return { subject, text, html }; +} + +export function buildRoleChangeEmail({ + role, + loginUrl, +}: { + role: UserRole; + loginUrl: string; +}) { + const roleLabel = USER_ROLE_LABELS[role]; + const nextSteps = whatsNextItems(role); + + const subject = `Your MHacks role has been updated to ${roleLabel}`; + + const text = [ + `Your MHacks portal role has been updated to ${roleLabel}.`, + "", + "Sign in to access your updated permissions:", + loginUrl, + "", + "Questions? Contact hackathon@mhacks.org.", + "", + "— The MHacks Team", + ].join("\n"); + + const html = renderEmailLayout( + "MHacks | Role Updated", + `${renderEmailSection( + `

    + Role Updated +

    + +

    + You're now ${escapeHtml(roleLabel)} +

    + +

    + Your MHacks portal role has been updated. Sign in to access + your updated permissions. +

    + +

    + ${renderSignInButton(loginUrl)} +

    `, + )} + ${renderWhatsNextSection(nextSteps)}`, + ); + + return { subject, text, html }; +} diff --git a/lib/email/send-invite-email.ts b/lib/email/send-invite-email.ts new file mode 100644 index 0000000..a8fc0c9 --- /dev/null +++ b/lib/email/send-invite-email.ts @@ -0,0 +1,54 @@ +import { sendEmail } from "@/lib/aws/ses"; +import type { UserRole } from "@/lib/db/schema/users"; +import { + buildInviteEmail, + buildRoleChangeEmail, +} from "@/lib/email/invite-template"; + +function getAppUrl() { + return ( + process.env.APP_URL ?? + (process.env.NODE_ENV === "production" + ? "https://mhacks.org" + : "http://127.0.0.1:3000") + ); +} + +async function sendOrThrow({ + to, + subject, + text, + html, +}: { + to: string; + subject: string; + text: string; + html: string; +}) { + const sent = await sendEmail({ to, subject, text, html }); + if (!sent) { + throw new Error("Email is not configured."); + } +} + +export async function sendInviteEmail( + email: string, + role: UserRole, + expiresAt: Date, +) { + const loginUrl = `${getAppUrl()}/login?email=${encodeURIComponent(email)}`; + const { subject, text, html } = buildInviteEmail({ + role, + loginUrl, + expiresAt, + }); + + await sendOrThrow({ to: email, subject, text, html }); +} + +export async function sendRoleChangeEmail(email: string, role: UserRole) { + const loginUrl = `${getAppUrl()}/login?email=${encodeURIComponent(email)}`; + const { subject, text, html } = buildRoleChangeEmail({ role, loginUrl }); + + await sendOrThrow({ to: email, subject, text, html }); +} diff --git a/lib/format/dates.ts b/lib/format/dates.ts new file mode 100644 index 0000000..e4c8b0b --- /dev/null +++ b/lib/format/dates.ts @@ -0,0 +1,41 @@ +export function coerceDate(value: Date | string) { + return value instanceof Date ? value : new Date(value); +} + +export function formatShortDate(value: Date | string) { + return coerceDate(value).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +export function formatMonthDay(value: Date | string) { + return coerceDate(value).toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }); +} + +export function formatShortDateTime(value: Date | string) { + return coerceDate(value).toLocaleString(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + }); +} + +export function formatTimelineTimestamp(value: Date | string) { + const date = coerceDate(value); + return { + date: date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + }), + time: date.toLocaleTimeString(undefined, { + hour: "numeric", + minute: "2-digit", + }), + }; +} diff --git a/lib/queries/user-invitations.ts b/lib/queries/user-invitations.ts new file mode 100644 index 0000000..746cc14 --- /dev/null +++ b/lib/queries/user-invitations.ts @@ -0,0 +1,132 @@ +import { desc, eq, ilike, sql } from "drizzle-orm"; +import { requireOrganizer } from "@/lib/auth/guards"; +import { db } from "@/lib/db"; +import { + pendingInviteForEmail, + userInvitations, +} from "@/lib/db/schema/user-invitations"; +import { users, type UserRole } from "@/lib/db/schema/users"; +import { + INVITE_PAGE_SIZE, + normalizeInviteEmail, + userInviteEmailSchema, +} from "@/lib/types/user-invitations"; + +function parseInviteEmail(email: string): string | null { + const normalizedEmail = normalizeInviteEmail(email); + if (!userInviteEmailSchema.safeParse(normalizedEmail).success) { + return null; + } + return normalizedEmail; +} + +export async function listUserInvites( + pageIndex = 0, + pageSize = INVITE_PAGE_SIZE, + search = "", +) { + await requireOrganizer(); + + const safePageIndex = Math.max(0, pageIndex); + const safePageSize = Math.min(Math.max(pageSize, 1), 50); + const trimmedSearch = search.trim().slice(0, 100); + const filters = trimmedSearch + ? ilike(userInvitations.email, `%${trimmedSearch}%`) + : undefined; + + const rows = await db + .select({ + id: userInvitations.id, + email: userInvitations.email, + role: userInvitations.role, + acceptedAt: userInvitations.acceptedAt, + revokedAt: userInvitations.revokedAt, + expiresAt: userInvitations.expiresAt, + createdAt: userInvitations.createdAt, + invitedByEmail: users.email, + totalCount: sql`count(*) over()::int`, + }) + .from(userInvitations) + .innerJoin(users, eq(userInvitations.invitedBy, users.id)) + .where(filters) + .orderBy(desc(userInvitations.createdAt)) + .limit(safePageSize) + .offset(safePageIndex * safePageSize); + + return { + items: rows.map( + ({ + id, + email, + role, + acceptedAt, + revokedAt, + expiresAt, + createdAt, + invitedByEmail, + }) => ({ + id, + email, + role, + acceptedAt, + revokedAt, + expiresAt, + createdAt, + invitedByEmail, + }), + ), + totalCount: rows[0]?.totalCount ?? 0, + }; +} + +export async function getPendingUserInvite( + email: string, +): Promise { + const normalizedEmail = parseInviteEmail(email); + if (!normalizedEmail) return null; + + const [invite] = await db + .select({ role: userInvitations.role }) + .from(userInvitations) + .where(pendingInviteForEmail(normalizedEmail)) + .limit(1); + + return invite?.role ?? null; +} + +export async function acceptPendingUserInvite( + userId: string, + email: string, +): Promise { + const normalizedEmail = parseInviteEmail(email); + if (!normalizedEmail) return null; + + const [invite] = await db + .select({ + id: userInvitations.id, + role: userInvitations.role, + }) + .from(userInvitations) + .where(pendingInviteForEmail(normalizedEmail)) + .limit(1); + + if (!invite) { + return null; + } + + const acceptedAt = new Date(); + + await db.transaction(async (tx) => { + await tx + .update(users) + .set({ role: invite.role }) + .where(eq(users.id, userId)); + + await tx + .update(userInvitations) + .set({ acceptedAt }) + .where(eq(userInvitations.id, invite.id)); + }); + + return invite.role; +} diff --git a/lib/supabase/client.ts b/lib/supabase/client.ts index e7d8fe6..12d8bbd 100644 --- a/lib/supabase/client.ts +++ b/lib/supabase/client.ts @@ -8,12 +8,3 @@ export function createClient() { process.env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!, ); } - -export async function ensureRealtimeAuth( - supabase: ReturnType, -) { - const { - data: { session }, - } = await supabase.auth.getSession(); - await supabase.realtime.setAuth(session?.access_token ?? null); -} diff --git a/lib/supabase/realtime-broadcast.ts b/lib/supabase/realtime-broadcast.ts new file mode 100644 index 0000000..53434db --- /dev/null +++ b/lib/supabase/realtime-broadcast.ts @@ -0,0 +1,16 @@ +import { createClient } from "@/lib/supabase/client"; + +type SupabaseBrowserClient = ReturnType; +type RealtimeChannel = ReturnType; + +export async function sendPrivateBroadcast( + channel: RealtimeChannel | null, + event: string, + payload: Record, +) { + await channel?.send({ + type: "broadcast", + event, + payload, + }); +} diff --git a/lib/supabase/realtime-errors.ts b/lib/supabase/realtime-errors.ts new file mode 100644 index 0000000..2867f03 --- /dev/null +++ b/lib/supabase/realtime-errors.ts @@ -0,0 +1,10 @@ +export function isBenignRealtimeChannelError(error: unknown) { + if (!error) return true; + + const message = error instanceof Error ? error.message : String(error); + return ( + message.includes("socket closed: 1001") || + message.includes("socket closed") || + message.includes("Channel closed") + ); +} diff --git a/lib/types/application-reviews.ts b/lib/types/application-reviews.ts index 36bfbf0..e675428 100644 --- a/lib/types/application-reviews.ts +++ b/lib/types/application-reviews.ts @@ -59,6 +59,9 @@ export const applicationSlugSchema = z .string() .regex(/^app_[a-f0-9]{24}$/, "Invalid application slug"); +export const REVIEW_SYNC_CHANNEL = "application-review:dashboard"; +export const REVIEW_SYNC_EVENT = "review_updated"; + export const reviewSyncPayloadSchema = z.object({ sourceUserId: z.uuid(), applicationId: z.uuid(), diff --git a/lib/types/user-invitations.ts b/lib/types/user-invitations.ts new file mode 100644 index 0000000..52efb56 --- /dev/null +++ b/lib/types/user-invitations.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; +import { coerceDate } from "@/lib/format/dates"; +import { userRole, type UserRole } from "@/lib/db/schema/users"; + +export const userInviteEmailSchema = z.email(); +export const userInviteRoleSchema = z.enum(userRole.enumValues); + +export const INVITE_PAGE_SIZE = 10; +export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; + +export const INVITE_SYNC_CHANNEL = "user-invites:dashboard"; +export const INVITE_SYNC_EVENT = "invites_updated"; + +export const inviteSyncPayloadSchema = z.object({ + sourceUserId: z.uuid(), +}); + +export const USER_ROLE_LABELS: Record = { + hacker: "Hacker", + organizer: "Organizer", +}; + +export function normalizeInviteEmail(email: string) { + return email.trim().toLowerCase(); +} + +export function inviteExpiresAt(from = new Date()) { + return new Date(from.getTime() + INVITE_TTL_MS); +} + +export { coerceDate as coerceInviteDate } from "@/lib/format/dates"; + +export type UserInviteListItem = { + id: string; + email: string; + role: UserRole; + acceptedAt: Date | null; + revokedAt: Date | null; + expiresAt: Date; + createdAt: Date; + invitedByEmail: string; +}; + +export type UserInviteListResult = { + items: UserInviteListItem[]; + totalCount: number; +}; + +export type CreateUserInviteResult = + | { ok: true } + | { error: string } + | { + pendingInvite: { + role: UserRole; + }; + } + | { + existingUser: { + role: UserRole; + }; + }; + +export function inviteStatus( + invite: Pick, +) { + if (invite.acceptedAt) return "Accepted"; + if (invite.revokedAt) return "Revoked"; + if (coerceDate(invite.expiresAt).getTime() <= Date.now()) { + return "Expired"; + } + return "Pending"; +} diff --git a/lib/utils/badge-classes.ts b/lib/utils/badge-classes.ts new file mode 100644 index 0000000..9e5ae88 --- /dev/null +++ b/lib/utils/badge-classes.ts @@ -0,0 +1,44 @@ +export type StatusBadgeVariant = "success" | "warning" | "neutral" | "info"; + +export type InviteStatusLabel = "Accepted" | "Pending" | "Revoked" | "Expired"; + +type ApplicationStatus = "pending" | "reviewed" | "flagged"; + +const INVITE_STATUS_VARIANT: Record = { + Accepted: "success", + Pending: "info", + Revoked: "neutral", + Expired: "warning", +}; + +export function statusBadgeVariantClass(variant: StatusBadgeVariant) { + switch (variant) { + case "success": + return "border-green-200 bg-green-50 text-green-700 dark:border-green-900/70 dark:bg-green-950/50 dark:text-green-300"; + case "warning": + return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-300"; + case "info": + return "border-blue-200 bg-blue-50 text-blue-700 dark:border-blue-900/70 dark:bg-blue-950/50 dark:text-blue-300"; + case "neutral": + return "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300"; + } +} + +export function inviteStatusBadgeClass(status: InviteStatusLabel) { + return statusBadgeVariantClass(INVITE_STATUS_VARIANT[status]); +} + +export function applicationStatusBadgeClass(status: ApplicationStatus) { + if (status === "reviewed") return statusBadgeVariantClass("success"); + if (status === "flagged") return statusBadgeVariantClass("warning"); + return statusBadgeVariantClass("neutral"); +} + +export function reviewEventTypeBadgeClass( + eventType: "review_completed" | "draft_saved", +) { + if (eventType === "review_completed") { + return statusBadgeVariantClass("success"); + } + return statusBadgeVariantClass("neutral"); +} diff --git a/package.json b/package.json index 1138232..323fb7d 100644 --- a/package.json +++ b/package.json @@ -24,6 +24,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1079.0", + "@aws-sdk/client-sesv2": "^3.1087.0", "@aws-sdk/s3-request-presigner": "^3.1079.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -46,6 +47,7 @@ "lucide-react": "^0.577.0", "next": "16.2.7", "next-themes": "^0.4.6", + "nodemailer": "^9.0.3", "postgres": "^3.4.9", "posthog-js": "^1.399.2", "posthog-node": "^5.41.0", @@ -66,6 +68,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.3.2", "@types/node": "^20.19.43", + "@types/nodemailer": "^8.0.1", "@types/react": "^19.2.17", "@types/react-dom": "^19.2.3", "dotenv": "^17.4.2", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b034988..0abf21b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1079.0 version: 3.1079.0 + '@aws-sdk/client-sesv2': + specifier: ^3.1087.0 + version: 3.1087.0 '@aws-sdk/s3-request-presigner': specifier: ^3.1079.0 version: 3.1079.0 @@ -77,6 +80,9 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + nodemailer: + specifier: ^9.0.3 + version: 9.0.3 postgres: specifier: ^3.4.9 version: 3.4.9 @@ -132,6 +138,9 @@ importers: '@types/node': specifier: ^20.19.43 version: 20.19.43 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -177,42 +186,82 @@ packages: resolution: {integrity: sha512-di9U/7Po7qlVYb2dq58ULsbBAE1pBIk53rux+50LQCvH1X+/l1Ys+BIk/QLBtdaK1nADk0xRNEBbA1QWVnMccw==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-sesv2@3.1087.0': + resolution: {integrity: sha512-LU7h9QOJMEO3YmhN8s5SS1qSv3K24gj6WaSYWsCKMVK6iqP+ltv+krae1aHEdBZSxRqap2Ll4hjhINB++SzukA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.974.27': resolution: {integrity: sha512-WRWEgIq6vx+NU6ot3VrRu4Jovj9MIObitSi6of/GV5THDDPccBhivCRNkWJutMM+m3GvdeI3l/UbGNcoOobxOA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.975.2': + resolution: {integrity: sha512-iyeXwziyjJpixq5OmhsIyrSWx8vwcI7gDo4yRUC3EP7NQtOo9iAJiIEc3G+/HkhtNXqOhofiCK7Lc34Sq+fJWg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.53': resolution: {integrity: sha512-+KDA3uc/HZ1vIneGu5QMQb0gAXDYrm2vOE60+BJ7lS0YinMQ5i2oV4PR1A16XkF6K1IbSwjEHd1hQIIgMsK48w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.58': + resolution: {integrity: sha512-vyGtvK1rY940eq7JT0yIGKuZ+2kpPSJcHibSvGlit5oiMFDamzC7cxBGLl4FLnd6suihMXDI2FSF2dL6TmBqPA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.55': resolution: {integrity: sha512-1gBfkWY3RWeBlCoB9lIJjXMx45/54wxcgfzv6BY9otTmMrZPcNPi1v+MwZxxaCUg441NV3jsr1efnFNCXiW70g==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.60': + resolution: {integrity: sha512-g9b9YzDrD5pcKiPBJfCSXRfFMrA39eR0guUhZ5SRm+7vMAVc43+effxbcamxBjSd5bUhrdKo5te/yQuWurLXLA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.972.60': resolution: {integrity: sha512-CV2md+PXvABwRjApWGhQ0wACy9WSFIhnUGrovLcjnjBCd/46TbuivLADtkF8IWNjtCQmQ+2IagSaxqBYqXBNAQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.2': + resolution: {integrity: sha512-Yr7yxNyQ8aHt9Ww0RPFUZx+xiem+vl7vuwhP0tniTijoesJNV5jou9HCgVpI0GEPAF+89TkOvilE5uRrZJnjaw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.59': resolution: {integrity: sha512-JG4S9yyA1GFzJdJXqLKrUzZbyK+VDp2QIsJD7YOicJHAhqymfHpDJIok2dLnhOdVB0I37RjdC53uOwCMVS00gw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.64': + resolution: {integrity: sha512-YQoSI4d6kXvoenoG/0Jv/PqaAuukHzGmGXGyHBQYeEUNsYovlNAn/Sw1wp/WQbhcQ3HsEMGgjEahvD3igz6ecQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.62': resolution: {integrity: sha512-S6Slq3Tx7bvFk5yc34XNADyZYTX2HUXvaFAnowGRQnhjBO8J/mP62Fn7lxvJwjaDyYm/7gh9h6HEHaltRyMFXw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.68': + resolution: {integrity: sha512-4akjzW9CjorByYfqXBXmYUh/h7Io3U4DtVgGGh9TQraZ7ZlyJqNyHwDRGiUFnHD+BTOeTbCesCa4sJaK7BGZ7A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.53': resolution: {integrity: sha512-EhfH+MQlqOMCkXIVa8MMObPzAQqwTTtxA7KhEJiyPeuNVA8PLOOUpgK7nBrgaDaGiIDLN/9LpGdaHuDjomeRTw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.58': + resolution: {integrity: sha512-1nYitRCaDmXWUrpBJt6WlcGjLx1JVsMY8rlYuHHsTYTSaYikbixYdQSyINN2VYq1F798uTO9qHAzytL25M8g3A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.972.59': resolution: {integrity: sha512-h8793pOjcImx0SB+VcLONcaQQ57VAvKVuqyewQMRKqqH+CSXsG2dwOeLMUJPMxLdNvL7dXOM0ueTukyNUnu5mA==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.2': + resolution: {integrity: sha512-pjMLaLU/JZi5lVfmR14V1OZqRBTuMHf6AwGNZA0K9hK+JKtO3jcLBarfD8iq5oc8cSowvc/9R32sqMVXZPo6xQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.59': resolution: {integrity: sha512-VoyO9+vl3XVmpZwn4obskrWIkrA/Jf3lSe1E3ZERlaN9u0D4YZ6+HywC3+L98QOXqZesEfedk67gRER8tK8+8w==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.64': + resolution: {integrity: sha512-7Buc7p0OvDHW7iBsu4b+YdS0WnaFBDGKDfbVQqaac9dkWiSiUtIoarBDsA1RmOVXZijaZJDoHJFIQiicQvWRlQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.58': resolution: {integrity: sha512-6uaWRRYJGhOqc9EoTSbLDf9nI/doSAb5vAwGshs5/Hlv5Ce25b246lBkbRd/77fLAi+uMI1a70mJzVyLyCEufQ==} engines: {node: '>=20.0.0'} @@ -221,6 +270,10 @@ packages: resolution: {integrity: sha512-A8PIePF9NIIOJ/4Lg1rl9xm/+QaKkHGetq+Z9wb5B+3Da31YYXRo8n7IDMh5C+HQI5eyEmjrwkGWVdYtnLtbXQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.32': + resolution: {integrity: sha512-6Yj2fr9XF67cndITea48rchTdVr3VGx6PN47bIKNinJAjLkmaIlz/4EBPCgJ8UmhVopiXmeAuPLI3+DXDDbMhQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/s3-request-presigner@3.1079.0': resolution: {integrity: sha512-NfHUaND7WyLUPkO7HCF3MFg4bdscY34A4tm4dPWa31qYzhGNZarRPr/CcRgllxzPoOSD/EHfZ4fQtZnMl2xWFg==} engines: {node: '>=20.0.0'} @@ -229,18 +282,34 @@ packages: resolution: {integrity: sha512-C379Sk+MiFZCfWZphKlMyLHKxV22OjoGM5KJjj5IJNJcOCWL4IGIpnEGzv1FQiRwhYXfq55SJMfxlqPE08JJ9g==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.40': + resolution: {integrity: sha512-wrGZ/authosokclY1DXsiWT/1WjfCI22FuZGgdcilF+XLTXs5dCjAtiFYSPsEToZkbm3Lj2YP8PoWg0yoMNu0g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1079.0': resolution: {integrity: sha512-cbietrLlHPhhmbnMPTuDS4Zj/KNGhY+3vVhn6dwjO6Dqzrwothzg2srtcY34T9mlICsTXn34avDoWLHSntP54A==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1087.0': + resolution: {integrity: sha512-umM+qNq16f2fH+VLM5MqXW4ORNQAjk+TOSto73xbUHcKaU41L48j786r3UWQYlejeJk37NlvRYgxBT+MBkfaYQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.973.15': resolution: {integrity: sha512-IULn8uBV/SMtmOIANsm4WHXIOtVPBWfOWs3WGL0j/sI+KhaYehvOw0ET+9urnn8MBpiijuU/0JOpuwKOE451PQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.1': + resolution: {integrity: sha512-W0IQZR0eaBqlBFIIofMapaWkw1W0U+Xi4dvW+BqwmCEMd8Ng2U6IhkxuPSjMVnR8klLjfuS9PeZWUl1N6UaZdg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.33': resolution: {integrity: sha512-ezbwz9WpuLctm6o7P2t2naDhVVPI5jFGrVefVybhcKGjU57VIyT46pQVO0RI2RYkUdhdj2Z9uSIlAzGZE9NW9A==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.35': + resolution: {integrity: sha512-pXzaWe3evZhjxDXAlMnqISe/XefTCGwBJG4nFTXaWSgAnMkqPEhxEPqJNhhpGesEvKFhvNpnozJJ4GTL11bRYw==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.3.0': resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} @@ -435,11 +504,11 @@ packages: '@esbuild-kit/core-utils@3.3.2': resolution: {integrity: sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild-kit/esm-loader@2.6.5': resolution: {integrity: sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA==} - deprecated: 'Merged into tsx: https://tsx.is' + deprecated: 'Merged into tsx: https://tsx.hirok.io' '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} @@ -1967,26 +2036,50 @@ packages: resolution: {integrity: sha512-qoiY4nrk5OCu1+eIR1VB8l5DmON/oKiqrd5zZFAhXJXjJlLWQusKEW/SkBDAtGDcPaz86m9kfcE1lngU0GlM6A==} engines: {node: '>=18.0.0'} + '@smithy/core@3.29.4': + resolution: {integrity: sha512-G1GRglAabzEhqghJMBAd54FkRS7SAFGHEwbhcI9r+O+LIMuFsLyXkLZkCoFSgAglRu8s/URVXJB0hglq3ZipIg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.6': resolution: {integrity: sha512-B2WQ/PV/H6Jeg3lrIq6bKUfa6Hy01mtK7CGs6lhjzHA6k4aagldH6T6eEjnzKl4HI0cJnAsxfJ19pgb5PV+CVQ==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.9': + resolution: {integrity: sha512-2nfV4qRKiYeXU4zD2vvSCfg5dfp/BuhrM73vt7q9gzBhxs4rbPxXY21wo+kyI3bRmXcEGRnCLTaW8O437jzHIg==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.3': resolution: {integrity: sha512-CwCc/7SMTj45y97MUnDTbTaxvtAsiNNRm81z3abROIuMbMsC2Iy5EKfkkVdsKrz8WExQAAMx1EJapq+9j4fFTQ==} engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.6': + resolution: {integrity: sha512-NHLgAlORUFZjn5ZfhYuyyKMlXA1WLYOdGxEhyNxrPpbJzoacGbl0chn1lN2KiZ8mpNVk0tV5607CSYlYs/OFgw==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.3': resolution: {integrity: sha512-qZTa4gQFUo8RM02rk6q5UVTDLNrQ1oS20LsepBzqq1QBVc/EHJ03OOUADcqMZiXHArW+Y7+OGY0BpdTwZRq/Yg==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.6': + resolution: {integrity: sha512-odd+HYx3OLcXRSEz0ZeF3JQdSYdK8QnRgA2N87cPW7coWIbKfRk7a9VQjfeWQLqnzrDLk23KMEn46p8N7M/JFg==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.2': resolution: {integrity: sha512-QgHflghMoPxCJ9axiCVh8KZfbC9fuP6vkXXyK//E3cq7nLaSSyyLj0GAoqVWezYeDQmXIZhmlRvLE16jsqDK6g==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.5': + resolution: {integrity: sha512-MO5VEhwVl0BN7xVoVeNrZfiUFoQtqxUbgl6/RwOTlMMxCSjblG8twSrVTwz3J4w9WZxd2rBfBAUXjH77agspBg==} + engines: {node: '>=18.0.0'} + '@smithy/types@4.15.1': resolution: {integrity: sha512-x3L0XSACF6UYzKpa9biqiRMgvH5+wnFFew9Tm/grFYqgaupPwx/+ojDPpPJM8dZON3S9tjz5U+PQYsCBd1Mw5Q==} engines: {node: '>=18.0.0'} + '@smithy/types@4.16.1': + resolution: {integrity: sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg==} + engines: {node: '>=18.0.0'} + '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} @@ -2220,6 +2313,9 @@ packages: '@types/node@20.19.43': resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==} + '@types/nodemailer@8.0.1': + resolution: {integrity: sha512-PxpaInm8V1JQDd4j0ds5HfvWQk8JupS1C0Picb96QJsrrRDjBH+DlK7L4ZdNSqNULhiZRQHc40nLVShaGxXAMw==} + '@types/react-dom@19.2.3': resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==} peerDependencies: @@ -4034,6 +4130,10 @@ packages: resolution: {integrity: sha512-J6l92tKHX6w8Jy5nO1Vuc01NoIiRGi/d6qBKVxh+IQ8Cr3b6HbVNfKiF8ZpFKufTwpwxMmce2W3iQZ861ZRyTg==} engines: {node: '>=18'} + nodemailer@9.0.3: + resolution: {integrity: sha512-n+YP+NKwR5zRWa60k3GiQ6Q3B4KXCoAw40dAKeCtYn020iNN74aWK2liXIC3ZEATeGql7we3tE3t8QwhY0eskw==} + engines: {node: '>=6.0.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -4915,6 +5015,18 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/client-sesv2@3.1087.0': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/credential-provider-node': 3.972.68 + '@aws-sdk/signature-v4-multi-region': 3.996.40 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/fetch-http-handler': 5.6.6 + '@smithy/node-http-handler': 4.9.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/core@3.974.27': dependencies: '@aws-sdk/types': 3.973.15 @@ -4926,6 +5038,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.975.2': + dependencies: + '@aws-sdk/types': 3.974.1 + '@aws-sdk/xml-builder': 3.972.35 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.29.4 + '@smithy/signature-v4': 5.6.5 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.53': dependencies: '@aws-sdk/core': 3.974.27 @@ -4934,6 +5057,14 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.58': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.55': dependencies: '@aws-sdk/core': 3.974.27 @@ -4944,6 +5075,16 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.60': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/fetch-http-handler': 5.6.6 + '@smithy/node-http-handler': 4.9.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.972.60': dependencies: '@aws-sdk/core': 3.974.27 @@ -4960,13 +5101,38 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.2': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/credential-provider-env': 3.972.58 + '@aws-sdk/credential-provider-http': 3.972.60 + '@aws-sdk/credential-provider-login': 3.972.64 + '@aws-sdk/credential-provider-process': 3.972.58 + '@aws-sdk/credential-provider-sso': 3.973.2 + '@aws-sdk/credential-provider-web-identity': 3.972.64 + '@aws-sdk/nested-clients': 3.997.32 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/credential-provider-imds': 4.4.9 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.59': dependencies: - '@aws-sdk/core': 3.974.27 + '@aws-sdk/core': 3.975.2 '@aws-sdk/nested-clients': 3.997.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-login@3.972.64': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/nested-clients': 3.997.32 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/credential-provider-node@3.972.62': @@ -4983,6 +5149,20 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.68': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.58 + '@aws-sdk/credential-provider-http': 3.972.60 + '@aws-sdk/credential-provider-ini': 3.973.2 + '@aws-sdk/credential-provider-process': 3.972.58 + '@aws-sdk/credential-provider-sso': 3.973.2 + '@aws-sdk/credential-provider-web-identity': 3.972.64 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/credential-provider-imds': 4.4.9 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.53': dependencies: '@aws-sdk/core': 3.974.27 @@ -4991,6 +5171,14 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.58': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.972.59': dependencies: '@aws-sdk/core': 3.974.27 @@ -5001,6 +5189,16 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.2': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/nested-clients': 3.997.32 + '@aws-sdk/token-providers': 3.1087.0 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.59': dependencies: '@aws-sdk/core': 3.974.27 @@ -5010,6 +5208,15 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.64': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/nested-clients': 3.997.32 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.58': dependencies: '@aws-sdk/core': 3.974.27 @@ -5021,13 +5228,24 @@ snapshots: '@aws-sdk/nested-clients@3.997.27': dependencies: - '@aws-sdk/core': 3.974.27 - '@aws-sdk/signature-v4-multi-region': 3.996.38 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/fetch-http-handler': 5.6.3 - '@smithy/node-http-handler': 4.9.3 - '@smithy/types': 4.15.1 + '@aws-sdk/core': 3.975.2 + '@aws-sdk/signature-v4-multi-region': 3.996.40 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/fetch-http-handler': 5.6.6 + '@smithy/node-http-handler': 4.9.6 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/nested-clients@3.997.32': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/signature-v4-multi-region': 3.996.40 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/fetch-http-handler': 5.6.6 + '@smithy/node-http-handler': 4.9.6 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/s3-request-presigner@3.1079.0': @@ -5046,13 +5264,29 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.40': + dependencies: + '@aws-sdk/types': 3.974.1 + '@smithy/signature-v4': 5.6.5 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1079.0': dependencies: - '@aws-sdk/core': 3.974.27 + '@aws-sdk/core': 3.975.2 '@aws-sdk/nested-clients': 3.997.27 - '@aws-sdk/types': 3.973.15 - '@smithy/core': 3.29.1 - '@smithy/types': 4.15.1 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/token-providers@3.1087.0': + dependencies: + '@aws-sdk/core': 3.975.2 + '@aws-sdk/nested-clients': 3.997.32 + '@aws-sdk/types': 3.974.1 + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 tslib: 2.8.1 '@aws-sdk/types@3.973.15': @@ -5060,11 +5294,21 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/types@3.974.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.33': dependencies: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.35': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.3.0': {} '@babel/code-frame@7.29.7': @@ -6630,34 +6874,67 @@ snapshots: '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/core@3.29.4': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.6': dependencies: '@smithy/core': 3.29.1 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.9': + dependencies: + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.3': dependencies: '@smithy/core': 3.29.1 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.6': + dependencies: + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/node-http-handler@4.9.3': dependencies: '@smithy/core': 3.29.1 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.9.6': + dependencies: + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/signature-v4@5.6.2': dependencies: '@smithy/core': 3.29.1 '@smithy/types': 4.15.1 tslib: 2.8.1 + '@smithy/signature-v4@5.6.5': + dependencies: + '@smithy/core': 3.29.4 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/types@4.15.1': dependencies: tslib: 2.8.1 + '@smithy/types@4.16.1': + dependencies: + tslib: 2.8.1 + '@standard-schema/spec@1.1.0': {} '@standard-schema/utils@0.3.0': {} @@ -6849,6 +7126,10 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 20.19.43 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -8668,6 +8949,8 @@ snapshots: node-releases@2.0.50: {} + nodemailer@9.0.3: {} + npm-run-path@4.0.1: dependencies: path-key: 3.1.1 diff --git a/scripts/gen-env-local.sh b/scripts/gen-env-local.sh index 4a66398..af52763 100755 --- a/scripts/gen-env-local.sh +++ b/scripts/gen-env-local.sh @@ -23,7 +23,7 @@ PUBLISHABLE_KEY=$(get_var PUBLISHABLE_KEY) S3_ACCESS_KEY=$(get_var S3_PROTOCOL_ACCESS_KEY_ID) S3_SECRET_KEY=$(get_var S3_PROTOCOL_ACCESS_KEY_SECRET) S3_ENDPOINT=$(get_var STORAGE_S3_URL) -S3_REGION=$(get_var S3_PROTOCOL_REGION) +SERVICE_ROLE_KEY=$(get_var SERVICE_ROLE_KEY) missing=() [[ -z "$DB_URL" ]] && missing+=("DATABASE_URL") @@ -32,7 +32,6 @@ missing=() [[ -z "$S3_ACCESS_KEY" ]] && missing+=("S3_PROTOCOL_ACCESS_KEY_ID") [[ -z "$S3_SECRET_KEY" ]] && missing+=("S3_PROTOCOL_ACCESS_KEY_SECRET") [[ -z "$S3_ENDPOINT" ]] && missing+=("STORAGE_S3_URL") -[[ -z "$S3_REGION" ]] && missing+=("S3_PROTOCOL_REGION") if [[ ${#missing[@]} -gt 0 ]]; then echo "Missing from supabase status: ${missing[*]}" >&2 @@ -45,13 +44,18 @@ cat > .env.local < statement-breakpoint +ALTER TABLE "user_invitations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "user_invitations" ADD CONSTRAINT "user_invitations_invited_by_users_id_fk" FOREIGN KEY ("invited_by") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE POLICY "user_invitations_organizer_select" ON "user_invitations" AS PERMISSIVE FOR SELECT TO "authenticated" USING (exists ( + select 1 + from public.users + where id = (select auth.uid()) + and role = 'organizer' +));--> statement-breakpoint +CREATE POLICY "user_invitations_organizer_insert" ON "user_invitations" AS PERMISSIVE FOR INSERT TO "authenticated" WITH CHECK (exists ( + select 1 + from public.users + where id = (select auth.uid()) + and role = 'organizer' +));--> statement-breakpoint +CREATE POLICY "user_invitations_organizer_update" ON "user_invitations" AS PERMISSIVE FOR UPDATE TO "authenticated" USING (exists ( + select 1 + from public.users + where id = (select auth.uid()) + and role = 'organizer' +)) WITH CHECK (exists ( + select 1 + from public.users + where id = (select auth.uid()) + and role = 'organizer' +)); diff --git a/supabase/migrations/20260716021813_handle_new_user_invites.sql b/supabase/migrations/20260716021813_handle_new_user_invites.sql new file mode 100644 index 0000000..911ce59 --- /dev/null +++ b/supabase/migrations/20260716021813_handle_new_user_invites.sql @@ -0,0 +1,32 @@ +CREATE OR REPLACE FUNCTION "public"."handle_new_user"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET search_path = public + AS $$ +DECLARE + invited_role public.user_role; +BEGIN + SELECT ui.role INTO invited_role + FROM public.user_invitations ui + WHERE lower(ui.email) = lower(new.email) + AND ui.accepted_at IS NULL + AND ui.revoked_at IS NULL + AND ui.expires_at > now() + ORDER BY ui.created_at DESC + LIMIT 1; + + INSERT INTO public.users (id, email, role) + VALUES (new.id, new.email, COALESCE(invited_role, 'hacker')) + ON CONFLICT (id) DO NOTHING; + + IF invited_role IS NOT NULL THEN + UPDATE public.user_invitations + SET accepted_at = now() + WHERE lower(email) = lower(new.email) + AND accepted_at IS NULL + AND revoked_at IS NULL + AND expires_at > now(); + END IF; + + RETURN new; +END; +$$; diff --git a/supabase/migrations/20260716021900_defer_invite_acceptance.sql b/supabase/migrations/20260716021900_defer_invite_acceptance.sql new file mode 100644 index 0000000..fbbf494 --- /dev/null +++ b/supabase/migrations/20260716021900_defer_invite_acceptance.sql @@ -0,0 +1,12 @@ +CREATE OR REPLACE FUNCTION "public"."handle_new_user"() RETURNS "trigger" + LANGUAGE "plpgsql" SECURITY DEFINER + SET search_path = public + AS $$ +BEGIN + INSERT INTO public.users (id, email) + VALUES (new.id, new.email) + ON CONFLICT (id) DO NOTHING; + + RETURN new; +END; +$$; diff --git a/supabase/migrations/20260716031350_user_invites_realtime.sql b/supabase/migrations/20260716031350_user_invites_realtime.sql new file mode 100644 index 0000000..8a3a484 --- /dev/null +++ b/supabase/migrations/20260716031350_user_invites_realtime.sql @@ -0,0 +1,2 @@ +CREATE POLICY "organizers_receive_invite_realtime" ON "realtime"."messages" AS PERMISSIVE FOR SELECT TO "authenticated" USING (public.is_organizer() AND realtime.topic() = 'user-invites:dashboard');--> statement-breakpoint +CREATE POLICY "organizers_send_invite_realtime" ON "realtime"."messages" AS PERMISSIVE FOR INSERT TO "authenticated" WITH CHECK (public.is_organizer() AND realtime.topic() = 'user-invites:dashboard'); \ No newline at end of file diff --git a/supabase/migrations/20260717043600_user_invitations_indexes.sql b/supabase/migrations/20260717043600_user_invitations_indexes.sql new file mode 100644 index 0000000..65cc557 --- /dev/null +++ b/supabase/migrations/20260717043600_user_invitations_indexes.sql @@ -0,0 +1,3 @@ +CREATE INDEX "user_invitations_email_lower_idx" ON "user_invitations" (lower("email")); +--> statement-breakpoint +CREATE INDEX "user_invitations_created_at_idx" ON "user_invitations" ("created_at" DESC); diff --git a/supabase/migrations/meta/20260716021812_snapshot.json b/supabase/migrations/meta/20260716021812_snapshot.json new file mode 100644 index 0000000..b77eab0 --- /dev/null +++ b/supabase/migrations/meta/20260716021812_snapshot.json @@ -0,0 +1,865 @@ +{ + "id": "78026ecd-933a-4275-bab0-1df3cc1dfc81", + "prevId": "61cf0084-c212-43e7-9983-13dea93ce17b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.hacker_applicants": { + "name": "hacker_applicants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ethnicity": { + "name": "ethnicity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "university": { + "name": "university", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "degree": { + "name": "degree", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "graduation_year": { + "name": "graduation_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_hackathons": { + "name": "previous_hackathons", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume": { + "name": "resume", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "what_would_you_do": { + "name": "what_would_you_do", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why_mhacks": { + "name": "why_mhacks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hill_to_die_on": { + "name": "hill_to_die_on", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anything_else": { + "name": "anything_else", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transportation_type": { + "name": "transportation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coming_from": { + "name": "coming_from", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shirt_size": { + "name": "shirt_size", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergies_description": { + "name": "allergies_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "needs_travel_reimbursement": { + "name": "needs_travel_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "would_attend_without_reimbursement": { + "name": "would_attend_without_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "airport_code": { + "name": "airport_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github": { + "name": "github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin": { + "name": "linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_site": { + "name": "personal_site", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follows_instagram": { + "name": "follows_instagram", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "sponsor_emails": { + "name": "sponsor_emails", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_applicants_user_id_users_id_fk": { + "name": "hacker_applicants_user_id_users_id_fk", + "tableFrom": "hacker_applicants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_applicants_user_id_unique": { + "name": "hacker_applicants_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "hacker_applicants_select_own_or_organizer": { + "name": "hacker_applicants_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_applicants\".\"user_id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_applicants_update_organizer": { + "name": "hacker_applicants_update_organizer", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_drafts": { + "name": "hacker_application_drafts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_drafts_user_id_fkey": { + "name": "hacker_application_drafts_user_id_fkey", + "tableFrom": "hacker_application_drafts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_drafts_select_own": { + "name": "hacker_application_drafts_select_own", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_insert_own": { + "name": "hacker_application_drafts_insert_own", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_update_own": { + "name": "hacker_application_drafts_update_own", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())", + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_delete_own": { + "name": "hacker_application_drafts_delete_own", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_review_events": { + "name": "hacker_application_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "review_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hacker_application_review_events_application_id_created_at_idx": { + "name": "hacker_application_review_events_application_id_created_at_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hacker_application_review_events_review_id_fkey": { + "name": "hacker_application_review_events_review_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_application_reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_application_id_fkey": { + "name": "hacker_application_review_events_application_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_reviewer_user_id_fkey": { + "name": "hacker_application_review_events_reviewer_user_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_review_events_organizer_select": { + "name": "hacker_application_review_events_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_review_events_organizer_insert": { + "name": "hacker_application_review_events_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_reviews": { + "name": "hacker_application_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effort_rating": { + "name": "effort_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "builder_rating": { + "name": "builder_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "flagged_for_review": { + "name": "flagged_for_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "review_comments": { + "name": "review_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_reviews_application_id_fkey": { + "name": "hacker_application_reviews_application_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_reviews_reviewer_user_id_fkey": { + "name": "hacker_application_reviews_reviewer_user_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_application_reviews_application_id_unique": { + "name": "hacker_application_reviews_application_id_unique", + "nullsNotDistinct": false, + "columns": [ + "application_id" + ] + } + }, + "policies": { + "hacker_application_reviews_organizer_select": { + "name": "hacker_application_reviews_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_insert": { + "name": "hacker_application_reviews_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_update": { + "name": "hacker_application_reviews_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_invitations": { + "name": "user_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_invitations_invited_by_users_id_fk": { + "name": "user_invitations_invited_by_users_id_fk", + "tableFrom": "user_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "user_invitations_organizer_select": { + "name": "user_invitations_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "user_invitations_organizer_insert": { + "name": "user_invitations_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "user_invitations_organizer_update": { + "name": "user_invitations_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'hacker'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": { + "users_select_own_or_organizer": { + "name": "users_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"users\".\"id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "pending", + "reviewed", + "flagged" + ] + }, + "public.review_event_type": { + "name": "review_event_type", + "schema": "public", + "values": [ + "draft_saved", + "review_completed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "hacker", + "organizer" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": { + "organizers_receive_review_realtime": { + "name": "organizers_receive_review_realtime", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + }, + "organizers_send_review_realtime": { + "name": "organizers_send_review_realtime", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + } + }, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/20260716021813_snapshot.json b/supabase/migrations/meta/20260716021813_snapshot.json new file mode 100644 index 0000000..c8d285e --- /dev/null +++ b/supabase/migrations/meta/20260716021813_snapshot.json @@ -0,0 +1,865 @@ +{ + "id": "3c09e909-1ddd-49ee-b7aa-a5384034704c", + "prevId": "78026ecd-933a-4275-bab0-1df3cc1dfc81", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.hacker_applicants": { + "name": "hacker_applicants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ethnicity": { + "name": "ethnicity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "university": { + "name": "university", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "degree": { + "name": "degree", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "graduation_year": { + "name": "graduation_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_hackathons": { + "name": "previous_hackathons", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume": { + "name": "resume", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "what_would_you_do": { + "name": "what_would_you_do", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why_mhacks": { + "name": "why_mhacks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hill_to_die_on": { + "name": "hill_to_die_on", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anything_else": { + "name": "anything_else", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transportation_type": { + "name": "transportation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coming_from": { + "name": "coming_from", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shirt_size": { + "name": "shirt_size", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergies_description": { + "name": "allergies_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "needs_travel_reimbursement": { + "name": "needs_travel_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "would_attend_without_reimbursement": { + "name": "would_attend_without_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "airport_code": { + "name": "airport_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github": { + "name": "github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin": { + "name": "linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_site": { + "name": "personal_site", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follows_instagram": { + "name": "follows_instagram", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "sponsor_emails": { + "name": "sponsor_emails", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_applicants_user_id_users_id_fk": { + "name": "hacker_applicants_user_id_users_id_fk", + "tableFrom": "hacker_applicants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_applicants_user_id_unique": { + "name": "hacker_applicants_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "hacker_applicants_select_own_or_organizer": { + "name": "hacker_applicants_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_applicants\".\"user_id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_applicants_update_organizer": { + "name": "hacker_applicants_update_organizer", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_drafts": { + "name": "hacker_application_drafts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_drafts_user_id_fkey": { + "name": "hacker_application_drafts_user_id_fkey", + "tableFrom": "hacker_application_drafts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_drafts_select_own": { + "name": "hacker_application_drafts_select_own", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_insert_own": { + "name": "hacker_application_drafts_insert_own", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_update_own": { + "name": "hacker_application_drafts_update_own", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())", + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_delete_own": { + "name": "hacker_application_drafts_delete_own", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_review_events": { + "name": "hacker_application_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "review_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hacker_application_review_events_application_id_created_at_idx": { + "name": "hacker_application_review_events_application_id_created_at_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hacker_application_review_events_review_id_fkey": { + "name": "hacker_application_review_events_review_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_application_reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_application_id_fkey": { + "name": "hacker_application_review_events_application_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_reviewer_user_id_fkey": { + "name": "hacker_application_review_events_reviewer_user_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_review_events_organizer_select": { + "name": "hacker_application_review_events_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_review_events_organizer_insert": { + "name": "hacker_application_review_events_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_reviews": { + "name": "hacker_application_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effort_rating": { + "name": "effort_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "builder_rating": { + "name": "builder_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "flagged_for_review": { + "name": "flagged_for_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "review_comments": { + "name": "review_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_reviews_application_id_fkey": { + "name": "hacker_application_reviews_application_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_reviews_reviewer_user_id_fkey": { + "name": "hacker_application_reviews_reviewer_user_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_application_reviews_application_id_unique": { + "name": "hacker_application_reviews_application_id_unique", + "nullsNotDistinct": false, + "columns": [ + "application_id" + ] + } + }, + "policies": { + "hacker_application_reviews_organizer_select": { + "name": "hacker_application_reviews_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_insert": { + "name": "hacker_application_reviews_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_update": { + "name": "hacker_application_reviews_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_invitations": { + "name": "user_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_invitations_invited_by_users_id_fk": { + "name": "user_invitations_invited_by_users_id_fk", + "tableFrom": "user_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "user_invitations_organizer_select": { + "name": "user_invitations_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "user_invitations_organizer_insert": { + "name": "user_invitations_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "user_invitations_organizer_update": { + "name": "user_invitations_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'hacker'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": { + "users_select_own_or_organizer": { + "name": "users_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"users\".\"id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "pending", + "reviewed", + "flagged" + ] + }, + "public.review_event_type": { + "name": "review_event_type", + "schema": "public", + "values": [ + "draft_saved", + "review_completed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "hacker", + "organizer" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": { + "organizers_receive_review_realtime": { + "name": "organizers_receive_review_realtime", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + }, + "organizers_send_review_realtime": { + "name": "organizers_send_review_realtime", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + } + }, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/supabase/migrations/meta/20260716021900_snapshot.json b/supabase/migrations/meta/20260716021900_snapshot.json new file mode 100644 index 0000000..8a4c1bc --- /dev/null +++ b/supabase/migrations/meta/20260716021900_snapshot.json @@ -0,0 +1,865 @@ +{ + "id": "eda4b6b4-aa68-4623-9268-084a8cec8f14", + "prevId": "3c09e909-1ddd-49ee-b7aa-a5384034704c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.hacker_applicants": { + "name": "hacker_applicants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ethnicity": { + "name": "ethnicity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "university": { + "name": "university", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "degree": { + "name": "degree", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "graduation_year": { + "name": "graduation_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_hackathons": { + "name": "previous_hackathons", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume": { + "name": "resume", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "what_would_you_do": { + "name": "what_would_you_do", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why_mhacks": { + "name": "why_mhacks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hill_to_die_on": { + "name": "hill_to_die_on", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anything_else": { + "name": "anything_else", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transportation_type": { + "name": "transportation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coming_from": { + "name": "coming_from", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shirt_size": { + "name": "shirt_size", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergies_description": { + "name": "allergies_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "needs_travel_reimbursement": { + "name": "needs_travel_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "would_attend_without_reimbursement": { + "name": "would_attend_without_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "airport_code": { + "name": "airport_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github": { + "name": "github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin": { + "name": "linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_site": { + "name": "personal_site", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follows_instagram": { + "name": "follows_instagram", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "sponsor_emails": { + "name": "sponsor_emails", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_applicants_user_id_users_id_fk": { + "name": "hacker_applicants_user_id_users_id_fk", + "tableFrom": "hacker_applicants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_applicants_user_id_unique": { + "name": "hacker_applicants_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "hacker_applicants_select_own_or_organizer": { + "name": "hacker_applicants_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_applicants\".\"user_id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_applicants_update_organizer": { + "name": "hacker_applicants_update_organizer", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_drafts": { + "name": "hacker_application_drafts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_drafts_user_id_fkey": { + "name": "hacker_application_drafts_user_id_fkey", + "tableFrom": "hacker_application_drafts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_drafts_select_own": { + "name": "hacker_application_drafts_select_own", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_insert_own": { + "name": "hacker_application_drafts_insert_own", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_update_own": { + "name": "hacker_application_drafts_update_own", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())", + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_delete_own": { + "name": "hacker_application_drafts_delete_own", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_review_events": { + "name": "hacker_application_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "review_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hacker_application_review_events_application_id_created_at_idx": { + "name": "hacker_application_review_events_application_id_created_at_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hacker_application_review_events_review_id_fkey": { + "name": "hacker_application_review_events_review_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_application_reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_application_id_fkey": { + "name": "hacker_application_review_events_application_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_reviewer_user_id_fkey": { + "name": "hacker_application_review_events_reviewer_user_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_review_events_organizer_select": { + "name": "hacker_application_review_events_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_review_events_organizer_insert": { + "name": "hacker_application_review_events_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_reviews": { + "name": "hacker_application_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effort_rating": { + "name": "effort_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "builder_rating": { + "name": "builder_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "flagged_for_review": { + "name": "flagged_for_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "review_comments": { + "name": "review_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_reviews_application_id_fkey": { + "name": "hacker_application_reviews_application_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_reviews_reviewer_user_id_fkey": { + "name": "hacker_application_reviews_reviewer_user_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_application_reviews_application_id_unique": { + "name": "hacker_application_reviews_application_id_unique", + "nullsNotDistinct": false, + "columns": [ + "application_id" + ] + } + }, + "policies": { + "hacker_application_reviews_organizer_select": { + "name": "hacker_application_reviews_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_insert": { + "name": "hacker_application_reviews_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_update": { + "name": "hacker_application_reviews_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_invitations": { + "name": "user_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_invitations_invited_by_users_id_fk": { + "name": "user_invitations_invited_by_users_id_fk", + "tableFrom": "user_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "user_invitations_organizer_select": { + "name": "user_invitations_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "user_invitations_organizer_insert": { + "name": "user_invitations_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "user_invitations_organizer_update": { + "name": "user_invitations_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'hacker'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": { + "users_select_own_or_organizer": { + "name": "users_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"users\".\"id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "pending", + "reviewed", + "flagged" + ] + }, + "public.review_event_type": { + "name": "review_event_type", + "schema": "public", + "values": [ + "draft_saved", + "review_completed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "hacker", + "organizer" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": { + "organizers_receive_review_realtime": { + "name": "organizers_receive_review_realtime", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + }, + "organizers_send_review_realtime": { + "name": "organizers_send_review_realtime", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + } + }, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/supabase/migrations/meta/20260716031350_snapshot.json b/supabase/migrations/meta/20260716031350_snapshot.json new file mode 100644 index 0000000..a5af660 --- /dev/null +++ b/supabase/migrations/meta/20260716031350_snapshot.json @@ -0,0 +1,887 @@ +{ + "id": "e7b65b9f-d89d-43f8-84a1-6e00381d0662", + "prevId": "eda4b6b4-aa68-4623-9268-084a8cec8f14", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.hacker_applicants": { + "name": "hacker_applicants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ethnicity": { + "name": "ethnicity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "university": { + "name": "university", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "degree": { + "name": "degree", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "graduation_year": { + "name": "graduation_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_hackathons": { + "name": "previous_hackathons", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume": { + "name": "resume", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "what_would_you_do": { + "name": "what_would_you_do", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why_mhacks": { + "name": "why_mhacks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hill_to_die_on": { + "name": "hill_to_die_on", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anything_else": { + "name": "anything_else", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transportation_type": { + "name": "transportation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coming_from": { + "name": "coming_from", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shirt_size": { + "name": "shirt_size", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergies_description": { + "name": "allergies_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "needs_travel_reimbursement": { + "name": "needs_travel_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "would_attend_without_reimbursement": { + "name": "would_attend_without_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "airport_code": { + "name": "airport_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github": { + "name": "github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin": { + "name": "linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_site": { + "name": "personal_site", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follows_instagram": { + "name": "follows_instagram", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "sponsor_emails": { + "name": "sponsor_emails", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_applicants_user_id_users_id_fk": { + "name": "hacker_applicants_user_id_users_id_fk", + "tableFrom": "hacker_applicants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_applicants_user_id_unique": { + "name": "hacker_applicants_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "hacker_applicants_select_own_or_organizer": { + "name": "hacker_applicants_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_applicants\".\"user_id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_applicants_update_organizer": { + "name": "hacker_applicants_update_organizer", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_drafts": { + "name": "hacker_application_drafts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_drafts_user_id_fkey": { + "name": "hacker_application_drafts_user_id_fkey", + "tableFrom": "hacker_application_drafts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_drafts_select_own": { + "name": "hacker_application_drafts_select_own", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_insert_own": { + "name": "hacker_application_drafts_insert_own", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_update_own": { + "name": "hacker_application_drafts_update_own", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())", + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_delete_own": { + "name": "hacker_application_drafts_delete_own", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_review_events": { + "name": "hacker_application_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "review_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hacker_application_review_events_application_id_created_at_idx": { + "name": "hacker_application_review_events_application_id_created_at_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hacker_application_review_events_review_id_fkey": { + "name": "hacker_application_review_events_review_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_application_reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_application_id_fkey": { + "name": "hacker_application_review_events_application_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_reviewer_user_id_fkey": { + "name": "hacker_application_review_events_reviewer_user_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_review_events_organizer_select": { + "name": "hacker_application_review_events_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_review_events_organizer_insert": { + "name": "hacker_application_review_events_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_reviews": { + "name": "hacker_application_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effort_rating": { + "name": "effort_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "builder_rating": { + "name": "builder_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "flagged_for_review": { + "name": "flagged_for_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "review_comments": { + "name": "review_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_reviews_application_id_fkey": { + "name": "hacker_application_reviews_application_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_reviews_reviewer_user_id_fkey": { + "name": "hacker_application_reviews_reviewer_user_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_application_reviews_application_id_unique": { + "name": "hacker_application_reviews_application_id_unique", + "nullsNotDistinct": false, + "columns": [ + "application_id" + ] + } + }, + "policies": { + "hacker_application_reviews_organizer_select": { + "name": "hacker_application_reviews_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_insert": { + "name": "hacker_application_reviews_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_update": { + "name": "hacker_application_reviews_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.user_invitations": { + "name": "user_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "invited_by": { + "name": "invited_by", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_invitations_invited_by_users_id_fk": { + "name": "user_invitations_invited_by_users_id_fk", + "tableFrom": "user_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "user_invitations_organizer_select": { + "name": "user_invitations_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "user_invitations_organizer_insert": { + "name": "user_invitations_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "user_invitations_organizer_update": { + "name": "user_invitations_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'hacker'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": { + "users_select_own_or_organizer": { + "name": "users_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"users\".\"id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "pending", + "reviewed", + "flagged" + ] + }, + "public.review_event_type": { + "name": "review_event_type", + "schema": "public", + "values": [ + "draft_saved", + "review_completed" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "hacker", + "organizer" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": { + "organizers_receive_invite_realtime": { + "name": "organizers_receive_invite_realtime", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "public.is_organizer() AND realtime.topic() = 'user-invites:dashboard'", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + }, + "organizers_receive_review_realtime": { + "name": "organizers_receive_review_realtime", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + }, + "organizers_send_invite_realtime": { + "name": "organizers_send_invite_realtime", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "public.is_organizer() AND realtime.topic() = 'user-invites:dashboard'", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + }, + "organizers_send_review_realtime": { + "name": "organizers_send_review_realtime", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + } + }, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/_journal.json b/supabase/migrations/meta/_journal.json index 1edbf38..ddd4e7d 100644 --- a/supabase/migrations/meta/_journal.json +++ b/supabase/migrations/meta/_journal.json @@ -29,6 +29,41 @@ "when": 1784082737209, "tag": "20260715023217_is_organizer_realtime_and_triggers", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1784168292290, + "tag": "20260716021812_user_invitations", + "breakpoints": true + }, + { + "idx": 5, + "version": "7", + "when": 1784168293290, + "tag": "20260716021813_handle_new_user_invites", + "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1784168294290, + "tag": "20260716021900_defer_invite_acceptance", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1784171630726, + "tag": "20260716031350_user_invites_realtime", + "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1784254560000, + "tag": "20260717043600_user_invitations_indexes", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/supabase/seeds/team-invites-demo.sql b/supabase/seeds/team-invites-demo.sql new file mode 100644 index 0000000..54069d8 --- /dev/null +++ b/supabase/seeds/team-invites-demo.sql @@ -0,0 +1,119 @@ +-- Demo team invite data for local development. +-- Covers pending, accepted, revoked, and expired states plus bulk rows for pagination. + +insert into public.user_invitations ( + id, + email, + role, + invited_by, + accepted_at, + revoked_at, + expires_at, + created_at +) +values + ( + '50000000-0000-4000-8000-000000000001', + 'reviewer@mhacks.test', + 'organizer', + '00000000-0000-4000-8000-000000000001', + now() - interval '3 days', + null, + now() + interval '4 days', + now() - interval '3 days' + ), + ( + '50000000-0000-4000-8000-000000000002', + 'pending-reviewer@mhacks.test', + 'organizer', + '00000000-0000-4000-8000-000000000001', + null, + null, + now() + interval '5 days', + now() - interval '2 days' + ), + ( + '50000000-0000-4000-8000-000000000003', + 'pending-hacker@mhacks.test', + 'hacker', + '00000000-0000-4000-8000-000000000002', + null, + null, + now() + interval '6 days', + now() - interval '1 day' + ), + ( + '50000000-0000-4000-8000-000000000004', + 'revoked-invite@mhacks.test', + 'organizer', + '00000000-0000-4000-8000-000000000001', + null, + now() - interval '12 hours', + now() + interval '3 days', + now() - interval '4 days' + ), + ( + '50000000-0000-4000-8000-000000000005', + 'expired-invite@mhacks.test', + 'organizer', + '00000000-0000-4000-8000-000000000001', + null, + null, + now() - interval '2 days', + now() - interval '9 days' + ), + ( + '50000000-0000-4000-8000-000000000006', + 'ops-lead@mhacks.test', + 'organizer', + '00000000-0000-4000-8000-000000000001', + null, + null, + now() + interval '7 days', + now() - interval '6 hours' + ) +on conflict (id) do update set + email = excluded.email, + role = excluded.role, + invited_by = excluded.invited_by, + accepted_at = excluded.accepted_at, + revoked_at = excluded.revoked_at, + expires_at = excluded.expires_at, + created_at = excluded.created_at; + +-- Bulk pending invites for pagination and search testing (12 more, 18 total). +with bulk_invites(n) as ( + select generate_series(101, 112) +) +insert into public.user_invitations ( + id, + email, + role, + invited_by, + accepted_at, + revoked_at, + expires_at, + created_at +) +select + ('50000000-0000-4000-8000-' || lpad(n::text, 12, '0'))::uuid, + 'bulk-invite-' || n || '@mhacks.test', + case when n % 3 = 0 then 'hacker' else 'organizer' end::user_role, + case + when n % 2 = 0 + then '00000000-0000-4000-8000-000000000001'::uuid + else '00000000-0000-4000-8000-000000000002'::uuid + end, + null, + null, + now() + ((n % 6) + 1 || ' days')::interval, + now() - ((n % 72) || ' hours')::interval +from bulk_invites +on conflict (id) do update set + email = excluded.email, + role = excluded.role, + invited_by = excluded.invited_by, + accepted_at = excluded.accepted_at, + revoked_at = excluded.revoked_at, + expires_at = excluded.expires_at, + created_at = excluded.created_at;