From fc0bacb9c7ca60cf17fa39236e3558d45ed93eed Mon Sep 17 00:00:00 2001 From: hujalex Date: Mon, 3 Aug 2026 21:31:11 -0400 Subject: [PATCH] first draft of user groups --- app/team/page.tsx | 31 + app/team/team-view.tsx | 447 +++++++ lib/actions/team.actions.ts | 458 +++++++ lib/actions/team.server.actions.ts | 109 ++ lib/auth/guards.ts | 6 + lib/db/index.ts | 3 +- lib/db/schema/teams.ts | 140 +++ lib/types/teams.ts | 48 + .../20260804011011_cynical_black_widow.sql | 64 + .../meta/20260804011011_snapshot.json | 1056 +++++++++++++++++ supabase/migrations/meta/_journal.json | 9 +- 11 files changed, 2369 insertions(+), 2 deletions(-) create mode 100644 app/team/page.tsx create mode 100644 app/team/team-view.tsx create mode 100644 lib/actions/team.actions.ts create mode 100644 lib/actions/team.server.actions.ts create mode 100644 lib/db/schema/teams.ts create mode 100644 lib/types/teams.ts create mode 100644 supabase/migrations/20260804011011_cynical_black_widow.sql create mode 100644 supabase/migrations/meta/20260804011011_snapshot.json diff --git a/app/team/page.tsx b/app/team/page.tsx new file mode 100644 index 0000000..5c13b2a --- /dev/null +++ b/app/team/page.tsx @@ -0,0 +1,31 @@ +import { requireHackerPage } from "@/lib/auth/guards"; +import { + getMyTeam, + getMyPendingInvitations, + getSentInvitations, +} from "@/lib/actions/team.actions"; +import { TeamView } from "./team-view"; + +// Not wrapped in a swallow-and-degrade try/catch the way apply/page.tsx +// handles its existing-application check — silently falling back to "no +// team" on a fetch error here would let a user attempt to create a second +// team while one already exists, so a failure here throws to Next.js's +// default error handling instead. +export default async function TeamPage() { + const { id: userId } = await requireHackerPage(); + + const [team, pendingInvitations, sentInvitations] = await Promise.all([ + getMyTeam(userId), + getMyPendingInvitations(userId), + getSentInvitations(userId), + ]); + + return ( + + ); +} diff --git a/app/team/team-view.tsx b/app/team/team-view.tsx new file mode 100644 index 0000000..89c3496 --- /dev/null +++ b/app/team/team-view.tsx @@ -0,0 +1,447 @@ +"use client"; + +import Image from "next/image"; +import { useState, useTransition } from "react"; +import { useForm } from "react-hook-form"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { z } from "zod"; +import { toast } from "sonner"; +import { MHacksLogo } from "@/components/mhacks-logo"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, + AlertDialogTrigger, +} from "@/components/ui/alert-dialog"; +import { + createTeam, + inviteToTeam, + acceptInvitation, + declineInvitation, + cancelInvitation, + leaveTeam, +} from "@/lib/actions/team.server.actions"; +import { + MAX_TEAM_SIZE, + teamNameSchema, + inviteEmailSchema, + type TeamWithMembers, + type PendingInvitationSummary, + type SentInvitationSummary, +} from "@/lib/types/teams"; + +const MOSS = "#3A4A26"; +const MOSS_FADED = "rgba(58,74,38,0.6)"; +const BORDER = "#c8d4a8"; + +interface TeamViewProps { + currentUserId: string; + team: TeamWithMembers | null; + pendingInvitations: PendingInvitationSummary[]; + sentInvitations: SentInvitationSummary[]; +} + +function formatDate(iso: string) { + return new Date(iso).toLocaleDateString(); +} + +function errorMessage(err: unknown, fallback: string) { + return err instanceof Error ? err.message : fallback; +} + +const createTeamFormSchema = z.object({ name: teamNameSchema }); +type CreateTeamFormValues = z.infer; + +const inviteFormSchema = z.object({ email: inviteEmailSchema }); +type InviteFormValues = z.infer; + +export function TeamView({ + currentUserId, + team, + pendingInvitations, + sentInvitations, +}: TeamViewProps) { + const [isPending, startTransition] = useTransition(); + // Tracks which specific action is in flight (e.g. "accept:", + // "cancel:", "leave") so one button's loading state doesn't gate + // every other button on the page — mirrors connections-list.tsx's + // revokingId pattern, generalized to more than one action kind. + const [pendingKey, setPendingKey] = useState(null); + + const createForm = useForm({ + resolver: zodResolver(createTeamFormSchema), + defaultValues: { name: "" }, + }); + + const inviteForm = useForm({ + resolver: zodResolver(inviteFormSchema), + defaultValues: { email: "" }, + }); + + function runAction(key: string, fn: () => Promise) { + setPendingKey(key); + startTransition(async () => { + try { + await fn(); + } catch (err) { + toast.error(errorMessage(err, "Something went wrong.")); + } finally { + setPendingKey(null); + } + }); + } + + const onCreateTeam = createForm.handleSubmit((values) => { + runAction("create", async () => { + await createTeam(values.name); + toast.success("Team created."); + }); + }); + + const onInvite = inviteForm.handleSubmit((values) => { + runAction("invite", async () => { + await inviteToTeam(values.email); + toast.success("Invitation sent."); + inviteForm.reset(); + }); + }); + + function onAccept(invitation: PendingInvitationSummary) { + runAction(`accept:${invitation.id}`, async () => { + await acceptInvitation(invitation.id); + toast.success(`Joined ${invitation.teamName}.`); + }); + } + + function onDecline(invitation: PendingInvitationSummary) { + runAction(`decline:${invitation.id}`, async () => { + await declineInvitation(invitation.id); + toast.success("Invitation declined."); + }); + } + + function onCancel(invitation: SentInvitationSummary) { + runAction(`cancel:${invitation.id}`, async () => { + await cancelInvitation(invitation.id); + toast.success("Invitation cancelled."); + }); + } + + function onLeave() { + runAction("leave", async () => { + await leaveTeam(); + toast.success("You left the team."); + }); + } + + return ( +
+ +
+ + + +

+ {team ? team.team.name : "Your Team"} +

+

+ {team + ? "Invite up to 4 people total to hack together." + : "Create a team or accept an invitation to join one."} +

+
+ + + {team ? ( + <> + {/* Member list */} +
+ {team.members.map((member) => ( +
+
+

+ {member.name ?? member.email} + {member.userId === currentUserId ? " (you)" : ""} +

+

+ {member.email} · joined {formatDate(member.joinedAt)} +

+
+
+ ))} +
+ + {/* Invite form / full state */} + {team.members.length >= MAX_TEAM_SIZE ? ( +

+ Your team is full. +

+ ) : ( +
+ +
+ + +
+ {inviteForm.formState.errors.email ? ( +

+ {inviteForm.formState.errors.email.message} +

+ ) : null} +
+ )} + + {/* Sent invitations */} + {sentInvitations.length > 0 ? ( +
+

+ Sent invitations +

+ {sentInvitations.map((invitation) => ( +
+
+

+ {invitation.invitedName ?? invitation.invitedEmail} +

+

+ {invitation.status} · sent{" "} + {formatDate(invitation.createdAt)} +

+
+ {invitation.status === "pending" ? ( + + ) : null} +
+ ))} +
+ ) : null} + + {/* Leave team */} +
+ + + + + + + Leave this team? + + You'll need a new invitation to rejoin. If + you're the last member, the team will be deleted. + + + + Cancel + + Leave team + + + + +
+ + ) : ( + <> + {/* Create-team form */} +
+ +
+ + +
+ {createForm.formState.errors.name ? ( +

+ {createForm.formState.errors.name.message} +

+ ) : null} +
+ + {/* Pending invitations */} + {pendingInvitations.length > 0 ? ( +
+

+ Invitations for you +

+ {pendingInvitations.map((invitation) => ( +
+
+

+ {invitation.teamName} +

+

+ from {invitation.invitedByName} ·{" "} + {formatDate(invitation.createdAt)} +

+
+
+ + +
+
+ ))} +
+ ) : ( +

+ You don't have a team yet. Create one, or wait for an + invite. +

+ )} + + )} +
+
+
+ ); +} diff --git a/lib/actions/team.actions.ts b/lib/actions/team.actions.ts new file mode 100644 index 0000000..1c19856 --- /dev/null +++ b/lib/actions/team.actions.ts @@ -0,0 +1,458 @@ +import { and, asc, desc, eq, ne } from "drizzle-orm"; +import { sql } from "drizzle-orm"; +import { db } from "@/lib/db"; +import { + teams, + teamMembers, + teamInvitations, + type TeamRow, + type TeamInvitationRow, +} from "@/lib/db/schema/teams"; +import { users } from "@/lib/db/schema/users"; +import { hackerApplicants } from "@/lib/db/schema/applications"; +import { + MAX_TEAM_SIZE, + teamNameSchema, + inviteEmailSchema, + type TeamWithMembers, + type PendingInvitationSummary, + type SentInvitationSummary, +} from "@/lib/types/teams"; + +// Core team logic, parameterized by `userId`, following the same shape as +// application-form.actions.ts. Every mutation re-derives scope from `userId` +// itself — never trust a caller-supplied teamId — since these functions are +// called from server actions that run through the trusted `db` connection, +// not RLS-checked per request. + +const ALREADY_ON_A_TEAM = "You're already on a team — leave it first."; + +function isUniqueViolation(err: unknown): boolean { + return ( + typeof err === "object" && + err !== null && + "code" in err && + (err as { code?: unknown }).code === "23505" + ); +} + +function displayName( + firstName: string | null, + lastName: string | null, +): string | null { + const full = [firstName, lastName] + .filter((part): part is string => Boolean(part && part.trim().length > 0)) + .join(" "); + return full.length > 0 ? full : null; +} + +export async function createTeamForUser( + userId: string, + name: string, +): Promise { + const parsedName = teamNameSchema.parse(name); + + return db.transaction(async (tx) => { + const [existingMembership] = await tx + .select({ userId: teamMembers.userId }) + .from(teamMembers) + .where(eq(teamMembers.userId, userId)) + .limit(1); + if (existingMembership) { + throw new Error(ALREADY_ON_A_TEAM); + } + + try { + const [team] = await tx + .insert(teams) + .values({ name: parsedName, createdByUserId: userId }) + .returning(); + + await tx.insert(teamMembers).values({ userId, teamId: team.id }); + + return team; + } catch (err) { + if (isUniqueViolation(err)) { + throw new Error(ALREADY_ON_A_TEAM); + } + throw err; + } + }); +} + +export async function inviteToTeam( + userId: string, + email: string, +): Promise { + const normalizedEmail = inviteEmailSchema.parse(email); + + return db.transaction(async (tx) => { + const [membership] = await tx + .select({ teamId: teamMembers.teamId }) + .from(teamMembers) + .where(eq(teamMembers.userId, userId)) + .limit(1); + if (!membership) { + throw new Error("You need to be on a team to invite people."); + } + const callerTeamId = membership.teamId; + + const [invitedUser] = await tx + .select({ id: users.id, role: users.role }) + .from(users) + .where(sql`lower(${users.email}) = ${normalizedEmail}`) + .limit(1); + if (!invitedUser) { + throw new Error("No account found with that email."); + } + if (invitedUser.id === userId) { + throw new Error("You can't invite yourself."); + } + if (invitedUser.role !== "hacker") { + throw new Error("That account can't join a team."); + } + + const [invitedMembership] = await tx + .select({ teamId: teamMembers.teamId }) + .from(teamMembers) + .where(eq(teamMembers.userId, invitedUser.id)) + .limit(1); + if (invitedMembership) { + throw new Error( + invitedMembership.teamId === callerTeamId + ? "They're already on your team." + : "They're already on a team.", + ); + } + + // Lock the team row before counting — not protecting the hard 4-member + // invariant (acceptInvitation's lock does that), just stops the team + // from visibly sending more invites than it has open slots for. + const [team] = await tx + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.id, callerTeamId)) + .for("update"); + if (!team) { + throw new Error("Your team no longer exists."); + } + + const currentMembers = await tx + .select({ userId: teamMembers.userId }) + .from(teamMembers) + .where(eq(teamMembers.teamId, callerTeamId)); + if (currentMembers.length >= MAX_TEAM_SIZE) { + throw new Error("Your team is full."); + } + + const [invitation] = await tx + .insert(teamInvitations) + .values({ + teamId: callerTeamId, + invitedUserId: invitedUser.id, + invitedByUserId: userId, + }) + .returning(); + + return invitation; + }); +} + +export async function acceptInvitation( + userId: string, + invitationId: string, +): Promise { + await db.transaction(async (tx) => { + const [invitation] = await tx + .select() + .from(teamInvitations) + .where(eq(teamInvitations.id, invitationId)) + .for("update"); + if (!invitation) { + throw new Error("Invitation not found."); + } + if (invitation.status !== "pending") { + throw new Error("This invitation is no longer pending."); + } + if (invitation.invitedUserId !== userId) { + throw new Error("This invitation isn't addressed to you."); + } + + const [existingMembership] = await tx + .select({ userId: teamMembers.userId }) + .from(teamMembers) + .where(eq(teamMembers.userId, userId)) + .limit(1); + if (existingMembership) { + throw new Error(ALREADY_ON_A_TEAM); + } + + // Lock the target team row — the same lock inviteToTeam takes — so two + // different pending invitations to this team can't both read a stale + // member count and both get accepted past the 4-person cap. This also + // serializes against a concurrent leaveTeam on the same team (see the + // leave-vs-accept scenario in the plan). + const [team] = await tx + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.id, invitation.teamId)) + .for("update"); + if (!team) { + throw new Error("This team no longer exists."); + } + + const currentMembers = await tx + .select({ userId: teamMembers.userId }) + .from(teamMembers) + .where(eq(teamMembers.teamId, invitation.teamId)); + if (currentMembers.length >= MAX_TEAM_SIZE) { + throw new Error("That team is full."); + } + + const now = new Date().toISOString(); + + try { + await tx + .insert(teamMembers) + .values({ userId, teamId: invitation.teamId }); + } catch (err) { + if (isUniqueViolation(err)) { + throw new Error(ALREADY_ON_A_TEAM); + } + throw err; + } + + await tx + .update(teamInvitations) + .set({ status: "accepted", respondedAt: now }) + .where(eq(teamInvitations.id, invitationId)); + + // A user can only be on one team — cancel their other pending invites + // so they don't dangle as unacceptable. + await tx + .update(teamInvitations) + .set({ status: "cancelled", respondedAt: now }) + .where( + and( + eq(teamInvitations.invitedUserId, userId), + eq(teamInvitations.status, "pending"), + ne(teamInvitations.id, invitationId), + ), + ); + }); +} + +export async function declineInvitation( + userId: string, + invitationId: string, +): Promise { + const now = new Date().toISOString(); + const result = await db + .update(teamInvitations) + .set({ status: "declined", respondedAt: now }) + .where( + and( + eq(teamInvitations.id, invitationId), + eq(teamInvitations.invitedUserId, userId), + eq(teamInvitations.status, "pending"), + ), + ) + .returning({ id: teamInvitations.id }); + + if (result.length === 0) { + throw new Error("Invitation not found or already handled."); + } +} + +export async function cancelInvitation( + userId: string, + invitationId: string, +): Promise { + const [membership] = await db + .select({ teamId: teamMembers.teamId }) + .from(teamMembers) + .where(eq(teamMembers.userId, userId)) + .limit(1); + if (!membership) { + throw new Error("You're not on a team."); + } + + const now = new Date().toISOString(); + const result = await db + .update(teamInvitations) + .set({ status: "cancelled", respondedAt: now }) + .where( + and( + eq(teamInvitations.id, invitationId), + eq(teamInvitations.teamId, membership.teamId), + eq(teamInvitations.status, "pending"), + ), + ) + .returning({ id: teamInvitations.id }); + + if (result.length === 0) { + throw new Error("Invitation not found or already handled."); + } +} + +export async function leaveTeam(userId: string): Promise { + await db.transaction(async (tx) => { + const [membership] = await tx + .select({ teamId: teamMembers.teamId }) + .from(teamMembers) + .where(eq(teamMembers.userId, userId)) + .limit(1); + if (!membership) { + throw new Error("You're not on a team."); + } + + // Lock the team row first — deleting just this user's own team_members + // row (keyed by user_id) doesn't block a teammate doing the same thing + // concurrently, which is what causes the empty-team orphan race. This + // lock is also what serializes against a concurrent acceptInvitation + // landing on the same team. + const [team] = await tx + .select({ id: teams.id }) + .from(teams) + .where(eq(teams.id, membership.teamId)) + .for("update"); + if (!team) { + return; + } + + await tx.delete(teamMembers).where(eq(teamMembers.userId, userId)); + + const remainingMembers = await tx + .select({ userId: teamMembers.userId }) + .from(teamMembers) + .where(eq(teamMembers.teamId, membership.teamId)); + + if (remainingMembers.length === 0) { + await tx.delete(teams).where(eq(teams.id, membership.teamId)); + } + }); +} + +export async function getMyTeam( + userId: string, +): Promise { + const [membership] = await db + .select({ teamId: teamMembers.teamId }) + .from(teamMembers) + .where(eq(teamMembers.userId, userId)) + .limit(1); + if (!membership) return null; + + const [team] = await db + .select() + .from(teams) + .where(eq(teams.id, membership.teamId)) + .limit(1); + if (!team) return null; + + const memberRows = await db + .select({ + userId: teamMembers.userId, + joinedAt: teamMembers.joinedAt, + email: users.email, + firstName: hackerApplicants.firstName, + lastName: hackerApplicants.lastName, + }) + .from(teamMembers) + .innerJoin(users, eq(users.id, teamMembers.userId)) + .leftJoin(hackerApplicants, eq(hackerApplicants.userId, teamMembers.userId)) + .where(eq(teamMembers.teamId, team.id)) + .orderBy(asc(teamMembers.joinedAt)); + + return { + team, + members: memberRows.map((row) => ({ + userId: row.userId, + email: row.email, + name: displayName(row.firstName, row.lastName), + joinedAt: row.joinedAt, + })), + }; +} + +export async function getMyPendingInvitations( + userId: string, +): Promise { + const rows = await db + .select({ + id: teamInvitations.id, + teamId: teamInvitations.teamId, + teamName: teams.name, + createdAt: teamInvitations.createdAt, + inviterEmail: users.email, + inviterFirstName: hackerApplicants.firstName, + inviterLastName: hackerApplicants.lastName, + }) + .from(teamInvitations) + .innerJoin(teams, eq(teams.id, teamInvitations.teamId)) + // invitedByUserId is nullable (onDelete: "set null") — leftJoin, not an + // inner join, so the invitation doesn't vanish if the inviter's account + // is ever removed (see the Data Model "audit only" rationale). + .leftJoin(users, eq(users.id, teamInvitations.invitedByUserId)) + .leftJoin( + hackerApplicants, + eq(hackerApplicants.userId, teamInvitations.invitedByUserId), + ) + .where( + and( + eq(teamInvitations.invitedUserId, userId), + eq(teamInvitations.status, "pending"), + ), + ) + .orderBy(desc(teamInvitations.createdAt)); + + return rows.map((row) => ({ + id: row.id, + teamId: row.teamId, + teamName: row.teamName, + createdAt: row.createdAt, + invitedByName: + displayName(row.inviterFirstName, row.inviterLastName) ?? + row.inviterEmail ?? + "someone", + })); +} + +export async function getSentInvitations( + userId: string, +): Promise { + const [membership] = await db + .select({ teamId: teamMembers.teamId }) + .from(teamMembers) + .where(eq(teamMembers.userId, userId)) + .limit(1); + if (!membership) return []; + + const rows = await db + .select({ + id: teamInvitations.id, + status: teamInvitations.status, + createdAt: teamInvitations.createdAt, + respondedAt: teamInvitations.respondedAt, + invitedEmail: users.email, + invitedFirstName: hackerApplicants.firstName, + invitedLastName: hackerApplicants.lastName, + }) + .from(teamInvitations) + .innerJoin(users, eq(users.id, teamInvitations.invitedUserId)) + .leftJoin( + hackerApplicants, + eq(hackerApplicants.userId, teamInvitations.invitedUserId), + ) + .where(eq(teamInvitations.teamId, membership.teamId)) + .orderBy(desc(teamInvitations.createdAt)); + + return rows.map((row) => ({ + id: row.id, + status: row.status, + createdAt: row.createdAt, + respondedAt: row.respondedAt, + invitedEmail: row.invitedEmail, + invitedName: displayName(row.invitedFirstName, row.invitedLastName), + })); +} diff --git a/lib/actions/team.server.actions.ts b/lib/actions/team.server.actions.ts new file mode 100644 index 0000000..6e09b3b --- /dev/null +++ b/lib/actions/team.server.actions.ts @@ -0,0 +1,109 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { requireSessionUser } from "@/lib/auth/guards"; +import { + createTeamForUser, + inviteToTeam as inviteToTeamForUser, + acceptInvitation as acceptInvitationForUser, + declineInvitation as declineInvitationForUser, + cancelInvitation as cancelInvitationForUser, + leaveTeam as leaveTeamForUser, + getMyTeam as getMyTeamForUser, + getMyPendingInvitations as getMyPendingInvitationsForUser, + getSentInvitations as getSentInvitationsForUser, +} from "@/lib/actions/team.actions"; +import type { TeamRow } from "@/lib/db/schema/teams"; +import type { + TeamWithMembers, + PendingInvitationSummary, + SentInvitationSummary, +} from "@/lib/types/teams"; + +function toActionError(error: unknown, fallback: string): Error { + console.error(fallback, error); + return new Error(error instanceof Error ? error.message : fallback); +} + +export const createTeam = async (name: string): Promise => { + const { id: userId } = await requireSessionUser(); + try { + const team = await createTeamForUser(userId, name); + revalidatePath("/team"); + return team; + } catch (error) { + throw toActionError(error, "Failed to create team"); + } +}; + +export const inviteToTeam = async (email: string): Promise<{ id: string }> => { + const { id: userId } = await requireSessionUser(); + try { + const invitation = await inviteToTeamForUser(userId, email); + revalidatePath("/team"); + return { id: invitation.id }; + } catch (error) { + throw toActionError(error, "Failed to send invitation"); + } +}; + +export const acceptInvitation = async (invitationId: string): Promise => { + const { id: userId } = await requireSessionUser(); + try { + await acceptInvitationForUser(userId, invitationId); + revalidatePath("/team"); + } catch (error) { + throw toActionError(error, "Failed to accept invitation"); + } +}; + +export const declineInvitation = async ( + invitationId: string, +): Promise => { + const { id: userId } = await requireSessionUser(); + try { + await declineInvitationForUser(userId, invitationId); + revalidatePath("/team"); + } catch (error) { + throw toActionError(error, "Failed to decline invitation"); + } +}; + +export const cancelInvitation = async (invitationId: string): Promise => { + const { id: userId } = await requireSessionUser(); + try { + await cancelInvitationForUser(userId, invitationId); + revalidatePath("/team"); + } catch (error) { + throw toActionError(error, "Failed to cancel invitation"); + } +}; + +export const leaveTeam = async (): Promise => { + const { id: userId } = await requireSessionUser(); + try { + await leaveTeamForUser(userId); + revalidatePath("/team"); + } catch (error) { + throw toActionError(error, "Failed to leave team"); + } +}; + +export const getMyTeam = async (): Promise => { + const { id: userId } = await requireSessionUser(); + return getMyTeamForUser(userId); +}; + +export const getMyPendingInvitations = async (): Promise< + PendingInvitationSummary[] +> => { + const { id: userId } = await requireSessionUser(); + return getMyPendingInvitationsForUser(userId); +}; + +export const getSentInvitations = async (): Promise< + SentInvitationSummary[] +> => { + const { id: userId } = await requireSessionUser(); + return getSentInvitationsForUser(userId); +}; diff --git a/lib/auth/guards.ts b/lib/auth/guards.ts index dbb3bda..ba28ab4 100644 --- a/lib/auth/guards.ts +++ b/lib/auth/guards.ts @@ -19,3 +19,9 @@ export async function requireOrganizerPage(): Promise { if (user.role !== "organizer") redirect("/apply"); return user; } + +export async function requireHackerPage(): Promise { + const user = await requireSessionUser(); + if (user.role !== "hacker") redirect("/apply"); + return user; +} diff --git a/lib/db/index.ts b/lib/db/index.ts index 7b6955c..77d6b33 100644 --- a/lib/db/index.ts +++ b/lib/db/index.ts @@ -2,6 +2,7 @@ import { drizzle } from "drizzle-orm/postgres-js"; import postgres from "postgres"; import * as applicationsSchema from "./schema/applications"; import * as usersSchema from "./schema/users"; +import * as teamsSchema from "./schema/teams"; // Disable prefetch — prepared statements are not supported in Supabase's // "Transaction" pool mode (the pooled connection string on port 6543). @@ -9,5 +10,5 @@ const client = postgres(process.env.DATABASE_URL ?? "", { prepare: false }); export const db = drizzle({ client, - schema: { ...applicationsSchema, ...usersSchema }, + schema: { ...applicationsSchema, ...usersSchema, ...teamsSchema }, }); diff --git a/lib/db/schema/teams.ts b/lib/db/schema/teams.ts new file mode 100644 index 0000000..5d685a4 --- /dev/null +++ b/lib/db/schema/teams.ts @@ -0,0 +1,140 @@ +import { + pgTable, + pgEnum, + pgPolicy, + uuid, + text, + timestamp, + foreignKey, + index, +} from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { authUid, authenticatedRole } from "drizzle-orm/supabase"; +import { isOrganizer } from "./rls"; +import { users } from "./users"; + +export const teams = pgTable( + "teams", + { + id: uuid().defaultRandom().primaryKey().notNull(), + name: text().notNull(), + createdByUserId: uuid("created_by_user_id"), // audit only — no owner/permission semantics, anyone on the team can invite/leave + createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + }, + (table) => [ + foreignKey({ + columns: [table.createdByUserId], + foreignColumns: [users.id], + name: "teams_created_by_user_id_users_id_fk", + }).onDelete("set null"), + pgPolicy("teams_select_member_or_organizer", { + for: "select", + to: authenticatedRole, + using: sql`exists ( + select 1 from team_members + where team_members.team_id = ${table.id} + and team_members.user_id = ${authUid} + ) OR ${isOrganizer}`, + }), + ], +).enableRLS(); + +export const teamMembers = pgTable( + "team_members", + { + // userId as the PK (not a team_id, user_id composite) is what makes "one + // team per user" a real DB guarantee instead of an app-level check. + userId: uuid("user_id").primaryKey().notNull(), + teamId: uuid("team_id").notNull(), + joinedAt: timestamp("joined_at", { withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + }, + (table) => [ + foreignKey({ + columns: [table.userId], + foreignColumns: [users.id], + name: "team_members_user_id_users_id_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [table.teamId], + foreignColumns: [teams.id], + name: "team_members_team_id_teams_id_fk", + }).onDelete("cascade"), + // supports leaveTeam's "count remaining members for this team" scan + index("team_members_team_id_idx").on(table.teamId), + pgPolicy("team_members_select_teammates_or_organizer", { + for: "select", + to: authenticatedRole, + using: sql`${table.teamId} in ( + select team_id from team_members where user_id = ${authUid} + ) OR ${isOrganizer}`, + }), + ], +).enableRLS(); + +export const teamInvitationStatus = pgEnum("team_invitation_status", [ + "pending", + "accepted", + "declined", + "cancelled", +]); +export type TeamInvitationStatus = + (typeof teamInvitationStatus.enumValues)[number]; + +export const teamInvitations = pgTable( + "team_invitations", + { + id: uuid().defaultRandom().primaryKey().notNull(), + teamId: uuid("team_id").notNull(), + invitedUserId: uuid("invited_user_id").notNull(), + invitedByUserId: uuid("invited_by_user_id"), // audit only, same rationale as teams.createdByUserId + status: teamInvitationStatus().default("pending").notNull(), + createdAt: timestamp("created_at", { withTimezone: true, mode: "string" }) + .defaultNow() + .notNull(), + respondedAt: timestamp("responded_at", { + withTimezone: true, + mode: "string", + }), // set on accept, decline, or cancel + }, + (table) => [ + foreignKey({ + columns: [table.teamId], + foreignColumns: [teams.id], + name: "team_invitations_team_id_teams_id_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [table.invitedUserId], + foreignColumns: [users.id], + name: "team_invitations_invited_user_id_users_id_fk", + }).onDelete("cascade"), + foreignKey({ + columns: [table.invitedByUserId], + foreignColumns: [users.id], + name: "team_invitations_invited_by_user_id_users_id_fk", + }).onDelete("set null"), // preserve the invitation record itself if the inviter's account is later removed + // supports getMyPendingInvitations (invitee's inbox) + index("team_invitations_invited_user_id_idx").on(table.invitedUserId), + // supports getSentInvitations (team's outbox) + index("team_invitations_team_id_idx").on(table.teamId), + pgPolicy("team_invitations_select_own_or_team_or_organizer", { + for: "select", + to: authenticatedRole, + using: sql`${table.invitedUserId} = ${authUid} + OR ${table.teamId} in ( + select team_id from team_members where user_id = ${authUid} + ) + OR ${isOrganizer}`, + }), + ], +).enableRLS(); + +export type TeamRow = typeof teams.$inferSelect; +export type NewTeam = typeof teams.$inferInsert; +export type TeamMemberRow = typeof teamMembers.$inferSelect; +export type NewTeamMember = typeof teamMembers.$inferInsert; +export type TeamInvitationRow = typeof teamInvitations.$inferSelect; +export type NewTeamInvitation = typeof teamInvitations.$inferInsert; diff --git a/lib/types/teams.ts b/lib/types/teams.ts new file mode 100644 index 0000000..38036fc --- /dev/null +++ b/lib/types/teams.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; +import type { TeamRow, TeamInvitationStatus } from "@/lib/db/schema/teams"; + +export const MAX_TEAM_SIZE = 4; + +export const teamNameSchema = z + .string() + .trim() + .min(1, "Team name is required") + .max(60, "Team name must be 60 characters or fewer"); + +export const inviteEmailSchema = z + .string() + .trim() + .toLowerCase() + .email("Enter a valid email address"); + +// `users` has no name column — display name is best-effort, derived from a +// submitted application's firstName/lastName when one exists (a hacker can +// have a team before finishing their application), falling back to email. +export type TeamMemberSummary = { + userId: string; + email: string; + name: string | null; + joinedAt: string; +}; + +export type TeamWithMembers = { + team: TeamRow; + members: TeamMemberSummary[]; +}; + +export type PendingInvitationSummary = { + id: string; + teamId: string; + teamName: string; + invitedByName: string; + createdAt: string; +}; + +export type SentInvitationSummary = { + id: string; + invitedEmail: string; + invitedName: string | null; + status: TeamInvitationStatus; + createdAt: string; + respondedAt: string | null; +}; diff --git a/supabase/migrations/20260804011011_cynical_black_widow.sql b/supabase/migrations/20260804011011_cynical_black_widow.sql new file mode 100644 index 0000000..a9dfe74 --- /dev/null +++ b/supabase/migrations/20260804011011_cynical_black_widow.sql @@ -0,0 +1,64 @@ +CREATE TYPE "public"."team_invitation_status" AS ENUM('pending', 'accepted', 'declined', 'cancelled');--> statement-breakpoint +CREATE TABLE "team_invitations" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "team_id" uuid NOT NULL, + "invited_user_id" uuid NOT NULL, + "invited_by_user_id" uuid, + "status" "team_invitation_status" DEFAULT 'pending' NOT NULL, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "responded_at" timestamp with time zone +); +--> statement-breakpoint +ALTER TABLE "team_invitations" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "team_members" ( + "user_id" uuid PRIMARY KEY NOT NULL, + "team_id" uuid NOT NULL, + "joined_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "team_members" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +CREATE TABLE "teams" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "name" text NOT NULL, + "created_by_user_id" uuid, + "created_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "teams" ENABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "team_invitations" ADD CONSTRAINT "team_invitations_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_invitations" ADD CONSTRAINT "team_invitations_invited_user_id_users_id_fk" FOREIGN KEY ("invited_user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_invitations" ADD CONSTRAINT "team_invitations_invited_by_user_id_users_id_fk" FOREIGN KEY ("invited_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "team_members" ADD CONSTRAINT "team_members_team_id_teams_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."teams"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "teams" ADD CONSTRAINT "teams_created_by_user_id_users_id_fk" FOREIGN KEY ("created_by_user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "team_invitations_invited_user_id_idx" ON "team_invitations" USING btree ("invited_user_id");--> statement-breakpoint +CREATE INDEX "team_invitations_team_id_idx" ON "team_invitations" USING btree ("team_id");--> statement-breakpoint +CREATE INDEX "team_members_team_id_idx" ON "team_members" USING btree ("team_id");--> statement-breakpoint +CREATE POLICY "team_invitations_select_own_or_team_or_organizer" ON "team_invitations" AS PERMISSIVE FOR SELECT TO "authenticated" USING ("team_invitations"."invited_user_id" = (select auth.uid()) + OR "team_invitations"."team_id" in ( + select team_id from team_members where user_id = (select auth.uid()) + ) + OR exists ( + select 1 + from public.users + where id = (select auth.uid()) + and role = 'organizer' +));--> statement-breakpoint +CREATE POLICY "team_members_select_teammates_or_organizer" ON "team_members" AS PERMISSIVE FOR SELECT TO "authenticated" USING ("team_members"."team_id" in ( + select team_id from team_members where user_id = (select auth.uid()) + ) OR exists ( + select 1 + from public.users + where id = (select auth.uid()) + and role = 'organizer' +));--> statement-breakpoint +CREATE POLICY "teams_select_member_or_organizer" ON "teams" AS PERMISSIVE FOR SELECT TO "authenticated" USING (exists ( + select 1 from team_members + where team_members.team_id = "teams"."id" + and team_members.user_id = (select auth.uid()) + ) OR exists ( + select 1 + from public.users + where id = (select auth.uid()) + and role = 'organizer' +)); \ No newline at end of file diff --git a/supabase/migrations/meta/20260804011011_snapshot.json b/supabase/migrations/meta/20260804011011_snapshot.json new file mode 100644 index 0000000..7c3f345 --- /dev/null +++ b/supabase/migrations/meta/20260804011011_snapshot.json @@ -0,0 +1,1056 @@ +{ + "id": "8eac3aea-972e-41cc-9050-9fa2c544ddf8", + "prevId": "61cf0084-c212-43e7-9983-13dea93ce17b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.hacker_applicants": { + "name": "hacker_applicants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "phone_number": { + "name": "phone_number", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ethnicity": { + "name": "ethnicity", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "university": { + "name": "university", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "country": { + "name": "country", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "degree": { + "name": "degree", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "graduation_year": { + "name": "graduation_year", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "previous_hackathons": { + "name": "previous_hackathons", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "major": { + "name": "major", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume": { + "name": "resume", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "what_would_you_do": { + "name": "what_would_you_do", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "why_mhacks": { + "name": "why_mhacks", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hill_to_die_on": { + "name": "hill_to_die_on", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "anything_else": { + "name": "anything_else", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transportation_type": { + "name": "transportation_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "coming_from": { + "name": "coming_from", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "shirt_size": { + "name": "shirt_size", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergies_description": { + "name": "allergies_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "needs_travel_reimbursement": { + "name": "needs_travel_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "would_attend_without_reimbursement": { + "name": "would_attend_without_reimbursement", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "airport_code": { + "name": "airport_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "github": { + "name": "github", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "linkedin": { + "name": "linkedin", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "personal_site": { + "name": "personal_site", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "follows_instagram": { + "name": "follows_instagram", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "sponsor_emails": { + "name": "sponsor_emails", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_applicants_user_id_users_id_fk": { + "name": "hacker_applicants_user_id_users_id_fk", + "tableFrom": "hacker_applicants", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_applicants_user_id_unique": { + "name": "hacker_applicants_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": { + "hacker_applicants_select_own_or_organizer": { + "name": "hacker_applicants_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_applicants\".\"user_id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_applicants_update_organizer": { + "name": "hacker_applicants_update_organizer", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_drafts": { + "name": "hacker_application_drafts", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_drafts_user_id_fkey": { + "name": "hacker_application_drafts_user_id_fkey", + "tableFrom": "hacker_application_drafts", + "tableTo": "users", + "schemaTo": "auth", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_drafts_select_own": { + "name": "hacker_application_drafts_select_own", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_insert_own": { + "name": "hacker_application_drafts_insert_own", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_update_own": { + "name": "hacker_application_drafts_update_own", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())", + "withCheck": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + }, + "hacker_application_drafts_delete_own": { + "name": "hacker_application_drafts_delete_own", + "as": "PERMISSIVE", + "for": "DELETE", + "to": [ + "authenticated" + ], + "using": "\"hacker_application_drafts\".\"user_id\" = (select auth.uid())" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_review_events": { + "name": "hacker_application_review_events", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "review_id": { + "name": "review_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "review_event_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changes": { + "name": "changes", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "hacker_application_review_events_application_id_created_at_idx": { + "name": "hacker_application_review_events_application_id_created_at_idx", + "columns": [ + { + "expression": "application_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "hacker_application_review_events_review_id_fkey": { + "name": "hacker_application_review_events_review_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_application_reviews", + "columnsFrom": [ + "review_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_application_id_fkey": { + "name": "hacker_application_review_events_application_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_review_events_reviewer_user_id_fkey": { + "name": "hacker_application_review_events_reviewer_user_id_fkey", + "tableFrom": "hacker_application_review_events", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "hacker_application_review_events_organizer_select": { + "name": "hacker_application_review_events_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_review_events_organizer_insert": { + "name": "hacker_application_review_events_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.hacker_application_reviews": { + "name": "hacker_application_reviews", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "reviewer_user_id": { + "name": "reviewer_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "effort_rating": { + "name": "effort_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "builder_rating": { + "name": "builder_rating", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "flagged_for_review": { + "name": "flagged_for_review", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "review_comments": { + "name": "review_comments", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "hacker_application_reviews_application_id_fkey": { + "name": "hacker_application_reviews_application_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "hacker_applicants", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "hacker_application_reviews_reviewer_user_id_fkey": { + "name": "hacker_application_reviews_reviewer_user_id_fkey", + "tableFrom": "hacker_application_reviews", + "tableTo": "users", + "columnsFrom": [ + "reviewer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "hacker_application_reviews_application_id_unique": { + "name": "hacker_application_reviews_application_id_unique", + "nullsNotDistinct": false, + "columns": [ + "application_id" + ] + } + }, + "policies": { + "hacker_application_reviews_organizer_select": { + "name": "hacker_application_reviews_organizer_select", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_insert": { + "name": "hacker_application_reviews_organizer_insert", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + }, + "hacker_application_reviews_organizer_update": { + "name": "hacker_application_reviews_organizer_update", + "as": "PERMISSIVE", + "for": "UPDATE", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)", + "withCheck": "exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.team_invitations": { + "name": "team_invitations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_user_id": { + "name": "invited_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "invited_by_user_id": { + "name": "invited_by_user_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "team_invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "responded_at": { + "name": "responded_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_invitations_invited_user_id_idx": { + "name": "team_invitations_invited_user_id_idx", + "columns": [ + { + "expression": "invited_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_invitations_team_id_idx": { + "name": "team_invitations_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_invitations_team_id_teams_id_fk": { + "name": "team_invitations_team_id_teams_id_fk", + "tableFrom": "team_invitations", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_invitations_invited_user_id_users_id_fk": { + "name": "team_invitations_invited_user_id_users_id_fk", + "tableFrom": "team_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_invitations_invited_by_user_id_users_id_fk": { + "name": "team_invitations_invited_by_user_id_users_id_fk", + "tableFrom": "team_invitations", + "tableTo": "users", + "columnsFrom": [ + "invited_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "team_invitations_select_own_or_team_or_organizer": { + "name": "team_invitations_select_own_or_team_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"team_invitations\".\"invited_user_id\" = (select auth.uid())\n OR \"team_invitations\".\"team_id\" in (\n select team_id from team_members where user_id = (select auth.uid())\n )\n OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.team_members": { + "name": "team_members", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "team_members_team_id_idx": { + "name": "team_members_team_id_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_members_user_id_users_id_fk": { + "name": "team_members_user_id_users_id_fk", + "tableFrom": "team_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "team_members_team_id_teams_id_fk": { + "name": "team_members_team_id_teams_id_fk", + "tableFrom": "team_members", + "tableTo": "teams", + "columnsFrom": [ + "team_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "team_members_select_teammates_or_organizer": { + "name": "team_members_select_teammates_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"team_members\".\"team_id\" in (\n select team_id from team_members where user_id = (select auth.uid())\n ) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.teams": { + "name": "teams", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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()" + } + }, + "indexes": {}, + "foreignKeys": { + "teams_created_by_user_id_users_id_fk": { + "name": "teams_created_by_user_id_users_id_fk", + "tableFrom": "teams", + "tableTo": "users", + "columnsFrom": [ + "created_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": { + "teams_select_member_or_organizer": { + "name": "teams_select_member_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "exists (\n select 1 from team_members\n where team_members.team_id = \"teams\".\"id\"\n and team_members.user_id = (select auth.uid())\n ) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'hacker'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": { + "users_select_own_or_organizer": { + "name": "users_select_own_or_organizer", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "\"users\".\"id\" = (select auth.uid()) OR exists (\n select 1\n from public.users\n where id = (select auth.uid())\n and role = 'organizer'\n)" + } + }, + "checkConstraints": {}, + "isRLSEnabled": true + } + }, + "enums": { + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "pending", + "reviewed", + "flagged" + ] + }, + "public.review_event_type": { + "name": "review_event_type", + "schema": "public", + "values": [ + "draft_saved", + "review_completed" + ] + }, + "public.team_invitation_status": { + "name": "team_invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "declined", + "cancelled" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "hacker", + "organizer" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": { + "organizers_receive_review_realtime": { + "name": "organizers_receive_review_realtime", + "as": "PERMISSIVE", + "for": "SELECT", + "to": [ + "authenticated" + ], + "using": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + }, + "organizers_send_review_realtime": { + "name": "organizers_send_review_realtime", + "as": "PERMISSIVE", + "for": "INSERT", + "to": [ + "authenticated" + ], + "withCheck": "public.is_organizer() AND (\n realtime.topic() = 'application-review:dashboard'\n OR realtime.topic() LIKE 'application-review:%'\n)", + "schema": "realtime", + "on": "\"realtime\".\"messages\"" + } + }, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/supabase/migrations/meta/_journal.json b/supabase/migrations/meta/_journal.json index 1edbf38..c1215d6 100644 --- a/supabase/migrations/meta/_journal.json +++ b/supabase/migrations/meta/_journal.json @@ -29,6 +29,13 @@ "when": 1784082737209, "tag": "20260715023217_is_organizer_realtime_and_triggers", "breakpoints": true + }, + { + "idx": 4, + "version": "7", + "when": 1785805811392, + "tag": "20260804011011_cynical_black_widow", + "breakpoints": true } ] -} +} \ No newline at end of file