From d73519fd322ad614991212e3ded05fae505717fb Mon Sep 17 00:00:00 2001 From: Hang Yeung Date: Sun, 9 Aug 2026 14:18:06 -0700 Subject: [PATCH 01/12] Add admin user invite flow. Organizers can invite users by email, assign roles, and accept invites on login, with local Mailpit SMTP for development. Co-authored-by: Cursor --- app/admin/team/loading.tsx | 5 + app/admin/team/page.tsx | 9 + app/admin/team/team-management-skeleton.tsx | 78 + app/admin/team/team-management.tsx | 643 +++++++++ lib/actions/auth.server.actions.ts | 3 + .../user-invitations.server.actions.ts | 208 +++ lib/admin/sections.ts | 15 + lib/aws/ses.ts | 55 + lib/db/index.ts | 2 + lib/db/schema/realtime-policies.ts | 20 + lib/db/schema/user-invitations.ts | 50 + lib/email/invite-template.ts | 691 +++++++++ lib/email/send-invite-email.ts | 54 + lib/queries/user-invitations.ts | 122 ++ lib/types/user-invitations.ts | 74 + package.json | 3 + pnpm-lock.yaml | 266 ++++ scripts/gen-env-local.sh | 10 +- supabase/config.toml | 10 +- .../20260809140000_user_invitations.sql | 36 + .../20260809140001_user_invites_realtime.sql | 2 + .../meta/20260809140000_snapshot.json | 1249 ++++++++++++++++ .../meta/20260809140001_snapshot.json | 1271 +++++++++++++++++ supabase/migrations/meta/_journal.json | 16 +- supabase/seeds/team-invites-demo.sql | 119 ++ 25 files changed, 5006 insertions(+), 5 deletions(-) create mode 100644 app/admin/team/loading.tsx create mode 100644 app/admin/team/page.tsx create mode 100644 app/admin/team/team-management-skeleton.tsx create mode 100644 app/admin/team/team-management.tsx create mode 100644 lib/actions/user-invitations.server.actions.ts create mode 100644 lib/aws/ses.ts create mode 100644 lib/db/schema/user-invitations.ts create mode 100644 lib/email/invite-template.ts create mode 100644 lib/email/send-invite-email.ts create mode 100644 lib/queries/user-invitations.ts create mode 100644 lib/types/user-invitations.ts create mode 100644 supabase/migrations/20260809140000_user_invitations.sql create mode 100644 supabase/migrations/20260809140001_user_invites_realtime.sql create mode 100644 supabase/migrations/meta/20260809140000_snapshot.json create mode 100644 supabase/migrations/meta/20260809140001_snapshot.json create mode 100644 supabase/seeds/team-invites-demo.sql 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..469481f --- /dev/null +++ b/app/admin/team/team-management.tsx @@ -0,0 +1,643 @@ +"use client"; + +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + useTransition, +} from "react"; +import type { Session } from "@supabase/supabase-js"; +import { + SearchIcon, + Trash2Icon, + UsersRoundIcon, + AlertTriangleIcon, +} 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 { + canRevokeInvite, + INVITE_PAGE_SIZE, + INVITE_SYNC_CHANNEL, + INVITE_SYNC_EVENT, + inviteStatus, + inviteSyncPayloadSchema, + normalizeInviteEmail, + type UserInviteListResult, + userInviteRoleSchema, +} from "@/lib/types/user-invitations"; +import { createClient } from "@/lib/supabase/client"; +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 { 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; + }; + +const ROLE_LABELS: Record = { + hacker: "Hacker", + organizer: "Organizer", + admin: "Admin", + volunteer: "Volunteer", + judge: "Judge", +}; + +type Organizer = { id: string; email: string }; +type SupabaseBrowserClient = ReturnType; +type InviteSyncChannel = ReturnType; + +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 TeamManagementProps = { + initialInvites: UserInviteListResult; +}; + +function formatInviteDate(value: Date | string) { + const date = value instanceof Date ? value : new Date(value); + return date.toLocaleDateString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + }); +} + +function inviteStatusBadgeClass(status: ReturnType) { + switch (status) { + case "Accepted": + 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 "Pending": + 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 "Revoked": + return "border-slate-200 bg-slate-50 text-slate-600 dark:border-slate-800 dark:bg-slate-900 dark:text-slate-300"; + case "Expired": + return "border-amber-200 bg-amber-50 text-amber-700 dark:border-amber-900/70 dark:bg-amber-950/50 dark:text-amber-300"; + } +} + +function inviteMetadata(invite: UserInviteListResult["items"][number]) { + const status = inviteStatus(invite); + const parts = [ + `Invited by ${invite.invitedByEmail}`, + `Created ${formatInviteDate(invite.createdAt)}`, + `Expires ${formatInviteDate(invite.expiresAt)}`, + ]; + + if (status === "Accepted" && invite.acceptedAt) { + parts.push(`Accepted ${formatInviteDate(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 [isSubmitting, startSubmitTransition] = useTransition(); + const [revokingInviteId, setRevokingInviteId] = useState(null); + const [inviteConfirmation, setInviteConfirmation] = + useState(null); + const [organizer, setOrganizer] = useState(null); + const [realtimeReady, setRealtimeReady] = useState(false); + const skipSearchEffect = useRef(true); + const searchInputRef = useRef(searchInput); + const pageIndexRef = useRef(pageIndex); + const supabase = useMemo(() => createClient(), []); + const inviteSyncChannel = useRef(null); + + 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 () => { + await inviteSyncChannel.current?.send({ + type: "broadcast", + event: INVITE_SYNC_EVENT, + payload: { + sourceUserId: organizer?.id ?? "", + }, + }); + }, [organizer?.id]); + + 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(() => { + if (!realtimeReady || !organizer) return; + + let active = true; + let channel: InviteSyncChannel | null = null; + + channel = supabase.channel(INVITE_SYNC_CHANNEL, { + config: { private: true }, + }); + if (!active) { + supabase.removeChannel(channel); + return; + } + inviteSyncChannel.current = channel; + + channel.on("broadcast", { event: INVITE_SYNC_EVENT }, ({ payload }) => { + const parsed = inviteSyncPayloadSchema.safeParse(payload); + if (!parsed.success) return; + if (parsed.data.sourceUserId === organizer.id) return; + + void refreshInvites(pageIndexRef.current).catch((error) => { + console.error("Unable to refresh invites after realtime sync:", error); + }); + }); + + channel.subscribe((status, err) => { + if (!active || status !== "CHANNEL_ERROR") return; + if (isBenignRealtimeChannelError(err)) return; + console.error("Unable to subscribe to invite sync channel:", err); + }); + + return () => { + active = false; + inviteSyncChannel.current = null; + if (channel) supabase.removeChannel(channel); + }; + }, [organizer, realtimeReady, refreshInvites, supabase]); + + useEffect(() => { + if (skipSearchEffect.current) { + skipSearchEffect.current = false; + return; + } + + const timeoutId = window.setTimeout(() => { + setPageIndex(0); + startSubmitTransition(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 (!result) { + 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; + } + + toast.error(result.error); + } + + function handleInviteSubmit(event: React.FormEvent) { + event.preventDefault(); + + startSubmitTransition(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); + + startSubmitTransition(async () => { + await sendInvite(email, inviteRole, { changeExistingUserRole: true }); + }); + } + + function handleConfirmPendingInviteReplacement() { + if (inviteConfirmation?.type !== "pending-invite") return; + + const { email, role: inviteRole } = inviteConfirmation; + setInviteConfirmation(null); + + startSubmitTransition(async () => { + await sendInvite(email, inviteRole, { replacePendingInvite: true }); + }); + } + + function handlePageChange(nextPageIndex: number) { + setPageIndex(nextPageIndex); + startSubmitTransition(async () => { + await refreshInvites(nextPageIndex); + }); + } + + function handleRevokeInvite(inviteId: string) { + setRevokingInviteId(inviteId); + startSubmitTransition(async () => { + const result = await revokeUserInvite(inviteId); + setRevokingInviteId(null); + if (result?.error) { + toast.error(result.error); + return; + } + + toast.success("Invite revoked."); + await refreshInvites(pageIndex); + void broadcastInviteUpdate(); + }); + } + + 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 ? ( +
+
+ +
+

+ {inviteConfirmation.type === "existing-user" + ? "Change existing user's role?" + : "Replace pending invite?"} +

+

+ {inviteConfirmation.type === "existing-user" ? ( + <> + + {inviteConfirmation.email} + {" "} + already has an account as{" "} + {ROLE_LABELS[inviteConfirmation.currentRole]}. Change + their role to {ROLE_LABELS[inviteConfirmation.role]}? + + ) : ( + <> + + {inviteConfirmation.email} + {" "} + already has a pending invite as{" "} + {ROLE_LABELS[inviteConfirmation.pendingRole]}. Revoke + that invite and send a new one as{" "} + {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)} + className="pl-9" + 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} +

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

+ {inviteMetadata(invite)} +

+
+ {canRevokeInvite(invite) ? ( + + ) : null} +
+ ); + })} +
+ + + )} +
+
+
+ ); +} 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..5839f40 --- /dev/null +++ b/lib/actions/user-invitations.server.actions.ts @@ -0,0 +1,208 @@ +"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 { + getPendingUserInvite as getPendingUserInviteQuery, + listUserInvites as listUserInvitesQuery, +} from "@/lib/queries/user-invitations"; +import { + type CreateUserInviteResult, + 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 getPendingUserInvite(email: string) { + return getPendingUserInviteQuery(email); +} + +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() + .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 }; + } + + 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), + }); + }); + + try { + await sendRoleChangeEmail(normalizedEmail, inviteRole); + } catch { + return { + error: "Role updated, but the notification email could not be sent.", + }; + } + + return; + } + + if (pendingInvite) { + await db.transaction(async (tx) => { + await tx + .update(userInvitations) + .set({ revokedAt: new Date() }) + .where(eq(userInvitations.id, pendingInvite.id)); + + await tx.insert(userInvitations).values({ + email: normalizedEmail, + role: inviteRole, + invitedBy: organizer.id, + expiresAt, + }); + }); + } else { + await db.insert(userInvitations).values({ + email: normalizedEmail, + role: inviteRole, + invitedBy: organizer.id, + expiresAt, + }); + } + + try { + await sendInviteEmail(normalizedEmail, inviteRole, expiresAt); + } catch { + return { error: "Invite created, but the email could not be sent." }; + } +} + +export async function revokeUserInvite( + inviteId: string, +): Promise<{ error: string } | undefined> { + 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." }; + } + + const expiresAt = + invite.expiresAt instanceof Date + ? invite.expiresAt + : new Date(invite.expiresAt); + + if (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)); +} 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..a445d6e --- /dev/null +++ b/lib/aws/ses.ts @@ -0,0 +1,55 @@ +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"; + +function getTransporter(): 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 }, + }); +} + +export async function sendEmail({ + to, + subject, + text, + html, +}: { + to: string; + subject: string; + text: string; + html: string; +}) { + const transporter = getTransporter(); + if (!transporter) return false; + + await transporter.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 c50d444..44572f9 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"; import * as reimbursementsSchema from "./schema/reimbursements"; import * as blacklistSchema from "./schema/blacklist"; @@ -14,6 +15,7 @@ export const db = drizzle({ schema: { ...applicationsSchema, ...usersSchema, + ...userInvitationsSchema, ...reimbursementsSchema, ...blacklistSchema, }, 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..aaff9ae --- /dev/null +++ b/lib/db/schema/user-invitations.ts @@ -0,0 +1,50 @@ +import { pgTable, pgPolicy, uuid, text, timestamp } 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(), + }, + () => [ + 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..84a67c2 --- /dev/null +++ b/lib/email/invite-template.ts @@ -0,0 +1,691 @@ +import type { UserRole } from "@/lib/db/schema/users"; + +const ROLE_LABELS: Record = { + hacker: "Hacker", + organizer: "Organizer", + admin: "Admin", + volunteer: "Volunteer", + judge: "Judge", +}; + +function escapeHtml(value: string) { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +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 = ROLE_LABELS[role]; + const expiration = formatInviteExpiration(expiresAt); + const safeLoginUrl = escapeHtml(loginUrl); + 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 = ` + + + + + MHacks | You're Invited + + + + + + + + + + +
    + + + + + + + + + + + + + + + + + + + + + + + + +
    + MHacks +
    +

    + 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. +

    +
    + + + + +
    +

    + Assigned role +

    +

    + ${escapeHtml(roleLabel)} +

    +
    +
    + + Sign in to MHacks + +

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

    +
    +

    + 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)}. +

    +
    +

    + What's Next? +

    + +
      + ${renderListItems(nextSteps)} +
    + +

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

    + +

    + — The MHacks Team +

    +
    +
    + +`; + + return { subject, text, html }; +} + +export function buildRoleChangeEmail({ + role, + loginUrl, +}: { + role: UserRole; + loginUrl: string; +}) { + const roleLabel = ROLE_LABELS[role]; + const safeLoginUrl = escapeHtml(loginUrl); + 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 = ` + + + + + MHacks | Role Updated + + + + + + + + + + +
    + + + + + + + + + + + + +
    + MHacks +
    +

    + Role Updated +

    + +

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

    + +

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

    + +

    + + Sign in to MHacks + +

    +
    +

    + What's Next? +

    + +
      + ${renderListItems(nextSteps)} +
    + +

    + — The MHacks Team +

    +
    +
    + +`; + + 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/queries/user-invitations.ts b/lib/queries/user-invitations.ts new file mode 100644 index 0000000..51199fc --- /dev/null +++ b/lib/queries/user-invitations.ts @@ -0,0 +1,122 @@ +import { desc, eq, ilike, or, 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 inviteSearchCondition(search: string) { + const term = `%${search.trim()}%`; + return or(ilike(userInvitations.email, term), ilike(users.email, term)); +} + +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 + ? inviteSearchCondition(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((row) => ({ + id: row.id, + email: row.email, + role: row.role, + acceptedAt: row.acceptedAt, + revokedAt: row.revokedAt, + expiresAt: row.expiresAt, + createdAt: row.createdAt, + invitedByEmail: row.invitedByEmail, + })), + totalCount: rows[0]?.totalCount ?? 0, + }; +} + +export async function getPendingUserInvite( + email: string, +): Promise { + const normalizedEmail = normalizeInviteEmail(email); + if (!userInviteEmailSchema.safeParse(normalizedEmail).success) { + 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 = normalizeInviteEmail(email); + if (!userInviteEmailSchema.safeParse(normalizedEmail).success) { + 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/types/user-invitations.ts b/lib/types/user-invitations.ts new file mode 100644 index 0000000..a7b0b09 --- /dev/null +++ b/lib/types/user-invitations.ts @@ -0,0 +1,74 @@ +import { z } from "zod"; +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 type InviteSyncPayload = z.infer; + +export function normalizeInviteEmail(email: string) { + return email.trim().toLowerCase(); +} + +export function inviteExpiresAt(from = new Date()) { + return new Date(from.getTime() + INVITE_TTL_MS); +} + +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 = + | { error: string } + | { + pendingInvite: { + id: string; + role: UserRole; + }; + } + | { + existingUser: { + role: UserRole; + }; + }; + +export function inviteStatus( + invite: Pick, +) { + if (invite.acceptedAt) return "Accepted"; + if (invite.revokedAt) return "Revoked"; + const expiresAt = + invite.expiresAt instanceof Date + ? invite.expiresAt + : new Date(invite.expiresAt); + if (expiresAt.getTime() <= Date.now()) return "Expired"; + return "Pending"; +} + +export function canRevokeInvite( + invite: Pick, +) { + return inviteStatus(invite) === "Pending"; +} diff --git a/package.json b/package.json index 2658dbe..12e6f24 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ }, "dependencies": { "@aws-sdk/client-s3": "^3.1092.0", + "@aws-sdk/client-sesv2": "^3.1106.0", "@aws-sdk/s3-request-presigner": "^3.1092.0", "@dnd-kit/core": "^6.3.1", "@dnd-kit/modifiers": "^9.0.0", @@ -51,6 +52,7 @@ "mcp-handler": "^1.1.0", "next": "16.2.7", "next-themes": "^0.4.6", + "nodemailer": "^9.0.5", "postgres": "^3.4.9", "posthog-js": "^1.406.2", "posthog-node": "^5.46.0", @@ -73,6 +75,7 @@ "devDependencies": { "@tailwindcss/postcss": "^4.3.3", "@types/node": "^24.13.3", + "@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 c7c1d17..45edcea 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@aws-sdk/client-s3': specifier: ^3.1092.0 version: 3.1092.0 + '@aws-sdk/client-sesv2': + specifier: ^3.1106.0 + version: 3.1106.0 '@aws-sdk/s3-request-presigner': specifier: ^3.1092.0 version: 3.1092.0 @@ -92,6 +95,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.5 + version: 9.0.5 postgres: specifier: ^3.4.9 version: 3.4.9 @@ -153,6 +159,9 @@ importers: '@types/node': specifier: ^24.13.3 version: 24.13.3 + '@types/nodemailer': + specifier: ^8.0.1 + version: 8.0.1 '@types/react': specifier: ^19.2.17 version: 19.2.17 @@ -204,18 +213,38 @@ packages: resolution: {integrity: sha512-NfcptdANQM1IgUT8QITKBN+PZPjshm5FyLKKjotEwscsDQGik4iDdLgwFYJSTlGoREv26Tf97WHpL7IZ3HF9nA==} engines: {node: '>=20.0.0'} + '@aws-sdk/client-sesv2@3.1106.0': + resolution: {integrity: sha512-cbPngGGYk8JIBVB9qcisq4hcN0WegWHO6G3IOD1cc3CLVE5RqWKRrko7STO+tS6RXcyHQzErMELuTSwswDtsRw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.976.0': resolution: {integrity: sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA==} engines: {node: '>=20.0.0'} + '@aws-sdk/core@3.977.6': + resolution: {integrity: sha512-QiaJV4/zDrB4ZY2mfeSXSzSTc36W16sZXcGz+SPFk0CJ26gziO0cS+4LjJUMAbdeeBOvS0k0Aq1cZpfGdUXxSw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.60': resolution: {integrity: sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-env@3.972.67': + resolution: {integrity: sha512-rcIpk5kxUqDaaNa6Xk23pQ6ViY7jlqzmfFWCahQcBT97ddXaXYYwzCen9Tz1Jvo6aJft6wDl5bN44/Jw5B4oLA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.62': resolution: {integrity: sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-http@3.972.69': + resolution: {integrity: sha512-nggwJtZ4eeNsUw5IeWBMXsi1ryct5idi0K+/SCRF3kybLubOMaNTb3XCihXpWMiVpyzyPeIrl0zTkzhBH9porA==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-ini@3.973.12': + resolution: {integrity: sha512-pNEf/OeyN5X3VmLKlgSO6TqaWmW10CvI3TfwL1XhsuhYjSLT2VDaxFnCPHnOeQXSaFisMX4jNhpETriqN8DOmg==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-ini@3.973.5': resolution: {integrity: sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw==} engines: {node: '>=20.0.0'} @@ -224,14 +253,30 @@ packages: resolution: {integrity: sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-login@3.972.74': + resolution: {integrity: sha512-0AQfDcf99TNmqVKv0owHrw/TQs6i4ZE5t9qmz6NvO53bE/sA/tpXhXL9AAcEP1qHc6Zzjd1UMb69+/9zdhvY3g==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.71': resolution: {integrity: sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-node@3.972.78': + resolution: {integrity: sha512-OgPAnfvbGAMWac6yvxJ1ihslrvDpPVwR68D2csospdNCCyPvHk9JLzYKwz48SNiS1T2znDwHauywRKRFfpyYng==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.60': resolution: {integrity: sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-process@3.972.67': + resolution: {integrity: sha512-IlUEejorGTWKb4/Dm7K5Yw4QxUmXLThLhrvBmzVBqZFTbW72cv9LTcITmo1dsnYriALE4h68mOq4LB99x6sQ7Q==} + engines: {node: '>=20.0.0'} + + '@aws-sdk/credential-provider-sso@3.973.11': + resolution: {integrity: sha512-gAQBkBZxUB84d71+pPcI9L+jh2ujhuAVxc/4FgGiWFDjkPBlMKxzd5XDtkSXTFX8Ro7ansnT88+XadasxMeCRw==} + engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-sso@3.973.4': resolution: {integrity: sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA==} engines: {node: '>=20.0.0'} @@ -240,6 +285,10 @@ packages: resolution: {integrity: sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng==} engines: {node: '>=20.0.0'} + '@aws-sdk/credential-provider-web-identity@3.972.73': + resolution: {integrity: sha512-SnlEmQa6SjOgs6iOPLUQl1Eyq4AKiAdPQlkOhFhqNfDtDCwibMGvL6QlkSmf3o6vAUSImzdPCxowT5dfQUZP1A==} + engines: {node: '>=20.0.0'} + '@aws-sdk/middleware-sdk-s3@3.972.65': resolution: {integrity: sha512-udwNhRfDTfCB98mAHjjgsnKQlxygB4e0X+Obne/XjJpvVsF0YCQC8ZErd/8Z6IPoLQjtiKHzwqEDbZiLrJEnOg==} engines: {node: '>=20.0.0'} @@ -248,6 +297,10 @@ packages: resolution: {integrity: sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA==} engines: {node: '>=20.0.0'} + '@aws-sdk/nested-clients@3.997.41': + resolution: {integrity: sha512-RDHqPGQWlF6tatA/Tp3rg6oIwtgN9IVderxE+9av2Y93Dfyu+mO1hZ5Bu2jpfZg2rwdNbsssnwM+sLafIczMlQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/s3-request-presigner@3.1092.0': resolution: {integrity: sha512-MF5u0f7NgLz3YusDAkYkFlTEEO1dutvAMDlE460vLxRBiln2Roo/IYfwHW+FNwE8Ys3dxiIPwTP3CfMz9a6Q0A==} engines: {node: '>=20.0.0'} @@ -256,10 +309,18 @@ packages: resolution: {integrity: sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng==} engines: {node: '>=20.0.0'} + '@aws-sdk/signature-v4-multi-region@3.996.43': + resolution: {integrity: sha512-lKekx8bLBXSv4O+cslk9Zfnw2XKSkWBs3uWL5QGhH2ZAQfNS7FE0vcSSN2vD/AhxX54ZTywWxR4STThoeOXlBA==} + engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1092.0': resolution: {integrity: sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ==} engines: {node: '>=20.0.0'} + '@aws-sdk/token-providers@3.1103.0': + resolution: {integrity: sha512-N4wy26MNn31ItGVHYHPrEuCIFY4MBBjC+C5v1lJKqIUSA7OZBdhleCY53zCCrXn27hsk7YNOaTuhQu807S4AfQ==} + engines: {node: '>=20.0.0'} + '@aws-sdk/types@3.974.2': resolution: {integrity: sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA==} engines: {node: '>=20.0.0'} @@ -268,6 +329,10 @@ packages: resolution: {integrity: sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA==} engines: {node: '>=20.0.0'} + '@aws-sdk/xml-builder@3.972.37': + resolution: {integrity: sha512-zKq4HQum8JwDyEuyfuI4bbiAcU0KxP6qy+9PR/IsR92IyE/DaBAikzAS50tjxip4bqIIANpCcG+Yyj6CVhXupg==} + engines: {node: '>=20.0.0'} + '@aws/lambda-invoke-store@0.3.0': resolution: {integrity: sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==} engines: {node: '>=18.0.0'} @@ -2191,18 +2256,38 @@ packages: resolution: {integrity: sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ==} engines: {node: '>=18.0.0'} + '@smithy/core@3.31.1': + resolution: {integrity: sha512-CyogUINxvi7C7LDsh8Syo6hVJOT9ckz4rG8dRZfTJ8r91HkMY59PnNooaj7WcHyxEkxPfBAmbgztZU+xTo76lg==} + engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.12': resolution: {integrity: sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q==} engines: {node: '>=18.0.0'} + '@smithy/credential-provider-imds@4.4.16': + resolution: {integrity: sha512-QfuLWAkLzptffFW980AFeHZFdqds2B64rpEd3uJ6lgs3xVn9QegGMUgUcj+4d7dRrAsya3r58ZKpku97WcFb4w==} + engines: {node: '>=18.0.0'} + + '@smithy/fetch-http-handler@5.6.13': + resolution: {integrity: sha512-4fW86pEUOMbrD5nkbyl/tTvPHHWJFbuB2odl6ps9lWfHoXf9HWh3Q/Smh59qH1g7+c/BSZghX6bbUk4gsiMs8A==} + engines: {node: '>=18.0.0'} + '@smithy/fetch-http-handler@5.6.9': resolution: {integrity: sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ==} engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.13': + resolution: {integrity: sha512-Nmd/Nl35zfYrd+a6OO2cDJb3GPh9bgTjIUhcM+JFfjpp8/osCgboDV5nCT1I01Pv6R13eSKDKLSoVa5ZB6Zsfw==} + engines: {node: '>=18.0.0'} + '@smithy/node-http-handler@4.9.9': resolution: {integrity: sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg==} engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.12': + resolution: {integrity: sha512-I6KLtq3H0qqSuV9vLglfi8puHqzygzWHOnI4z/Rdoo+q50vvo18vBRdPAvvEtcaKROz7Zn6qnPa14kRfPH6PcQ==} + engines: {node: '>=18.0.0'} + '@smithy/signature-v4@5.6.8': resolution: {integrity: sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg==} engines: {node: '>=18.0.0'} @@ -2444,6 +2529,9 @@ packages: '@types/node@24.13.3': resolution: {integrity: sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==} + '@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: @@ -4352,6 +4440,10 @@ packages: resolution: {integrity: sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ==} engines: {node: '>=18'} + nodemailer@9.0.5: + resolution: {integrity: sha512-wvjiKvjczmsN7U/8006JOdXubgBk2XFAbioDMbT+sM7cPs0QrhJTa6KBRX7P5REGGkDcLUz/EarWidb8G8C1jQ==} + engines: {node: '>=6.0.0'} + npm-run-path@4.0.1: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} @@ -5289,6 +5381,18 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/client-sesv2@3.1106.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-node': 3.972.78 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/core@3.976.0': dependencies: '@aws-sdk/types': 3.974.2 @@ -5300,6 +5404,17 @@ snapshots: bowser: 2.14.1 tslib: 2.8.1 + '@aws-sdk/core@3.977.6': + dependencies: + '@aws-sdk/types': 3.974.2 + '@aws-sdk/xml-builder': 3.972.37 + '@aws/lambda-invoke-store': 0.3.0 + '@smithy/core': 3.31.1 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + bowser: 2.14.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.60': dependencies: '@aws-sdk/core': 3.976.0 @@ -5308,6 +5423,14 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-env@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.62': dependencies: '@aws-sdk/core': 3.976.0 @@ -5318,6 +5441,32 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-http@3.972.69': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-ini@3.973.12': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-login': 3.972.74 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-ini@3.973.5': dependencies: '@aws-sdk/core': 3.976.0 @@ -5343,6 +5492,15 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-login@3.972.74': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.71': dependencies: '@aws-sdk/credential-provider-env': 3.972.60 @@ -5357,6 +5515,20 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-node@3.972.78': + dependencies: + '@aws-sdk/credential-provider-env': 3.972.67 + '@aws-sdk/credential-provider-http': 3.972.69 + '@aws-sdk/credential-provider-ini': 3.973.12 + '@aws-sdk/credential-provider-process': 3.972.67 + '@aws-sdk/credential-provider-sso': 3.973.11 + '@aws-sdk/credential-provider-web-identity': 3.972.73 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/credential-provider-imds': 4.4.16 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.60': dependencies: '@aws-sdk/core': 3.976.0 @@ -5365,6 +5537,24 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-process@3.972.67': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@aws-sdk/credential-provider-sso@3.973.11': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/token-providers': 3.1103.0 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/credential-provider-sso@3.973.4': dependencies: '@aws-sdk/core': 3.976.0 @@ -5384,6 +5574,15 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/credential-provider-web-identity@3.972.73': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/middleware-sdk-s3@3.972.65': dependencies: '@aws-sdk/core': 3.976.0 @@ -5404,6 +5603,17 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/nested-clients@3.997.41': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/signature-v4-multi-region': 3.996.43 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/fetch-http-handler': 5.6.13 + '@smithy/node-http-handler': 4.9.13 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/s3-request-presigner@3.1092.0': dependencies: '@aws-sdk/core': 3.976.0 @@ -5420,6 +5630,13 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/signature-v4-multi-region@3.996.43': + dependencies: + '@aws-sdk/types': 3.974.2 + '@smithy/signature-v4': 5.6.12 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/token-providers@3.1092.0': dependencies: '@aws-sdk/core': 3.976.0 @@ -5429,6 +5646,15 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/token-providers@3.1103.0': + dependencies: + '@aws-sdk/core': 3.977.6 + '@aws-sdk/nested-clients': 3.997.41 + '@aws-sdk/types': 3.974.2 + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws-sdk/types@3.974.2': dependencies: '@smithy/types': 4.16.1 @@ -5439,6 +5665,11 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@aws-sdk/xml-builder@3.972.37': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@aws/lambda-invoke-store@0.3.0': {} '@babel/code-frame@7.29.7': @@ -7147,24 +7378,53 @@ snapshots: '@smithy/types': 4.16.1 tslib: 2.8.1 + '@smithy/core@3.31.1': + dependencies: + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.12': dependencies: '@smithy/core': 3.29.7 '@smithy/types': 4.16.1 tslib: 2.8.1 + '@smithy/credential-provider-imds@4.4.16': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + + '@smithy/fetch-http-handler@5.6.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/fetch-http-handler@5.6.9': dependencies: '@smithy/core': 3.29.7 '@smithy/types': 4.16.1 tslib: 2.8.1 + '@smithy/node-http-handler@4.9.13': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/node-http-handler@4.9.9': dependencies: '@smithy/core': 3.29.7 '@smithy/types': 4.16.1 tslib: 2.8.1 + '@smithy/signature-v4@5.6.12': + dependencies: + '@smithy/core': 3.31.1 + '@smithy/types': 4.16.1 + tslib: 2.8.1 + '@smithy/signature-v4@5.6.8': dependencies: '@smithy/core': 3.29.7 @@ -7366,6 +7626,10 @@ snapshots: dependencies: undici-types: 7.18.2 + '@types/nodemailer@8.0.1': + dependencies: + '@types/node': 24.13.3 + '@types/react-dom@19.2.3(@types/react@19.2.17)': dependencies: '@types/react': 19.2.17 @@ -9286,6 +9550,8 @@ snapshots: node-releases@2.0.51: {} + nodemailer@9.0.5: {} + 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/20260809140001_user_invites_realtime.sql b/supabase/migrations/20260809140001_user_invites_realtime.sql new file mode 100644 index 0000000..8a3a484 --- /dev/null +++ b/supabase/migrations/20260809140001_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/meta/20260809140000_snapshot.json b/supabase/migrations/meta/20260809140000_snapshot.json new file mode 100644 index 0000000..6f5b8d9 --- /dev/null +++ b/supabase/migrations/meta/20260809140000_snapshot.json @@ -0,0 +1,1249 @@ +{ + "id": "d35545ac-1818-4f7b-9830-b348131ea410", + "prevId": "fd261732-a141-4697-baff-eea60eed1b97", + "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", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_applicants_user_id_unique": { + "name": "hacker_applicants_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + }, + "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", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "hacker_application_review_events_review_id_fkey": { + "name": "hacker_application_review_events_review_id_fkey", + "tableFrom": "hacker_application_review_events", + "columnsFrom": [ + "review_id" + ], + "tableTo": "hacker_application_reviews", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "hacker_application_review_events_application_id_fkey": { + "name": "hacker_application_review_events_application_id_fkey", + "tableFrom": "hacker_application_review_events", + "columnsFrom": [ + "application_id" + ], + "tableTo": "hacker_applicants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "hacker_application_review_events_reviewer_user_id_fkey": { + "name": "hacker_application_review_events_reviewer_user_id_fkey", + "tableFrom": "hacker_application_review_events", + "columnsFrom": [ + "reviewer_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "application_id" + ], + "tableTo": "hacker_applicants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "hacker_application_reviews_reviewer_user_id_fkey": { + "name": "hacker_application_reviews_reviewer_user_id_fkey", + "tableFrom": "hacker_application_reviews", + "columnsFrom": [ + "reviewer_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_application_reviews_application_id_unique": { + "name": "hacker_application_reviews_application_id_unique", + "columns": [ + "application_id" + ], + "nullsNotDistinct": false + } + }, + "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.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "full_name_normalized": { + "name": "full_name_normalized", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "nullif(lower(regexp_replace(btrim(\"full_name\"), '[[:space:]]+', ' ', 'g')), '')" + } + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number_normalized": { + "name": "phone_number_normalized", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "nullif(regexp_replace(\"phone_number\", '[^0-9+]', '', 'g'), '')" + } + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "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": { + "blacklist_full_name_normalized_key": { + "name": "blacklist_full_name_normalized_key", + "columns": [ + { + "expression": "full_name_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"blacklist\".\"full_name_normalized\" is not null", + "concurrently": false + }, + "blacklist_phone_number_normalized_key": { + "name": "blacklist_phone_number_normalized_key", + "columns": [ + { + "expression": "phone_number_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"blacklist\".\"phone_number_normalized\" is not null", + "concurrently": false + } + }, + "foreignKeys": { + "blacklist_created_by_user_id_fkey": { + "name": "blacklist_created_by_user_id_fkey", + "tableFrom": "blacklist", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "blacklist_organizer_select": { + "name": "blacklist_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)" + }, + "blacklist_organizer_insert": { + "name": "blacklist_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)" + }, + "blacklist_organizer_update": { + "name": "blacklist_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)" + }, + "blacklist_organizer_delete": { + "name": "blacklist_organizer_delete", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": { + "blacklist_identifier_present_check": { + "name": "blacklist_identifier_present_check", + "value": "\"blacklist\".\"full_name\" is not null or \"blacklist\".\"phone_number\" is not null" + } + }, + "isRLSEnabled": true + }, + "public.hacker_reimbursements": { + "name": "hacker_reimbursements", + "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 + }, + "region": { + "name": "region", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "reimbursement_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "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_reimbursements_user_id_fkey": { + "name": "hacker_reimbursements_user_id_fkey", + "tableFrom": "hacker_reimbursements", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "hacker_reimbursements_region_fkey": { + "name": "hacker_reimbursements_region_fkey", + "tableFrom": "hacker_reimbursements", + "columnsFrom": [ + "region" + ], + "tableTo": "reimbursement_regions", + "columnsTo": [ + "region" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "hacker_reimbursements_decided_by_user_id_fkey": { + "name": "hacker_reimbursements_decided_by_user_id_fkey", + "tableFrom": "hacker_reimbursements", + "columnsFrom": [ + "decided_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_reimbursements_user_id_unique": { + "name": "hacker_reimbursements_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + }, + "policies": { + "hacker_reimbursements_select_own_or_organizer": { + "name": "hacker_reimbursements_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_reimbursements\".\"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_reimbursements_organizer_insert": { + "name": "hacker_reimbursements_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_reimbursements_organizer_update": { + "name": "hacker_reimbursements_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)" + }, + "hacker_reimbursements_organizer_delete": { + "name": "hacker_reimbursements_organizer_delete", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.reimbursement_regions": { + "name": "reimbursement_regions", + "schema": "", + "columns": { + "region": { + "name": "region", + "type": "smallint", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "reimbursement_regions_select_authenticated": { + "name": "reimbursement_regions_select_authenticated", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "true" + } + }, + "checkConstraints": { + "reimbursement_regions_amount_cents_check": { + "name": "reimbursement_regions_amount_cents_check", + "value": "\"reimbursement_regions\".\"amount_cents\" >= 0" + } + }, + "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", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "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 + }, + "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 + } + }, + "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.reimbursement_status": { + "name": "reimbursement_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "hacker", + "organizer", + "admin", + "volunteer", + "judge" + ] + } + }, + "schemas": {}, + "views": {}, + "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)", + "on": "\"realtime\".\"messages\"", + "schema": "realtime" + }, + "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)", + "on": "\"realtime\".\"messages\"", + "schema": "realtime" + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/supabase/migrations/meta/20260809140001_snapshot.json b/supabase/migrations/meta/20260809140001_snapshot.json new file mode 100644 index 0000000..35d0183 --- /dev/null +++ b/supabase/migrations/meta/20260809140001_snapshot.json @@ -0,0 +1,1271 @@ +{ + "id": "a59413f8-a8cf-43c0-b39d-95dd221d4cef", + "prevId": "d35545ac-1818-4f7b-9830-b348131ea410", + "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", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_applicants_user_id_unique": { + "name": "hacker_applicants_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + }, + "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", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "schemaTo": "auth", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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, + "with": {}, + "method": "btree", + "concurrently": false + } + }, + "foreignKeys": { + "hacker_application_review_events_review_id_fkey": { + "name": "hacker_application_review_events_review_id_fkey", + "tableFrom": "hacker_application_review_events", + "columnsFrom": [ + "review_id" + ], + "tableTo": "hacker_application_reviews", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "hacker_application_review_events_application_id_fkey": { + "name": "hacker_application_review_events_application_id_fkey", + "tableFrom": "hacker_application_review_events", + "columnsFrom": [ + "application_id" + ], + "tableTo": "hacker_applicants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "hacker_application_review_events_reviewer_user_id_fkey": { + "name": "hacker_application_review_events_reviewer_user_id_fkey", + "tableFrom": "hacker_application_review_events", + "columnsFrom": [ + "reviewer_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "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", + "columnsFrom": [ + "application_id" + ], + "tableTo": "hacker_applicants", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "hacker_application_reviews_reviewer_user_id_fkey": { + "name": "hacker_application_reviews_reviewer_user_id_fkey", + "tableFrom": "hacker_application_reviews", + "columnsFrom": [ + "reviewer_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_application_reviews_application_id_unique": { + "name": "hacker_application_reviews_application_id_unique", + "columns": [ + "application_id" + ], + "nullsNotDistinct": false + } + }, + "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.blacklist": { + "name": "blacklist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "full_name": { + "name": "full_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "full_name_normalized": { + "name": "full_name_normalized", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "nullif(lower(regexp_replace(btrim(\"full_name\"), '[[:space:]]+', ' ', 'g')), '')" + } + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "phone_number_normalized": { + "name": "phone_number_normalized", + "type": "text", + "primaryKey": false, + "notNull": false, + "generated": { + "type": "stored", + "as": "nullif(regexp_replace(\"phone_number\", '[^0-9+]', '', 'g'), '')" + } + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by_user_id": { + "name": "created_by_user_id", + "type": "uuid", + "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": { + "blacklist_full_name_normalized_key": { + "name": "blacklist_full_name_normalized_key", + "columns": [ + { + "expression": "full_name_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"blacklist\".\"full_name_normalized\" is not null", + "concurrently": false + }, + "blacklist_phone_number_normalized_key": { + "name": "blacklist_phone_number_normalized_key", + "columns": [ + { + "expression": "phone_number_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "with": {}, + "method": "btree", + "where": "\"blacklist\".\"phone_number_normalized\" is not null", + "concurrently": false + } + }, + "foreignKeys": { + "blacklist_created_by_user_id_fkey": { + "name": "blacklist_created_by_user_id_fkey", + "tableFrom": "blacklist", + "columnsFrom": [ + "created_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "blacklist_organizer_select": { + "name": "blacklist_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)" + }, + "blacklist_organizer_insert": { + "name": "blacklist_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)" + }, + "blacklist_organizer_update": { + "name": "blacklist_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)" + }, + "blacklist_organizer_delete": { + "name": "blacklist_organizer_delete", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": { + "blacklist_identifier_present_check": { + "name": "blacklist_identifier_present_check", + "value": "\"blacklist\".\"full_name\" is not null or \"blacklist\".\"phone_number\" is not null" + } + }, + "isRLSEnabled": true + }, + "public.hacker_reimbursements": { + "name": "hacker_reimbursements", + "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 + }, + "region": { + "name": "region", + "type": "smallint", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "reimbursement_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "decided_by_user_id": { + "name": "decided_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "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_reimbursements_user_id_fkey": { + "name": "hacker_reimbursements_user_id_fkey", + "tableFrom": "hacker_reimbursements", + "columnsFrom": [ + "user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "cascade" + }, + "hacker_reimbursements_region_fkey": { + "name": "hacker_reimbursements_region_fkey", + "tableFrom": "hacker_reimbursements", + "columnsFrom": [ + "region" + ], + "tableTo": "reimbursement_regions", + "columnsTo": [ + "region" + ], + "onUpdate": "no action", + "onDelete": "no action" + }, + "hacker_reimbursements_decided_by_user_id_fkey": { + "name": "hacker_reimbursements_decided_by_user_id_fkey", + "tableFrom": "hacker_reimbursements", + "columnsFrom": [ + "decided_by_user_id" + ], + "tableTo": "users", + "columnsTo": [ + "id" + ], + "onUpdate": "no action", + "onDelete": "set null" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_reimbursements_user_id_unique": { + "name": "hacker_reimbursements_user_id_unique", + "columns": [ + "user_id" + ], + "nullsNotDistinct": false + } + }, + "policies": { + "hacker_reimbursements_select_own_or_organizer": { + "name": "hacker_reimbursements_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_reimbursements\".\"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_reimbursements_organizer_insert": { + "name": "hacker_reimbursements_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_reimbursements_organizer_update": { + "name": "hacker_reimbursements_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)" + }, + "hacker_reimbursements_organizer_delete": { + "name": "hacker_reimbursements_organizer_delete", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.reimbursement_regions": { + "name": "reimbursement_regions", + "schema": "", + "columns": { + "region": { + "name": "region", + "type": "smallint", + "primaryKey": true, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount_cents": { + "name": "amount_cents", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "reimbursement_regions_select_authenticated": { + "name": "reimbursement_regions_select_authenticated", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "true" + } + }, + "checkConstraints": { + "reimbursement_regions_amount_cents_check": { + "name": "reimbursement_regions_amount_cents_check", + "value": "\"reimbursement_regions\".\"amount_cents\" >= 0" + } + }, + "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", + "columns": [ + "email" + ], + "nullsNotDistinct": false + } + }, + "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 + }, + "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 + } + }, + "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.reimbursement_status": { + "name": "reimbursement_status", + "schema": "public", + "values": [ + "pending", + "approved", + "denied" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "hacker", + "organizer", + "admin", + "volunteer", + "judge" + ] + } + }, + "schemas": {}, + "views": {}, + "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)", + "on": "\"realtime\".\"messages\"", + "schema": "realtime" + }, + "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)", + "on": "\"realtime\".\"messages\"", + "schema": "realtime" + }, + "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_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\"" + } + }, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/supabase/migrations/meta/_journal.json b/supabase/migrations/meta/_journal.json index f4d87fd..aa22788 100644 --- a/supabase/migrations/meta/_journal.json +++ b/supabase/migrations/meta/_journal.json @@ -71,6 +71,20 @@ "when": 1786128366444, "tag": "20260807184606_reimbursement_regions_seed", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1786292400000, + "tag": "20260809140000_user_invitations", + "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1786292401000, + "tag": "20260809140001_user_invites_realtime", + "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; From 3ebbe6b09c7ed0ba2235f9339e516f26c6c96a0c Mon Sep 17 00:00:00 2001 From: Hang Yeung Date: Sun, 9 Aug 2026 14:26:27 -0700 Subject: [PATCH 02/12] Harden user invite acceptance and authorization. Prevent invite enumeration, unsafe role downgrades, and nondeterministic pending invite selection during OTP sign-in. Co-authored-by: Cursor --- lib/actions/auth.server.actions.ts | 3 +- .../user-invitations.server.actions.ts | 12 +--- lib/queries/user-invitations.ts | 61 ++++++++++++------- lib/types/user-invitations.ts | 7 ++- 4 files changed, 49 insertions(+), 34 deletions(-) diff --git a/lib/actions/auth.server.actions.ts b/lib/actions/auth.server.actions.ts index 5d00413..cbe6ddf 100644 --- a/lib/actions/auth.server.actions.ts +++ b/lib/actions/auth.server.actions.ts @@ -58,7 +58,8 @@ export async function verifyOtp( if (error) return { error: error.message }; if (data.user) { - await acceptPendingUserInvite(data.user.id, email); + const verifiedEmail = data.user.email ?? email; + await acceptPendingUserInvite(data.user.id, verifiedEmail); const posthog = getPostHogClient(); posthog.capture({ diff --git a/lib/actions/user-invitations.server.actions.ts b/lib/actions/user-invitations.server.actions.ts index 5839f40..a9f4a0d 100644 --- a/lib/actions/user-invitations.server.actions.ts +++ b/lib/actions/user-invitations.server.actions.ts @@ -1,6 +1,6 @@ "use server"; -import { eq, sql } from "drizzle-orm"; +import { desc, eq, sql } from "drizzle-orm"; import { z } from "zod"; import { requireOrganizer } from "@/lib/auth/guards"; import { db } from "@/lib/db"; @@ -13,10 +13,7 @@ import { sendInviteEmail, sendRoleChangeEmail, } from "@/lib/email/send-invite-email"; -import { - getPendingUserInvite as getPendingUserInviteQuery, - listUserInvites as listUserInvitesQuery, -} from "@/lib/queries/user-invitations"; +import { listUserInvites as listUserInvitesQuery } from "@/lib/queries/user-invitations"; import { type CreateUserInviteResult, inviteExpiresAt, @@ -33,10 +30,6 @@ export async function listUserInvites( return listUserInvitesQuery(pageIndex, pageSize, search); } -export async function getPendingUserInvite(email: string) { - return getPendingUserInviteQuery(email); -} - export async function createUserInvite( email: string, role: UserRole, @@ -78,6 +71,7 @@ export async function createUserInvite( }) .from(userInvitations) .where(pendingInviteForEmail(normalizedEmail)) + .orderBy(desc(userInvitations.createdAt)) .limit(1), ]); diff --git a/lib/queries/user-invitations.ts b/lib/queries/user-invitations.ts index 51199fc..950e5e4 100644 --- a/lib/queries/user-invitations.ts +++ b/lib/queries/user-invitations.ts @@ -1,4 +1,4 @@ -import { desc, eq, ilike, or, sql } from "drizzle-orm"; +import { and, desc, eq, ilike, or, sql } from "drizzle-orm"; import { requireOrganizer } from "@/lib/auth/guards"; import { db } from "@/lib/db"; import { @@ -65,7 +65,8 @@ export async function listUserInvites( }; } -export async function getPendingUserInvite( +export async function acceptPendingUserInvite( + userId: string, email: string, ): Promise { const normalizedEmail = normalizeInviteEmail(email); @@ -73,21 +74,18 @@ export async function getPendingUserInvite( return null; } - const [invite] = await db - .select({ role: userInvitations.role }) - .from(userInvitations) - .where(pendingInviteForEmail(normalizedEmail)) + const [user] = await db + .select({ id: users.id, role: users.role }) + .from(users) + .where( + and( + eq(users.id, userId), + sql`lower(${users.email}) = ${normalizedEmail}`, + ), + ) .limit(1); - return invite?.role ?? null; -} - -export async function acceptPendingUserInvite( - userId: string, - email: string, -): Promise { - const normalizedEmail = normalizeInviteEmail(email); - if (!userInviteEmailSchema.safeParse(normalizedEmail).success) { + if (!user) { return null; } @@ -98,6 +96,7 @@ export async function acceptPendingUserInvite( }) .from(userInvitations) .where(pendingInviteForEmail(normalizedEmail)) + .orderBy(desc(userInvitations.createdAt)) .limit(1); if (!invite) { @@ -105,18 +104,36 @@ export async function acceptPendingUserInvite( } const acceptedAt = new Date(); + const shouldApplyRole = user.role === "hacker" && user.role !== invite.role; await db.transaction(async (tx) => { - await tx - .update(users) - .set({ role: invite.role }) - .where(eq(users.id, userId)); + if (shouldApplyRole) { + const [updatedUser] = await tx + .update(users) + .set({ role: invite.role }) + .where( + and( + eq(users.id, userId), + sql`lower(${users.email}) = ${normalizedEmail}`, + ), + ) + .returning({ id: users.id }); - await tx + if (!updatedUser) { + throw new Error("Unable to apply invite role for user."); + } + } + + const [acceptedInvite] = await tx .update(userInvitations) .set({ acceptedAt }) - .where(eq(userInvitations.id, invite.id)); + .where(eq(userInvitations.id, invite.id)) + .returning({ id: userInvitations.id }); + + if (!acceptedInvite) { + throw new Error("Unable to accept invite."); + } }); - return invite.role; + return shouldApplyRole ? invite.role : user.role; } diff --git a/lib/types/user-invitations.ts b/lib/types/user-invitations.ts index a7b0b09..8240437 100644 --- a/lib/types/user-invitations.ts +++ b/lib/types/user-invitations.ts @@ -1,8 +1,11 @@ import { z } from "zod"; -import { userRole, type UserRole } from "@/lib/db/schema/users"; +import { type UserRole } from "@/lib/db/schema/users"; + +export const INVITABLE_USER_ROLES = ["organizer", "hacker"] as const; +export type InvitableUserRole = (typeof INVITABLE_USER_ROLES)[number]; export const userInviteEmailSchema = z.email(); -export const userInviteRoleSchema = z.enum(userRole.enumValues); +export const userInviteRoleSchema = z.enum(INVITABLE_USER_ROLES); export const INVITE_PAGE_SIZE = 10; export const INVITE_TTL_MS = 7 * 24 * 60 * 60 * 1000; From 2f56740b56d6ed1a316f6b53c55edc4b011a8080 Mon Sep 17 00:00:00 2001 From: Hang Yeung Date: Sun, 9 Aug 2026 14:39:09 -0700 Subject: [PATCH 03/12] Simplify user invite code after review. Deduplicate role labels, tighten invite types, reuse inviteStatus for revoke checks, and split list vs mutation loading state in the team UI. Co-authored-by: Cursor --- app/admin/team/team-management.tsx | 85 +++++++++---------- .../user-invitations.server.actions.ts | 49 +++++------ lib/aws/ses.ts | 15 +++- lib/display/user-roles.ts | 13 +++ lib/email/invite-template.ts | 13 +-- lib/queries/user-invitations.ts | 10 +-- lib/types/user-invitations.ts | 9 -- 7 files changed, 92 insertions(+), 102 deletions(-) create mode 100644 lib/display/user-roles.ts diff --git a/app/admin/team/team-management.tsx b/app/admin/team/team-management.tsx index 469481f..fdb43f6 100644 --- a/app/admin/team/team-management.tsx +++ b/app/admin/team/team-management.tsx @@ -22,16 +22,17 @@ import { revokeUserInvite, } from "@/lib/actions/user-invitations.server.actions"; import type { UserRole } from "@/lib/db/schema/users"; +import { userRoleLabel } from "@/lib/display/user-roles"; import { - canRevokeInvite, + INVITABLE_USER_ROLES, INVITE_PAGE_SIZE, INVITE_SYNC_CHANNEL, INVITE_SYNC_EVENT, inviteStatus, inviteSyncPayloadSchema, normalizeInviteEmail, + type InvitableUserRole, type UserInviteListResult, - userInviteRoleSchema, } from "@/lib/types/user-invitations"; import { createClient } from "@/lib/supabase/client"; import { ListPagination } from "@/app/admin/applications/components/list-pagination"; @@ -60,24 +61,16 @@ type InviteConfirmation = | { type: "pending-invite"; email: string; - role: UserRole; + role: InvitableUserRole; pendingRole: UserRole; } | { type: "existing-user"; email: string; - role: UserRole; + role: InvitableUserRole; currentRole: UserRole; }; -const ROLE_LABELS: Record = { - hacker: "Hacker", - organizer: "Organizer", - admin: "Admin", - volunteer: "Volunteer", - judge: "Judge", -}; - type Organizer = { id: string; email: string }; type SupabaseBrowserClient = ReturnType; type InviteSyncChannel = ReturnType; @@ -119,8 +112,10 @@ function inviteStatusBadgeClass(status: ReturnType) { } } -function inviteMetadata(invite: UserInviteListResult["items"][number]) { - const status = inviteStatus(invite); +function inviteMetadata( + invite: UserInviteListResult["items"][number], + status: ReturnType, +) { const parts = [ `Invited by ${invite.invitedByEmail}`, `Created ${formatInviteDate(invite.createdAt)}`, @@ -141,8 +136,9 @@ export default function TeamManagement({ const [pageIndex, setPageIndex] = useState(0); const [inviteEmail, setInviteEmail] = useState(""); const [searchInput, setSearchInput] = useState(""); - const [role, setRole] = useState("organizer"); - const [isSubmitting, startSubmitTransition] = useTransition(); + const [role, setRole] = useState("organizer"); + const [isMutating, startMutateTransition] = useTransition(); + const [, startListTransition] = useTransition(); const [revokingInviteId, setRevokingInviteId] = useState(null); const [inviteConfirmation, setInviteConfirmation] = useState(null); @@ -175,14 +171,16 @@ export default function TeamManagement({ ); const broadcastInviteUpdate = useCallback(async () => { + if (!organizer?.id) return; + await inviteSyncChannel.current?.send({ type: "broadcast", event: INVITE_SYNC_EVENT, payload: { - sourceUserId: organizer?.id ?? "", + sourceUserId: organizer.id, }, }); - }, [organizer?.id]); + }, [organizer]); useEffect(() => { let cancelled = false; @@ -256,10 +254,6 @@ export default function TeamManagement({ channel = supabase.channel(INVITE_SYNC_CHANNEL, { config: { private: true }, }); - if (!active) { - supabase.removeChannel(channel); - return; - } inviteSyncChannel.current = channel; channel.on("broadcast", { event: INVITE_SYNC_EVENT }, ({ payload }) => { @@ -293,7 +287,7 @@ export default function TeamManagement({ const timeoutId = window.setTimeout(() => { setPageIndex(0); - startSubmitTransition(async () => { + startListTransition(async () => { await refreshInvites(0, searchInput.trim()); }); }, 300); @@ -310,7 +304,7 @@ export default function TeamManagement({ async function sendInvite( email: string, - inviteRole: UserRole, + inviteRole: InvitableUserRole, options?: { replacePendingInvite?: boolean; changeExistingUserRole?: boolean; @@ -359,7 +353,7 @@ export default function TeamManagement({ function handleInviteSubmit(event: React.FormEvent) { event.preventDefault(); - startSubmitTransition(async () => { + startMutateTransition(async () => { await sendInvite(inviteEmail, role); }); } @@ -375,7 +369,7 @@ export default function TeamManagement({ } setInviteConfirmation(null); - startSubmitTransition(async () => { + startMutateTransition(async () => { await sendInvite(email, inviteRole, { changeExistingUserRole: true }); }); } @@ -386,21 +380,21 @@ export default function TeamManagement({ const { email, role: inviteRole } = inviteConfirmation; setInviteConfirmation(null); - startSubmitTransition(async () => { + startMutateTransition(async () => { await sendInvite(email, inviteRole, { replacePendingInvite: true }); }); } function handlePageChange(nextPageIndex: number) { setPageIndex(nextPageIndex); - startSubmitTransition(async () => { + startListTransition(async () => { await refreshInvites(nextPageIndex); }); } function handleRevokeInvite(inviteId: string) { setRevokingInviteId(inviteId); - startSubmitTransition(async () => { + startMutateTransition(async () => { const result = await revokeUserInvite(inviteId); setRevokingInviteId(null); if (result?.error) { @@ -453,8 +447,8 @@ export default function TeamManagement({ {inviteConfirmation.email} {" "} already has an account as{" "} - {ROLE_LABELS[inviteConfirmation.currentRole]}. Change - their role to {ROLE_LABELS[inviteConfirmation.role]}? + {userRoleLabel(inviteConfirmation.currentRole)}. Change + their role to {userRoleLabel(inviteConfirmation.role)}? ) : ( <> @@ -462,9 +456,9 @@ export default function TeamManagement({ {inviteConfirmation.email} {" "} already has a pending invite as{" "} - {ROLE_LABELS[inviteConfirmation.pendingRole]}. Revoke + {userRoleLabel(inviteConfirmation.pendingRole)}. Revoke that invite and send a new one as{" "} - {ROLE_LABELS[inviteConfirmation.role]}? + {userRoleLabel(inviteConfirmation.role)}? )}

    @@ -488,7 +482,7 @@ export default function TeamManagement({ ? "default" : "destructive" } - disabled={isSubmitting} + disabled={isMutating} onClick={ inviteConfirmation.type === "existing-user" ? handleConfirmExistingUserRoleChange @@ -523,16 +517,17 @@ export default function TeamManagement({ @@ -540,12 +535,12 @@ export default function TeamManagement({ @@ -596,7 +591,7 @@ export default function TeamManagement({ {invite.email}

    - {ROLE_LABELS[invite.role]} + {userRoleLabel(invite.role)}

    - {inviteMetadata(invite)} + {inviteMetadata(invite, status)}

    - {canRevokeInvite(invite) ? ( + {status === "Pending" ? (