diff --git a/Dockerfile b/Dockerfile index 41ed2df..f376c6c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -36,6 +36,8 @@ COPY --from=builder /app/.next/standalone ./ COPY --from=builder /app/.next/static ./.next/static COPY --from=builder /app/prisma ./prisma COPY --from=builder /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder /app/node_modules/@prisma ./node_modules/@prisma # Set ownership RUN chown -R nextjs:nodejs /app diff --git a/docker-compose.yml b/docker-compose.yml index 3de2b50..0d5792e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -4,7 +4,7 @@ services: restart: unless-stopped labels: - "traefik.enable=true" - - "traefik.http.routers.rvote.rule=Host(`rvote.online`) || Host(`www.rvote.online`)" + - "traefik.http.routers.rvote.rule=Host(`rvote.online`) || Host(`www.rvote.online`) || HostRegexp(`{subdomain:[a-z0-9-]+}.rvote.online`)" - "traefik.http.routers.rvote.entrypoints=web" - "traefik.http.services.rvote.loadbalancer.server.port=3000" - "traefik.docker.network=traefik-public" @@ -13,6 +13,8 @@ services: - NEXTAUTH_SECRET=${NEXTAUTH_SECRET} - NEXTAUTH_URL=https://rvote.online - RESEND_API_KEY=${RESEND_API_KEY} + - ROOT_DOMAIN=rvote.online + - NEXT_PUBLIC_ROOT_DOMAIN=rvote.online networks: - traefik-public - rvote-internal diff --git a/prisma/migrations/20260210220827_add_spaces/migration.sql b/prisma/migrations/20260210220827_add_spaces/migration.sql new file mode 100644 index 0000000..98c3d86 --- /dev/null +++ b/prisma/migrations/20260210220827_add_spaces/migration.sql @@ -0,0 +1,219 @@ +-- CreateEnum +CREATE TYPE "SpaceRole" AS ENUM ('ADMIN', 'MEMBER'); + +-- CreateEnum +CREATE TYPE "ProposalStatus" AS ENUM ('RANKING', 'VOTING', 'PASSED', 'FAILED', 'ARCHIVED'); + +-- CreateEnum +CREATE TYPE "VoteChoice" AS ENUM ('YES', 'NO', 'ABSTAIN'); + +-- CreateTable +CREATE TABLE "User" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "passwordHash" TEXT, + "name" TEXT, + "credits" INTEGER NOT NULL DEFAULT 0, + "lastCreditAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "emailVerified" TIMESTAMP(3), + "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 "Space" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "description" TEXT, + "isPublic" BOOLEAN NOT NULL DEFAULT false, + "promotionThreshold" INTEGER NOT NULL DEFAULT 100, + "votingPeriodDays" INTEGER NOT NULL DEFAULT 7, + "creditsPerDay" INTEGER NOT NULL DEFAULT 10, + "maxCredits" INTEGER NOT NULL DEFAULT 500, + "startingCredits" INTEGER NOT NULL DEFAULT 50, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Space_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SpaceMember" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "role" "SpaceRole" NOT NULL DEFAULT 'MEMBER', + "credits" INTEGER NOT NULL DEFAULT 0, + "lastCreditAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "joinedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SpaceMember_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "SpaceInvite" ( + "id" TEXT NOT NULL, + "spaceId" TEXT NOT NULL, + "email" TEXT, + "token" TEXT NOT NULL, + "maxUses" INTEGER, + "uses" INTEGER NOT NULL DEFAULT 0, + "expiresAt" TIMESTAMP(3), + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "SpaceInvite_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Proposal" ( + "id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT NOT NULL, + "authorId" TEXT NOT NULL, + "spaceId" TEXT, + "status" "ProposalStatus" NOT NULL DEFAULT 'RANKING', + "score" INTEGER NOT NULL DEFAULT 0, + "votingEndsAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Proposal_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "Vote" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "weight" INTEGER NOT NULL, + "creditCost" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "decaysAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Vote_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "FinalVote" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "proposalId" TEXT NOT NULL, + "vote" "VoteChoice" NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FinalVote_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 "Space_slug_key" ON "Space"("slug"); + +-- CreateIndex +CREATE INDEX "SpaceMember_spaceId_idx" ON "SpaceMember"("spaceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SpaceMember_userId_spaceId_key" ON "SpaceMember"("userId", "spaceId"); + +-- CreateIndex +CREATE UNIQUE INDEX "SpaceInvite_token_key" ON "SpaceInvite"("token"); + +-- CreateIndex +CREATE INDEX "SpaceInvite_token_idx" ON "SpaceInvite"("token"); + +-- CreateIndex +CREATE INDEX "SpaceInvite_email_spaceId_idx" ON "SpaceInvite"("email", "spaceId"); + +-- CreateIndex +CREATE INDEX "Proposal_spaceId_status_idx" ON "Proposal"("spaceId", "status"); + +-- CreateIndex +CREATE UNIQUE INDEX "Vote_userId_proposalId_key" ON "Vote"("userId", "proposalId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FinalVote_userId_proposalId_key" ON "FinalVote"("userId", "proposalId"); + +-- 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 "SpaceMember" ADD CONSTRAINT "SpaceMember_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SpaceMember" ADD CONSTRAINT "SpaceMember_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "SpaceInvite" ADD CONSTRAINT "SpaceInvite_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_authorId_fkey" FOREIGN KEY ("authorId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Proposal" ADD CONSTRAINT "Proposal_spaceId_fkey" FOREIGN KEY ("spaceId") REFERENCES "Space"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Vote" ADD CONSTRAINT "Vote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "Vote" ADD CONSTRAINT "Vote_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FinalVote" ADD CONSTRAINT "FinalVote_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "FinalVote" ADD CONSTRAINT "FinalVote_proposalId_fkey" FOREIGN KEY ("proposalId") REFERENCES "Proposal"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/migration_lock.toml b/prisma/migrations/migration_lock.toml new file mode 100644 index 0000000..044d57c --- /dev/null +++ b/prisma/migrations/migration_lock.toml @@ -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" diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0d9095..d81b495 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -24,6 +24,9 @@ model User { // NextAuth fields accounts Account[] sessions Session[] + + // Space memberships + spaceMemberships SpaceMember[] } model Account { @@ -61,12 +64,85 @@ model VerificationToken { @@unique([identifier, token]) } +// ─── Spaces (Multi-Tenant) ─────────────────────────────────────────── + +model Space { + id String @id @default(cuid()) + name String + slug String @unique + description String? @db.Text + isPublic Boolean @default(false) + + // Configurable per-space voting parameters + promotionThreshold Int @default(100) + votingPeriodDays Int @default(7) + creditsPerDay Int @default(10) + maxCredits Int @default(500) + startingCredits Int @default(50) + + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + members SpaceMember[] + proposals Proposal[] + invites SpaceInvite[] +} + +enum SpaceRole { + ADMIN + MEMBER +} + +model SpaceMember { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + role SpaceRole @default(MEMBER) + + // Per-space credits + credits Int @default(0) + lastCreditAt DateTime @default(now()) + + joinedAt DateTime @default(now()) + + @@unique([userId, spaceId]) + @@index([spaceId]) +} + +model SpaceInvite { + id String @id @default(cuid()) + spaceId String + space Space @relation(fields: [spaceId], references: [id], onDelete: Cascade) + + // For email invites + email String? + + // For shareable link invites + token String @unique @default(cuid()) + + // Invite metadata + maxUses Int? + uses Int @default(0) + expiresAt DateTime? + createdBy String + createdAt DateTime @default(now()) + + @@index([token]) + @@index([email, spaceId]) +} + +// ─── Proposals & Voting ────────────────────────────────────────────── + model Proposal { id String @id @default(cuid()) title String description String @db.Text authorId String author User @relation(fields: [authorId], references: [id]) + spaceId String? + space Space? @relation(fields: [spaceId], references: [id], onDelete: Cascade) status ProposalStatus @default(RANKING) score Int @default(0) votes Vote[] @@ -74,6 +150,8 @@ model Proposal { votingEndsAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + + @@index([spaceId, status]) } enum ProposalStatus { diff --git a/prisma/seed-spaces.ts b/prisma/seed-spaces.ts new file mode 100644 index 0000000..6dc34f2 --- /dev/null +++ b/prisma/seed-spaces.ts @@ -0,0 +1,84 @@ +/** + * Migration script: Create a "Legacy" space and migrate existing data. + * + * Run on the production DB after the schema migration: + * npx tsx prisma/seed-spaces.ts + */ + +import { PrismaClient } from "@prisma/client"; + +const prisma = new PrismaClient(); + +async function main() { + console.log("Starting space migration..."); + + // 1. Create the legacy space + const legacySpace = await prisma.space.upsert({ + where: { slug: "legacy" }, + update: {}, + create: { + name: "rVote Community", + slug: "legacy", + description: "The original rVote community space.", + isPublic: true, + promotionThreshold: 100, + votingPeriodDays: 7, + creditsPerDay: 10, + maxCredits: 500, + startingCredits: 50, + }, + }); + + console.log(`Legacy space created/found: ${legacySpace.id}`); + + // 2. Enroll all existing users as members with their current credits + const users = await prisma.user.findMany({ + select: { id: true, credits: true, lastCreditAt: true }, + }); + + for (const user of users) { + await prisma.spaceMember.upsert({ + where: { + userId_spaceId: { userId: user.id, spaceId: legacySpace.id }, + }, + update: {}, + create: { + userId: user.id, + spaceId: legacySpace.id, + role: "MEMBER", + credits: user.credits, + lastCreditAt: user.lastCreditAt, + }, + }); + } + + console.log(`Enrolled ${users.length} users into legacy space`); + + // Make the first user an admin + if (users.length > 0) { + await prisma.spaceMember.update({ + where: { + userId_spaceId: { userId: users[0].id, spaceId: legacySpace.id }, + }, + data: { role: "ADMIN" }, + }); + console.log(`Made first user admin: ${users[0].id}`); + } + + // 3. Assign all existing proposals to the legacy space + const result = await prisma.proposal.updateMany({ + where: { spaceId: null }, + data: { spaceId: legacySpace.id }, + }); + + console.log(`Assigned ${result.count} proposals to legacy space`); + + console.log("Migration complete!"); +} + +main() + .catch((e) => { + console.error("Migration failed:", e); + process.exit(1); + }) + .finally(() => prisma.$disconnect()); diff --git a/src/app/api/spaces/[slug]/credits/route.ts b/src/app/api/spaces/[slug]/credits/route.ts new file mode 100644 index 0000000..b74ec85 --- /dev/null +++ b/src/app/api/spaces/[slug]/credits/route.ts @@ -0,0 +1,42 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { requireSpaceMembership } from "@/lib/spaces"; +import { calculateAvailableCredits, maxAffordableWeight } from "@/lib/credits"; +import { NextRequest, NextResponse } from "next/server"; + +// GET — Get the current user's credits in this space +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const session = await auth(); + const { slug } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let space, membership; + try { + const result = await requireSpaceMembership(session.user.id, slug); + space = result.space; + membership = result.membership; + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const available = calculateAvailableCredits( + membership.credits, + membership.lastCreditAt, + space.creditsPerDay, + space.maxCredits + ); + + return NextResponse.json({ + stored: membership.credits, + available, + maxAffordableVote: maxAffordableWeight(available), + creditsPerDay: space.creditsPerDay, + maxCredits: space.maxCredits, + }); +} diff --git a/src/app/api/spaces/[slug]/invites/[id]/route.ts b/src/app/api/spaces/[slug]/invites/[id]/route.ts new file mode 100644 index 0000000..1db1edc --- /dev/null +++ b/src/app/api/spaces/[slug]/invites/[id]/route.ts @@ -0,0 +1,32 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { requireSpaceAdmin } from "@/lib/spaces"; +import { NextRequest, NextResponse } from "next/server"; + +// DELETE — Revoke an invite (admin only) +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ slug: string; id: string }> } +) { + const session = await auth(); + const { slug, id } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + await requireSpaceAdmin(session.user.id, slug); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const invite = await prisma.spaceInvite.findUnique({ where: { id } }); + if (!invite) { + return NextResponse.json({ error: "Invite not found" }, { status: 404 }); + } + + await prisma.spaceInvite.delete({ where: { id } }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/spaces/[slug]/invites/route.ts b/src/app/api/spaces/[slug]/invites/route.ts new file mode 100644 index 0000000..85ffbf2 --- /dev/null +++ b/src/app/api/spaces/[slug]/invites/route.ts @@ -0,0 +1,71 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { requireSpaceAdmin } from "@/lib/spaces"; +import { NextRequest, NextResponse } from "next/server"; + +// GET — List invites for a space (admin only) +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const session = await auth(); + const { slug } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let space; + try { + const result = await requireSpaceAdmin(session.user.id, slug); + space = result.space; + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const invites = await prisma.spaceInvite.findMany({ + where: { spaceId: space.id }, + orderBy: { createdAt: "desc" }, + }); + + return NextResponse.json(invites); +} + +// POST — Create an invite (admin only) +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const session = await auth(); + const { slug } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let space; + try { + const result = await requireSpaceAdmin(session.user.id, slug); + space = result.space; + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const { email, maxUses, expiresAt } = await req.json(); + + const invite = await prisma.spaceInvite.create({ + data: { + spaceId: space.id, + email: email || null, + maxUses: maxUses || null, + expiresAt: expiresAt ? new Date(expiresAt) : null, + createdBy: session.user.id, + }, + }); + + const rootDomain = process.env.ROOT_DOMAIN || "rvote.online"; + const protocol = process.env.NODE_ENV === "production" ? "https" : "http"; + const inviteUrl = `${protocol}://${slug}.${rootDomain}/join?token=${invite.token}`; + + return NextResponse.json({ ...invite, inviteUrl }, { status: 201 }); +} diff --git a/src/app/api/spaces/[slug]/members/[userId]/route.ts b/src/app/api/spaces/[slug]/members/[userId]/route.ts new file mode 100644 index 0000000..ca03d00 --- /dev/null +++ b/src/app/api/spaces/[slug]/members/[userId]/route.ts @@ -0,0 +1,90 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { requireSpaceAdmin } from "@/lib/spaces"; +import { NextRequest, NextResponse } from "next/server"; + +// PATCH — Update member role (admin only) +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ slug: string; userId: string }> } +) { + const session = await auth(); + const { slug, userId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let space; + try { + const result = await requireSpaceAdmin(session.user.id, slug); + space = result.space; + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const { role } = await req.json(); + if (!role || !["ADMIN", "MEMBER"].includes(role)) { + return NextResponse.json({ error: "Role must be ADMIN or MEMBER" }, { status: 400 }); + } + + const member = await prisma.spaceMember.findUnique({ + where: { userId_spaceId: { userId, spaceId: space.id } }, + }); + if (!member) { + return NextResponse.json({ error: "Member not found" }, { status: 404 }); + } + + const updated = await prisma.spaceMember.update({ + where: { id: member.id }, + data: { role }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + + return NextResponse.json(updated); +} + +// DELETE — Remove member (admin only) +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ slug: string; userId: string }> } +) { + const session = await auth(); + const { slug, userId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let space; + try { + const result = await requireSpaceAdmin(session.user.id, slug); + space = result.space; + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + // Prevent removing yourself if you're the last admin + if (userId === session.user.id) { + const adminCount = await prisma.spaceMember.count({ + where: { spaceId: space.id, role: "ADMIN" }, + }); + if (adminCount <= 1) { + return NextResponse.json( + { error: "Cannot remove the last admin" }, + { status: 400 } + ); + } + } + + const member = await prisma.spaceMember.findUnique({ + where: { userId_spaceId: { userId, spaceId: space.id } }, + }); + if (!member) { + return NextResponse.json({ error: "Member not found" }, { status: 404 }); + } + + await prisma.spaceMember.delete({ where: { id: member.id } }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/spaces/[slug]/members/credits/route.ts b/src/app/api/spaces/[slug]/members/credits/route.ts new file mode 100644 index 0000000..13b9a9c --- /dev/null +++ b/src/app/api/spaces/[slug]/members/credits/route.ts @@ -0,0 +1,57 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { requireSpaceAdmin } from "@/lib/spaces"; +import { NextRequest, NextResponse } from "next/server"; + +// POST — Allot credits to a member (admin only) +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const session = await auth(); + const { slug } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let space; + try { + const result = await requireSpaceAdmin(session.user.id, slug); + space = result.space; + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const { userId, amount } = await req.json(); + + if (!userId || typeof userId !== "string") { + return NextResponse.json({ error: "userId is required" }, { status: 400 }); + } + + if (typeof amount !== "number" || amount <= 0) { + return NextResponse.json({ error: "Amount must be a positive number" }, { status: 400 }); + } + + const member = await prisma.spaceMember.findUnique({ + where: { userId_spaceId: { userId, spaceId: space.id } }, + }); + if (!member) { + return NextResponse.json({ error: "Member not found" }, { status: 404 }); + } + + const updated = await prisma.spaceMember.update({ + where: { id: member.id }, + data: { + credits: { increment: amount }, + lastCreditAt: new Date(), + }, + include: { user: { select: { id: true, name: true, email: true } } }, + }); + + return NextResponse.json({ + success: true, + member: updated, + newCredits: updated.credits, + }); +} diff --git a/src/app/api/spaces/[slug]/members/route.ts b/src/app/api/spaces/[slug]/members/route.ts new file mode 100644 index 0000000..3e40d71 --- /dev/null +++ b/src/app/api/spaces/[slug]/members/route.ts @@ -0,0 +1,92 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { requireSpaceAdmin, requireSpaceMembership } from "@/lib/spaces"; +import { NextRequest, NextResponse } from "next/server"; + +// GET — List members of a space +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const session = await auth(); + const { slug } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + await requireSpaceMembership(session.user.id, slug); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const space = await prisma.space.findUnique({ where: { slug } }); + const members = await prisma.spaceMember.findMany({ + where: { spaceId: space!.id }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + orderBy: { joinedAt: "asc" }, + }); + + return NextResponse.json(members); +} + +// POST — Add a member by email (admin only) +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const session = await auth(); + const { slug } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + let space; + try { + const result = await requireSpaceAdmin(session.user.id, slug); + space = result.space; + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const { email, role = "MEMBER" } = await req.json(); + + if (!email || typeof email !== "string") { + return NextResponse.json({ error: "Email is required" }, { status: 400 }); + } + + // Find user by email + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) { + return NextResponse.json( + { error: "No user found with this email. They must create an account first." }, + { status: 404 } + ); + } + + // Check if already a member + const existing = await prisma.spaceMember.findUnique({ + where: { userId_spaceId: { userId: user.id, spaceId: space.id } }, + }); + if (existing) { + return NextResponse.json({ error: "User is already a member" }, { status: 409 }); + } + + const member = await prisma.spaceMember.create({ + data: { + userId: user.id, + spaceId: space.id, + role: role === "ADMIN" ? "ADMIN" : "MEMBER", + credits: space.startingCredits, + }, + include: { + user: { select: { id: true, name: true, email: true } }, + }, + }); + + return NextResponse.json(member, { status: 201 }); +} diff --git a/src/app/api/spaces/[slug]/route.ts b/src/app/api/spaces/[slug]/route.ts new file mode 100644 index 0000000..c2a7a37 --- /dev/null +++ b/src/app/api/spaces/[slug]/route.ts @@ -0,0 +1,88 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { requireSpaceAdmin, requireSpaceMembership } from "@/lib/spaces"; +import { NextRequest, NextResponse } from "next/server"; + +// GET /api/spaces/[slug] — Get space details +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const { slug } = await params; + + const space = await prisma.space.findUnique({ + where: { slug }, + include: { + _count: { select: { members: true, proposals: true } }, + }, + }); + + if (!space) { + return NextResponse.json({ error: "Space not found" }, { status: 404 }); + } + + return NextResponse.json(space); +} + +// PATCH /api/spaces/[slug] — Update space (admin only) +export async function PATCH( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const session = await auth(); + const { slug } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + await requireSpaceAdmin(session.user.id, slug); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + const body = await req.json(); + const allowedFields = [ + "name", "description", "isPublic", + "promotionThreshold", "votingPeriodDays", + "creditsPerDay", "maxCredits", "startingCredits", + ]; + + const data: Record = {}; + for (const field of allowedFields) { + if (body[field] !== undefined) { + data[field] = body[field]; + } + } + + const updated = await prisma.space.update({ + where: { slug }, + data, + }); + + return NextResponse.json(updated); +} + +// DELETE /api/spaces/[slug] — Delete space (admin only) +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + const session = await auth(); + const { slug } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + try { + await requireSpaceAdmin(session.user.id, slug); + } catch (e: any) { + return NextResponse.json({ error: e.message }, { status: e.status || 403 }); + } + + await prisma.space.delete({ where: { slug } }); + + return NextResponse.json({ success: true }); +} diff --git a/src/app/api/spaces/join/[token]/route.ts b/src/app/api/spaces/join/[token]/route.ts new file mode 100644 index 0000000..a0e7d91 --- /dev/null +++ b/src/app/api/spaces/join/[token]/route.ts @@ -0,0 +1,110 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { NextRequest, NextResponse } from "next/server"; + +// POST — Accept an invite and join a space +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + const session = await auth(); + const { token } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const invite = await prisma.spaceInvite.findUnique({ + where: { token }, + include: { space: true }, + }); + + if (!invite) { + return NextResponse.json({ error: "Invalid invite link" }, { status: 404 }); + } + + // Check expiry + if (invite.expiresAt && invite.expiresAt < new Date()) { + return NextResponse.json({ error: "This invite has expired" }, { status: 410 }); + } + + // Check max uses + if (invite.maxUses !== null && invite.uses >= invite.maxUses) { + return NextResponse.json({ error: "This invite has reached its usage limit" }, { status: 410 }); + } + + // Check email restriction + if (invite.email && invite.email !== session.user.email) { + return NextResponse.json( + { error: "This invite is for a different email address" }, + { status: 403 } + ); + } + + // Check if already a member + const existing = await prisma.spaceMember.findUnique({ + where: { + userId_spaceId: { userId: session.user.id, spaceId: invite.spaceId }, + }, + }); + + if (existing) { + return NextResponse.json({ + success: true, + alreadyMember: true, + space: invite.space, + }); + } + + // Join the space + await prisma.$transaction([ + prisma.spaceMember.create({ + data: { + userId: session.user.id, + spaceId: invite.spaceId, + role: "MEMBER", + credits: invite.space.startingCredits, + }, + }), + prisma.spaceInvite.update({ + where: { id: invite.id }, + data: { uses: { increment: 1 } }, + }), + ]); + + return NextResponse.json({ + success: true, + space: invite.space, + }); +} + +// GET — Get invite info (public, for showing the join page) +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ token: string }> } +) { + const { token } = await params; + + const invite = await prisma.spaceInvite.findUnique({ + where: { token }, + include: { + space: { + select: { name: true, slug: true, description: true }, + }, + }, + }); + + if (!invite) { + return NextResponse.json({ error: "Invalid invite link" }, { status: 404 }); + } + + const expired = invite.expiresAt ? invite.expiresAt < new Date() : false; + const maxedOut = invite.maxUses !== null ? invite.uses >= invite.maxUses : false; + + return NextResponse.json({ + space: invite.space, + expired, + maxedOut, + valid: !expired && !maxedOut, + }); +} diff --git a/src/app/api/spaces/route.ts b/src/app/api/spaces/route.ts new file mode 100644 index 0000000..f15b8f4 --- /dev/null +++ b/src/app/api/spaces/route.ts @@ -0,0 +1,86 @@ +import { auth } from "@/lib/auth"; +import { prisma } from "@/lib/prisma"; +import { generateUniqueSlug } from "@/lib/spaces"; +import { NextRequest, NextResponse } from "next/server"; + +// GET /api/spaces — List the user's spaces +export async function GET() { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const memberships = await prisma.spaceMember.findMany({ + where: { userId: session.user.id }, + include: { + space: { + include: { + _count: { select: { members: true, proposals: true } }, + }, + }, + }, + orderBy: { joinedAt: "desc" }, + }); + + const spaces = memberships.map((m) => ({ + ...m.space, + role: m.role, + memberCount: m.space._count.members, + proposalCount: m.space._count.proposals, + })); + + return NextResponse.json(spaces); +} + +// POST /api/spaces — Create a new space +export async function POST(req: NextRequest) { + const session = await auth(); + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const body = await req.json(); + const { name, description, slug: requestedSlug } = body; + + if (!name || typeof name !== "string" || name.trim().length === 0) { + return NextResponse.json({ error: "Name is required" }, { status: 400 }); + } + + if (name.length > 100) { + return NextResponse.json({ error: "Name must be 100 characters or less" }, { status: 400 }); + } + + const slug = requestedSlug + ? requestedSlug.toLowerCase().replace(/[^a-z0-9-]/g, "") + : await generateUniqueSlug(name); + + // Check slug uniqueness + const existing = await prisma.space.findUnique({ where: { slug } }); + if (existing) { + return NextResponse.json({ error: "This slug is already taken" }, { status: 409 }); + } + + // Create space + admin membership in a transaction + const space = await prisma.$transaction(async (tx) => { + const newSpace = await tx.space.create({ + data: { + name: name.trim(), + slug, + description: description?.trim() || null, + }, + }); + + await tx.spaceMember.create({ + data: { + userId: session.user.id, + spaceId: newSpace.id, + role: "ADMIN", + credits: newSpace.startingCredits, + }, + }); + + return newSpace; + }); + + return NextResponse.json(space, { status: 201 }); +} diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 2709c31..2acaf67 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -1,206 +1,11 @@ -"use client"; - -import { useState } from "react"; +import { InteractiveDemo } from "@/components/InteractiveDemo"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"; +import { Card, CardContent } from "@/components/ui/card"; import { Badge } from "@/components/ui/badge"; import Link from "next/link"; -import { - ChevronUp, - ChevronDown, - Check, - X, - Minus, - ArrowRight, - RotateCcw, - Coins, - TrendingUp, - Clock, -} from "lucide-react"; - -interface DemoProposal { - id: number; - title: string; - description: string; - score: number; - userVote: number; - pendingVote: number; - stage: "ranking" | "voting"; - yesVotes: number; - noVotes: number; -} - -const initialProposals: DemoProposal[] = [ - { - id: 1, - title: "Allocate 15% of treasury to ecosystem grants program", - description: "Fund community developers building tools and integrations for the ecosystem over the next 6 months", - score: 72, - userVote: 0, - pendingVote: 0, - stage: "ranking", - yesVotes: 0, - noVotes: 0, - }, - { - id: 2, - title: "Establish a community moderation council", - description: "Elect 7 members to handle disputes, enforce guidelines, and maintain community standards", - score: 58, - userVote: 0, - pendingVote: 0, - stage: "ranking", - yesVotes: 0, - noVotes: 0, - }, - { - id: 3, - title: "Partner with University research lab for governance study", - description: "Collaborate with academic researchers to analyze and improve our decision-making processes", - score: 41, - userVote: 0, - pendingVote: 0, - stage: "ranking", - yesVotes: 0, - noVotes: 0, - }, - { - id: 4, - title: "Create bounty program for security audits", - description: "Reward external security researchers who identify vulnerabilities in our smart contracts", - score: 35, - userVote: 0, - pendingVote: 0, - stage: "ranking", - yesVotes: 0, - noVotes: 0, - }, - { - id: 5, - title: "Host quarterly virtual town halls", - description: "Regular video conferences for community updates, Q&A sessions, and open discussion", - score: 23, - userVote: 0, - pendingVote: 0, - stage: "ranking", - yesVotes: 0, - noVotes: 0, - }, -]; +import { ArrowRight } from "lucide-react"; export default function DemoPage() { - const [credits, setCredits] = useState(100); - const [proposals, setProposals] = useState(initialProposals); - - const maxWeight = Math.floor(Math.sqrt(credits)); - - function handleUpvote(proposalId: number) { - setProposals((prev) => - prev.map((p) => { - if (p.id !== proposalId) return p; - - // If already voted up, remove the vote - if (p.userVote > 0) { - const refund = p.userVote * p.userVote; - setCredits((c) => c + refund); - return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 }; - } - - // If already voted down, can't upvote - if (p.userVote < 0) return p; - - // Increment pending vote - const newPending = p.pendingVote + 1; - const newCost = newPending * newPending; - if (newCost <= credits && newPending <= maxWeight) { - return { ...p, pendingVote: newPending }; - } - return p; - }) - ); - } - - function handleDownvote(proposalId: number) { - setProposals((prev) => - prev.map((p) => { - if (p.id !== proposalId) return p; - - // If already voted down, remove the vote - if (p.userVote < 0) { - const refund = p.userVote * p.userVote; - setCredits((c) => c + refund); - return { ...p, score: p.score - p.userVote, userVote: 0, pendingVote: 0 }; - } - - // If already voted up, can't downvote - if (p.userVote > 0) return p; - - // Decrement pending vote - const newPending = p.pendingVote - 1; - const newCost = newPending * newPending; - if (newCost <= credits && Math.abs(newPending) <= maxWeight) { - return { ...p, pendingVote: newPending }; - } - return p; - }) - ); - } - - function cancelPending(proposalId: number) { - setProposals((prev) => - prev.map((p) => (p.id === proposalId ? { ...p, pendingVote: 0 } : p)) - ); - } - - function confirmVote(proposalId: number) { - setProposals((prev) => - prev.map((p) => { - if (p.id !== proposalId || p.pendingVote === 0) return p; - - const cost = p.pendingVote * p.pendingVote; - const newScore = p.score + p.pendingVote; - const promoted = newScore >= 100 && p.stage === "ranking"; - - setCredits((c) => c - cost); - - return { - ...p, - score: newScore, - userVote: p.pendingVote, - pendingVote: 0, - stage: promoted ? "voting" : p.stage, - yesVotes: promoted ? 8 : p.yesVotes, - noVotes: promoted ? 3 : p.noVotes, - }; - }) - ); - } - - function castFinalVote(proposalId: number, vote: "yes" | "no" | "abstain") { - setProposals((prev) => - prev.map((p) => { - if (p.id === proposalId) { - return { - ...p, - yesVotes: vote === "yes" ? p.yesVotes + 1 : p.yesVotes, - noVotes: vote === "no" ? p.noVotes + 1 : p.noVotes, - }; - } - return p; - }) - ); - } - - function resetDemo() { - setCredits(100); - setProposals(initialProposals); - } - - const rankingProposals = proposals - .filter((p) => p.stage === "ranking") - .sort((a, b) => b.score - a.score); - const votingProposals = proposals.filter((p) => p.stage === "voting"); - return (
@@ -214,296 +19,15 @@ export default function DemoPage() {

- {/* Credits display */} - - -
-
-
- - {credits} - credits -
- - Max vote: ±{maxWeight} - -
- -
-
-
- - {/* Quadratic cost explainer */} - - - - - Quadratic Voting Cost - - - Each additional vote costs exponentially more credits - - - -
- {[1, 2, 3, 4, 5].map((w) => ( -
-
+{w}
-
vote{w > 1 ? "s" : ""}
-
{w * w}¢
-
- ))} -
-
-
- - {/* Ranking stage */} -
-
- Stage 1 -

Quadratic Ranking

- - Score +100 to advance → - -
- -
- {rankingProposals.map((proposal) => { - const hasPending = proposal.pendingVote !== 0; - const hasVoted = proposal.userVote !== 0; - const pendingCost = proposal.pendingVote * proposal.pendingVote; - const displayScore = hasPending ? proposal.score + proposal.pendingVote : proposal.score; - const progressPercent = Math.min((displayScore / 100) * 100, 100); - - const isUpvoted = (hasVoted && proposal.userVote > 0) || proposal.pendingVote > 0; - const isDownvoted = (hasVoted && proposal.userVote < 0) || proposal.pendingVote < 0; - - return ( -
0 - ? "ring-2 ring-orange-500/50" - : "ring-2 ring-blue-500/50" - : "" - }`} - > - {/* Reddit-style vote column */} -
- - - - {displayScore} - - - -
- - {/* Proposal content */} -
-

{proposal.title}

-

- {proposal.description} -

- - {/* Progress bar */} -
-
- Progress to voting stage - - {displayScore}/100 - -
-
-
-
-
- - {/* Pending vote confirmation */} - {hasPending && ( -
- 0 - ? "border-orange-500/50 text-orange-600 bg-orange-500/10" - : "border-blue-500/50 text-blue-600 bg-blue-500/10" - } - > - {proposal.pendingVote > 0 ? "+" : ""}{proposal.pendingVote} vote = {pendingCost} credits - - - -
- )} - - {/* Existing vote indicator */} - {hasVoted && !hasPending && ( -
- 0 - ? "bg-orange-500/20 text-orange-600" - : "bg-blue-500/20 text-blue-600" - } - > - You voted: {proposal.userVote > 0 ? "+" : ""}{proposal.userVote} - -
- )} -
-
- ); - })} -
-
- - {/* Voting stage */} - {votingProposals.length > 0 && ( -
-
- Stage 2 -

Pass/Fail Voting

- - One member = one vote - -
- - {votingProposals.map((proposal) => { - const total = proposal.yesVotes + proposal.noVotes; - const yesPercent = total > 0 ? (proposal.yesVotes / total) * 100 : 50; - return ( - - -
- {proposal.title} -
- - 6 days left -
-
- {proposal.description} -
- -
-
-
-
-
-
- - {proposal.yesVotes} Yes ({Math.round(yesPercent)}%) - - - {proposal.noVotes} No ({Math.round(100 - yesPercent)}%) - -
-
- -
- - - -
- - - ); - })} -
- )} + {/* CTA */}

Ready to try it for real?

- Create an account to start ranking and voting on community proposals. - You'll get 50 credits to start and earn 10 more each day. + Create a Space for your community to start ranking and voting on proposals. + Invite members and allot credits to get started.

- {!session?.user ? ( - - ) : ( - - )} +
@@ -137,7 +75,7 @@ export default async function HomePage() {
- +

Quadratic

@@ -182,6 +120,22 @@ export default async function HomePage() {
+ {/* Interactive Demo inline */} +
+
+ + Interactive Demo + +

Try It Yourself

+

+ Click the vote arrows to rank proposals. Watch how quadratic costs scale in real-time. +

+
+
+ +
+
+ {/* What is Quadratic Proposal Ranking */}
@@ -431,57 +385,6 @@ export default async function HomePage() {
- {/* Stats */} - {(rankingCount > 0 || userCount > 1) && ( -
-
- - -
{userCount}
-

Members

-
-
- - -
{rankingCount}
-

Being Ranked

-
-
- - -
{votingCount}
-

In Voting

-
-
- - -
{passedCount}
-

Passed

-
-
-
-
- )} - - {/* Active proposals */} - {proposals.length > 0 && ( -
-
-

Active Proposals

- -
- -
- )} - {/* CTA */}
@@ -491,24 +394,22 @@ export default async function HomePage() { Join the rSpace Ecosystem

Ready to prioritize democratically?

- Experience Quadratic Proposal Ranking firsthand. Try the interactive demo or - create an account to start building your community's backlog together. + Create a Space for your community and start using Quadratic Proposal Ranking. + Invite members, allot credits, and let the best ideas rise to the top.

+ - {!session?.user && ( - - )}
diff --git a/src/app/proposals/[id]/page.tsx b/src/app/proposals/[id]/page.tsx index 1d4908a..f33317a 100644 --- a/src/app/proposals/[id]/page.tsx +++ b/src/app/proposals/[id]/page.tsx @@ -1,330 +1,27 @@ -import { auth } from "@/lib/auth"; import { prisma } from "@/lib/prisma"; -import { notFound } from "next/navigation"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Button } from "@/components/ui/button"; -import { VoteButtons } from "@/components/VoteButtons"; -import { FinalVotePanel } from "@/components/FinalVotePanel"; -import { calculateAvailableCredits } from "@/lib/credits"; -import { getEffectiveWeight, getDecayPercentage } from "@/lib/voting"; -import { formatDistanceToNow, format } from "date-fns"; -import Link from "next/link"; -import { ArrowLeft, User, Clock, TrendingUp } from "lucide-react"; -import { ProposalStatus } from "@prisma/client"; +import { redirect, notFound } from "next/navigation"; -const statusColors: Record = { - RANKING: "bg-blue-500/10 text-blue-500 border-blue-500/20", - VOTING: "bg-yellow-500/10 text-yellow-500 border-yellow-500/20", - PASSED: "bg-green-500/10 text-green-500 border-green-500/20", - FAILED: "bg-red-500/10 text-red-500 border-red-500/20", - ARCHIVED: "bg-gray-500/10 text-gray-500 border-gray-500/20", -}; - -export default async function ProposalPage({ +export default async function ProposalDetailPage({ params, }: { params: Promise<{ id: string }>; }) { const { id } = await params; - const session = await auth(); + // Look up the proposal's space and redirect to the space-scoped URL const proposal = await prisma.proposal.findUnique({ where: { id }, - include: { - author: { select: { id: true, name: true, email: true } }, - votes: { - include: { - user: { select: { id: true, name: true } }, - }, - orderBy: { weight: "desc" }, - }, - finalVotes: true, - }, + include: { space: { select: { slug: true } } }, }); - if (!proposal) { - notFound(); + if (!proposal) notFound(); + + if (proposal.space) { + const rootDomain = process.env.ROOT_DOMAIN || "rvote.online"; + const protocol = process.env.NODE_ENV === "production" ? "https" : "http"; + redirect(`${protocol}://${proposal.space.slug}.${rootDomain}/proposals/${id}`); } - // Get user's credits and vote - let availableCredits = 0; - let userVote: { weight: number; effectiveWeight: number } | undefined; - let userFinalVote: "YES" | "NO" | "ABSTAIN" | undefined; - - if (session?.user?.id) { - const user = await prisma.user.findUnique({ - where: { id: session.user.id }, - select: { credits: true, lastCreditAt: true }, - }); - - if (user) { - availableCredits = calculateAvailableCredits(user.credits, user.lastCreditAt); - } - - const existingVote = proposal.votes.find((v) => v.userId === session.user.id); - if (existingVote) { - userVote = { - weight: existingVote.weight, - effectiveWeight: getEffectiveWeight(existingVote.weight, existingVote.createdAt), - }; - } - - const existingFinalVote = proposal.finalVotes.find( - (v) => v.userId === session.user.id - ); - if (existingFinalVote) { - userFinalVote = existingFinalVote.vote; - } - } - - // Calculate final vote counts - const finalVoteCounts = proposal.finalVotes.reduce( - (acc, fv) => { - acc[fv.vote.toLowerCase() as "yes" | "no" | "abstain"]++; - acc.total++; - return acc; - }, - { yes: 0, no: 0, abstain: 0, total: 0 } - ); - - // Calculate effective score - const effectiveScore = proposal.votes.reduce((sum, v) => { - return sum + getEffectiveWeight(v.weight, v.createdAt); - }, 0); - - const isRanking = proposal.status === "RANKING"; - const isVoting = proposal.status === "VOTING"; - const isCompleted = proposal.status === "PASSED" || proposal.status === "FAILED"; - const progressToVoting = Math.min((effectiveScore / 100) * 100, 100); - - return ( -
- - -
- {/* Main content */} -
- - -
- {proposal.title} - - {proposal.status} - -
-
- - - {proposal.author.name || proposal.author.email.split("@")[0]} - - - - {formatDistanceToNow(proposal.createdAt, { addSuffix: true })} - -
-
- -
-

{proposal.description}

-
-
-
- - {/* Votes list for ranking stage */} - {isRanking && proposal.votes.length > 0 && ( - - - Recent Votes - - -
- {proposal.votes.slice(0, 10).map((vote) => { - const effectiveWeight = getEffectiveWeight( - vote.weight, - vote.createdAt - ); - const decayPct = getDecayPercentage(vote.createdAt); - return ( -
-
- 0 ? "default" : "destructive"} - > - {vote.weight > 0 ? "+" : ""} - {effectiveWeight} - - - {vote.user.name || "Anonymous"} - -
-
- {decayPct > 0 && ( - - {decayPct}% decayed •{" "} - - )} - {formatDistanceToNow(vote.createdAt, { - addSuffix: true, - })} -
-
- ); - })} -
-
-
- )} -
- - {/* Sidebar */} -
- {/* Ranking vote panel */} - {isRanking && ( - - - Ranking - - -
- -
- - {!session?.user && ( -

- - Sign in - {" "} - to vote -

- )} - -
-
- - - Progress to voting - - {effectiveScore}/100 -
-
-
-
-
- -
-

- {proposal.votes.length} votes cast -

- {session?.user && ( -

- You have {availableCredits} credits -

- )} -
- - - )} - - {/* Pass/fail vote panel */} - {isVoting && proposal.votingEndsAt && ( - - )} - - {/* Completed result */} - {isCompleted && ( - - - Result - - -
- - {proposal.status} - -
- -
-
-
- {finalVoteCounts.yes} -
-
Yes
-
-
-
- {finalVoteCounts.no} -
-
No
-
-
-
- {finalVoteCounts.abstain} -
-
Abstain
-
-
- - {proposal.votingEndsAt && ( -

- Voting ended {format(proposal.votingEndsAt, "PPP")} -

- )} -
-
- )} - - {/* Info card */} - - - Details - - -
- Created - {format(proposal.createdAt, "PPP")} -
-
- Last updated - {format(proposal.updatedAt, "PPP")} -
- {proposal.votingEndsAt && ( -
- - {isVoting ? "Voting ends" : "Voting ended"} - - {format(proposal.votingEndsAt, "PPP")} -
- )} -
-
-
-
-
- ); + // Fallback: redirect to spaces list if no space assigned + redirect("/spaces"); } diff --git a/src/app/proposals/new/page.tsx b/src/app/proposals/new/page.tsx index afd6b99..0086d59 100644 --- a/src/app/proposals/new/page.tsx +++ b/src/app/proposals/new/page.tsx @@ -1,131 +1,5 @@ -"use client"; - -import { useState } from "react"; -import { useRouter } from "next/navigation"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Textarea } from "@/components/ui/textarea"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Loader2, ArrowLeft } from "lucide-react"; -import { toast } from "sonner"; -import Link from "next/link"; +import { redirect } from "next/navigation"; export default function NewProposalPage() { - const router = useRouter(); - - const [title, setTitle] = useState(""); - const [description, setDescription] = useState(""); - const [isLoading, setIsLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - - if (!title.trim()) { - toast.error("Title is required"); - return; - } - - if (!description.trim()) { - toast.error("Description is required"); - return; - } - - setIsLoading(true); - - try { - const res = await fetch("/api/proposals", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ title, description }), - }); - - const data = await res.json(); - - if (!res.ok) { - throw new Error(data.error || "Failed to create proposal"); - } - - toast.success("Proposal created successfully!"); - router.push(`/proposals/${data.proposal.id}`); - } catch (error) { - toast.error(error instanceof Error ? error.message : "An error occurred"); - } finally { - setIsLoading(false); - } - } - - return ( -
- - - - - Create New Proposal - - Submit a proposal for the community to rank and vote on. Proposals - start in the ranking stage and advance to pass/fail voting when they - reach a score of +100. - - -
- -
- - setTitle(e.target.value)} - maxLength={200} - required - disabled={isLoading} - /> -

- {title.length}/200 characters -

-
- -
- -