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}
+
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+ 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
+
+
+
+ -
+ Open the sign-in link above.
+
+ -
+ Confirm your email address.
+
+ -
+ Enter the 6-digit code we email you to finish signing in.
+
+
+
+
+ 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
+
+
+
+
+
+
+
+
+
+
+
+
+
+ |
+
+
+
+ |
+
+ 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" ? (