mirror of
https://github.com/Hackdex-App/hackdex-website.git
synced 2026-06-20 22:29:47 -05:00
Implement patcher version server actions
This commit is contained in:
parent
1a479a324b
commit
5ff983f417
|
|
@ -11,6 +11,8 @@ import { revalidatePath, revalidateTag } from "next/cache";
|
|||
import { unstable_cache as cache } from "next/cache";
|
||||
import { sortOrderedTags, getCoverUrls } from "@/utils/format";
|
||||
import { Database, Constants } from "@/types/db";
|
||||
import { getPatcherSelectablePatches } from "@/utils/patches/patcher-selectable-patches";
|
||||
import type { SelectablePatch } from "@/types/patcher";
|
||||
|
||||
const PATCHES_DOWNLOAD_PERMISSION_VALUES = Constants.public.Enums[
|
||||
"Patches Download Permission"
|
||||
|
|
@ -57,6 +59,10 @@ export interface HackMetadata {
|
|||
created_at: string;
|
||||
changelog: string | null;
|
||||
} | null;
|
||||
patcherSelector: {
|
||||
selectablePatches: SelectablePatch[];
|
||||
defaultPatchId: number | null;
|
||||
};
|
||||
}
|
||||
|
||||
export async function getHackMetadata(slug: string): Promise<HackMetadata | null> {
|
||||
|
|
@ -160,6 +166,8 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
|
|||
}
|
||||
}
|
||||
|
||||
const { selectablePatches, defaultPatchId } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
|
||||
|
||||
return {
|
||||
hack,
|
||||
images,
|
||||
|
|
@ -172,6 +180,10 @@ export async function getHackMetadata(slug: string): Promise<HackMetadata | null
|
|||
} : null,
|
||||
otherHacks,
|
||||
patch,
|
||||
patcherSelector: {
|
||||
selectablePatches,
|
||||
defaultPatchId,
|
||||
}
|
||||
};
|
||||
},
|
||||
[`hack:${slug}:metadata`],
|
||||
|
|
@ -207,7 +219,12 @@ export async function getHackDownloads(slug: string): Promise<number | null> {
|
|||
return runner();
|
||||
}
|
||||
|
||||
export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
|
||||
export async function getSignedPatchUrl(
|
||||
slug: string,
|
||||
options?: {
|
||||
patchId?: number;
|
||||
}
|
||||
): Promise<{ ok: true; url: string } | { ok: false; error: string }> {
|
||||
const supabase = await createClient();
|
||||
|
||||
// Get user for permission check
|
||||
|
|
@ -243,16 +260,20 @@ export async function getSignedPatchUrl(slug: string): Promise<{ ok: true; url:
|
|||
return { ok: false, error: "Archive hacks do not have patch files available" };
|
||||
}
|
||||
|
||||
// Check if patch exists
|
||||
if (hack.current_patch == null) {
|
||||
return { ok: false, error: "No patch available" };
|
||||
// Get selectable patches and validate selected patch id
|
||||
const { selectablePatches } = await getPatcherSelectablePatches(supabase, slug, hack.current_patch);
|
||||
const allowedPatchIds = new Set(selectablePatches.map((patch) => patch.id));
|
||||
const selectedPatchId = options?.patchId ?? hack.current_patch;
|
||||
|
||||
if (selectedPatchId === null || !allowedPatchIds.has(selectedPatchId)) {
|
||||
return { ok: false, error: "Patch not available" };
|
||||
}
|
||||
|
||||
// Fetch patch info
|
||||
const { data: patch, error: patchError } = await supabase
|
||||
.from("patches")
|
||||
.select("id, bucket, filename")
|
||||
.eq("id", hack.current_patch as number)
|
||||
.eq("id", selectedPatchId)
|
||||
.maybeSingle();
|
||||
|
||||
if (patchError || !patch) {
|
||||
|
|
@ -461,7 +482,9 @@ export async function getPatchDownloadUrl(patchId: number): Promise<{ ok: true;
|
|||
return { ok: false, error: "Unauthorized" };
|
||||
}
|
||||
if (permission === "Current") {
|
||||
if (hack.current_patch == null || patch.id !== hack.current_patch) {
|
||||
const { selectablePatches } = await getPatcherSelectablePatches(supabase, hack.slug, hack.current_patch);
|
||||
const allowedPatchIds = new Set(selectablePatches.map((patch) => patch.id));
|
||||
if (hack.current_patch == null && !allowedPatchIds.has(patch.id)) {
|
||||
return { ok: false, error: "Unauthorized" };
|
||||
}
|
||||
}
|
||||
|
|
@ -815,20 +838,29 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
|
|||
return { ok: false, error: "Patch not found" };
|
||||
}
|
||||
|
||||
// Check if this patch is newer than current_patch
|
||||
// Check if hack has any patches in hack_patcher_patches
|
||||
const { data: patcherPatches, error: ppErr } = await supabase
|
||||
.from("hack_patcher_patches")
|
||||
.select("patch_id")
|
||||
.eq("hack_slug", slug);
|
||||
if (ppErr) return { ok: false, error: ppErr.message };
|
||||
|
||||
// Check if this patch is newer than current_patch, but only if there are no patcher patches
|
||||
let willBecomeCurrent = false;
|
||||
const serviceClient = await createServiceClient();
|
||||
if (hack.current_patch) {
|
||||
const { data: currentPatch } = await serviceClient
|
||||
.from("patches")
|
||||
.select("created_at")
|
||||
.eq("id", hack.current_patch)
|
||||
.maybeSingle();
|
||||
if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) {
|
||||
if (patcherPatches.length === 0) {
|
||||
if (hack.current_patch) {
|
||||
const { data: currentPatch } = await serviceClient
|
||||
.from("patches")
|
||||
.select("created_at")
|
||||
.eq("id", hack.current_patch)
|
||||
.maybeSingle();
|
||||
if (currentPatch && new Date(patch.created_at) > new Date(currentPatch.created_at)) {
|
||||
willBecomeCurrent = true;
|
||||
}
|
||||
} else {
|
||||
willBecomeCurrent = true;
|
||||
}
|
||||
} else {
|
||||
willBecomeCurrent = true;
|
||||
}
|
||||
|
||||
// Publish the patch
|
||||
|
|
@ -838,7 +870,7 @@ export async function publishPatchVersion(slug: string, patchId: number): Promis
|
|||
.eq("id", patchId);
|
||||
if (updateErr) return { ok: false, error: updateErr.message };
|
||||
|
||||
// If newer than current_patch, update current_patch
|
||||
// If newer than current_patch and no patcher patches, update current_patch
|
||||
if (willBecomeCurrent) {
|
||||
const { error: updateHackErr } = await supabase
|
||||
.from("hacks")
|
||||
|
|
@ -945,3 +977,60 @@ export async function confirmReuploadPatchVersion(
|
|||
return { ok: true };
|
||||
}
|
||||
|
||||
export async function updatePatcherSelectablePatches(
|
||||
slug: string,
|
||||
patchIds: number[],
|
||||
): Promise<{ ok: true } | { ok: false; error: string }> {
|
||||
const supabase = await createClient();
|
||||
const { data: { user } } = await supabase.auth.getUser();
|
||||
if (!user) return { ok: false, error: "Unauthorized" };
|
||||
|
||||
// Fetch hack and verify permissions
|
||||
const { data: hack, error: hErr } = await supabase
|
||||
.from("hacks")
|
||||
.select("slug, created_by, current_patch, original_author, is_archive")
|
||||
.eq("slug", slug)
|
||||
.maybeSingle();
|
||||
if (hErr || !hack) return { ok: false, error: "Hack not found" };
|
||||
|
||||
// Check permissions: creator first (optimization), then admin
|
||||
if (!canEditAsCreator(hack, user.id)) {
|
||||
const editableAsAdmin = await canEditAsAdmin(hack, user.id, supabase);
|
||||
if (!editableAsAdmin) {
|
||||
return { ok: false, error: "Forbidden" };
|
||||
}
|
||||
}
|
||||
|
||||
// Dedupe patch ids
|
||||
const uniquePatchIds = [...new Set(patchIds)];
|
||||
if (uniquePatchIds.length === 0 && hack.current_patch === null) return { ok: false, error: "No patch ids provided" };
|
||||
|
||||
// Verify patches belong to this hack
|
||||
const { data: patches, error: pErr } = await supabase
|
||||
.from("patches")
|
||||
.select("id, parent_hack, published, archived")
|
||||
.in("id", uniquePatchIds)
|
||||
.eq("parent_hack", slug);
|
||||
if (pErr || patches.length !== uniquePatchIds.length) return { ok: false, error: "One or more patches do not belong to this hack" };
|
||||
|
||||
// Verify patches are published
|
||||
const publishedPatches = patches.filter((patch) => patch.published);
|
||||
if (publishedPatches.length !== uniquePatchIds.length) return { ok: false, error: "One or more patches are not published" };
|
||||
|
||||
// Verify patches are not archived
|
||||
const archivedPatches = patches.filter((patch) => patch.archived);
|
||||
if (archivedPatches.length > 0) return { ok: false, error: "One or more patches are archived" };
|
||||
|
||||
// Replace patcher patches
|
||||
const { error: replaceErr } = await supabase.rpc("replace_hack_patcher_patches", {
|
||||
p_hack_slug: slug,
|
||||
p_patch_ids: uniquePatchIds,
|
||||
});
|
||||
if (replaceErr) return { ok: false, error: replaceErr.message };
|
||||
|
||||
revalidateTag(`hack:${slug}:metadata`);
|
||||
revalidatePath(`/hack/${slug}`);
|
||||
revalidatePath(`/hack/${slug}/versions`);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -493,6 +493,10 @@ export type Database = {
|
|||
}
|
||||
is_archiver: { Args: never; Returns: boolean }
|
||||
is_claims_admin: { Args: never; Returns: boolean }
|
||||
replace_hack_patcher_patches: {
|
||||
Args: { p_hack_slug: string; p_patch_ids: number[] }
|
||||
Returns: undefined
|
||||
}
|
||||
set_claim: {
|
||||
Args: { claim: string; uid: string; value: Json }
|
||||
Returns: string
|
||||
|
|
|
|||
11
src/types/patcher.ts
Normal file
11
src/types/patcher.ts
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
export interface SelectablePatch {
|
||||
id: number;
|
||||
version: string;
|
||||
created_at: string;
|
||||
};
|
||||
|
||||
export interface PatcherPatchSelection {
|
||||
savedPatchIds: number[];
|
||||
selectablePatches: SelectablePatch[];
|
||||
defaultPatchId: number | null;
|
||||
};
|
||||
58
src/utils/patches/patcher-selectable-patches.ts
Normal file
58
src/utils/patches/patcher-selectable-patches.ts
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import type { Database } from "@/types/db";
|
||||
import type { PatcherPatchSelection, SelectablePatch } from "@/types/patcher";
|
||||
import type { SupabaseClient } from "@supabase/supabase-js";
|
||||
|
||||
export async function getPatcherSelectablePatches(
|
||||
supabase: SupabaseClient<Database>,
|
||||
slug: string,
|
||||
currentPatchId: number | null,
|
||||
): Promise<PatcherPatchSelection> {
|
||||
const { data: rows, error } = await supabase
|
||||
.from("hack_patcher_patches")
|
||||
.select("patch_id, sort_order, patches!inner(id, version, created_at, published, archived)")
|
||||
.eq("hack_slug", slug)
|
||||
.eq("patches.published", true)
|
||||
.eq("patches.archived", false)
|
||||
.order("sort_order", { ascending: true });
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return {
|
||||
savedPatchIds: [],
|
||||
selectablePatches: [],
|
||||
defaultPatchId: null,
|
||||
};
|
||||
}
|
||||
const savedPatchIds = rows.map((row) => row.patch_id);
|
||||
let selectablePatches: SelectablePatch[] = rows.map((row) => row.patches).flat().map((patch) => ({
|
||||
id: patch.id,
|
||||
version: patch.version,
|
||||
created_at: patch.created_at,
|
||||
}));
|
||||
if (selectablePatches.length === 0 && currentPatchId !== null) {
|
||||
const { data: currentPatch, error: currentPatchError } = await supabase
|
||||
.from("patches")
|
||||
.select("id, version, created_at, published, archived")
|
||||
.eq("id", currentPatchId)
|
||||
.maybeSingle();
|
||||
if (currentPatchError) {
|
||||
console.error(currentPatchError);
|
||||
return {
|
||||
savedPatchIds: [],
|
||||
selectablePatches: [],
|
||||
defaultPatchId: null,
|
||||
};
|
||||
}
|
||||
if (currentPatch?.published && !currentPatch?.archived) {
|
||||
selectablePatches = [currentPatch];
|
||||
}
|
||||
}
|
||||
const defaultPatchId = (currentPatchId !== null && selectablePatches.some((patch) => patch.id === currentPatchId))
|
||||
? currentPatchId
|
||||
: selectablePatches[0]?.id ?? null;
|
||||
|
||||
return {
|
||||
savedPatchIds,
|
||||
selectablePatches,
|
||||
defaultPatchId,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
create or replace function public.replace_hack_patcher_patches(
|
||||
p_hack_slug text,
|
||||
p_patch_ids bigint[]
|
||||
)
|
||||
returns void
|
||||
language plpgsql
|
||||
security invoker
|
||||
set search_path = public
|
||||
as $$
|
||||
begin
|
||||
delete from public.hack_patcher_patches
|
||||
where hack_slug = p_hack_slug;
|
||||
|
||||
insert into public.hack_patcher_patches (hack_slug, patch_id, sort_order)
|
||||
select
|
||||
p_hack_slug,
|
||||
patch_id,
|
||||
ordinality::integer
|
||||
from unnest(p_patch_ids) with ordinality as t(patch_id, ordinality);
|
||||
end;
|
||||
$$;
|
||||
Loading…
Reference in New Issue
Block a user