diff --git a/app/api/gifts/purchase/route.ts b/app/api/gifts/purchase/route.ts new file mode 100644 index 0000000..31331ee --- /dev/null +++ b/app/api/gifts/purchase/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { handleGiftsPurchasePost } from "@/infrastructure/api-handlers/gifts-purchase-handler"; + +export async function POST(request: NextRequest) { + return handleGiftsPurchasePost(request); +} diff --git a/app/api/gifts/redeem/route.ts b/app/api/gifts/redeem/route.ts new file mode 100644 index 0000000..ac68b5d --- /dev/null +++ b/app/api/gifts/redeem/route.ts @@ -0,0 +1,6 @@ +import { NextRequest } from "next/server"; +import { handleGiftsRedeemPost } from "@/infrastructure/api-handlers/gifts-redeem-handler"; + +export async function POST(request: NextRequest) { + return handleGiftsRedeemPost(request); +} diff --git a/app/api/gifts/status/[code]/route.ts b/app/api/gifts/status/[code]/route.ts new file mode 100644 index 0000000..ae409ae --- /dev/null +++ b/app/api/gifts/status/[code]/route.ts @@ -0,0 +1,10 @@ +import { NextRequest } from "next/server"; +import { handleGiftsStatusGet } from "@/infrastructure/api-handlers/gifts-status-handler"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ code: string }> } +) { + const { code } = await params; + return handleGiftsStatusGet(request, code); +} diff --git a/prisma/migrations/0005_gift_codes/migration.sql b/prisma/migrations/0005_gift_codes/migration.sql new file mode 100644 index 0000000..7f891e1 --- /dev/null +++ b/prisma/migrations/0005_gift_codes/migration.sql @@ -0,0 +1,45 @@ +-- FEAT-14-A: Add GiftCode model and GiftCodeStatus enum +-- Add premiumGiftExpiresAt to User for tracking gifted premium expiry + +-- CreateEnum +CREATE TYPE "GiftCodeStatus" AS ENUM ('PENDING_PAYMENT', 'PAID', 'REDEEMED', 'EXPIRED'); + +-- AlterTable: add gift fields to User +ALTER TABLE "User" + ADD COLUMN "premiumGiftExpiresAt" TIMESTAMP(3); + +-- CreateTable +CREATE TABLE "GiftCode" ( + "id" TEXT NOT NULL, + "code" TEXT NOT NULL, + "status" "GiftCodeStatus" NOT NULL DEFAULT 'PENDING_PAYMENT', + "durationMonths" INTEGER NOT NULL, + "purchaserEmail" TEXT, + "purchaserId" TEXT, + "recipientEmail" TEXT, + "redeemedByUserId" TEXT, + "stripeSessionId" TEXT, + "expiresAt" TIMESTAMP(3), + "redeemedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "GiftCode_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex: unique code +CREATE UNIQUE INDEX "GiftCode_code_key" ON "GiftCode"("code"); + +-- CreateIndex: unique stripeSessionId +CREATE UNIQUE INDEX "GiftCode_stripeSessionId_key" ON "GiftCode"("stripeSessionId"); + +-- CreateIndex +CREATE INDEX "GiftCode_code_idx" ON "GiftCode"("code"); + +-- CreateIndex +CREATE INDEX "GiftCode_status_idx" ON "GiftCode"("status"); + +-- AddForeignKey +ALTER TABLE "GiftCode" ADD CONSTRAINT "GiftCode_purchaserId_fkey" FOREIGN KEY ("purchaserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "GiftCode" ADD CONSTRAINT "GiftCode_redeemedByUserId_fkey" FOREIGN KEY ("redeemedByUserId") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index dc2de5f..37986eb 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -13,22 +13,32 @@ enum SubscriptionTier { professional } +enum GiftCodeStatus { + PENDING_PAYMENT + PAID + REDEEMED + EXPIRED +} + model User { id String @id @default(uuid()) email String @unique createdAt DateTime @default(now()) lastActiveAt DateTime @default(now()) - subscriptionTier SubscriptionTier @default(free) - stripeCustomerId String? @unique - profile Profile? - sessions Session[] - magicTokens MagicToken[] - enrollments Enrollment[] - dailyTasks DailyTask[] - streak Streak? - checkins Checkin[] - conversations Conversation[] - adaptationProposals AdaptationProposal[] + subscriptionTier SubscriptionTier @default(free) + stripeCustomerId String? @unique + premiumGiftExpiresAt DateTime? + profile Profile? + sessions Session[] + magicTokens MagicToken[] + enrollments Enrollment[] + dailyTasks DailyTask[] + streak Streak? + checkins Checkin[] + conversations Conversation[] + adaptationProposals AdaptationProposal[] + purchasedGiftCodes GiftCode[] @relation("GiftCodePurchaser") + redeemedGiftCodes GiftCode[] @relation("GiftCodeRedeemer") } model Profile { @@ -152,3 +162,24 @@ model AdaptationProposal { @@index([userId, status]) } + +model GiftCode { + id String @id @default(uuid()) + code String @unique + status GiftCodeStatus @default(PENDING_PAYMENT) + durationMonths Int + purchaserEmail String? + purchaserId String? + recipientEmail String? + redeemedByUserId String? + stripeSessionId String? @unique + expiresAt DateTime? + redeemedAt DateTime? + createdAt DateTime @default(now()) + + purchaser User? @relation("GiftCodePurchaser", fields: [purchaserId], references: [id]) + redeemedBy User? @relation("GiftCodeRedeemer", fields: [redeemedByUserId], references: [id]) + + @@index([code]) + @@index([status]) +} diff --git a/src/infrastructure/api-handlers/gifts-purchase-handler.ts b/src/infrastructure/api-handlers/gifts-purchase-handler.ts new file mode 100644 index 0000000..a01b762 --- /dev/null +++ b/src/infrastructure/api-handlers/gifts-purchase-handler.ts @@ -0,0 +1,88 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { randomBytes } from "crypto"; +import { getSessionUserId } from "@/infrastructure/auth/session"; +import { repositories } from "@/infrastructure/db/repositories"; +import { getStripeClient, STRIPE_PRICES, GIFT_PRICE_DURATION_MONTHS } from "@/infrastructure/stripe/stripe-client"; +import { sendGiftPurchaseConfirmationEmail } from "@/infrastructure/email/gift-emails"; +import { logger } from "@/infrastructure/logging/logger"; + +const VALID_GIFT_PRICE_IDS = new Set([ + STRIPE_PRICES.giftPremium1M, + STRIPE_PRICES.giftPremium3M, + STRIPE_PRICES.giftPremium6M, + STRIPE_PRICES.giftPremium12M, +].filter(Boolean)); + +function generateGiftCode(): string { + const bytes = randomBytes(8); + const hex = bytes.toString("hex").toUpperCase(); + return `${hex.slice(0, 4)}-${hex.slice(4, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}`; +} + +export async function handleGiftsPurchasePost(request: NextRequest): Promise { + const body = await request.json().catch(() => ({})); + const priceId = String(body.priceId ?? ""); + const recipientEmail = body.recipientEmail ? String(body.recipientEmail) : undefined; + + if (!priceId || !VALID_GIFT_PRICE_IDS.has(priceId)) { + return NextResponse.json({ error: "Invalid priceId" }, { status: 400 }); + } + + const durationMonths = GIFT_PRICE_DURATION_MONTHS[priceId]; + if (!durationMonths) { + return NextResponse.json({ error: "Could not resolve gift duration" }, { status: 400 }); + } + + // Auth is optional — purchaser may or may not be logged in + const userId = await getSessionUserId(); + let purchaserEmail: string | undefined; + let purchaserId: string | undefined; + + if (userId) { + const user = await repositories.user.getById(userId); + purchaserEmail = user?.email; + purchaserId = userId; + } else if (body.email) { + purchaserEmail = String(body.email); + } + + const code = generateGiftCode(); + const appUrl = process.env.APP_URL ?? "http://localhost:3000"; + + try { + const stripe = getStripeClient(); + + // Create Stripe Checkout session (mode: payment, not subscription) + const session = await stripe.checkout.sessions.create({ + mode: "payment", + payment_method_types: ["card"], + line_items: [{ price: priceId, quantity: 1 }], + ...(purchaserEmail ? { customer_email: purchaserEmail } : {}), + metadata: { + giftCode: code, + durationMonths: String(durationMonths), + ...(recipientEmail ? { recipientEmail } : {}), + ...(purchaserId ? { purchaserId } : {}), + }, + success_url: `${appUrl}/gift/confirmation?session_id={CHECKOUT_SESSION_ID}`, + cancel_url: `${appUrl}/gift`, + }); + + // Persist the GiftCode record with PENDING_PAYMENT status + await repositories.gift.create({ + code, + durationMonths, + purchaserEmail, + purchaserId, + recipientEmail, + stripeSessionId: session.id, + }); + + logger.info("Gift checkout session created", { code, priceId, durationMonths }); + return NextResponse.json({ checkoutUrl: session.url, code }); + } catch (error) { + logger.error("Failed to create gift checkout session", { error }); + return NextResponse.json({ error: "Failed to create checkout session" }, { status: 500 }); + } +} diff --git a/src/infrastructure/api-handlers/gifts-redeem-handler.ts b/src/infrastructure/api-handlers/gifts-redeem-handler.ts new file mode 100644 index 0000000..9812357 --- /dev/null +++ b/src/infrastructure/api-handlers/gifts-redeem-handler.ts @@ -0,0 +1,89 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { requireUserId } from "@/infrastructure/auth/require-user"; +import { repositories } from "@/infrastructure/db/repositories"; +import { prisma } from "@/infrastructure/db/prisma-client"; +import { sendGiftRedemptionEmail } from "@/infrastructure/email/gift-emails"; +import { logger } from "@/infrastructure/logging/logger"; + +export async function handleGiftsRedeemPost(request: NextRequest): Promise { + // Auth required + const auth = await requireUserId(); + if ("response" in auth) return auth.response; + + const body = await request.json().catch(() => ({})); + const code = String(body.code ?? "").trim().toUpperCase(); + + if (!code) { + return NextResponse.json({ error: "Missing code" }, { status: 400 }); + } + + const giftCode = await repositories.gift.getByCode(code); + + if (!giftCode) { + return NextResponse.json({ error: "Invalid gift code" }, { status: 404 }); + } + + if (giftCode.status === "EXPIRED" || (giftCode.expiresAt && giftCode.expiresAt < new Date())) { + return NextResponse.json({ error: "Gift code has expired" }, { status: 410 }); + } + + if (giftCode.status === "REDEEMED") { + return NextResponse.json({ error: "Gift code has already been redeemed" }, { status: 409 }); + } + + if (giftCode.status !== "PAID") { + return NextResponse.json({ error: "Gift code payment is not yet confirmed" }, { status: 402 }); + } + + // Check if the user is already on a paid tier via a recurring subscription + const user = await repositories.user.getById(auth.userId); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + + if (user.subscriptionTier === "premium" || user.subscriptionTier === "professional") { + // User already has an active subscription — still allow redeem; extend gift expiry on top + logger.info("User already premium, extending gift on top", { userId: auth.userId, code }); + } + + // Calculate new expiry: start from today or existing gift expiry (whichever is later) + const now = new Date(); + const existingRow = await prisma.user.findUnique({ + where: { id: auth.userId }, + select: { premiumGiftExpiresAt: true }, + }); + const base = existingRow?.premiumGiftExpiresAt && existingRow.premiumGiftExpiresAt > now + ? existingRow.premiumGiftExpiresAt + : now; + const premiumExpiresAt = new Date(base); + premiumExpiresAt.setMonth(premiumExpiresAt.getMonth() + giftCode.durationMonths); + + // Atomically: mark code redeemed + upgrade user tier + set gift expiry + await prisma.$transaction([ + prisma.giftCode.update({ + where: { code }, + data: { status: "REDEEMED", redeemedByUserId: auth.userId, redeemedAt: now }, + }), + prisma.user.update({ + where: { id: auth.userId }, + data: { subscriptionTier: "premium", premiumGiftExpiresAt: premiumExpiresAt }, + }), + ]); + + logger.info("Gift code redeemed", { code, userId: auth.userId, premiumExpiresAt }); + + // Send confirmation email (non-blocking) + const recipientEmail = giftCode.recipientEmail ?? user.email; + sendGiftRedemptionEmail({ + recipientEmail, + durationMonths: giftCode.durationMonths, + premiumExpiresAt, + }).catch((err) => logger.error("Failed to send gift redemption email", { err })); + + return NextResponse.json({ + message: "Gift code redeemed successfully", + premiumExpiresAt: premiumExpiresAt.toISOString(), + durationMonths: giftCode.durationMonths, + }); +} diff --git a/src/infrastructure/api-handlers/gifts-status-handler.ts b/src/infrastructure/api-handlers/gifts-status-handler.ts new file mode 100644 index 0000000..067de8b --- /dev/null +++ b/src/infrastructure/api-handlers/gifts-status-handler.ts @@ -0,0 +1,63 @@ +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; +import { repositories } from "@/infrastructure/db/repositories"; +import { logger } from "@/infrastructure/logging/logger"; + +// Simple in-process rate limiter for the public status endpoint +// Keyed by IP, max 20 requests per minute +const rateLimitMap = new Map(); +const RATE_LIMIT = 20; +const WINDOW_MS = 60_000; + +function checkRateLimit(ip: string): boolean { + const now = Date.now(); + const entry = rateLimitMap.get(ip); + + if (!entry || now > entry.resetAt) { + rateLimitMap.set(ip, { count: 1, resetAt: now + WINDOW_MS }); + return true; + } + + if (entry.count >= RATE_LIMIT) { + return false; + } + + entry.count++; + return true; +} + +export async function handleGiftsStatusGet( + _request: NextRequest, + code: string +): Promise { + const ip = + _request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown"; + + if (!checkRateLimit(ip)) { + return NextResponse.json({ error: "Too many requests" }, { status: 429 }); + } + + const normalizedCode = code.trim().toUpperCase(); + if (!normalizedCode) { + return NextResponse.json({ error: "Missing code" }, { status: 400 }); + } + + try { + const giftCode = await repositories.gift.getByCode(normalizedCode); + + if (!giftCode) { + return NextResponse.json({ error: "Gift code not found" }, { status: 404 }); + } + + // Return only public-safe fields — no PII + return NextResponse.json({ + code: giftCode.code, + status: giftCode.status, + durationMonths: giftCode.durationMonths, + expiresAt: giftCode.expiresAt?.toISOString() ?? null, + }); + } catch (error) { + logger.error("Failed to fetch gift code status", { code: normalizedCode, error }); + return NextResponse.json({ error: "Internal server error" }, { status: 500 }); + } +} diff --git a/src/infrastructure/db/repositories/index.ts b/src/infrastructure/db/repositories/index.ts index 81d02db..4bcbd8c 100644 --- a/src/infrastructure/db/repositories/index.ts +++ b/src/infrastructure/db/repositories/index.ts @@ -5,6 +5,7 @@ import { PrismaTrackingRepository } from "./prisma-tracking-repository.ts"; import { PrismaUserRepository } from "./prisma-user-repository.ts"; import { PrismaUserStateRepository } from "./prisma-user-state-repository.ts"; import { PrismaAdaptationRepository } from "./prisma-adaptation-repository.ts"; +import { PrismaGiftRepository } from "./prisma-gift-repository.ts"; export const repositories = { auth: new PrismaAuthRepository(), @@ -13,5 +14,6 @@ export const repositories = { tracking: new PrismaTrackingRepository(), user: new PrismaUserRepository(), userState: new PrismaUserStateRepository(), - adaptation: new PrismaAdaptationRepository() + adaptation: new PrismaAdaptationRepository(), + gift: new PrismaGiftRepository(), }; diff --git a/src/infrastructure/db/repositories/prisma-gift-repository.ts b/src/infrastructure/db/repositories/prisma-gift-repository.ts new file mode 100644 index 0000000..9aee502 --- /dev/null +++ b/src/infrastructure/db/repositories/prisma-gift-repository.ts @@ -0,0 +1,87 @@ +import { prisma } from "@/infrastructure/db/prisma-client"; +import type { GiftCodeStatus } from "@prisma/client"; + +export type GiftCodeRecord = { + id: string; + code: string; + status: GiftCodeStatus; + durationMonths: number; + purchaserEmail: string | null; + purchaserId: string | null; + recipientEmail: string | null; + redeemedByUserId: string | null; + stripeSessionId: string | null; + expiresAt: Date | null; + redeemedAt: Date | null; + createdAt: Date; +}; + +export type CreateGiftCodeInput = { + code: string; + durationMonths: number; + purchaserEmail?: string; + purchaserId?: string; + recipientEmail?: string; + stripeSessionId?: string; +}; + +export class PrismaGiftRepository { + async create(input: CreateGiftCodeInput): Promise { + return prisma.giftCode.create({ + data: { + code: input.code, + durationMonths: input.durationMonths, + purchaserEmail: input.purchaserEmail ?? null, + purchaserId: input.purchaserId ?? null, + recipientEmail: input.recipientEmail ?? null, + stripeSessionId: input.stripeSessionId ?? null, + }, + }); + } + + async getByCode(code: string): Promise { + return prisma.giftCode.findUnique({ where: { code } }); + } + + async getByStripeSessionId(sessionId: string): Promise { + return prisma.giftCode.findUnique({ where: { stripeSessionId: sessionId } }); + } + + async markPaid(code: string, stripeSessionId?: string): Promise { + await prisma.giftCode.update({ + where: { code }, + data: { + status: "PAID", + ...(stripeSessionId ? { stripeSessionId } : {}), + }, + }); + } + + async redeem( + code: string, + redeemedByUserId: string + ): Promise { + return prisma.giftCode.update({ + where: { code }, + data: { + status: "REDEEMED", + redeemedByUserId, + redeemedAt: new Date(), + }, + }); + } + + async markExpired(code: string): Promise { + await prisma.giftCode.update({ + where: { code }, + data: { status: "EXPIRED" }, + }); + } + + async updateStripeSession(code: string, stripeSessionId: string): Promise { + await prisma.giftCode.update({ + where: { code }, + data: { stripeSessionId }, + }); + } +} diff --git a/src/infrastructure/email/gift-emails.ts b/src/infrastructure/email/gift-emails.ts new file mode 100644 index 0000000..a683c0f --- /dev/null +++ b/src/infrastructure/email/gift-emails.ts @@ -0,0 +1,87 @@ +import { Resend } from "resend"; +import { logger } from "@/infrastructure/logging/logger"; + +// TODO(FEAT-14-E): Replace inline HTML with proper React Email templates +// (GiftPurchaseEmail, GiftRecipientEmail, GiftRedemptionEmail) + +function escapeHtml(value: string): string { + return value + .replace(/&/g, "&") + .replace(//g, ">") + .replace(/"/g, """) + .replace(/'/g, "'"); +} + +const FROM = "Neuroforge "; +const APP_URL = process.env.NEXT_PUBLIC_APP_URL ?? "https://app.neuroforge.app"; + +async function sendEmail(to: string, subject: string, html: string): Promise { + if (!process.env.RESEND_API_KEY) { + logger.info(`[GIFT EMAIL STUB] To: ${to} | Subject: ${subject}`); + return; + } + const resend = new Resend(process.env.RESEND_API_KEY); + try { + const data = await resend.emails.send({ from: FROM, to: [to], subject, html }); + logger.info("Gift email sent", { to, subject, id: data.data?.id }); + } catch (error) { + logger.error("Failed to send gift email", { to, subject, error }); + } +} + +export async function sendGiftRedemptionEmail(params: { + recipientEmail: string; + durationMonths: number; + premiumExpiresAt: Date; +}): Promise { + const { recipientEmail, durationMonths, premiumExpiresAt } = params; + const expiryStr = escapeHtml(premiumExpiresAt.toLocaleDateString("en-GB", { year: "numeric", month: "long", day: "numeric" })); + await sendEmail( + recipientEmail, + "Your Neuroforge Premium gift has been activated", + ` +
+

Welcome to Neuroforge Premium

+

Your gift subscription has been activated. You now have ${durationMonths} month${durationMonths !== 1 ? "s" : ""} of Premium access.

+

Access expires: ${expiryStr}

+ + Start your protocol + +
+ ` + ); +} + +export async function sendGiftPurchaseConfirmationEmail(params: { + purchaserEmail: string; + code: string; + durationMonths: number; + recipientEmail?: string; +}): Promise { + const { purchaserEmail, code, durationMonths, recipientEmail } = params; + const safeCode = escapeHtml(code); + const recipientNote = recipientEmail + ? `

This gift is for ${escapeHtml(recipientEmail)}.

` + : ""; + await sendEmail( + purchaserEmail, + "Your Neuroforge gift code is ready", + ` +
+

Your gift is confirmed

+

You've purchased ${durationMonths} month${durationMonths !== 1 ? "s" : ""} of Neuroforge Premium as a gift.

+ ${recipientNote} +
+ ${safeCode} +
+

Share this code with the recipient. They can redeem it at:

+ + ${APP_URL}/redeem + +
+ ` + ); +} diff --git a/src/infrastructure/stripe/stripe-client.ts b/src/infrastructure/stripe/stripe-client.ts index b3816f3..77db119 100644 --- a/src/infrastructure/stripe/stripe-client.ts +++ b/src/infrastructure/stripe/stripe-client.ts @@ -14,7 +14,19 @@ export function getStripeClient(): Stripe { export const STRIPE_PRICES = { premiumMonthly: process.env.STRIPE_PRICE_PREMIUM_MONTHLY ?? "", premiumAnnual: process.env.STRIPE_PRICE_PREMIUM_ANNUAL ?? "", - professionalMonthly: process.env.STRIPE_PRICE_PRO_MONTHLY ?? "" + professionalMonthly: process.env.STRIPE_PRICE_PRO_MONTHLY ?? "", + giftPremium1M: process.env.STRIPE_GIFT_PREMIUM_1M_PRICE_ID ?? "", + giftPremium3M: process.env.STRIPE_GIFT_PREMIUM_3M_PRICE_ID ?? "", + giftPremium6M: process.env.STRIPE_GIFT_PREMIUM_6M_PRICE_ID ?? "", + giftPremium12M: process.env.STRIPE_GIFT_PREMIUM_12M_PRICE_ID ?? "", } as const; +// Maps a gift price ID to how many months of premium it grants +export const GIFT_PRICE_DURATION_MONTHS: Record = { + [process.env.STRIPE_GIFT_PREMIUM_1M_PRICE_ID ?? ""]: 1, + [process.env.STRIPE_GIFT_PREMIUM_3M_PRICE_ID ?? ""]: 3, + [process.env.STRIPE_GIFT_PREMIUM_6M_PRICE_ID ?? ""]: 6, + [process.env.STRIPE_GIFT_PREMIUM_12M_PRICE_ID ?? ""]: 12, +}; + export const FREE_PROTOCOL_LIMIT = 5;