Refactor code

This commit is contained in:
2026-02-03 12:17:47 +01:00
parent ea5eb6fa59
commit 8572e22c5d
185 changed files with 1268 additions and 1458 deletions

View File

@ -4,6 +4,7 @@ import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import { DeleteObjectCommand } from "@aws-sdk/client-s3";
// Deletes an artwork and all related assets and records.
export async function deleteArtwork(artworkId: string) {
const artwork = await prisma.artwork.findUnique({
where: { id: artworkId },

View File

@ -8,6 +8,7 @@ import sharp from "sharp";
const GALLERY_TARGET_SIZE = 300;
// Generates a gallery-sized variant for a single artwork.
export async function generateGalleryVariant(
artworkId: string,
opts?: { force?: boolean },

View File

@ -2,10 +2,11 @@
import { prisma } from "@/lib/prisma";
// Returns color swatches for a given artwork.
export async function getArtworkColors(artworkId: string) {
return prisma.artworkColor.findMany({
where: { artworkId },
include: { color: true },
orderBy: [{ type: "asc" }],
});
}
}

View File

@ -2,6 +2,7 @@
import { prisma } from "@/lib/prisma";
// Returns album/category options for artwork filters.
export async function getArtworkFilterOptions() {
const [albums, categories] = await Promise.all([
prisma.album.findMany({ select: { id: true, name: true }, orderBy: { name: "asc" } }),

View File

@ -1,9 +1,10 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma"
import { prisma } from "@/lib/prisma";
// Loads a single artwork with relations for the edit page.
export async function getSingleArtwork(id: string) {
return await prisma.artwork.findUnique({
return prisma.artwork.findUnique({
where: { id },
include: {
// album: true,
@ -17,7 +18,7 @@ export async function getSingleArtwork(id: string) {
// sortContexts: true,
tags: true,
variants: true,
timelapse: true
}
})
timelapse: true,
},
});
}

View File

@ -1,15 +1,19 @@
"use server";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { ArtworkTableInput, artworkTableInputSchema, artworkTableOutputSchema } from "@/schemas/artworks/tableSchema";
import { type ArtworkTableInput, artworkTableInputSchema, artworkTableOutputSchema } from "@/schemas/artworks/tableSchema";
// Builds the admin artworks table page with filters, sorting, and pagination.
function triToBool(tri: "any" | "true" | "false"): boolean | undefined {
if (tri === "any") return undefined;
return tri === "true";
}
function mapSortingToOrderBy(sorting: ArtworkTableInput["sorting"]) {
const allowed: Record<string, (desc: boolean) => any> = {
function mapSortingToOrderBy(
sorting: ArtworkTableInput["sorting"]
): Prisma.ArtworkOrderByWithRelationInput[] {
const allowed: Record<string, (desc: boolean) => Prisma.ArtworkOrderByWithRelationInput> = {
createdAt: (desc) => ({ createdAt: desc ? "desc" : "asc" }),
updatedAt: (desc) => ({ updatedAt: desc ? "desc" : "asc" }),
sortIndex: (desc) => ({ sortIndex: desc ? "desc" : "asc" }),
@ -25,9 +29,10 @@ function mapSortingToOrderBy(sorting: ArtworkTableInput["sorting"]) {
tagsCount: (desc) => ({ tags: { _count: desc ? "desc" : "asc" } }),
};
const orderBy = sorting
.map((s) => allowed[s.id]?.(s.desc))
.filter(Boolean);
const orderBy = sorting.flatMap((s) => {
const mapper = allowed[s.id];
return mapper ? [mapper(s.desc)] : [];
});
orderBy.push({ id: "desc" });
return orderBy;
@ -44,7 +49,7 @@ export async function getArtworksTablePage(input: unknown) {
const nsfw = triToBool(filters.nsfw);
const needsWork = triToBool(filters.needsWork);
const where: any = {
const where: Prisma.ArtworkWhereInput = {
...(typeof published === "boolean" ? { published } : {}),
...(typeof nsfw === "boolean" ? { nsfw } : {}),
...(typeof needsWork === "boolean" ? { needsWork } : {}),

View File

@ -1,13 +1,9 @@
"use server";
import { prisma } from "@/lib/prisma";
import type { GalleryVariantStats } from "@/types/Artwork";
export type GalleryVariantStats = {
total: number;
withGallery: number;
missing: number;
};
// Returns counts for gallery variant presence.
export async function getGalleryVariantStats(): Promise<GalleryVariantStats> {
const [total, withGallery] = await Promise.all([
prisma.artwork.count(),

View File

@ -2,25 +2,30 @@
import { prisma } from "@/lib/prisma";
import { s3 } from "@/lib/s3";
import {
confirmArtworkTimelapseUploadSchema,
createArtworkTimelapseUploadSchema,
deleteArtworkTimelapseSchema,
setArtworkTimelapseEnabledSchema,
} from "@/schemas/artworks/timelapse";
import type {
ConfirmArtworkTimelapseUploadInput,
CreateArtworkTimelapseUploadInput,
DeleteArtworkTimelapseInput,
SetArtworkTimelapseEnabledInput,
} from "@/schemas/artworks/timelapse";
import { PutObjectCommand, DeleteObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { revalidatePath } from "next/cache";
import { z } from "zod/v4";
import { v4 as uuidv4 } from "uuid";
const createUploadSchema = z.object({
artworkId: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/**
* Creates a presigned PUT url so the client can upload large timelapse videos directly to S3
* (avoids Next.js body-size/proxy limits).
*/
export async function createArtworkTimelapseUpload(input: z.infer<typeof createUploadSchema>) {
const { artworkId, fileName, mimeType, sizeBytes } = createUploadSchema.parse(input);
export async function createArtworkTimelapseUpload(input: CreateArtworkTimelapseUploadInput) {
const { artworkId, fileName, mimeType, sizeBytes } =
createArtworkTimelapseUploadSchema.parse(input);
const ext = fileName.includes(".") ? fileName.split(".").pop() : undefined;
const suffix = ext ? `.${ext}` : "";
@ -41,17 +46,10 @@ export async function createArtworkTimelapseUpload(input: z.infer<typeof createU
return { uploadUrl, s3Key, fileName, mimeType, sizeBytes };
}
const confirmSchema = z.object({
artworkId: z.string().min(1),
s3Key: z.string().min(1),
fileName: z.string().min(1),
mimeType: z.string().min(1),
sizeBytes: z.number().int().positive(),
});
/** Persist uploaded timelapse metadata in DB (upsert by artworkId). */
export async function confirmArtworkTimelapseUpload(input: z.infer<typeof confirmSchema>) {
const { artworkId, s3Key, fileName, mimeType, sizeBytes } = confirmSchema.parse(input);
export async function confirmArtworkTimelapseUpload(input: ConfirmArtworkTimelapseUploadInput) {
const { artworkId, s3Key, fileName, mimeType, sizeBytes } =
confirmArtworkTimelapseUploadSchema.parse(input);
// If an old timelapse exists, delete the old object so you don't leak storage.
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
@ -91,13 +89,8 @@ export async function confirmArtworkTimelapseUpload(input: z.infer<typeof confir
return { ok: true };
}
const enabledSchema = z.object({
artworkId: z.string().min(1),
enabled: z.boolean(),
});
export async function setArtworkTimelapseEnabled(input: z.infer<typeof enabledSchema>) {
const { artworkId, enabled } = enabledSchema.parse(input);
export async function setArtworkTimelapseEnabled(input: SetArtworkTimelapseEnabledInput) {
const { artworkId, enabled } = setArtworkTimelapseEnabledSchema.parse(input);
await prisma.artworkTimelapse.update({
where: { artworkId },
@ -108,12 +101,8 @@ export async function setArtworkTimelapseEnabled(input: z.infer<typeof enabledSc
return { ok: true };
}
const deleteSchema = z.object({
artworkId: z.string().min(1),
});
export async function deleteArtworkTimelapse(input: z.infer<typeof deleteSchema>) {
const { artworkId } = deleteSchema.parse(input);
export async function deleteArtworkTimelapse(input: DeleteArtworkTimelapseInput) {
const { artworkId } = deleteArtworkTimelapseSchema.parse(input);
const existing = await prisma.artworkTimelapse.findUnique({ where: { artworkId } });
if (!existing) return { ok: true };

View File

@ -1,12 +1,13 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma";
import { artworkSchema } from "@/schemas/artworks/imageSchema";
import { normalizeNames, slugify } from "@/utils/artworkHelpers";
import { z } from "zod/v4";
// Updates an artwork and its tag/category relationships.
export async function updateArtwork(
values: z.infer<typeof artworkSchema>,
values: z.infer<typeof artworkSchema>,
id: string
) {
const validated = artworkSchema.safeParse(values);
@ -36,12 +37,11 @@ export async function updateArtwork(
const categoriesToCreate = normalizeNames(newCategoryNames);
const updatedArtwork = await prisma.$transaction(async (tx) => {
if(setAsHeader) {
if (setAsHeader) {
await tx.artwork.updateMany({
where: { setAsHeader: true },
data: { setAsHeader: false },
})
});
}
const tagsRelation =
@ -73,7 +73,7 @@ export async function updateArtwork(
: {};
return tx.artwork.update({
where: { id: id },
where: { id },
data: {
name,
slug: slugify(name),

View File

@ -2,28 +2,25 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { z } from "zod/v4";
import type { SignUpResponse } from "@/types/auth";
import { registerFirstUserSchema } from "@/schemas/auth";
import type { RegisterFirstUserInput } from "@/schemas/auth";
const schema = z.object({
name: z.string().min(1).max(200),
email: z.string().email().max(320),
password: z.string().min(8).max(128),
});
export async function registerFirstUser(input: z.infer<typeof schema>) {
// Registers the very first user and upgrades them to admin.
export async function registerFirstUser(input: RegisterFirstUserInput) {
const count = await prisma.user.count();
if (count !== 0) throw new Error("Registration is disabled.");
const { name, email, password } = schema.parse(input);
const { name, email, password } = registerFirstUserSchema.parse(input);
const res = await auth.api.signUpEmail({
const res = (await auth.api.signUpEmail({
body: { name, email, password },
});
})) as SignUpResponse;
const userId =
(res as any)?.user?.id ??
(res as any)?.data?.user?.id ??
(res as any)?.data?.id;
res.user?.id ??
res.data?.user?.id ??
res.data?.id;
if (!userId) throw new Error("Signup failed: no user id returned.");

View File

@ -1,25 +1,27 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma"
import { categorySchema } from "@/schemas/artworks/categorySchema"
import { prisma } from "@/lib/prisma";
import { categorySchema } from "@/schemas/artworks/categorySchema";
import type * as z from "zod/v4";
export async function createCategory(formData: categorySchema) {
const parsed = categorySchema.safeParse(formData)
// Creates a new artwork category.
export async function createCategory(formData: z.infer<typeof categorySchema>) {
const parsed = categorySchema.safeParse(formData);
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
console.error("Validation failed", parsed.error);
throw new Error("Invalid input");
}
const data = parsed.data
const data = parsed.data;
const created = await prisma.artCategory.create({
data: {
name: data.name,
slug: data.slug,
description: data.description
description: data.description,
},
})
});
return created
}
return created;
}

View File

@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// Deletes a category if it is not referenced by artworks or tags.
export async function deleteCategory(catId: string) {
const cat = await prisma.artCategory.findUnique({
where: { id: catId },
@ -11,7 +12,7 @@ export async function deleteCategory(catId: string) {
_count: {
select: {
tagLinks: true,
artworks: true
artworks: true,
},
},
},
@ -32,6 +33,6 @@ export async function deleteCategory(catId: string) {
await prisma.artCategory.delete({ where: { id: catId } });
revalidatePath("/categories");
return { success: true };
}

View File

@ -1,19 +1,20 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma"
import { prisma } from "@/lib/prisma";
// Category data fetchers for admin pages.
export async function getCategoriesWithTags() {
return await prisma.artCategory.findMany({
return prisma.artCategory.findMany({
include: { tagLinks: { include: { tag: true } } },
orderBy: { sortIndex: "asc" },
})
});
}
export async function getCategoriesWithCount() {
return await prisma.artCategory.findMany({
return prisma.artCategory.findMany({
include: {
_count: { select: { artworks: true, tagLinks: true } },
},
orderBy: [{ sortIndex: "asc" }, { name: "asc" }],
})
});
}

View File

@ -1,27 +1,28 @@
"use server"
"use server";
import { prisma } from '@/lib/prisma';
import { categorySchema } from '@/schemas/artworks/categorySchema';
import { z } from 'zod/v4';
import { prisma } from "@/lib/prisma";
import { categorySchema } from "@/schemas/artworks/categorySchema";
import type * as z from "zod/v4";
// Updates an artwork category by id.
export async function updateCategory(id: string, rawData: z.infer<typeof categorySchema>) {
const parsed = categorySchema.safeParse(rawData)
const parsed = categorySchema.safeParse(rawData);
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
console.error("Validation failed", parsed.error);
throw new Error("Invalid input");
}
const data = parsed.data
const data = parsed.data;
const updated = await prisma.artCategory.update({
where: { id },
data: {
name: data.name,
slug: data.slug,
description: data.description
description: data.description,
},
})
});
return updated
}
return updated;
}

View File

@ -1,16 +1,9 @@
"use server";
import { prisma } from "@/lib/prisma";
import type { ArtworkColorStats } from "@/types/colors";
export type ArtworkColorStats = {
total: number;
ready: number;
pending: number;
processing: number;
failed: number;
missingSortKey: number;
};
// Aggregates color-processing status counts for artworks.
export async function getArtworkColorStats(): Promise<ArtworkColorStats> {
const [
total,

View File

@ -3,15 +3,9 @@
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
import type { ProcessColorsResult } from "@/types/colors";
export type ProcessColorsResult = {
picked: number;
processed: number;
ok: number;
failed: number;
results: Array<{ artworkId: string; ok: boolean; error?: string }>;
};
// Processes pending/failed artwork colors with a configurable batch size.
export async function processPendingArtworkColors(args?: {
limit?: number;
includeFailed?: boolean;

View File

@ -2,6 +2,7 @@
import { prisma } from "@/lib/prisma";
// Deletes a custom commission card by id.
export async function deleteCommissionCustomCard(id: string) {
await prisma.commissionCustomCard.delete({
where: { id },

View File

@ -1,6 +1,7 @@
"use server";
import { s3 } from "@/lib/s3";
import type { CommissionCustomCardImageItem } from "@/types/commissions";
import {
DeleteObjectCommand,
ListObjectsV2Command,
@ -9,13 +10,6 @@ import {
const PREFIX = "commissions/custom-cards/";
export type CommissionCustomCardImageItem = {
key: string;
url: string;
size: number | null;
lastModified: string | null;
};
function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`;
}

View File

@ -6,6 +6,7 @@ import {
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
// Creates a new custom commission card with options/extras.
export async function createCommissionCustomCard(
formData: CommissionCustomCardValues
) {

View File

@ -6,6 +6,7 @@ import {
type CommissionCustomCardValues,
} from "@/schemas/commissionCustomCard";
// Updates a custom commission card and resets related options/extras.
export async function updateCommissionCustomCard(
id: string,
rawData: CommissionCustomCardValues

View File

@ -2,6 +2,7 @@
import { prisma } from "@/lib/prisma";
// Updates sort order for custom commission cards.
export async function updateCommissionCustomCardSortOrder(
items: { id: string; sortIndex: number }[]
) {

View File

@ -1,6 +1,7 @@
"use server";
import { s3 } from "@/lib/s3";
import type { CommissionExampleItem } from "@/types/commissions";
import {
DeleteObjectCommand,
ListObjectsV2Command,
@ -9,13 +10,6 @@ import {
const PREFIX = "commissions/examples/";
export type CommissionExampleItem = {
key: string;
url: string;
size: number | null;
lastModified: string | null;
};
function buildImageUrl(key: string) {
return `/api/image/${encodeURI(key)}`;
}

View File

@ -1,7 +1,8 @@
'use server';
"use server";
import { prisma } from "@/lib/prisma";
// Returns the latest active commission guidelines (markdown + example image).
export async function getActiveGuidelines(): Promise<{
markdown: string | null;
exampleImageUrl: string | null;

View File

@ -1,7 +1,8 @@
'use server';
"use server";
import { prisma } from "@/lib/prisma";
// Deactivates existing guidelines and creates a new active version.
export async function saveGuidelines(markdown: string, exampleImageUrl: string | null) {
await prisma.commissionGuidelines.updateMany({
where: { isActive: true },

View File

@ -1,8 +1,9 @@
"use server";
import { prisma } from "@/lib/prisma";
import { z } from "zod";
import { z } from "zod/v4";
// Deletes a commission request by id.
export async function deleteCommissionRequest(id: string) {
const parsed = z.string().min(1).parse(id);

View File

@ -1,8 +1,9 @@
"use server";
import { prisma } from "@/lib/prisma";
import { calculatePriceRange, PriceSource } from "@/utils/commissionPricing";
import { calculatePriceRange, type PriceSource } from "@/utils/commissionPricing";
// Loads a commission request with related data and computed price estimate.
export async function getCommissionRequestById(id: string) {
const req = await prisma.commissionRequest.findUnique({
where: { id },

View File

@ -1,39 +1,24 @@
"use server";
import type { Prisma } from "@/generated/prisma/client";
import { prisma } from "@/lib/prisma";
import {
commissionRequestTableRowSchema,
commissionStatusSchema,
} from "@/schemas/commissions/requests";
import { z } from "zod";
export type CursorPagination = { pageIndex: number; pageSize: number };
export type SortDir = "asc" | "desc";
const triStateSchema = z.enum(["any", "true", "false"]);
const sortingSchema = z.array(
z.object({
id: z.string(),
desc: z.boolean(),
})
);
const filtersSchema = z.object({
q: z.string().optional(),
email: z.string().optional(),
status: z.union([z.literal("any"), commissionStatusSchema]).default("any"),
hasFiles: triStateSchema.default("any"),
});
import { commissionRequestTableRowSchema } from "@/schemas/commissions/requests";
import type {
CommissionRequestsTableFilters,
CommissionRequestsTableSorting,
} from "@/schemas/commissions/requestsTable";
import type { CursorPagination } from "@/types/pagination";
import { z } from "zod/v4";
// Builds a paginated, filtered, and sorted commission-requests table payload for the admin UI.
export async function getCommissionRequestsTablePage(input: {
pagination: CursorPagination;
sorting: z.infer<typeof sortingSchema>;
filters: z.infer<typeof filtersSchema>;
sorting: CommissionRequestsTableSorting;
filters: CommissionRequestsTableFilters;
}) {
const { pagination, sorting, filters } = input;
const where: any = {};
const where: Prisma.CommissionRequestWhereInput = {};
if (filters.q) {
const q = filters.q.trim();
@ -60,7 +45,7 @@ export async function getCommissionRequestsTablePage(input: {
// sorting
const sort = sorting?.[0] ?? { id: "createdAt", desc: true };
const orderBy: any =
const orderBy: Prisma.CommissionRequestOrderByWithRelationInput =
sort.id === "createdAt"
? { createdAt: sort.desc ? "desc" : "asc" }
: sort.id === "status"
@ -94,7 +79,7 @@ export async function getCommissionRequestsTablePage(input: {
customerName: r.customerName,
customerEmail: r.customerEmail,
customerSocials: r.customerSocials ?? null,
status: r.status as any,
status: r.status,
fileCount: r._count.files,
}));

View File

@ -2,8 +2,9 @@
import { prisma } from "@/lib/prisma";
import { commissionStatusSchema } from "@/schemas/commissions/requests";
import { z } from "zod";
import { z } from "zod/v4";
// Sets a commission request status (admin action).
export async function setCommissionRequestStatus(input: {
id: string;
status: z.infer<typeof commissionStatusSchema>;

View File

@ -1,20 +1,14 @@
"use server";
import { prisma } from "@/lib/prisma";
import { commissionStatusSchema } from "@/schemas/commissions/requests";
import { z } from "zod/v4";
import {
updateCommissionRequestSchema,
} from "@/schemas/commissions/updateRequest";
import type { UpdateCommissionRequestInput } from "@/schemas/commissions/updateRequest";
const updateSchema = z.object({
id: z.string().min(1),
status: commissionStatusSchema,
customerName: z.string().min(1).max(200),
customerEmail: z.string().email().max(320),
customerSocials: z.string().max(2000).optional().nullable(),
message: z.string().min(1).max(20_000),
});
export async function updateCommissionRequest(input: z.infer<typeof updateSchema>) {
const data = updateSchema.parse(input);
// Updates editable fields on a commission request.
export async function updateCommissionRequest(input: UpdateCommissionRequestInput) {
const data = updateCommissionRequestSchema.parse(input);
await prisma.commissionRequest.update({
where: { id: data.id },

View File

@ -1,21 +1,18 @@
"use server";
import { revalidatePath } from "next/cache";
import { z } from "zod";
import { COMMISSION_STATUSES } from "@/lib/commissions/kanban";
import { prisma } from "@/lib/prisma"; // adjust to your prisma import
// import { requireAdmin } from "@/lib/auth/requireAdmin"; // recommended if you have it
import { prisma } from "@/lib/prisma";
import {
updateCommissionRequestStatusSchema,
} from "@/schemas/commissions/updateRequestStatus";
import type { UpdateCommissionRequestStatusInput } from "@/schemas/commissions/updateRequestStatus";
const schema = z.object({
id: z.string().min(1),
status: z.enum(COMMISSION_STATUSES),
});
export async function updateCommissionRequestStatus(input: z.infer<typeof schema>) {
// await requireAdmin(); // enforce auth/role check here
const { id, status } = schema.parse(input);
// Updates a commission request status and revalidates the kanban page.
export async function updateCommissionRequestStatus(
input: UpdateCommissionRequestStatusInput
) {
const { id, status } = updateCommissionRequestStatusSchema.parse(input);
await prisma.commissionRequest.update({
where: { id },
@ -23,5 +20,5 @@ export async function updateCommissionRequestStatus(input: z.infer<typeof schema
});
// revalidate the board page so a refresh always reflects server truth
revalidatePath("/commissions/board");
revalidatePath("/commissions/kanban");
}

View File

@ -1,19 +1,18 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma"
import { prisma } from "@/lib/prisma";
// Deletes a commission type and its link records.
export async function deleteCommissionType(typeId: string) {
await prisma.commissionTypeOption.deleteMany({
where: { typeId },
})
});
await prisma.commissionTypeExtra.deleteMany({
where: { typeId },
})
});
await prisma.commissionType.delete({
where: { id: typeId },
})
}
});
}

View File

@ -4,8 +4,9 @@ import { prisma } from "@/lib/prisma";
import { commissionExtraSchema } from "@/schemas/commissionType";
import { revalidatePath } from "next/cache";
const LIST_PATH = "/commissions/extras";
const LIST_PATH = "/commissions/types/extras";
// CRUD helpers for commission extras (admin-only pages).
export async function createCommissionExtra(input: unknown) {
const data = commissionExtraSchema.parse(input);
const created = await prisma.commissionExtra.create({ data });
@ -24,7 +25,6 @@ export async function deleteCommissionExtra(id: string) {
// Optional safety:
// const used = await prisma.commissionTypeExtra.count({ where: { extraId: id } });
// if (used > 0) throw new Error("Extra is linked to types.");
console.log("TBD");
// await prisma.commissionExtra.delete({ where: { id } });
// revalidatePath(LIST_PATH);
await prisma.commissionExtra.delete({ where: { id } });
revalidatePath(LIST_PATH);
}

View File

@ -2,9 +2,11 @@
import { prisma } from "@/lib/prisma";
import { commissionTypeSchema } from "@/schemas/commissionType";
import type * as z from "zod/v4";
// Creates a commission option entry.
export async function createCommissionOption(data: { name: string }) {
return await prisma.commissionOption.create({
return prisma.commissionOption.create({
data: {
name: data.name,
description: "",
@ -12,8 +14,9 @@ export async function createCommissionOption(data: { name: string }) {
});
}
// Creates a commission extra entry.
export async function createCommissionExtra(data: { name: string }) {
return await prisma.commissionExtra.create({
return prisma.commissionExtra.create({
data: {
name: data.name,
description: "",
@ -21,11 +24,12 @@ export async function createCommissionExtra(data: { name: string }) {
});
}
// Creates a commission custom input entry.
export async function createCommissionCustomInput(data: {
name: string;
fieldId: string;
}) {
return await prisma.commissionCustomInput.create({
return prisma.commissionCustomInput.create({
data: {
name: data.name,
fieldId: data.fieldId,
@ -33,7 +37,10 @@ export async function createCommissionCustomInput(data: {
});
}
export async function createCommissionType(formData: commissionTypeSchema) {
// Creates a commission type with nested options/extras/custom inputs.
export async function createCommissionType(
formData: z.infer<typeof commissionTypeSchema>
) {
const parsed = commissionTypeSchema.safeParse(formData);
if (!parsed.success) {

View File

@ -4,13 +4,9 @@ import { prisma } from "@/lib/prisma";
import { commissionOptionSchema } from "@/schemas/commissionType";
import { revalidatePath } from "next/cache";
const LIST_PATH = "/commissions/options";
function toInt(v: string) {
const n = Number.parseInt(v, 10);
return Number.isFinite(n) ? n : 0;
}
const LIST_PATH = "/commissions/types/options";
// CRUD helpers for commission options (admin-only pages).
export async function createCommissionOption(input: unknown) {
const data = commissionOptionSchema.parse(input);
const created = await prisma.commissionOption.create({
@ -37,7 +33,6 @@ export async function updateCommissionOption(id: string, input: unknown) {
}
export async function deleteCommissionOption(id: string) {
console.log("TBD");
// await prisma.commissionOption.delete({ where: { id } });
// revalidatePath(LIST_PATH);
await prisma.commissionOption.delete({ where: { id } });
revalidatePath(LIST_PATH);
}

View File

@ -1,7 +1,8 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma";
// Updates sort order for commission types.
export async function updateCommissionTypeSortOrder(
ordered: { id: string; sortIndex: number }[]
) {
@ -10,7 +11,7 @@ export async function updateCommissionTypeSortOrder(
where: { id },
data: { sortIndex },
})
)
);
await Promise.all(updates)
}
await Promise.all(updates);
}

View File

@ -4,9 +4,10 @@ import { prisma } from "@/lib/prisma";
import { commissionTypeSchema } from "@/schemas/commissionType";
import type * as z from "zod/v4";
// Updates a commission type and resets related nested records.
export async function updateCommissionType(
id: string,
rawData: z.infer<typeof commissionTypeSchema>,
rawData: z.infer<typeof commissionTypeSchema>
) {
const data = commissionTypeSchema.parse(rawData);

View File

@ -1,12 +1,10 @@
"use server";
import { prisma } from "@/lib/prisma";
import type { CountRow } from "@/types/dashboard";
type CountRow<K extends string> = {
[P in K]: string;
} & { _count: { _all: number } };
function toCountMapSafe(rows: any[], key: string) {
// Aggregates dashboard stats for admin overview cards and tables.
function toCountMapSafe<K extends string>(rows: Array<CountRow<K>>, key: K) {
const out: Record<string, number> = {};
for (const r of rows) out[String(r[key])] = Number(r?._count?._all ?? 0);
return out;

View File

@ -1,18 +1,20 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma"
import { TagFormInput, tagSchema } from "@/schemas/artworks/tagSchema"
import { prisma } from "@/lib/prisma";
import { tagSchema } from "@/schemas/artworks/tagSchema";
import type { TagFormInput } from "@/schemas/artworks/tagSchema";
// Creates a tag and related category links/aliases.
export async function createTag(formData: TagFormInput) {
const parsed = tagSchema.safeParse(formData)
const parsed = tagSchema.safeParse(formData);
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
console.error("Validation failed", parsed.error);
throw new Error("Invalid input");
}
const data = parsed.data
const data = parsed.data;
const parentId = data.parentId ?? null;
const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-");
@ -52,5 +54,5 @@ export async function createTag(formData: TagFormInput) {
return tag;
});
return created
return created;
}

View File

@ -3,6 +3,7 @@
import { prisma } from "@/lib/prisma";
import { revalidatePath } from "next/cache";
// Deletes a tag if it has no artwork references or child tags.
export async function deleteTag(tagId: string) {
const tag = await prisma.tag.findUnique({
where: { id: tagId },
@ -39,6 +40,6 @@ export async function deleteTag(tagId: string) {
});
revalidatePath("/tags");
return { success: true };
}

View File

@ -1,7 +1,8 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma"
import { prisma } from "@/lib/prisma";
// Returns tags ordered by sortIndex.
export async function getTags() {
return await prisma.tag.findMany({ orderBy: { sortIndex: "asc" } })
return prisma.tag.findMany({ orderBy: { sortIndex: "asc" } });
}

View File

@ -1,7 +1,8 @@
"use server"
"use server";
import { prisma } from "@/lib/prisma";
// Returns true if possibleAncestorId is a descendant of tagId (cycle check).
export async function isDescendant(tagId: string, possibleAncestorId: string): Promise<boolean> {
// Walk upwards across any category hierarchy; if we hit tagId, it's a cycle.
const visited = new Set<string>();

View File

@ -1,18 +1,20 @@
"use server"
"use server";
import { prisma } from '@/lib/prisma';
import { TagFormInput, tagSchema } from '@/schemas/artworks/tagSchema';
import { isDescendant } from './isDescendant';
import { prisma } from "@/lib/prisma";
import { tagSchema } from "@/schemas/artworks/tagSchema";
import type { TagFormInput } from "@/schemas/artworks/tagSchema";
import { isDescendant } from "./isDescendant";
// Updates a tag and its category/alias relationships.
export async function updateTag(id: string, rawData: TagFormInput) {
const parsed = tagSchema.safeParse(rawData)
const parsed = tagSchema.safeParse(rawData);
if (!parsed.success) {
console.error("Validation failed", parsed.error)
throw new Error("Invalid input")
console.error("Validation failed", parsed.error);
throw new Error("Invalid input");
}
const data = parsed.data
const data = parsed.data;
const parentId = data.parentId ?? null;
const tagSlug = data.name.toLowerCase().replace(/\s+/g, "-");
@ -111,5 +113,5 @@ export async function updateTag(id: string, rawData: TagFormInput) {
return tag;
});
return updated
return updated;
}

View File

@ -1,10 +1,11 @@
'use server';
"use server";
import { prisma } from "@/lib/prisma";
// Returns the most recent Terms of Service markdown.
export async function getLatestTos(): Promise<string | null> {
const tos = await prisma.termsOfService.findFirst({
orderBy: { createdAt: 'desc' },
orderBy: { createdAt: "desc" },
});
return tos?.markdown ?? null;
}

View File

@ -1,11 +1,12 @@
'use server';
"use server";
import { prisma } from "@/lib/prisma";
// Saves a new Terms of Service version.
export async function saveTosAction(markdown: string) {
await prisma.termsOfService.create({
data: {
markdown,
},
});
}
}

View File

@ -1,9 +1,9 @@
"use server";
import { createImageFromFile } from "./createImageFromFile";
import type { BulkResult } from "@/types/uploads";
type BulkResult =
| { ok: true; artworkId: string; name: string }
| { ok: false; name: string; error: string };
// Bulk image upload server action used by the admin UI.
export async function createImagesBulk(formData: FormData): Promise<BulkResult[]> {
const entries = formData.getAll("file");
const files = entries.filter((x): x is File => x instanceof File);

View File

@ -1,203 +1,12 @@
"use server"
"use server";
import { fileUploadSchema } from "@/schemas/artworks/imageSchema";
import type { fileUploadSchema } from "@/schemas/artworks/imageSchema";
import "dotenv/config";
import { z } from "zod/v4";
import type { z } from "zod/v4";
import { createImageFromFile } from "./createImageFromFile";
// Creates a single artwork image using the shared upload pipeline.
export async function createImage(values: z.infer<typeof fileUploadSchema>) {
const imageFile = values.file[0];
return createImageFromFile(imageFile, { colorMode: "inline" });
}
/*
export async function createImage(values: z.infer<typeof fileUploadSchema>) {
const imageFile = values.file[0];
if (!(imageFile instanceof File)) {
console.log("No image or invalid type");
return null;
}
const fileName = imageFile.name;
const fileType = imageFile.type;
const fileSize = imageFile.size;
const lastModified = new Date(imageFile.lastModified);
const fileKey = uuidv4();
const arrayBuffer = await imageFile.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
const realFileType = fileType.split("/")[1];
const originalKey = `original/${fileKey}.${realFileType}`;
const modifiedKey = `modified/${fileKey}.webp`;
const resizedKey = `resized/${fileKey}.webp`;
const thumbnailKey = `thumbnail/${fileKey}.webp`;
const sharpData = sharp(buffer);
const metadata = await sharpData.metadata();
//--- Original file
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: originalKey,
Body: buffer,
ContentType: "image/" + metadata.format,
})
);
//--- Modified file
const modifiedBuffer = await sharp(buffer)
.toFormat('webp')
.toBuffer()
const modifiedMetadata = await sharp(modifiedBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: modifiedKey,
Body: modifiedBuffer,
ContentType: "image/" + modifiedMetadata.format,
})
);
//--- Resized file
const { width, height } = modifiedMetadata;
const targetSize = 400;
let resizeOptions;
if (width && height) {
if (height < width) {
resizeOptions = { height: targetSize };
} else {
resizeOptions = { width: targetSize };
}
} else {
resizeOptions = { height: targetSize };
}
const resizedBuffer = await sharp(modifiedBuffer)
.resize({ ...resizeOptions, withoutEnlargement: true })
.toFormat('webp')
.toBuffer();
const resizedMetadata = await sharp(resizedBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: resizedKey,
Body: resizedBuffer,
ContentType: "image/" + resizedMetadata.format,
})
);
//--- Thumbnail file
const thumbnailTargetSize = 160;
let thumbnailOptions;
if (width && height) {
if (height < width) {
thumbnailOptions = { height: thumbnailTargetSize };
} else {
thumbnailOptions = { width: thumbnailTargetSize };
}
} else {
thumbnailOptions = { height: thumbnailTargetSize };
}
const thumbnailBuffer = await sharp(modifiedBuffer)
.resize({ ...thumbnailOptions, withoutEnlargement: true })
.toFormat('webp')
.toBuffer();
const thumbnailMetadata = await sharp(thumbnailBuffer).metadata();
await s3.send(
new PutObjectCommand({
Bucket: `${process.env.BUCKET_NAME}`,
Key: thumbnailKey,
Body: thumbnailBuffer,
ContentType: "image/" + thumbnailMetadata.format,
})
);
const fileRecord = await prisma.fileData.create({
data: {
name: fileName,
fileKey,
originalFile: fileName,
uploadDate: lastModified,
fileType: realFileType,
fileSize: fileSize,
},
});
const artworkSlug = fileName.toLowerCase().replace(/\s+/g, "-");
const artworkRecord = await prisma.artwork.create({
data: {
name: fileName,
slug: artworkSlug,
creationDate: lastModified,
fileId: fileRecord.id,
},
});
await prisma.artworkMetadata.create({
data: {
artworkId: artworkRecord.id,
format: metadata.format || "unknown",
width: metadata.width || 0,
height: metadata.height || 0,
space: metadata.space || "unknown",
channels: metadata.channels || 0,
depth: metadata.depth || "unknown",
density: metadata.density ?? undefined,
bitsPerSample: metadata.bitsPerSample ?? undefined,
isProgressive: metadata.isProgressive ?? undefined,
isPalette: metadata.isPalette ?? undefined,
hasProfile: metadata.hasProfile ?? undefined,
hasAlpha: metadata.hasAlpha ?? undefined,
autoOrientW: metadata.autoOrient?.width ?? undefined,
autoOrientH: metadata.autoOrient?.height ?? undefined,
},
});
await prisma.fileVariant.createMany({
data: [
{
s3Key: originalKey,
type: "original",
height: metadata.height,
width: metadata.width,
fileExtension: metadata.format,
mimeType: "image/" + metadata.format,
sizeBytes: metadata.size,
artworkId: artworkRecord.id
},
{
s3Key: modifiedKey,
type: "modified",
height: modifiedMetadata.height,
width: modifiedMetadata.width,
fileExtension: modifiedMetadata.format,
mimeType: "image/" + modifiedMetadata.format,
sizeBytes: modifiedMetadata.size,
artworkId: artworkRecord.id
},
{
s3Key: resizedKey,
type: "resized",
height: resizedMetadata.height,
width: resizedMetadata.width,
fileExtension: resizedMetadata.format,
mimeType: "image/" + resizedMetadata.format,
sizeBytes: resizedMetadata.size,
artworkId: artworkRecord.id
},
{
s3Key: thumbnailKey,
type: "thumbnail",
height: thumbnailMetadata.height,
width: thumbnailMetadata.width,
fileExtension: thumbnailMetadata.format,
mimeType: "image/" + thumbnailMetadata.format,
sizeBytes: thumbnailMetadata.size,
artworkId: artworkRecord.id
}
],
});
return artworkRecord
}
*/

View File

@ -8,12 +8,12 @@ import sharp from "sharp";
import { v4 as uuidv4 } from "uuid";
import { generateArtworkColorsForArtwork } from "../artworks/generateArtworkColors";
// Upload pipeline that generates variants and metadata, then creates artwork records.
export async function createImageFromFile(
imageFile: File,
opts?: { originalName?: string; colorMode?: "inline" | "defer" | "off" },
) {
if (!(imageFile instanceof File)) {
console.log("No image or invalid type");
return null;
}

View File

@ -2,24 +2,20 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { z } from "zod/v4";
import type { SessionWithRole } from "@/types/auth";
import { createUserSchema } from "@/schemas/users";
import type { CreateUserInput } from "@/schemas/users";
const schema = z.object({
name: z.string().min(1).max(200),
email: z.string().email().max(320),
password: z.string().min(8).max(128),
role: z.enum(["user", "admin"]).default("user"),
});
export async function createUser(input: z.infer<typeof schema>) {
// Creates a new user account (admin-only).
export async function createUser(input: CreateUserInput) {
const session = await auth.api.getSession({ headers: await headers() });
const role = (session as any)?.user?.role;
const role = (session as SessionWithRole)?.user?.role;
if (!session || role !== "admin") {
throw new Error("Forbidden");
}
const data = schema.parse(input);
const data = createUserSchema.parse(input);
return auth.api.createUser({
body: {

View File

@ -4,13 +4,15 @@ import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import { z } from "zod/v4";
import type { SessionWithRole } from "@/types/auth";
// Deletes a user account with safety checks (admin-only, cannot delete self or last admin).
export async function deleteUser(id: string) {
const userId = z.string().min(1).parse(id);
const session = await auth.api.getSession({ headers: await headers() });
const role = (session as any)?.user?.role as string | undefined;
const currentUserId = (session as any)?.user?.id as string | undefined;
const role = (session as SessionWithRole)?.user?.role;
const currentUserId = (session as SessionWithRole)?.user?.id;
if (!session || role !== "admin") throw new Error("Forbidden");
if (!currentUserId) throw new Error("Session missing user id");
@ -40,5 +42,5 @@ async function await_attachTarget(userId: string) {
select: { id: true, role: true },
});
if (!target) throw new Error("User not found.");
return target as { id: string; role: "admin" | "user" };
return target;
}

View File

@ -3,20 +3,13 @@
import { auth } from "@/lib/auth";
import { prisma } from "@/lib/prisma";
import { headers } from "next/headers";
import type { SessionWithRole } from "@/types/auth";
import type { UsersListRow } from "@/types/users";
export type UsersListRow = {
id: string;
name: string | null;
email: string;
role: "admin" | "user";
emailVerified: boolean;
createdAt: Date;
updatedAt: Date;
};
// Returns all users for the admin users table.
export async function getUsers(): Promise<UsersListRow[]> {
const session = await auth.api.getSession({ headers: await headers() });
const role = (session as any)?.user?.role as string | undefined;
const role = (session as SessionWithRole)?.user?.role;
if (!session || role !== "admin") {
throw new Error("Forbidden");
@ -35,5 +28,9 @@ export async function getUsers(): Promise<UsersListRow[]> {
},
});
return rows as UsersListRow[];
return rows.map((r) => ({
...r,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}));
}

View File

@ -2,21 +2,20 @@
import { auth } from "@/lib/auth";
import { headers } from "next/headers";
import { z } from "zod/v4";
import type { SessionWithRole } from "@/types/auth";
import { resendVerificationSchema } from "@/schemas/users";
import type { ResendVerificationInput } from "@/schemas/users";
const schema = z.object({
email: z.string().email(),
});
export async function resendVerification(input: z.infer<typeof schema>) {
// Resends a verification email for a user (admin-only).
export async function resendVerification(input: ResendVerificationInput) {
const session = await auth.api.getSession({ headers: await headers() });
const role = (session as any)?.user?.role as string | undefined;
const role = (session as SessionWithRole)?.user?.role;
if (!session || role !== "admin") throw new Error("Forbidden");
const { email } = schema.parse(input);
const { email } = resendVerificationSchema.parse(input);
// Uses the public auth route (same origin)
const res = await fetch("http://localhost/api/auth/send-verification-email", {
const res = await fetch(`${process.env.BETTER_AUTH_URL}/api/auth/send-verification-email`, {
// NOTE: In production, you should use an absolute URL from env, or use authClient.
// This is kept minimal; if you want, I'll refactor to authClient to avoid hostname concerns.
method: "POST",