Add internal grant endpoint + temp-password / force-change flow

- /api/internal/grant: secret-auth endpoint for payment-router to
  provision access on a paid order — find-or-create user (emailVerified,
  temp password, mustChangePassword), enroll by course slug list,
  AccessLog, delivery email. Idempotent by order_id.
- User.mustChangePassword flag (+ migration); (student) layout redirects
  flagged users to /change-password (forced first-login change).
- email.ts: sendCourseGrantEmail (temp password for new buyers).
- middleware: /api/internal is public (own secret auth).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 16:25:35 +05:00
parent 5a67ac8086
commit a7abf454d9
11 changed files with 293 additions and 1 deletions
+118
View File
@@ -0,0 +1,118 @@
import { NextRequest, NextResponse } from "next/server";
import { prisma } from "@/lib/prisma";
import { sendCourseGrantEmail } from "@/lib/email";
import bcrypt from "bcryptjs";
import crypto from "crypto";
/**
* Internal access-provisioning endpoint, called by payment-router on a paid order.
* Auth: shared secret in `x-internal-secret`. Idempotent by order_id (AccessLog note).
*
* Body: { email, name?, course_slugs: string[], order_id, payment_system? }
*/
export async function POST(request: NextRequest) {
const secret = process.env.INTERNAL_GRANT_SECRET;
if (!secret || request.headers.get("x-internal-secret") !== secret) {
return NextResponse.json({ ok: false, error: "unauthorized" }, { status: 401 });
}
let body: {
email?: string;
name?: string;
course_slugs?: string[];
order_id?: string;
};
try {
body = (await request.json()) as typeof body;
} catch {
return NextResponse.json({ ok: false, error: "bad_json" }, { status: 400 });
}
const email = (body.email ?? "").trim().toLowerCase();
const name = (body.name ?? "").trim();
const slugs = Array.isArray(body.course_slugs)
? body.course_slugs.filter((s) => typeof s === "string" && s.trim())
: [];
const orderId = (body.order_id ?? "").trim();
if (!email || slugs.length === 0 || !orderId) {
return NextResponse.json(
{ ok: false, error: "missing_fields", need: ["email", "course_slugs", "order_id"] },
{ status: 400 },
);
}
// Idempotency: this order already provisioned?
const marker = `order:${orderId}`;
const already = await prisma.accessLog.findFirst({ where: { note: { contains: marker } } });
if (already) {
return NextResponse.json({ ok: true, idempotent: true });
}
// Resolve course slugs → ids
const courses = await prisma.course.findMany({
where: { slug: { in: slugs } },
select: { id: true, title: true, slug: true },
});
const resolvedSlugs = new Set(courses.map((c) => c.slug));
const unresolved = slugs.filter((s) => !resolvedSlugs.has(s));
if (courses.length === 0) {
return NextResponse.json(
{ ok: false, error: "no_courses_resolved", slugs },
{ status: 422 },
);
}
// Find or create user
let user = await prisma.user.findFirst({
where: { email: { equals: email, mode: "insensitive" } },
select: { id: true, name: true },
});
let tempPassword: string | null = null;
if (!user) {
tempPassword = "sb-" + crypto.randomBytes(3).toString("hex");
const hash = await bcrypt.hash(tempPassword, 10);
user = await prisma.user.create({
data: {
email,
name: name || email,
emailVerified: true,
mustChangePassword: true,
accounts: {
create: { accountId: email, providerId: "credential", password: hash },
},
},
select: { id: true, name: true },
});
}
// Enroll + audit log
await prisma.courseEnrollment.createMany({
data: courses.map((c) => ({ userId: user!.id, courseId: c.id })),
skipDuplicates: true,
});
await prisma.accessLog.createMany({
data: courses.map((c) => ({
courseId: c.id,
userId: user!.id,
action: "granted",
method: "auto-payment",
note: `auto-payment ${marker}`,
})),
});
// Delivery email (temp password for new buyers, plain "added" for existing)
await sendCourseGrantEmail(
email,
user.name ?? name,
courses.map((c) => c.title),
tempPassword,
);
return NextResponse.json({
ok: true,
created: tempPassword !== null,
granted: courses.map((c) => c.slug),
unresolved,
});
}