From 92c86e80afd42082023e53771881663dbadbe71b Mon Sep 17 00:00:00 2001 From: Jared Schoeny Date: Sat, 18 Jul 2026 22:37:41 -0600 Subject: [PATCH] Make pending hacks assignable to admin for review (#66) * Extract common Modal component * Move admin pending hacks list to client component * Add assigning pending hacks to admin for review --- src/app/dashboard/actions.ts | 16 + src/app/dashboard/page.tsx | 146 +---- src/components/Dashboard/PendingHacks.tsx | 423 ++++++++++++++ src/components/Hack/PatcherVersionManager.tsx | 161 +++--- src/components/Hack/VersionActions.tsx | 516 ++++++++---------- src/components/Primitives/Modal.tsx | 43 ++ src/types/db.ts | 3 + ...0260718201617_assigned_admin_for_hacks.sql | 5 + 8 files changed, 802 insertions(+), 511 deletions(-) create mode 100644 src/components/Dashboard/PendingHacks.tsx create mode 100644 src/components/Primitives/Modal.tsx create mode 100644 supabase/migrations/20260718201617_assigned_admin_for_hacks.sql diff --git a/src/app/dashboard/actions.ts b/src/app/dashboard/actions.ts index f29274f..a416cbc 100644 --- a/src/app/dashboard/actions.ts +++ b/src/app/dashboard/actions.ts @@ -309,3 +309,19 @@ export const getHackInsights = async ({ slug }: { slug: string }): Promise => { + const supa = await createClient(); + const { data: userResp } = await supa.auth.getUser(); + const user = userResp.user; + if (!user) throw new Error("Unauthorized"); + + const { data: admin } = await supa.rpc("is_admin"); + if (!admin) throw new Error("Unauthorized"); + + const { error } = await supa + .from("hacks") + .update({ assigned_admin: user.id }) + .in("slug", slugs); + if (error) throw error; +}; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index ea51be7..347d274 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,12 +1,11 @@ import { createClient, createServiceClient } from "@/utils/supabase/server"; import Link from "next/link"; import { redirect } from "next/navigation"; -import { FiAlertTriangle, FiExternalLink } from "react-icons/fi"; import DashboardClient from "@/components/Dashboard/DashboardClient"; import ArchiverManagement from "@/components/Dashboard/ArchiverManagement"; import { getDownloadsSeriesAll } from "./actions"; -import type { HackRow } from "@/components/Dashboard/DashboardClient"; -import { FaCircleCheck } from "react-icons/fa6"; +import type { PendingHack } from "@/components/Dashboard/PendingHacks"; +import PendingHacks from "@/components/Dashboard/PendingHacks"; export default async function DashboardPage() { const supa = await createClient(); @@ -15,27 +14,24 @@ export default async function DashboardPage() { if (!user) redirect("/login"); const { data: isAdmin } = await supa.rpc("is_admin"); - let pendingHacks: (HackRow & { - created_by: string; - creator_username: string | null; - creator_full_name: string | null; - creator_email: string | null; - creator_verified: boolean; - })[] = []; + let pendingHacks: PendingHack[] = []; if (isAdmin) { const { data: pendingHacksData } = await supa .from("hacks") - .select("slug,title,approved,updated_at,downloads,current_patch,version,created_at,created_by") + .select("slug,title,approved,updated_at,downloads,current_patch,version,created_at,created_by,assigned_admin") .eq("approved", false) .order("created_at", { ascending: false }); if (pendingHacksData && pendingHacksData.length > 0) { // Fetch creator usernames - const creatorIds = [...new Set(pendingHacksData.map(h => h.created_by as string))]; + const profileIds = [ + ...new Set(pendingHacksData.map(h => h.created_by as string)), + ...new Set(pendingHacksData.map(h => h.assigned_admin).filter(a => a !== null) as string[]), + ]; const { data: profiles } = await supa .from("profiles") .select("id,username,full_name,verified") - .in("id", creatorIds); + .in("id", profileIds); const usernameById = new Map(); const fullNameById = new Map(); @@ -49,7 +45,7 @@ export default async function DashboardPage() { // Fetch creator emails using service client (admin API) const serviceClient = await createServiceClient(); const emailById = new Map(); - for (const userId of creatorIds) { + for (const userId of profileIds) { try { const { data: userData, error } = await serviceClient.auth.admin.getUserById(userId); if (!error && userData?.user?.email) { @@ -67,6 +63,7 @@ export default async function DashboardPage() { creator_full_name: fullNameById.get(h.created_by as string) || null, creator_email: emailById.get(h.created_by as string) || null, creator_verified: verifiedById.get(h.created_by as string) || false, + assigned_admin_username: usernameById.get(h.assigned_admin as string) || null, })); } } @@ -110,126 +107,7 @@ export default async function DashboardPage() { displayName={full_name || `@${username}`} /> - {pendingHacks.length > 0 && ( -
-
-

Pending hacks

- - {pendingHacks.length} - -
-
- {/* Header row (desktop only) */} -
-
Title
-
Creator
-
Created
-
-
- {pendingHacks.map((h) => { - const createdDate = h.created_at - ? new Date(h.created_at).toLocaleTimeString(undefined, { - year: "numeric", - month: "short", - day: "numeric", - hour: "2-digit", - minute: "2-digit", - }) - : "Unknown"; - const creator = h.creator_username ? `@${h.creator_username}` : "Unknown"; - const hasMissingPatch = h.current_patch === null; - const bgClasses = hasMissingPatch - ? "bg-amber-50/5 dark:bg-amber-900/5 hover:bg-amber-400/10 dark:hover:bg-amber-600/10" - : "bg-amber-500/5 hover:bg-amber-500/10"; - - return ( - - {/* Desktop row */} -
-
-
-
-
-
{h.title}
- {hasMissingPatch && ( -
- - Missing Patch -
- )} -
-
/{h.slug}
-
-
-
-
- {h.creator_full_name &&
{h.creator_full_name}
} -
- {creator} - {h.creator_verified && ( -
- -
- Creator is verified -
-
- )} -
- {h.creator_email && ( -
{h.creator_email}
- )} -
-
-
-
{createdDate}
- -
-
-
- {/* Mobile card */} -
-
-
-
-
{h.title}
- {hasMissingPatch && ( -
- - Missing Patch -
- )} -
-
/{h.slug}
-
- {(h.creator_full_name || h.creator_email) && ( -
- {h.creator_full_name && {h.creator_full_name}} - {h.creator_full_name && h.creator_email && } - {h.creator_email && {h.creator_email}} -
- )} -
- {creator} - - {createdDate} -
-
-
- -
-
- - ); - })} -
-
-
- )} + {isAdmin && } {isAdmin && } diff --git a/src/components/Dashboard/PendingHacks.tsx b/src/components/Dashboard/PendingHacks.tsx new file mode 100644 index 0000000..f71538f --- /dev/null +++ b/src/components/Dashboard/PendingHacks.tsx @@ -0,0 +1,423 @@ +"use client"; + +import React, { useState, useEffect } from "react"; +import Link from "next/link"; +import type { HackRow } from "./DashboardClient"; +import { FiAlertTriangle, FiExternalLink } from "react-icons/fi"; +import { FaCircleCheck, FaRegCircle, FaUserCheck } from "react-icons/fa6"; +import { assignHacksToAdminForReview } from "@/app/dashboard/actions"; +import { useRouter } from "next/navigation"; + +export type PendingHack = HackRow & { + created_by: string; + creator_username: string | null; + creator_full_name: string | null; + creator_email: string | null; + creator_verified: boolean; + assigned_admin: string | null; + assigned_admin_username: string | null; +} + +interface PendingHacksProps { + hacks: PendingHack[]; + userId: string; +} + +type Filter = + "unassigned" | + "assigned_to_me" | + "recent" | + "all"; + +export default function PendingHacks({ hacks, userId }: PendingHacksProps) { + const router = useRouter(); + const [filter, setFilter] = useState("unassigned"); + const [claimMode, _setClaimMode] = useState(false); + const [selectedHacks, setSelectedHacks] = useState([]); + const [lastClaimedCount, setLastClaimedCount] = useState(0); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const filteredHacks = React.useMemo(() => { + let filtered = hacks; + if (filter === "assigned_to_me") { + filtered = filtered.filter((h) => h.assigned_admin && h.assigned_admin === userId); + } else if (filter === "unassigned") { + filtered = filtered.filter((h) => h.assigned_admin === null); + } else if (filter === "recent") { + // From within the last 30 days + filtered = filtered.filter((h) => new Date(h.created_at).getTime() > Date.now() - 30 * 24 * 60 * 60 * 1000); + } + return filtered as PendingHack[]; + }, [filter, hacks, userId]); + + const setClaimMode = (mode: boolean) => { + _setClaimMode(mode); + if (!mode) { + setSelectedHacks([]); + } + }; + + const onClaimSelected = async () => { + try { + setLoading(true); + await assignHacksToAdminForReview({ slugs: selectedHacks.map((h) => h.slug) }); + setLastClaimedCount(selectedHacks.length); + setSelectedHacks([]); + setError(null); + setClaimMode(false); + router.refresh(); + } catch (error) { + setError(error instanceof Error ? error.message : "An unknown error occurred"); + } finally { + setLoading(false); + } + }; + + const onHackSelected = (hack: PendingHack) => { + setSelectedHacks(selectedHacks.includes(hack) ? selectedHacks.filter((h) => h.slug !== hack.slug) : [...selectedHacks, hack]); + }; + + if (hacks.length === 0 || (filteredHacks.length === 0 && filter !== "assigned_to_me")) { + return ( + +
+ No pending hacks to review! 🎉 +
+
+ ) + } + + if (filteredHacks.length === 0 && filter === "assigned_to_me") { + return ( + +
+ None of the currently {hacks.length} pending hacks have been claimed by you for review. +
+
+ ) + } + + return ( + + {/* Header row (desktop only) */} +
+
Title
+
Creator
+
Created
+
+
+ {filteredHacks.map((h) => { + return ( + + ); + })} + {filter === "recent" && filteredHacks.length < hacks.length && ( +
+ + {hacks.length - filteredHacks.length} older pending hacks +
+ )} +
+
+ ); +} + +interface PendingHacksContainerProps { + totalCount: number; + filteredCount: number; + selectedCount: number; + lastClaimedCount: number; + loading: boolean; + error: string | null; + children: React.ReactNode; + filter: Filter; + setFilter: (filter: Filter) => void; + claimMode: boolean; + setClaimMode: (claimMode: boolean) => void; + onClaimSelected: () => void; +} + +function PendingHacksContainer({ + totalCount, + filteredCount, + selectedCount, + lastClaimedCount, + loading, + error, + children, + filter, + setFilter, + claimMode, + setClaimMode, + onClaimSelected, +}: PendingHacksContainerProps) { + return ( +
+
+
+

Pending hacks

+ + {filteredCount} + +
+ {/* Filter dropdown */} + {totalCount > 0 && !claimMode && ( +
+ + +
+ )} + {totalCount > 0 && claimMode && ( +
+ + +
+ )} +
+
+ Use "Claim" to select hacks whose creator you have already reached out to for verification. +
+ {error && ( +
+ {error} +
+ )} + {lastClaimedCount > 0 && ( +
+ {lastClaimedCount} hacks claimed successfully! +
+ )} +
+ {children} +
+
+ ); +} + +interface PendingHackCardProps { + hack: PendingHack; + userId: string; + selectMode: boolean; + selected: boolean; + onSelect: (hack: PendingHack) => void; +} + +function PendingHackCard({ hack, userId, selectMode, selected, onSelect }: PendingHackCardProps) { + const createdDate = hack.created_at + ? new Date(hack.created_at).toLocaleTimeString(undefined, { + year: "numeric", + month: "short", + day: "numeric", + hour: "2-digit", + minute: "2-digit", + }) + : "Unknown"; + const creator = hack.creator_username ? `@${hack.creator_username}` : "Unknown"; + const hasMissingPatch = hack.current_patch === null; + const bgClasses = hasMissingPatch + ? "bg-amber-50/5 dark:bg-amber-900/5 hover:bg-amber-400/10 dark:hover:bg-amber-600/10" + : "bg-amber-500/5 hover:bg-amber-500/10"; + + const content = <> + {/* Desktop row */} +
+
+
+
+
+
{hack.title}
+ {hasMissingPatch && ( +
+ + Missing Patch +
+ )} +
+
/{hack.slug}
+ {hack.assigned_admin && hack.assigned_admin === userId ? ( +
+ + Assigned to you +
+ ) : hack.assigned_admin_username && ( +
+ Assigned to @{hack.assigned_admin_username} +
+ )} +
+
+
+
+ {hack.creator_full_name &&
{hack.creator_full_name}
} +
+ {creator} + {hack.creator_verified && ( +
+ +
+ Creator is verified +
+
+ )} +
+ {hack.creator_email && ( +
{hack.creator_email}
+ )} +
+
+
+
{createdDate}
+ {selectMode && selected ? ( + + ) : selectMode ? ( + + ) : ( + + )} +
+
+
+ {/* Mobile card */} +
+
+
+
+
{hack.title}
+ {hasMissingPatch && ( +
+ + Missing Patch +
+ )} +
+
/{hack.slug}
+
+ {(hack.creator_full_name || hack.creator_email) && ( +
+ {hack.creator_full_name && {hack.creator_full_name}} + {hack.creator_full_name && hack.creator_email && } + {hack.creator_email && {hack.creator_email}} +
+ )} +
+ {creator} + + {createdDate} +
+
+ {hack.assigned_admin && hack.assigned_admin === userId ? ( +
+ + Assigned to you +
+ ) : hack.assigned_admin_username && ( +
+ Assigned to @{hack.assigned_admin_username} +
+ )} +
+ {selectMode && selected ? ( + + ) : selectMode ? ( + + ) : ( + + )} +
+
+ + + if (selectMode) { + return ( +
onSelect(hack)} + > + {content} +
+ ); + } + + return ( + + {content} + + ); +} diff --git a/src/components/Hack/PatcherVersionManager.tsx b/src/components/Hack/PatcherVersionManager.tsx index cb1fba2..74059c9 100644 --- a/src/components/Hack/PatcherVersionManager.tsx +++ b/src/components/Hack/PatcherVersionManager.tsx @@ -2,7 +2,7 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; import { useRouter } from "next/navigation"; -import { FiX } from "react-icons/fi"; +import Modal from "@/components/Primitives/Modal"; import { updatePatcherSelectablePatches } from "@/app/hack/[slug]/actions"; import PatcherVersionSettings from "@/components/Hack/PatcherVersionSettings"; import VersionList from "@/components/Hack/VersionList"; @@ -308,105 +308,68 @@ export default function PatcherVersionManager({ isCustomPatcherActive={publishedOption === "custom"} onTogglePatcherPatch={togglePatch} /> - {showPublishModal && ( - !saving && setShowPublishModal(false)} - > -

- These patches will be available to choose from in the downloader on your hack's homepage: + !saving && setShowPublishModal(false)} + > +

+ These patches will be available to choose from in the downloader on your hack's homepage: +

+ {draftVersionLabels.length > 0 ? ( +
    + {draftVersionLabels.map((label, index) => ( +
  • + - + {label} + {draftOption === "custom" && index === 0 && ( + + Default + + )} +
  • + ))} +
+ ) : ( +

No current patch is set.

+ )} + {draftOption === "custom" && ( +

+ Public version name: {draftCustomVersionName.trim()}

- {draftVersionLabels.length > 0 ? ( -
    - {draftVersionLabels.map((label, index) => ( -
  • - - - {label} - {draftOption === "custom" && index === 0 && ( - - Default - - )} -
  • - ))} -
- ) : ( -

No current patch is set.

- )} - {draftOption === "custom" && ( -

- Public version name: {draftCustomVersionName.trim()} -

- )} - {selectedUnpublishedVersionLabels.length > 0 && ( -

- Selected unpublished versions will be published when these changes are saved. -

- )} - {publishError && ( -

- {publishError} -

- )} -
- - -
-
- )} + )} + {selectedUnpublishedVersionLabels.length > 0 && ( +

+ Selected unpublished versions will be published when these changes are saved. +

+ )} + {publishError && ( +

+ {publishError} +

+ )} +
+ + +
+ ); } - -function Modal({ - title, - children, - onClose, -}: { - title: string; - children: React.ReactNode; - onClose: () => void; -}) { - return ( -
-
-
- -

{title}

- {children} -
-
- ); -} diff --git a/src/components/Hack/VersionActions.tsx b/src/components/Hack/VersionActions.tsx index 9fb86ef..c221c75 100644 --- a/src/components/Hack/VersionActions.tsx +++ b/src/components/Hack/VersionActions.tsx @@ -1,7 +1,7 @@ "use client"; import React, { useState, useEffect, useRef } from "react"; -import { FiX, FiMoreVertical } from "react-icons/fi"; +import { FiMoreVertical } from "react-icons/fi"; import { FaDownload, FaTrash, @@ -10,6 +10,7 @@ import { FaCheck, } from "react-icons/fa6"; import { Menu, MenuButton, MenuItem, MenuItems, MenuSeparator } from "@headlessui/react"; +import Modal from "@/components/Primitives/Modal"; import { archivePatchVersion, restorePatchVersion, @@ -422,32 +423,31 @@ export default function VersionActions({ {/* Restore Modal */} - {showRestoreModal && ( - !actionLoading && setShowRestoreModal(false)} - > -

- Restore version {patch.version}? This will make it visible again. -

-
- - -
-
- )} + !actionLoading && setShowRestoreModal(false)} + > +

+ Restore version {patch.version}? This will make it visible again. +

+
+ + +
+
); } @@ -581,115 +581,223 @@ export default function VersionActions({ {/* Delete Modal */} - {showDeleteModal && ( - !actionLoading && setShowDeleteModal(false)} - > - {archiveWouldRemoveLastCustomPatch ? ( + !actionLoading && setShowDeleteModal(false)} + > + {archiveWouldRemoveLastCustomPatch ? ( +

+ Version {patch.version} is one of the last 2 versions in the Custom patcher list. Switch to Latest published patch or add another Custom version before archiving it. +

+ ) : ( + <>

- Version {patch.version} is one of the last 2 versions in the Custom patcher list. Switch to Latest published patch or add another Custom version before archiving it. + Are you sure you want to archive version {patch.version}? This will hide it from public view, but it can be restored later.

- ) : ( - <> -

- Are you sure you want to archive version {patch.version}? This will hide it from public view, but it can be restored later. + {isInCustomPatcherList && ( +

+ This version will also be removed from the Custom patcher list.

- {isInCustomPatcherList && ( -

- This version will also be removed from the Custom patcher list. -

- )} - - )} -
- {!archiveWouldRemoveLastCustomPatch && ( - )} + + )} +
+ {!archiveWouldRemoveLastCustomPatch && ( -
- - )} + )} + +
+
{/* Rollback Modal */} - {showRollbackModal && ( - !actionLoading && setShowRollbackModal(false)} - > -

- Rollback to version {patch.version}? This will set this version as the current patch and unpublish all newer versions. -

-
- - -
-
- )} + !actionLoading && setShowRollbackModal(false)} + > +

+ Rollback to version {patch.version}? This will set this version as the current patch and unpublish all newer versions. +

+
+ + +
+
{/* Publish Modal */} - {showPublishModal && ( - !actionLoading && setShowPublishModal(false)} - > -

- Publish version {patch.version}? This will make it viewable to the public along with its changelog. -

-

- {isCustomPatcherActive - ? "This will not add the version to the Custom patcher list. Add it through Patcher Version Settings if you want it available in the homepage downloader." - : "If this version is newer than the current patch, it will become the primary download used for all users."} -

-
- - -
-
- )} + !actionLoading && setShowPublishModal(false)} + > +

+ Publish version {patch.version}? This will make it viewable to the public along with its changelog. +

+

+ {isCustomPatcherActive + ? "This will not add the version to the Custom patcher list. Add it through Patcher Version Settings if you want it available in the homepage downloader." + : "If this version is newer than the current patch, it will become the primary download used for all users."} +

+
+ + +
+
{/* Re-upload Modal */} - {showReuploadModal && ( - { - if (!actionLoading) { + { + if (!actionLoading) { + setShowReuploadModal(false); + setReuploadFile(null); + setReuploadError(null); + setChecksumStatus("idle"); + setChecksumError(""); + setGenStatus("idle"); + setGenError(""); + setBaseRomFile(null); + setPatchMode("bps"); + } + }} + > +
+ +
+
+ + +
+ + {patchMode === "bps" && ( +
+ +

Upload a BPS patch file.

+ {checksumStatus === "validating" &&
Validating checksum…
} + {checksumStatus === "valid" &&
Checksum valid.
} + {checksumStatus === "invalid" && !!checksumError &&
{checksumError}
} + {checksumStatus === "unknown" && !!checksumError &&
{checksumError}
} +
+ )} + + {patchMode === "rom" && ( +
+
+
Required base ROM
+
{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Unknown base ROM"}
+
+ + {baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"} + + {baseRomNeedsPermission && ( + + )} + {baseRomMissing && ( + + )} +
+ {!!genError &&
{genError}
} +
+ +
+ + +

We'll generate a .bps patch on-device. No ROMs are uploaded.

+ {genStatus === "generating" &&
Generating patch…
} + {genStatus === "ready" && reuploadFile &&
Patch ready: {reuploadFile.name}
} + {genStatus === "error" && !!genError &&
{genError}
} +
+
+ )} +
+ {reuploadError && ( +

{reuploadError}

+ )} +
+
+ + - -
- - {patchMode === "bps" && ( -
- -

Upload a BPS patch file.

- {checksumStatus === "validating" &&
Validating checksum…
} - {checksumStatus === "valid" &&
Checksum valid.
} - {checksumStatus === "invalid" && !!checksumError &&
{checksumError}
} - {checksumStatus === "unknown" && !!checksumError &&
{checksumError}
} -
- )} - - {patchMode === "rom" && ( -
-
-
Required base ROM
-
{baseRomEntry ? `${baseRomEntry.name} (${baseRomEntry.platform})` : "Unknown base ROM"}
-
- - {baseRomReady ? "Ready" : baseRomNeedsPermission ? "Permission needed" : "Base ROM needed"} - - {baseRomNeedsPermission && ( - - )} - {baseRomMissing && ( - - )} -
- {!!genError &&
{genError}
} -
- -
- - -

We'll generate a .bps patch on-device. No ROMs are uploaded.

- {genStatus === "generating" &&
Generating patch…
} - {genStatus === "ready" && reuploadFile &&
Patch ready: {reuploadFile.name}
} - {genStatus === "error" && !!genError &&
{genError}
} -
-
- )} -
- {reuploadError && ( -

{reuploadError}

- )} - -
- - -
- - )} + }} + disabled={actionLoading} + className="flex-1 rounded-md border border-[var(--border)] bg-[var(--surface-2)] px-4 py-2 text-sm font-medium hover:bg-[var(--surface-3)] disabled:opacity-50" + > + Cancel + + + ); } -function Modal({ - title, - children, - onClose, -}: { - title: string; - children: React.ReactNode; - onClose: () => void; -}) { - return ( -
-
-
- -

{title}

- {children} -
-
- ); -} - diff --git a/src/components/Primitives/Modal.tsx b/src/components/Primitives/Modal.tsx new file mode 100644 index 0000000..93be4c3 --- /dev/null +++ b/src/components/Primitives/Modal.tsx @@ -0,0 +1,43 @@ +import { FiX } from "react-icons/fi"; + +interface ModalProps { + title: string; + children: React.ReactNode; + visible: boolean; + onClose: () => void; +} + +export default function Modal({ + title, + children, + visible, + onClose, +}: ModalProps) { + if (!visible) return null; + + return ( +
+
+
+ +

{title}

+ {children} +
+
+ ); +} diff --git a/src/types/db.ts b/src/types/db.ts index 7719f48..51ceb89 100644 --- a/src/types/db.ts +++ b/src/types/db.ts @@ -169,6 +169,7 @@ export type Database = { approved: boolean approved_at: string | null approved_by: string | null + assigned_admin: string | null base_rom: string box_art: string | null completion_status: @@ -206,6 +207,7 @@ export type Database = { approved?: boolean approved_at?: string | null approved_by?: string | null + assigned_admin?: string | null base_rom: string box_art?: string | null completion_status?: @@ -243,6 +245,7 @@ export type Database = { approved?: boolean approved_at?: string | null approved_by?: string | null + assigned_admin?: string | null base_rom?: string box_art?: string | null completion_status?: diff --git a/supabase/migrations/20260718201617_assigned_admin_for_hacks.sql b/supabase/migrations/20260718201617_assigned_admin_for_hacks.sql new file mode 100644 index 0000000..6a5931b --- /dev/null +++ b/supabase/migrations/20260718201617_assigned_admin_for_hacks.sql @@ -0,0 +1,5 @@ +alter table if exists public.hacks + add column if not exists assigned_admin uuid; + +alter table public.hacks add constraint "hacks_assigned_admin_fkey" FOREIGN KEY (assigned_admin) REFERENCES auth.users(id) ON DELETE SET NULL not valid; +alter table public.hacks validate constraint "hacks_assigned_admin_fkey";