Personal favorites tracker: Next.js 16 App Router, Prisma/PostgreSQL, NextAuth v5 credentials auth with admin/friend roles, category-driven custom fields, lending library, optional S3 cover images. Deployment: multi-stage Dockerfile (standalone output) whose entrypoint runs prisma migrate deploy and the idempotent seed on every boot; env validation made lazy so next build works in the secret-free image. See DEPLOY.md for Coolify setup. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
163 lines
4.1 KiB
TypeScript
163 lines
4.1 KiB
TypeScript
import { PrismaClient } from "@prisma/client";
|
|
import bcrypt from "bcryptjs";
|
|
|
|
const prisma = new PrismaClient();
|
|
|
|
type FieldDef = {
|
|
key: string;
|
|
label: string;
|
|
type: "text" | "textarea" | "number" | "date" | "url" | "select";
|
|
options?: string[];
|
|
};
|
|
|
|
const CATEGORIES: Array<{
|
|
key: string;
|
|
label: string;
|
|
icon: string;
|
|
sortOrder: number;
|
|
fieldSchema: FieldDef[];
|
|
}> = [
|
|
{
|
|
key: "books",
|
|
label: "Books",
|
|
icon: "BookOpen",
|
|
sortOrder: 1,
|
|
fieldSchema: [
|
|
{ key: "author", label: "Author", type: "text" },
|
|
{ key: "isbn", label: "ISBN", type: "text" },
|
|
{ key: "publishedYear", label: "Published year", type: "number" },
|
|
{ key: "pageCount", label: "Pages", type: "number" },
|
|
],
|
|
},
|
|
{
|
|
key: "movies",
|
|
label: "Movies",
|
|
icon: "Clapperboard",
|
|
sortOrder: 2,
|
|
fieldSchema: [
|
|
{ key: "director", label: "Director", type: "text" },
|
|
{ key: "runtimeMinutes", label: "Runtime (min)", type: "number" },
|
|
{ key: "releaseYear", label: "Release year", type: "number" },
|
|
],
|
|
},
|
|
{
|
|
key: "tv-series",
|
|
label: "TV Series",
|
|
icon: "Tv",
|
|
sortOrder: 3,
|
|
fieldSchema: [
|
|
{ key: "creator", label: "Creator", type: "text" },
|
|
{ key: "seasons", label: "Seasons", type: "number" },
|
|
{ key: "firstAirYear", label: "First aired", type: "number" },
|
|
],
|
|
},
|
|
{
|
|
key: "recipes",
|
|
label: "Recipes",
|
|
icon: "ChefHat",
|
|
sortOrder: 4,
|
|
fieldSchema: [
|
|
{ key: "sourceUrl", label: "Source URL", type: "url" },
|
|
{ key: "prepTimeMinutes", label: "Prep time (min)", type: "number" },
|
|
{ key: "servings", label: "Servings", type: "number" },
|
|
],
|
|
},
|
|
{
|
|
key: "youtube",
|
|
label: "YouTube",
|
|
icon: "Youtube",
|
|
sortOrder: 5,
|
|
fieldSchema: [
|
|
{ key: "videoUrl", label: "Video URL", type: "url" },
|
|
{ key: "channel", label: "Channel", type: "text" },
|
|
],
|
|
},
|
|
{
|
|
key: "music",
|
|
label: "Music",
|
|
icon: "Music",
|
|
sortOrder: 6,
|
|
fieldSchema: [
|
|
{ key: "artist", label: "Artist", type: "text" },
|
|
{ key: "album", label: "Album", type: "text" },
|
|
{ key: "releaseYear", label: "Release year", type: "number" },
|
|
],
|
|
},
|
|
{
|
|
key: "concerts",
|
|
label: "Concerts",
|
|
icon: "Mic2",
|
|
sortOrder: 7,
|
|
fieldSchema: [
|
|
{ key: "artist", label: "Artist", type: "text" },
|
|
{ key: "venue", label: "Venue", type: "text" },
|
|
{ key: "eventDate", label: "Date", type: "date" },
|
|
],
|
|
},
|
|
{
|
|
key: "restaurants",
|
|
label: "Restaurants",
|
|
icon: "UtensilsCrossed",
|
|
sortOrder: 8,
|
|
fieldSchema: [
|
|
{ key: "location", label: "Location", type: "text" },
|
|
{ key: "cuisine", label: "Cuisine", type: "text" },
|
|
{ key: "website", label: "Website", type: "url" },
|
|
],
|
|
},
|
|
{
|
|
key: "articles",
|
|
label: "Articles & Sites",
|
|
icon: "Newspaper",
|
|
sortOrder: 9,
|
|
fieldSchema: [
|
|
{ key: "url", label: "URL", type: "url" },
|
|
{ key: "siteName", label: "Site", type: "text" },
|
|
],
|
|
},
|
|
];
|
|
|
|
async function main() {
|
|
for (const cat of CATEGORIES) {
|
|
await prisma.category.upsert({
|
|
where: { key: cat.key },
|
|
update: {}, // never overwrite admin's edits (label, isShared, fieldSchema)
|
|
create: cat,
|
|
});
|
|
}
|
|
console.log(`Seeded ${CATEGORIES.length} categories (existing rows untouched).`);
|
|
|
|
const adminExists = await prisma.user.findFirst({ where: { role: "ADMIN" } });
|
|
if (adminExists) {
|
|
console.log("Admin user already exists — skipping bootstrap.");
|
|
return;
|
|
}
|
|
|
|
const email = process.env.ADMIN_EMAIL;
|
|
const password = process.env.ADMIN_PASSWORD;
|
|
if (!email || !password) {
|
|
console.warn(
|
|
"No admin user exists and ADMIN_EMAIL/ADMIN_PASSWORD are not set — skipping admin bootstrap."
|
|
);
|
|
return;
|
|
}
|
|
|
|
await prisma.user.create({
|
|
data: {
|
|
email,
|
|
passwordHash: await bcrypt.hash(password, 12),
|
|
name: "Admin",
|
|
role: "ADMIN",
|
|
},
|
|
});
|
|
console.log(`Created admin user ${email}.`);
|
|
}
|
|
|
|
main()
|
|
.then(() => prisma.$disconnect())
|
|
.catch(async (e) => {
|
|
console.error(e);
|
|
await prisma.$disconnect();
|
|
process.exit(1);
|
|
});
|