diff --git a/client/src/App.tsx b/client/src/App.tsx index 9a334ed..79fdad3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -9,6 +9,7 @@ * * Public routes (no auth required): * - `/sign-in/*`, `/sign-up/*` — Clerk's hosted auth pages + * - `/invite/:token` — shareable, temporary invite link landing page * * `RootRoute` (mounted at `/`) decides where to send a signed-in user: * usually `DashboardPage` inside AppShell, but it can also bounce to @@ -30,6 +31,7 @@ import { AppShell } from "./components/AppShell"; import { AccountModalProvider } from "./components/AccountModal"; import { SignInPage } from "./pages/SignInPage"; import { SignUpPage } from "./pages/SignUpPage"; +import { InviteLinkPage } from "./pages/InviteLinkPage"; import { LeaguesPage } from "./pages/LeaguesPage"; import { TeamPage } from "./pages/TeamPage"; import { LeaguePage } from "./pages/LeaguePage"; @@ -54,6 +56,7 @@ export default function App() { } /> } /> + } /> } /> {/* Account/settings are surfaced via the AccountModal now; redirect any lingering deep links back to the dashboard. */} diff --git a/client/src/hooks/useHuddles.ts b/client/src/hooks/useHuddles.ts index a6631ab..96d4178 100644 --- a/client/src/hooks/useHuddles.ts +++ b/client/src/hooks/useHuddles.ts @@ -18,6 +18,7 @@ import type { DuesPayment, DuesResponse, CountdownConfig, + InviteLinkHuddle, } from "../types/huddle"; function authHeader(token: string | null): Record { @@ -245,6 +246,66 @@ export function useRotateInviteCode() { }); } +export function useGenerateInviteLink() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { huddleId: string }) => { + const token = await getToken(); + try { + const res = await axios.post<{ huddle: Huddle }>( + `/api/huddles/${input.huddleId}/invite-link`, + {}, + { headers: authHeader(token) }, + ); + return res.data.huddle; + } catch (err) { + throw new Error(errorMessage(err, "Failed to generate invite link")); + } + }, + onSuccess: (huddle) => { + queryClient.invalidateQueries({ queryKey: ["huddle", huddle.id] }); + queryClient.invalidateQueries({ queryKey: ["huddles"] }); + }, + }); +} + +export function useRevokeInviteLink() { + const { getToken } = useAuth(); + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: async (input: { huddleId: string }) => { + const token = await getToken(); + try { + const res = await axios.delete<{ huddle: Huddle }>( + `/api/huddles/${input.huddleId}/invite-link`, + { headers: authHeader(token) }, + ); + return res.data.huddle; + } catch (err) { + throw new Error(errorMessage(err, "Failed to revoke invite link")); + } + }, + onSuccess: (huddle) => { + queryClient.invalidateQueries({ queryKey: ["huddle", huddle.id] }); + queryClient.invalidateQueries({ queryKey: ["huddles"] }); + }, + }); +} + +/** Public lookup for the /invite/:token landing page — no auth required. */ +export function useInviteLinkLookup(token: string | null) { + return useQuery({ + queryKey: ["invite-link", token], + queryFn: async () => { + const res = await axios.get<{ huddle: InviteLinkHuddle }>(`/api/invite-links/${token}`); + return res.data.huddle; + }, + enabled: !!token, + retry: false, + }); +} + export function useLookupHuddleByCode() { const { getToken } = useAuth(); return useMutation({ diff --git a/client/src/pages/CommissionerPage.tsx b/client/src/pages/CommissionerPage.tsx index b8e70bc..846ef43 100644 --- a/client/src/pages/CommissionerPage.tsx +++ b/client/src/pages/CommissionerPage.tsx @@ -28,6 +28,8 @@ import { useHuddlePendingClaims, useDecideClaim, useRotateInviteCode, + useGenerateInviteLink, + useRevokeInviteLink, useAddCommissioner, useRemoveCommissioner, useDeleteHuddle, @@ -374,6 +376,120 @@ function InviteCodePanel({ ); } +function InviteLinkPanel({ + huddleId, + inviteLinkToken, + inviteLinkExpiresAt, +}: { + huddleId: string; + inviteLinkToken?: string | null; + inviteLinkExpiresAt?: string | null; +}) { + const generate = useGenerateInviteLink(); + const revoke = useRevokeInviteLink(); + const [copied, setCopied] = useState(false); + const [confirmingRegenerate, setConfirmingRegenerate] = useState(false); + const [confirmingRevoke, setConfirmingRevoke] = useState(false); + + const token = generate.data?.inviteLinkToken ?? inviteLinkToken; + const expiresAt = generate.data?.inviteLinkExpiresAt ?? inviteLinkExpiresAt; + const hasActiveLink = !!token && !!expiresAt && new Date(expiresAt).getTime() > Date.now(); + const link = token ? `${window.location.origin}/invite/${token}` : null; + + const handleCopy = () => { + if (link) { + navigator.clipboard.writeText(link); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }; + + return ( + + + {hasActiveLink && link ? ( + <> +
+
+ {link} +
+ + {copied ? "Copied!" : "Copy"} + +
+

+ Expires {new Date(expiresAt!).toLocaleString()} +

+ + ) : ( +

No active invite link.

+ )} + + {!confirmingRegenerate && !confirmingRevoke && ( +
+ setConfirmingRegenerate(true)}> + {hasActiveLink ? "Regenerate…" : "Generate link"} + + {hasActiveLink && setConfirmingRevoke(true)}>Revoke} +
+ )} + + {confirmingRegenerate && ( +
+

+ {hasActiveLink + ? "The old link stops working immediately. Continue?" + : "Generate a new invite link?"} +

+
+ setConfirmingRegenerate(false)} disabled={generate.isPending}> + Cancel + + + generate.mutate({ huddleId }, { onSuccess: () => setConfirmingRegenerate(false) }) + } + disabled={generate.isPending} + > + {generate.isPending ? "Generating…" : "Yes, generate"} + +
+
+ )} + + {confirmingRevoke && ( +
+

+ This link stops working immediately. Continue? +

+
+ setConfirmingRevoke(false)} disabled={revoke.isPending}> + Cancel + + + revoke.mutate({ huddleId }, { onSuccess: () => setConfirmingRevoke(false) }) + } + disabled={revoke.isPending} + > + {revoke.isPending ? "Revoking…" : "Yes, revoke"} + +
+
+ )} + + {(generate.isError || revoke.isError) && ( +

+ {((generate.error ?? revoke.error) as Error).message} +

+ )} +
+ ); +} + // ─── Manage commissioners panel ─────────────────────────────────────────────── function ManageCommissionersPanel({ @@ -1908,6 +2024,13 @@ export function CommissionerPage() { inviteCode={detail.huddle.inviteCode} /> +
+ +
+ +

Huddle

+ + + ); +} + +function Footer() { + return ( +
+ © {new Date().getFullYear()} Huddle +
+ ); +} + +function Shell({ children }: { children: React.ReactNode }) { + return ( +
+
+ ); +} + +export function InviteLinkPage() { + const { token } = useParams<{ token: string }>(); + const { isSignedIn, isLoaded } = useUser(); + const navigate = useNavigate(); + const dispatch = useAppDispatch(); + const { data: huddle, isLoading, isError } = useInviteLinkLookup(token ?? null); + + // Once signed in, select the league and hand off — dispatch and navigate + // together in one effect so the redirect can't fire before the league is + // actually selected. + useEffect(() => { + if (!isLoaded || !isSignedIn || !huddle) return; + if (huddle.leagueId) { + dispatch(setSelectedLeague(huddle.leagueId)); + navigate("/league-settings", { replace: true }); + } else { + navigate("/", { replace: true }); + } + }, [isLoaded, isSignedIn, huddle, dispatch, navigate]); + + if (!token) { + return ( + +

Invite link not found

+ + + +
+ ); + } + + if (isLoading || !isLoaded || isSignedIn) { + // isSignedIn is included here too: once true, the effect above takes + // over and redirects — this state is just a brief loading placeholder. + return ( + +

Loading…

+
+ ); + } + + if (isError || !huddle) { + return ( + +

Invite link not valid

+

+ This invite link is invalid or has expired. Ask your commissioner for a new one. +

+ + + +
+ ); + } + + const afterParam = `?after=${encodeURIComponent(`/invite/${token}`)}`; + + return ( + +

+ You're invited to join {huddle.name} +

+

+ Sign up for Huddle to claim your team in this league. +

+
+ + + + + + +
+
+ ); +} diff --git a/client/src/pages/SignInPage.tsx b/client/src/pages/SignInPage.tsx index ab09c6a..7afeb23 100644 --- a/client/src/pages/SignInPage.tsx +++ b/client/src/pages/SignInPage.tsx @@ -1,9 +1,14 @@ import { SignIn } from "@clerk/clerk-react"; +import { useSearchParams } from "react-router-dom"; export function SignInPage() { + const [params] = useSearchParams(); + // Carries an invite link's path through sign-in — see InviteLinkPage.tsx. + const after = params.get("after") || "/"; + return (
- +
); } diff --git a/client/src/pages/SignUpPage.tsx b/client/src/pages/SignUpPage.tsx index 31c2336..5aeeb82 100644 --- a/client/src/pages/SignUpPage.tsx +++ b/client/src/pages/SignUpPage.tsx @@ -1,9 +1,14 @@ import { SignUp } from "@clerk/clerk-react"; +import { useSearchParams } from "react-router-dom"; export function SignUpPage() { + const [params] = useSearchParams(); + // Carries an invite link's path through sign-up — see InviteLinkPage.tsx. + const after = params.get("after") || "/"; + return (
- +
); } diff --git a/client/src/types/huddle.ts b/client/src/types/huddle.ts index 815a1d2..6fe139f 100644 --- a/client/src/types/huddle.ts +++ b/client/src/types/huddle.ts @@ -8,11 +8,21 @@ export interface Huddle { name: string; inviteCode?: string; inviteCodeUpdatedAt?: string; + /** Present only for commissioners; null when no invite link is active. */ + inviteLinkToken?: string | null; + inviteLinkExpiresAt?: string | null; createdAt: string; updatedAt: string; myStatus?: HuddleMemberStatus; } +/** Minimal public shape returned by GET /api/invite-links/:token. */ +export interface InviteLinkHuddle { + id: string; + name: string; + leagueId: string | null; +} + export interface UserSummary { id: string; username: string | null; diff --git a/server/drizzle/0010_romantic_captain_america.sql b/server/drizzle/0010_romantic_captain_america.sql new file mode 100644 index 0000000..523a24a --- /dev/null +++ b/server/drizzle/0010_romantic_captain_america.sql @@ -0,0 +1,3 @@ +ALTER TABLE "huddles" ADD COLUMN "invite_link_token" text;--> statement-breakpoint +ALTER TABLE "huddles" ADD COLUMN "invite_link_expires_at" timestamp with time zone;--> statement-breakpoint +CREATE UNIQUE INDEX "huddles_invite_link_token_uniq" ON "huddles" USING btree ("invite_link_token"); \ No newline at end of file diff --git a/server/drizzle/meta/0010_snapshot.json b/server/drizzle/meta/0010_snapshot.json new file mode 100644 index 0000000..abfe8d7 --- /dev/null +++ b/server/drizzle/meta/0010_snapshot.json @@ -0,0 +1,2301 @@ +{ + "id": "6344b4e3-ad1b-40bc-9209-1c8c135255fd", + "prevId": "e7714668-09f6-4c83-ac3f-f6c67766813c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.huddle_active_trophies": { + "name": "huddle_active_trophies", + "schema": "", + "columns": { + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "trophy_type": { + "name": "trophy_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "huddle_active_trophies_huddle_id_huddles_id_fk": { + "name": "huddle_active_trophies_huddle_id_huddles_id_fk", + "tableFrom": "huddle_active_trophies", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "huddle_active_trophies_huddle_id_trophy_type_pk": { + "name": "huddle_active_trophies_huddle_id_trophy_type_pk", + "columns": [ + "huddle_id", + "trophy_type" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_announcements": { + "name": "huddle_announcements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "huddle_announcements_huddle_idx": { + "name": "huddle_announcements_huddle_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_announcements_huddle_id_huddles_id_fk": { + "name": "huddle_announcements_huddle_id_huddles_id_fk", + "tableFrom": "huddle_announcements", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_awards": { + "name": "huddle_awards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "roster_id": { + "name": "roster_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "glyph": { + "name": "glyph", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "granted_by": { + "name": "granted_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "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": { + "huddle_awards_huddle_idx": { + "name": "huddle_awards_huddle_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "huddle_awards_huddle_roster_idx": { + "name": "huddle_awards_huddle_roster_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "roster_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_awards_huddle_id_huddles_id_fk": { + "name": "huddle_awards_huddle_id_huddles_id_fk", + "tableFrom": "huddle_awards", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_commissioners": { + "name": "huddle_commissioners", + "schema": "", + "columns": { + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "huddle_commissioners_user_idx": { + "name": "huddle_commissioners_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_commissioners_huddle_id_huddles_id_fk": { + "name": "huddle_commissioners_huddle_id_huddles_id_fk", + "tableFrom": "huddle_commissioners", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "huddle_commissioners_huddle_id_user_id_pk": { + "name": "huddle_commissioners_huddle_id_user_id_pk", + "columns": [ + "huddle_id", + "user_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_countdown_config": { + "name": "huddle_countdown_config", + "schema": "", + "columns": { + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subtitle": { + "name": "subtitle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_at": { + "name": "target_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "huddle_countdown_config_huddle_id_huddles_id_fk": { + "name": "huddle_countdown_config_huddle_id_huddles_id_fk", + "tableFrom": "huddle_countdown_config", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_dues_config": { + "name": "huddle_dues_config", + "schema": "", + "columns": { + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "huddle_dues_config_huddle_id_huddles_id_fk": { + "name": "huddle_dues_config_huddle_id_huddles_id_fk", + "tableFrom": "huddle_dues_config", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_dues_payments": { + "name": "huddle_dues_payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "roster_id": { + "name": "roster_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "marked_by": { + "name": "marked_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "huddle_dues_payments_huddle_roster_uniq": { + "name": "huddle_dues_payments_huddle_roster_uniq", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "roster_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_dues_payments_huddle_id_huddles_id_fk": { + "name": "huddle_dues_payments_huddle_id_huddles_id_fk", + "tableFrom": "huddle_dues_payments", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_forum_replies": { + "name": "huddle_forum_replies", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "huddle_forum_replies_topic_idx": { + "name": "huddle_forum_replies_topic_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_forum_replies_topic_id_huddle_forum_topics_id_fk": { + "name": "huddle_forum_replies_topic_id_huddle_forum_topics_id_fk", + "tableFrom": "huddle_forum_replies", + "tableTo": "huddle_forum_topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "huddle_forum_replies_huddle_id_huddles_id_fk": { + "name": "huddle_forum_replies_huddle_id_huddles_id_fk", + "tableFrom": "huddle_forum_replies", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_forum_topics": { + "name": "huddle_forum_topics", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body": { + "name": "body", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "reply_count": { + "name": "reply_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_by": { + "name": "deleted_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "huddle_forum_topics_huddle_updated_idx": { + "name": "huddle_forum_topics_huddle_updated_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_forum_topics_huddle_id_huddles_id_fk": { + "name": "huddle_forum_topics_huddle_id_huddles_id_fk", + "tableFrom": "huddle_forum_topics", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_payout_entries": { + "name": "huddle_payout_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "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": { + "huddle_payout_entries_huddle_idx": { + "name": "huddle_payout_entries_huddle_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_payout_entries_huddle_id_huddles_id_fk": { + "name": "huddle_payout_entries_huddle_id_huddles_id_fk", + "tableFrom": "huddle_payout_entries", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_poll_options": { + "name": "huddle_poll_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "huddle_poll_options_poll_idx": { + "name": "huddle_poll_options_poll_idx", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_poll_options_poll_id_huddle_polls_id_fk": { + "name": "huddle_poll_options_poll_id_huddle_polls_id_fk", + "tableFrom": "huddle_poll_options", + "tableTo": "huddle_polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_poll_votes": { + "name": "huddle_poll_votes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "poll_id": { + "name": "poll_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_id": { + "name": "option_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "huddle_poll_votes_poll_user_idx": { + "name": "huddle_poll_votes_poll_user_idx", + "columns": [ + { + "expression": "poll_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "huddle_poll_votes_option_user_uniq": { + "name": "huddle_poll_votes_option_user_uniq", + "columns": [ + { + "expression": "option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_poll_votes_poll_id_huddle_polls_id_fk": { + "name": "huddle_poll_votes_poll_id_huddle_polls_id_fk", + "tableFrom": "huddle_poll_votes", + "tableTo": "huddle_polls", + "columnsFrom": [ + "poll_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "huddle_poll_votes_option_id_huddle_poll_options_id_fk": { + "name": "huddle_poll_votes_option_id_huddle_poll_options_id_fk", + "tableFrom": "huddle_poll_votes", + "tableTo": "huddle_poll_options", + "columnsFrom": [ + "option_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_polls": { + "name": "huddle_polls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "topic_id": { + "name": "topic_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "is_dashboard_poll": { + "name": "is_dashboard_poll", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allow_multiple": { + "name": "allow_multiple", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_vote_changes": { + "name": "allow_vote_changes", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "results_visibility": { + "name": "results_visibility", + "type": "poll_results_visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'always'" + }, + "closes_at": { + "name": "closes_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()" + } + }, + "indexes": { + "huddle_polls_topic_idx": { + "name": "huddle_polls_topic_idx", + "columns": [ + { + "expression": "topic_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "huddle_polls_dashboard_active_uniq": { + "name": "huddle_polls_dashboard_active_uniq", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"huddle_polls\".\"is_dashboard_poll\" = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_polls_huddle_id_huddles_id_fk": { + "name": "huddle_polls_huddle_id_huddles_id_fk", + "tableFrom": "huddle_polls", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "huddle_polls_topic_id_huddle_forum_topics_id_fk": { + "name": "huddle_polls_topic_id_huddle_forum_topics_id_fk", + "tableFrom": "huddle_polls", + "tableTo": "huddle_forum_topics", + "columnsFrom": [ + "topic_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_survey_answer_options": { + "name": "huddle_survey_answer_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "option_id": { + "name": "option_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "huddle_survey_answer_options_question_idx": { + "name": "huddle_survey_answer_options_question_idx", + "columns": [ + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "huddle_survey_answer_options_response_option_uniq": { + "name": "huddle_survey_answer_options_response_option_uniq", + "columns": [ + { + "expression": "response_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "option_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_survey_answer_options_response_id_huddle_survey_responses_id_fk": { + "name": "huddle_survey_answer_options_response_id_huddle_survey_responses_id_fk", + "tableFrom": "huddle_survey_answer_options", + "tableTo": "huddle_survey_responses", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "huddle_survey_answer_options_question_id_huddle_survey_questions_id_fk": { + "name": "huddle_survey_answer_options_question_id_huddle_survey_questions_id_fk", + "tableFrom": "huddle_survey_answer_options", + "tableTo": "huddle_survey_questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "huddle_survey_answer_options_option_id_huddle_survey_options_id_fk": { + "name": "huddle_survey_answer_options_option_id_huddle_survey_options_id_fk", + "tableFrom": "huddle_survey_answer_options", + "tableTo": "huddle_survey_options", + "columnsFrom": [ + "option_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_survey_answers": { + "name": "huddle_survey_answers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "response_id": { + "name": "response_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "text_value": { + "name": "text_value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "huddle_survey_answers_response_idx": { + "name": "huddle_survey_answers_response_idx", + "columns": [ + { + "expression": "response_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "huddle_survey_answers_question_idx": { + "name": "huddle_survey_answers_question_idx", + "columns": [ + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_survey_answers_response_id_huddle_survey_responses_id_fk": { + "name": "huddle_survey_answers_response_id_huddle_survey_responses_id_fk", + "tableFrom": "huddle_survey_answers", + "tableTo": "huddle_survey_responses", + "columnsFrom": [ + "response_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "huddle_survey_answers_question_id_huddle_survey_questions_id_fk": { + "name": "huddle_survey_answers_question_id_huddle_survey_questions_id_fk", + "tableFrom": "huddle_survey_answers", + "tableTo": "huddle_survey_questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_survey_options": { + "name": "huddle_survey_options", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "question_id": { + "name": "question_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "huddle_survey_options_question_idx": { + "name": "huddle_survey_options_question_idx", + "columns": [ + { + "expression": "question_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_survey_options_question_id_huddle_survey_questions_id_fk": { + "name": "huddle_survey_options_question_id_huddle_survey_questions_id_fk", + "tableFrom": "huddle_survey_options", + "tableTo": "huddle_survey_questions", + "columnsFrom": [ + "question_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_survey_questions": { + "name": "huddle_survey_questions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "survey_id": { + "name": "survey_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "survey_question_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "required": { + "name": "required", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "huddle_survey_questions_survey_idx": { + "name": "huddle_survey_questions_survey_idx", + "columns": [ + { + "expression": "survey_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_survey_questions_survey_id_huddle_surveys_id_fk": { + "name": "huddle_survey_questions_survey_id_huddle_surveys_id_fk", + "tableFrom": "huddle_survey_questions", + "tableTo": "huddle_surveys", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_survey_responses": { + "name": "huddle_survey_responses", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "survey_id": { + "name": "survey_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "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": { + "huddle_survey_responses_survey_user_uniq": { + "name": "huddle_survey_responses_survey_user_uniq", + "columns": [ + { + "expression": "survey_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_survey_responses_survey_id_huddle_surveys_id_fk": { + "name": "huddle_survey_responses_survey_id_huddle_surveys_id_fk", + "tableFrom": "huddle_survey_responses", + "tableTo": "huddle_surveys", + "columnsFrom": [ + "survey_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddle_surveys": { + "name": "huddle_surveys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "closes_at": { + "name": "closes_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "results_published": { + "name": "results_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "auto_publish_on_close": { + "name": "auto_publish_on_close", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "anonymity": { + "name": "anonymity", + "type": "survey_anonymity", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "huddle_surveys_huddle_created_idx": { + "name": "huddle_surveys_huddle_created_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "huddle_surveys_huddle_id_huddles_id_fk": { + "name": "huddle_surveys_huddle_id_huddles_id_fk", + "tableFrom": "huddle_surveys", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.huddles": { + "name": "huddles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "league_provider": { + "name": "league_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "league_id": { + "name": "league_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invite_code": { + "name": "invite_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "invite_code_updated_at": { + "name": "invite_code_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "invite_link_token": { + "name": "invite_link_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "invite_link_expires_at": { + "name": "invite_link_expires_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": { + "huddles_invite_code_uniq": { + "name": "huddles_invite_code_uniq", + "columns": [ + { + "expression": "invite_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "huddles_invite_link_token_uniq": { + "name": "huddles_invite_link_token_uniq", + "columns": [ + { + "expression": "invite_link_token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.side_bets": { + "name": "side_bets", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "proposer_id": { + "name": "proposer_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "opponent_id": { + "name": "opponent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "proposer_roster_id": { + "name": "proposer_roster_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "opponent_roster_id": { + "name": "opponent_roster_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "week": { + "name": "week", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "season": { + "name": "season", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "prize_description": { + "name": "prize_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "side_bet_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "winner_id": { + "name": "winner_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settlement_note": { + "name": "settlement_note", + "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": { + "side_bets_huddle_idx": { + "name": "side_bets_huddle_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "side_bets_proposer_idx": { + "name": "side_bets_proposer_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "proposer_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "side_bets_opponent_idx": { + "name": "side_bets_opponent_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "opponent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "side_bets_huddle_id_huddles_id_fk": { + "name": "side_bets_huddle_id_huddles_id_fk", + "tableFrom": "side_bets", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.team_claims": { + "name": "team_claims", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "huddle_id": { + "name": "huddle_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "roster_id": { + "name": "roster_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "claim_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "decided_at": { + "name": "decided_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "decided_by": { + "name": "decided_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "team_claims_huddle_roster_approved_uniq": { + "name": "team_claims_huddle_roster_approved_uniq", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "roster_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"team_claims\".\"status\" = 'approved'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_claims_huddle_user_approved_uniq": { + "name": "team_claims_huddle_user_approved_uniq", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"team_claims\".\"status\" = 'approved'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_claims_huddle_idx": { + "name": "team_claims_huddle_idx", + "columns": [ + { + "expression": "huddle_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "team_claims_user_idx": { + "name": "team_claims_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "team_claims_huddle_id_huddles_id_fk": { + "name": "team_claims_huddle_id_huddles_id_fk", + "tableFrom": "team_claims", + "tableTo": "huddles", + "columnsFrom": [ + "huddle_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.claim_status": { + "name": "claim_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.poll_results_visibility": { + "name": "poll_results_visibility", + "schema": "public", + "values": [ + "always", + "after_vote", + "after_close" + ] + }, + "public.side_bet_status": { + "name": "side_bet_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "rejected", + "cancelled", + "settled" + ] + }, + "public.survey_anonymity": { + "name": "survey_anonymity", + "schema": "public", + "values": [ + "none", + "anonymous_to_league", + "anonymous_to_all" + ] + }, + "public.survey_question_type": { + "name": "survey_question_type", + "schema": "public", + "values": [ + "short_text", + "paragraph", + "multiple_choice", + "checkboxes" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json index 61dd559..6033dc1 100644 --- a/server/drizzle/meta/_journal.json +++ b/server/drizzle/meta/_journal.json @@ -71,6 +71,13 @@ "when": 1785782904747, "tag": "0009_zippy_ozymandias", "breakpoints": true + }, + { + "idx": 10, + "version": "7", + "when": 1785791787209, + "tag": "0010_romantic_captain_america", + "breakpoints": true } ] } \ No newline at end of file diff --git a/server/src/db/schema.ts b/server/src/db/schema.ts index 6350156..6bbb1e9 100644 --- a/server/src/db/schema.ts +++ b/server/src/db/schema.ts @@ -31,6 +31,10 @@ export const huddles = pgTable( }) .defaultNow() .notNull(), + /** Null when no invite link is active. Regenerating overwrites both + * columns (implicitly revoking the previous link), mirroring inviteCode. */ + inviteLinkToken: text("invite_link_token"), + inviteLinkExpiresAt: timestamp("invite_link_expires_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }) .defaultNow() .notNull(), @@ -40,6 +44,7 @@ export const huddles = pgTable( }, (t) => ({ uniqInviteCode: uniqueIndex("huddles_invite_code_uniq").on(t.inviteCode), + uniqInviteLinkToken: uniqueIndex("huddles_invite_link_token_uniq").on(t.inviteLinkToken), }), ); diff --git a/server/src/routes/huddleRoutes.ts b/server/src/routes/huddleRoutes.ts index 0a7b8f9..e243d9a 100644 --- a/server/src/routes/huddleRoutes.ts +++ b/server/src/routes/huddleRoutes.ts @@ -10,8 +10,10 @@ import { deleteAnnouncement, deleteHuddle, forceRemoveClaim, + generateInviteLink, getHuddle, getHuddleByInviteCode, + getHuddleByInviteLinkToken, isCommissioner, listAnnouncements, listClaimsForHuddle, @@ -19,6 +21,7 @@ import { listHuddlesForUser, linkLeague, removeCommissioner, + revokeInviteLink, rotateInviteCode, submitClaim, unclaimTeam, @@ -94,6 +97,8 @@ function serializeHuddle( name: string; inviteCode: string; inviteCodeUpdatedAt: Date; + inviteLinkToken: string | null; + inviteLinkExpiresAt: Date | null; createdAt: Date; updatedAt: Date; }, @@ -105,7 +110,12 @@ function serializeHuddle( leagueId: h.leagueId, name: h.name, ...(includeCode - ? { inviteCode: h.inviteCode, inviteCodeUpdatedAt: h.inviteCodeUpdatedAt } + ? { + inviteCode: h.inviteCode, + inviteCodeUpdatedAt: h.inviteCodeUpdatedAt, + inviteLinkToken: h.inviteLinkToken, + inviteLinkExpiresAt: h.inviteLinkExpiresAt, + } : {}), createdAt: h.createdAt, updatedAt: h.updatedAt, @@ -532,6 +542,61 @@ export function initHuddleRoutes(app: Express) { }, ); + // POST /api/huddles/:id/invite-link — commissioner only, generates/replaces the active link + app.post( + "/api/huddles/:id/invite-link", + requireAuth, + async (req: Request, res: Response) => { + try { + const { userId } = getAuth(req); + const huddle = await generateInviteLink({ + huddleId: req.params.id!, + userId: userId!, + }); + res.json({ huddle: serializeHuddle(huddle, true) }); + } catch (err) { + handleError(err, res); + } + }, + ); + + // DELETE /api/huddles/:id/invite-link — commissioner only + app.delete( + "/api/huddles/:id/invite-link", + requireAuth, + async (req: Request, res: Response) => { + try { + const { userId } = getAuth(req); + const huddle = await revokeInviteLink({ + huddleId: req.params.id!, + userId: userId!, + }); + res.json({ huddle: serializeHuddle(huddle, true) }); + } catch (err) { + handleError(err, res); + } + }, + ); + + // GET /api/invite-links/:token — public, no auth. Resolves a shareable + // invite link to the huddle it points at, for the /invite/:token landing + // page. Deliberately minimal: just enough to select the league client-side. + app.get( + "/api/invite-links/:token", + async (req: Request, res: Response) => { + try { + const huddle = await getHuddleByInviteLinkToken(req.params.token!); + if (!huddle) { + res.status(404).json({ error: "This invite link is invalid or has expired" }); + return; + } + res.json({ huddle: { id: huddle.id, name: huddle.name, leagueId: huddle.leagueId } }); + } catch (err) { + handleError(err, res); + } + }, + ); + // PATCH /api/huddles/:id app.patch( "/api/huddles/:id", diff --git a/server/src/services/huddlesService.ts b/server/src/services/huddlesService.ts index 59b841d..ddec00a 100644 --- a/server/src/services/huddlesService.ts +++ b/server/src/services/huddlesService.ts @@ -510,6 +510,58 @@ export async function rotateInviteCode(opts: { return updated!; } +// ---- Invite link ---- + +const INVITE_LINK_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days + +function generateInviteLinkToken(): string { + return randomBytes(24).toString("base64url"); +} + +export async function generateInviteLink(opts: { + huddleId: string; + userId: string; +}): Promise { + if (!(await isCommissioner(opts.huddleId, opts.userId))) + fail(403, "Only a commissioner can generate an invite link"); + + const token = generateInviteLinkToken(); + const [updated] = await db + .update(huddles) + .set({ + inviteLinkToken: token, + inviteLinkExpiresAt: new Date(Date.now() + INVITE_LINK_TTL_MS), + updatedAt: new Date(), + }) + .where(eq(huddles.id, opts.huddleId)) + .returning(); + if (!updated) fail(500, "Failed to generate invite link"); + return updated!; +} + +export async function revokeInviteLink(opts: { huddleId: string; userId: string }): Promise { + if (!(await isCommissioner(opts.huddleId, opts.userId))) + fail(403, "Only a commissioner can revoke the invite link"); + + const [updated] = await db + .update(huddles) + .set({ inviteLinkToken: null, inviteLinkExpiresAt: null, updatedAt: new Date() }) + .where(eq(huddles.id, opts.huddleId)) + .returning(); + if (!updated) fail(500, "Failed to revoke invite link"); + return updated!; +} + +/** Resolves a token to its huddle, or null if missing/expired — expired is + * treated the same as not-found, no reason to distinguish for the caller. */ +export async function getHuddleByInviteLinkToken(token: string): Promise { + const rows = await db.select().from(huddles).where(eq(huddles.inviteLinkToken, token)).limit(1); + const huddle = rows[0]; + if (!huddle) return null; + if (!huddle.inviteLinkExpiresAt || huddle.inviteLinkExpiresAt.getTime() <= Date.now()) return null; + return huddle; +} + export async function deleteHuddle(opts: { huddleId: string; userId: string;