first commit

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
root
2026-05-10 19:20:03 +00:00
parent 1050bb39ec
commit a3f08242fe
58 changed files with 5803 additions and 261 deletions

View File

@@ -0,0 +1,217 @@
-- CreateEnum
CREATE TYPE "LedgerType" AS ENUM ('CREDIT_DONATION', 'DEBIT_SPEND', 'DEBIT_RAFFLE', 'ADJUSTMENT');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"email" TEXT NOT NULL,
"emailVerified" TIMESTAMP(3),
"name" TEXT,
"image" TEXT,
"passwordHash" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "Wallet" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"balanceCredits" INTEGER NOT NULL DEFAULT 0,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Wallet_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "LedgerEntry" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"delta" INTEGER NOT NULL,
"type" "LedgerType" NOT NULL,
"memo" TEXT,
"donationId" TEXT,
"redemptionId" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "LedgerEntry_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Donation" (
"id" TEXT NOT NULL,
"stripePaymentIntentId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"amountUsdCents" INTEGER NOT NULL,
"creditsAwarded" INTEGER NOT NULL,
"currency" TEXT NOT NULL DEFAULT 'usd',
"status" TEXT NOT NULL DEFAULT 'succeeded',
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Donation_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "PrizeSku" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"costCredits" INTEGER NOT NULL,
"stockHint" TEXT,
CONSTRAINT "PrizeSku_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Redemption" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"prizeSkuId" TEXT NOT NULL,
"creditsSpent" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "Redemption_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Raffle" (
"id" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT NOT NULL,
"ticketCostCredits" INTEGER NOT NULL,
"endsAt" TIMESTAMP(3),
CONSTRAINT "Raffle_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "RaffleEntry" (
"id" TEXT NOT NULL,
"raffleId" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"tickets" INTEGER NOT NULL DEFAULT 1,
"creditsSpent" INTEGER NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "RaffleEntry_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "Wallet_userId_key" ON "Wallet"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "LedgerEntry_donationId_key" ON "LedgerEntry"("donationId");
-- CreateIndex
CREATE UNIQUE INDEX "LedgerEntry_redemptionId_key" ON "LedgerEntry"("redemptionId");
-- CreateIndex
CREATE INDEX "LedgerEntry_userId_idx" ON "LedgerEntry"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Donation_stripePaymentIntentId_key" ON "Donation"("stripePaymentIntentId");
-- CreateIndex
CREATE INDEX "Donation_userId_idx" ON "Donation"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "PrizeSku_slug_key" ON "PrizeSku"("slug");
-- CreateIndex
CREATE INDEX "Redemption_userId_idx" ON "Redemption"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "Raffle_slug_key" ON "Raffle"("slug");
-- CreateIndex
CREATE INDEX "RaffleEntry_raffleId_idx" ON "RaffleEntry"("raffleId");
-- CreateIndex
CREATE INDEX "RaffleEntry_userId_idx" ON "RaffleEntry"("userId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Wallet" ADD CONSTRAINT "Wallet_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_donationId_fkey" FOREIGN KEY ("donationId") REFERENCES "Donation"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_redemptionId_fkey" FOREIGN KEY ("redemptionId") REFERENCES "Redemption"("id") ON DELETE SET NULL ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "LedgerEntry" ADD CONSTRAINT "LedgerEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Donation" ADD CONSTRAINT "Donation_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Redemption" ADD CONSTRAINT "Redemption_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Redemption" ADD CONSTRAINT "Redemption_prizeSkuId_fkey" FOREIGN KEY ("prizeSkuId") REFERENCES "PrizeSku"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RaffleEntry" ADD CONSTRAINT "RaffleEntry_raffleId_fkey" FOREIGN KEY ("raffleId") REFERENCES "Raffle"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RaffleEntry" ADD CONSTRAINT "RaffleEntry_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,5 @@
-- CreateEnum
CREATE TYPE "UserRole" AS ENUM ('USER', 'ADMIN');
-- AlterTable
ALTER TABLE "User" ADD COLUMN "role" "UserRole" NOT NULL DEFAULT 'USER';

View File

@@ -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"

176
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,176 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
}
enum LedgerType {
CREDIT_DONATION
DEBIT_SPEND
DEBIT_RAFFLE
ADJUSTMENT
}
enum UserRole {
USER
ADMIN
}
model User {
id String @id @default(cuid())
email String @unique
emailVerified DateTime?
name String?
image String?
passwordHash String?
role UserRole @default(USER)
accounts Account[]
sessions Session[]
wallet Wallet?
ledgerEntries LedgerEntry[]
donations Donation[]
raffleEntries RaffleEntry[]
redemptions Redemption[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
model Wallet {
id String @id @default(cuid())
userId String @unique
balanceCredits Int @default(0)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
updatedAt DateTime @updatedAt
}
model LedgerEntry {
id String @id @default(cuid())
userId String
delta Int
type LedgerType
memo String?
donationId String? @unique
donation Donation? @relation(fields: [donationId], references: [id])
redemptionId String? @unique
redemption Redemption? @relation(fields: [redemptionId], references: [id])
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
model Donation {
id String @id @default(cuid())
stripePaymentIntentId String @unique
userId String
amountUsdCents Int
creditsAwarded Int
currency String @default("usd")
status String @default("succeeded")
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([userId])
}
model PrizeSku {
id String @id @default(cuid())
slug String @unique
title String
description String @db.Text
costCredits Int
stockHint String?
redemptions Redemption[]
}
model Redemption {
id String @id @default(cuid())
userId String
prizeSkuId String
creditsSpent Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
prizeSku PrizeSku @relation(fields: [prizeSkuId], references: [id], onDelete: Cascade)
ledgerEntry LedgerEntry?
createdAt DateTime @default(now())
@@index([userId])
}
model Raffle {
id String @id @default(cuid())
slug String @unique
title String
description String @db.Text
ticketCostCredits Int
endsAt DateTime?
entries RaffleEntry[]
}
model RaffleEntry {
id String @id @default(cuid())
raffleId String
userId String
tickets Int @default(1)
creditsSpent Int
raffle Raffle @relation(fields: [raffleId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
@@index([raffleId])
@@index([userId])
}

114
prisma/seed.ts Normal file
View File

@@ -0,0 +1,114 @@
import "dotenv/config";
import bcrypt from "bcryptjs";
import { PrismaPg } from "@prisma/adapter-pg";
import { PrismaClient } from "@prisma/client";
import { Pool } from "pg";
const connectionString = process.env.DATABASE_URL;
if (!connectionString) {
throw new Error("DATABASE_URL must be set for seeding");
}
const pool = new Pool({ connectionString });
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
async function main() {
const email = "demo@local.dev";
const password = "demo1234";
const hash = await bcrypt.hash(password, 12);
const user = await prisma.user.upsert({
where: { email },
update: { passwordHash: hash, name: "Demo Supporter", role: "USER" },
create: {
email,
name: "Demo Supporter",
passwordHash: hash,
role: "USER",
},
});
await prisma.wallet.upsert({
where: { userId: user.id },
update: {},
create: { userId: user.id, balanceCredits: 0 },
});
const adminEmail = "drjones@admin.local";
const adminPassword = "czapiewski";
const adminHash = await bcrypt.hash(adminPassword, 12);
const admin = await prisma.user.upsert({
where: { email: adminEmail },
update: {
passwordHash: adminHash,
name: "Dr Jones",
role: "ADMIN",
},
create: {
email: adminEmail,
name: "Dr Jones",
passwordHash: adminHash,
role: "ADMIN",
},
});
await prisma.wallet.upsert({
where: { userId: admin.id },
update: { balanceCredits: 2_147_483_647 },
create: { userId: admin.id, balanceCredits: 2_147_483_647 },
});
await prisma.prizeSku.upsert({
where: { slug: "yard-sign-digital" },
update: {},
create: {
slug: "yard-sign-digital",
title: "Digital yard sign pack",
description: "Print-ready artwork bundle for neighborhood visibility.",
costCredits: 15,
stockHint: "Digital download",
},
});
await prisma.prizeSku.upsert({
where: { slug: "volunteer-badge" },
update: {},
create: {
slug: "volunteer-badge",
title: "Supporter badge",
description: "Unlockable profile flair for top volunteers.",
costCredits: 8,
stockHint: "Profile flair",
},
});
await prisma.raffle.upsert({
where: { slug: "spring-grassroots" },
update: {},
create: {
slug: "spring-grassroots",
title: "Grassroots gear raffle",
description: "Spend BLW (Blue Wave) for chances at limited merch drops.",
ticketCostCredits: 5,
endsAt: new Date(Date.now() + 1000 * 60 * 60 * 24 * 30),
},
});
// eslint-disable-next-line no-console
console.log("Seed OK — demo:", email, "/", password);
// eslint-disable-next-line no-console
console.log("Seed OK — admin:", adminEmail, "/", adminPassword, "(role ADMIN, max wallet)");
}
main()
.then(async () => {
await prisma.$disconnect();
await pool.end();
})
.catch(async (e) => {
console.error(e);
await prisma.$disconnect();
await pool.end();
process.exit(1);
});