feat: full site build — Project/Melody schema (Option A), admin CRUD, public sections, uploads, email+SMTP, internal analytics, legal pages, docs
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "CategoryKind" AS ENUM ('PORTFOLIO', 'APP', 'MELODY');
|
||||
|
||||
-- CreateEnum
|
||||
CREATE TYPE "PublishStatus" AS ENUM ('DRAFT', 'PUBLISHED', 'ARCHIVED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Category" (
|
||||
"id" TEXT NOT NULL,
|
||||
"kind" "CategoryKind" NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"nameAr" TEXT NOT NULL,
|
||||
"nameEn" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Category_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Work" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"titleAr" TEXT NOT NULL,
|
||||
"titleEn" TEXT NOT NULL,
|
||||
"summaryAr" TEXT,
|
||||
"summaryEn" TEXT,
|
||||
"coverImage" TEXT,
|
||||
"images" TEXT[],
|
||||
"externalUrl" TEXT,
|
||||
"status" "PublishStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"categoryId" TEXT NOT NULL,
|
||||
"order" INTEGER NOT NULL DEFAULT 0,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Work_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "App" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"titleAr" TEXT NOT NULL,
|
||||
"titleEn" TEXT NOT NULL,
|
||||
"descAr" TEXT,
|
||||
"descEn" TEXT,
|
||||
"platform" TEXT,
|
||||
"repoUrl" TEXT,
|
||||
"liveUrl" TEXT,
|
||||
"screenshots" TEXT[],
|
||||
"status" "PublishStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"categoryId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "App_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "Melody" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"titleAr" TEXT NOT NULL,
|
||||
"titleEn" TEXT NOT NULL,
|
||||
"audioFile" TEXT NOT NULL,
|
||||
"coverImage" TEXT,
|
||||
"durationSec" INTEGER,
|
||||
"status" "PublishStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"categoryId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Melody_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "ContactMessage" (
|
||||
"id" TEXT NOT NULL,
|
||||
"name" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"message" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "ContactMessage_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "User" (
|
||||
"id" TEXT NOT NULL,
|
||||
"email" TEXT NOT NULL,
|
||||
"password" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Category_slug_key" ON "Category"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Work_slug_key" ON "Work"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "App_slug_key" ON "App"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "Melody_slug_key" ON "Melody"("slug");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Work" ADD CONSTRAINT "Work_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "App" ADD CONSTRAINT "App_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
-- AddForeignKey
|
||||
ALTER TABLE "Melody" ADD CONSTRAINT "Melody_categoryId_fkey" FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,87 @@
|
||||
-- T-16: replace Work/App with a unified Project model and align categories.
|
||||
|
||||
-- The old content tables are intentionally removed because the database has no production content yet.
|
||||
DROP TABLE "Work";
|
||||
DROP TABLE "App";
|
||||
DROP TABLE "Melody";
|
||||
|
||||
CREATE TYPE "ProjectType" AS ENUM ('PORTFOLIO', 'APP', 'WEBSITE', 'DESIGN');
|
||||
|
||||
CREATE TYPE "CategoryKind_new" AS ENUM ('PROJECT', 'MELODY');
|
||||
|
||||
ALTER TABLE "Category"
|
||||
ALTER COLUMN "kind" TYPE "CategoryKind_new"
|
||||
USING (
|
||||
CASE
|
||||
WHEN "kind"::text = 'MELODY' THEN 'MELODY'::"CategoryKind_new"
|
||||
ELSE 'PROJECT'::"CategoryKind_new"
|
||||
END
|
||||
);
|
||||
|
||||
DROP TYPE "CategoryKind";
|
||||
ALTER TYPE "CategoryKind_new" RENAME TO "CategoryKind";
|
||||
|
||||
CREATE TABLE "Project" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "ProjectType" NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"titleAr" TEXT NOT NULL,
|
||||
"titleEn" TEXT NOT NULL,
|
||||
"summaryAr" TEXT,
|
||||
"summaryEn" TEXT,
|
||||
"descAr" TEXT,
|
||||
"descEn" TEXT,
|
||||
"coverImage" TEXT,
|
||||
"images" TEXT[] NOT NULL,
|
||||
"technologies" TEXT[] NOT NULL,
|
||||
"externalUrl" TEXT,
|
||||
"repoUrl" TEXT,
|
||||
"platform" TEXT,
|
||||
"appStoreUrl" TEXT,
|
||||
"testflightUrl" TEXT,
|
||||
"appVersion" TEXT,
|
||||
"supportUrl" TEXT,
|
||||
"appPrivacyUrl" TEXT,
|
||||
"status" "PublishStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"publishedAt" TIMESTAMP(3),
|
||||
"categoryId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Project_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE TABLE "Melody" (
|
||||
"id" TEXT NOT NULL,
|
||||
"slug" TEXT NOT NULL,
|
||||
"titleAr" TEXT NOT NULL,
|
||||
"titleEn" TEXT NOT NULL,
|
||||
"descAr" TEXT,
|
||||
"descEn" TEXT,
|
||||
"audioFile" TEXT NOT NULL,
|
||||
"coverImage" TEXT,
|
||||
"durationSec" INTEGER,
|
||||
"isDownloadable" BOOLEAN NOT NULL DEFAULT false,
|
||||
"status" "PublishStatus" NOT NULL DEFAULT 'DRAFT',
|
||||
"isFeatured" BOOLEAN NOT NULL DEFAULT false,
|
||||
"sortOrder" INTEGER NOT NULL DEFAULT 0,
|
||||
"categoryId" TEXT NOT NULL,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
|
||||
CONSTRAINT "Melody_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "Project_slug_key" ON "Project"("slug");
|
||||
CREATE INDEX "Project_type_status_idx" ON "Project"("type", "status");
|
||||
CREATE UNIQUE INDEX "Melody_slug_key" ON "Melody"("slug");
|
||||
|
||||
ALTER TABLE "Project"
|
||||
ADD CONSTRAINT "Project_categoryId_fkey"
|
||||
FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE SET NULL ON UPDATE CASCADE;
|
||||
|
||||
ALTER TABLE "Melody"
|
||||
ADD CONSTRAINT "Melody_categoryId_fkey"
|
||||
FOREIGN KEY ("categoryId") REFERENCES "Category"("id") ON DELETE RESTRICT ON UPDATE CASCADE;
|
||||
@@ -0,0 +1,20 @@
|
||||
-- CreateEnum
|
||||
CREATE TYPE "AnalyticsEventType" AS ENUM ('PAGE_VIEW', 'PROJECT_OPEN', 'MELODY_PLAY', 'PROJECT_LINK_CLICK', 'CONTACT_SUBMITTED');
|
||||
|
||||
-- CreateTable
|
||||
CREATE TABLE "AnalyticsEvent" (
|
||||
"id" TEXT NOT NULL,
|
||||
"type" "AnalyticsEventType" NOT NULL,
|
||||
"path" TEXT,
|
||||
"projectId" TEXT,
|
||||
"melodyId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "AnalyticsEvent_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnalyticsEvent_type_createdAt_idx" ON "AnalyticsEvent"("type", "createdAt");
|
||||
|
||||
-- CreateIndex
|
||||
CREATE INDEX "AnalyticsEvent_createdAt_idx" ON "AnalyticsEvent"("createdAt");
|
||||
@@ -0,0 +1,3 @@
|
||||
# Please do not edit this file manually
|
||||
# It should be added in your version-control system (e.g., Git)
|
||||
provider = "postgresql"
|
||||
@@ -0,0 +1,127 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
enum ProjectType {
|
||||
PORTFOLIO
|
||||
APP
|
||||
WEBSITE
|
||||
DESIGN
|
||||
}
|
||||
|
||||
enum CategoryKind {
|
||||
PROJECT
|
||||
MELODY
|
||||
}
|
||||
|
||||
enum PublishStatus {
|
||||
DRAFT
|
||||
PUBLISHED
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum AnalyticsEventType {
|
||||
PAGE_VIEW
|
||||
PROJECT_OPEN
|
||||
MELODY_PLAY
|
||||
PROJECT_LINK_CLICK
|
||||
CONTACT_SUBMITTED
|
||||
}
|
||||
|
||||
model Category {
|
||||
id String @id @default(cuid())
|
||||
kind CategoryKind
|
||||
slug String @unique
|
||||
nameAr String
|
||||
nameEn String
|
||||
order Int @default(0)
|
||||
projects Project[]
|
||||
melodies Melody[]
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
type ProjectType
|
||||
slug String @unique
|
||||
titleAr String
|
||||
titleEn String
|
||||
summaryAr String?
|
||||
summaryEn String?
|
||||
descAr String?
|
||||
descEn String?
|
||||
coverImage String?
|
||||
images String[]
|
||||
technologies String[]
|
||||
externalUrl String?
|
||||
repoUrl String?
|
||||
platform String?
|
||||
appStoreUrl String?
|
||||
testflightUrl String?
|
||||
appVersion String?
|
||||
supportUrl String?
|
||||
appPrivacyUrl String?
|
||||
status PublishStatus @default(DRAFT)
|
||||
isFeatured Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
publishedAt DateTime?
|
||||
categoryId String?
|
||||
category Category? @relation(fields: [categoryId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([type, status])
|
||||
}
|
||||
|
||||
model Melody {
|
||||
id String @id @default(cuid())
|
||||
slug String @unique
|
||||
titleAr String
|
||||
titleEn String
|
||||
descAr String?
|
||||
descEn String?
|
||||
audioFile String
|
||||
coverImage String?
|
||||
durationSec Int?
|
||||
isDownloadable Boolean @default(false)
|
||||
status PublishStatus @default(DRAFT)
|
||||
isFeatured Boolean @default(false)
|
||||
sortOrder Int @default(0)
|
||||
categoryId String
|
||||
category Category @relation(fields: [categoryId], references: [id])
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
}
|
||||
|
||||
model ContactMessage {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
email String
|
||||
message String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
|
||||
model AnalyticsEvent {
|
||||
id String @id @default(cuid())
|
||||
type AnalyticsEventType
|
||||
path String?
|
||||
projectId String?
|
||||
melodyId String?
|
||||
createdAt DateTime @default(now())
|
||||
|
||||
@@index([type, createdAt])
|
||||
@@index([createdAt])
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(cuid())
|
||||
email String @unique
|
||||
password String
|
||||
createdAt DateTime @default(now())
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { hash } from "bcryptjs";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
const email = process.env.ADMIN_EMAIL?.trim().toLowerCase();
|
||||
const password = process.env.ADMIN_PASSWORD;
|
||||
|
||||
if (!email || !password) {
|
||||
throw new Error("ADMIN_EMAIL and ADMIN_PASSWORD are required to seed the admin user.");
|
||||
}
|
||||
|
||||
if (password.length < 12) {
|
||||
throw new Error("ADMIN_PASSWORD must be at least 12 characters long.");
|
||||
}
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
try {
|
||||
const passwordHash = await hash(password, 12);
|
||||
|
||||
await prisma.user.upsert({
|
||||
where: { email },
|
||||
update: { password: passwordHash },
|
||||
create: { email, password: passwordHash },
|
||||
});
|
||||
} finally {
|
||||
await prisma.$disconnect();
|
||||
}
|
||||
Reference in New Issue
Block a user