Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions app/api/gifts/purchase/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
6 changes: 6 additions & 0 deletions app/api/gifts/redeem/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
10 changes: 10 additions & 0 deletions app/api/gifts/status/[code]/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
45 changes: 45 additions & 0 deletions prisma/migrations/0005_gift_codes/migration.sql
Original file line number Diff line number Diff line change
@@ -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;
53 changes: 42 additions & 11 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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])
}
88 changes: 88 additions & 0 deletions src/infrastructure/api-handlers/gifts-purchase-handler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
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 });
}
}
89 changes: 89 additions & 0 deletions src/infrastructure/api-handlers/gifts-redeem-handler.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> {
// 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 },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Enforce single-use redemption in the transactional update

Redemption first checks status, but the transactional write updates by code only, so two concurrent requests can both pass the pre-check and both commit, granting premium time to multiple accounts from one gift code. This is reproducible under concurrent redeems of the same PAID code; the write should include a status predicate (or equivalent optimistic locking) so only one request can transition PAID -> REDEEMED.

Useful? React with 👍 / 👎.

data: { status: "REDEEMED", redeemedByUserId: auth.userId, redeemedAt: now },
}),
prisma.user.update({
where: { id: auth.userId },
data: { subscriptionTier: "premium", premiumGiftExpiresAt: premiumExpiresAt },
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Keep professional tier when redeeming a gift

The user update inside redemption unconditionally sets subscriptionTier to premium, so a currently professional subscriber who redeems a gift is silently downgraded and can lose access to pro-only features (the tier ranking in src/infrastructure/auth/access-control.ts treats professional as higher than premium). This only occurs for users already on professional, but in that case it is a user-visible regression and likely revenue-impacting.

Useful? React with 👍 / 👎.

}),
]);

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,
});
}
63 changes: 63 additions & 0 deletions src/infrastructure/api-handlers/gifts-status-handler.ts
Original file line number Diff line number Diff line change
@@ -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<string, { count: number; resetAt: number }>();
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<NextResponse> {
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 });
}
}
4 changes: 3 additions & 1 deletion src/infrastructure/db/repositories/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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(),
};
Loading