Initial commit
This commit is contained in:
71
app/api/account/profile/route.ts
Normal file
71
app/api/account/profile/route.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
import { sanitizeText } from "../../../../lib/auth/validation";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
gender: z.string().optional().nullable(),
|
||||
dob: z.string().optional().nullable(),
|
||||
address: z.string().optional().nullable(),
|
||||
avatarUrl: z.string().optional().nullable(),
|
||||
email: z.string().optional(), // included in profile but not updatable
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.sub } });
|
||||
if (!user) return fail(new Error("Invalid user"));
|
||||
|
||||
return ok({
|
||||
profile: {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
gender: user.gender,
|
||||
dob: user.dob ? user.dob.toISOString().slice(0, 10) : "",
|
||||
address: user.address,
|
||||
avatarUrl: user.image,
|
||||
email: user.email,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const parsed = Body.safeParse(body);
|
||||
if (!parsed.success) {
|
||||
console.error("Validation error:", parsed.error.errors);
|
||||
return fail(new Error("Invalid input: " + parsed.error.errors.map(e => e.message).join(", ")));
|
||||
}
|
||||
|
||||
const data = parsed.data;
|
||||
await prisma.user.update({
|
||||
where: { id: session.sub },
|
||||
data: {
|
||||
firstName: sanitizeText(data.firstName),
|
||||
lastName: sanitizeText(data.lastName),
|
||||
gender: data.gender ? sanitizeText(data.gender) : null,
|
||||
dob: data.dob ? new Date(data.dob) : null,
|
||||
address: data.address ? sanitizeText(data.address) : null,
|
||||
image: data.avatarUrl ? data.avatarUrl : null,
|
||||
},
|
||||
});
|
||||
|
||||
return ok({ message: "Profile updated" });
|
||||
}
|
||||
44
app/api/account/webinars/route.ts
Normal file
44
app/api/account/webinars/route.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const registrations = await prisma.webinarRegistration.findMany({
|
||||
where: { userId: session.sub },
|
||||
include: {
|
||||
webinar: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return ok({
|
||||
registrations: registrations.map((reg) => ({
|
||||
id: reg.id,
|
||||
userId: reg.userId,
|
||||
webinarId: reg.webinarId,
|
||||
registeredAt: reg.createdAt,
|
||||
webinar: {
|
||||
id: reg.webinar.id,
|
||||
title: reg.webinar.title,
|
||||
description: reg.webinar.description,
|
||||
startAt: reg.webinar.startAt,
|
||||
duration: reg.webinar.duration,
|
||||
speaker: reg.webinar.speaker,
|
||||
priceCents: reg.webinar.priceCents,
|
||||
category: reg.webinar.category,
|
||||
bannerUrl: reg.webinar.bannerUrl,
|
||||
capacity: reg.webinar.capacity,
|
||||
visibility: reg.webinar.visibility,
|
||||
isActive: reg.webinar.isActive,
|
||||
},
|
||||
})),
|
||||
});
|
||||
}
|
||||
32
app/api/admin/contact-messages/route.ts
Normal file
32
app/api/admin/contact-messages/route.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const auth = await getSession();
|
||||
if (!auth || auth.role !== "ADMIN") {
|
||||
return NextResponse.json({ ok: false, message: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Database not available" },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
const messages = await prisma.contactMessage.findMany({
|
||||
orderBy: { createdAt: "desc" },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, messages });
|
||||
} catch (error) {
|
||||
console.error("[ADMIN] Failed to fetch contact messages:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to fetch messages" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
175
app/api/admin/setup/route.ts
Normal file
175
app/api/admin/setup/route.ts
Normal file
@@ -0,0 +1,175 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadSystemConfig, saveSystemConfig } from "@/lib/system-config";
|
||||
import { cookies } from "next/headers";
|
||||
import { verifySession } from "@/lib/auth/jwt";
|
||||
import { getCached, setCached, deleteCached, cacheKeys } from "@/lib/redis";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
async function validateSession() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("ep_session")?.value;
|
||||
if (!token) return null;
|
||||
try {
|
||||
const decoded = await verifySession(token);
|
||||
return decoded;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const session = await validateSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
// Try to get from cache first
|
||||
let cachedSetup = await getCached(cacheKeys.adminSetup);
|
||||
if (cachedSetup) {
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
data: cachedSetup,
|
||||
});
|
||||
}
|
||||
|
||||
const appSetup = await prisma.appSetup.findUnique({
|
||||
where: { id: 1 },
|
||||
});
|
||||
|
||||
const systemConfig = await loadSystemConfig();
|
||||
|
||||
const setupData = {
|
||||
googleAuth: {
|
||||
enabled: appSetup?.googleAuthEnabled || false,
|
||||
clientId: systemConfig.googleAuth?.clientId || "",
|
||||
clientSecret: systemConfig.googleAuth?.clientSecret || "",
|
||||
},
|
||||
oauth: {
|
||||
google: { enabled: false, clientId: "", clientSecret: "" },
|
||||
github: { enabled: false, clientId: "", clientSecret: "" },
|
||||
facebook: { enabled: false, clientId: "", clientSecret: "" },
|
||||
discord: { enabled: false, clientId: "", clientSecret: "" },
|
||||
},
|
||||
googleCalendar: {
|
||||
enabled: systemConfig.googleCalendar?.enabled || false,
|
||||
serviceAccountEmail: systemConfig.googleCalendar?.serviceAccountEmail || "",
|
||||
serviceAccountKey: systemConfig.googleCalendar?.serviceAccountKey || "",
|
||||
calendarId: systemConfig.googleCalendar?.calendarId || "",
|
||||
},
|
||||
socials: appSetup?.socials || {},
|
||||
email: {
|
||||
smtp: {
|
||||
enabled: systemConfig.email?.enabled || false,
|
||||
host: systemConfig.email?.smtp?.host || "",
|
||||
port: systemConfig.email?.smtp?.port || 587,
|
||||
username: systemConfig.email?.smtp?.user || "",
|
||||
password: systemConfig.email?.smtp?.pass || "",
|
||||
from: systemConfig.email?.from || "",
|
||||
},
|
||||
},
|
||||
pagination: {
|
||||
itemsPerPage: appSetup?.paginationItemsPerPage || 10,
|
||||
},
|
||||
};
|
||||
|
||||
// Cache for 5 minutes
|
||||
await setCached(cacheKeys.adminSetup, setupData, 300);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
data: setupData,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching admin setup:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to fetch configuration" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const session = await validateSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Unauthorized" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const body = await request.json();
|
||||
const { googleAuth, googleCalendar, socials, email, pagination } = body;
|
||||
|
||||
// Update database for public-facing settings
|
||||
await prisma.appSetup.upsert({
|
||||
where: { id: 1 },
|
||||
update: {
|
||||
googleAuthEnabled: googleAuth?.enabled || false,
|
||||
socials: socials || {},
|
||||
paginationItemsPerPage: pagination?.itemsPerPage || 10,
|
||||
},
|
||||
create: {
|
||||
id: 1,
|
||||
googleAuthEnabled: googleAuth?.enabled || false,
|
||||
socials: socials || {},
|
||||
paginationItemsPerPage: pagination?.itemsPerPage || 10,
|
||||
categories: ["Basics", "Planning", "Tax", "Healthcare", "Advanced"],
|
||||
},
|
||||
});
|
||||
|
||||
// Update system-config.json for sensitive data
|
||||
const currentConfig = await loadSystemConfig();
|
||||
const updatedConfig = {
|
||||
...currentConfig,
|
||||
googleAuth: {
|
||||
clientId: googleAuth?.clientId || "",
|
||||
clientSecret: googleAuth?.clientSecret || "",
|
||||
redirectUri: `${process.env.APP_BASE_URL || "http://localhost:3001"}/auth/google/callback`,
|
||||
},
|
||||
googleCalendar: {
|
||||
enabled: googleCalendar?.enabled || false,
|
||||
serviceAccountEmail: googleCalendar?.serviceAccountEmail || "",
|
||||
serviceAccountKey: googleCalendar?.serviceAccountKey || "",
|
||||
calendarId: googleCalendar?.calendarId || "",
|
||||
},
|
||||
email: {
|
||||
...currentConfig.email,
|
||||
smtp: {
|
||||
enabled: email?.smtp?.enabled || false,
|
||||
host: email?.smtp?.host || "",
|
||||
port: email?.smtp?.port || 587,
|
||||
user: email?.smtp?.username || "",
|
||||
pass: email?.smtp?.password || "",
|
||||
},
|
||||
from: email?.smtp?.from || "",
|
||||
},
|
||||
};
|
||||
|
||||
await saveSystemConfig(updatedConfig, prisma);
|
||||
|
||||
// Invalidate cache after update
|
||||
await deleteCached(cacheKeys.adminSetup);
|
||||
|
||||
console.log("[SETUP] Configuration saved");
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
message: "Configuration updated successfully",
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error updating admin setup:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to update configuration" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
166
app/api/admin/users/route.ts
Normal file
166
app/api/admin/users/route.ts
Normal file
@@ -0,0 +1,166 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { NextResponse } from "next/server";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const QuerySchema = z.object({
|
||||
page: z.string().default("1").transform(Number),
|
||||
search: z.string().optional(),
|
||||
});
|
||||
|
||||
const UpdateUserSchema = z.object({
|
||||
role: z.enum(["USER", "ADMIN"]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return fail(new Error("Unauthorized"), { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const parsed = QuerySchema.safeParse(
|
||||
Object.fromEntries(new URL(req.url).searchParams)
|
||||
);
|
||||
if (!parsed.success) return fail(new Error("Invalid query parameters"));
|
||||
|
||||
const appSetup = await prisma.appSetup.findUnique({ where: { id: 1 } });
|
||||
const pageSize = appSetup?.paginationItemsPerPage || 10;
|
||||
const page = Math.max(1, parsed.data.page);
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
const searchFilter = parsed.data.search
|
||||
? {
|
||||
OR: [
|
||||
{ email: { contains: parsed.data.search, mode: "insensitive" as const } },
|
||||
{ firstName: { contains: parsed.data.search, mode: "insensitive" as const } },
|
||||
{ lastName: { contains: parsed.data.search, mode: "insensitive" as const } },
|
||||
],
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const [users, total] = await Promise.all([
|
||||
prisma.user.findMany({
|
||||
where: searchFilter,
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
emailVerified: true,
|
||||
createdAt: true,
|
||||
gender: true,
|
||||
dob: true,
|
||||
address: true,
|
||||
image: true,
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
prisma.user.count({ where: searchFilter }),
|
||||
]);
|
||||
|
||||
// Fetch webinars for each user
|
||||
const usersWithWebinars = await Promise.all(
|
||||
users.map(async (user) => {
|
||||
const registrations = await prisma.webinarRegistration.findMany({
|
||||
where: { userId: user.id, status: { not: "CANCELLED" } },
|
||||
});
|
||||
return {
|
||||
...user,
|
||||
_count: {
|
||||
webinarRegistrations: registrations.length,
|
||||
},
|
||||
registeredWebinars: await prisma.webinarRegistration.findMany({
|
||||
where: { userId: user.id, status: { not: "CANCELLED" } },
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
webinar: {
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
startAt: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 5,
|
||||
}),
|
||||
};
|
||||
})
|
||||
);
|
||||
|
||||
return ok({
|
||||
users: usersWithWebinars,
|
||||
pagination: {
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
pages: Math.ceil(total / pageSize),
|
||||
hasMore: page < Math.ceil(total / pageSize),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function PATCH(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return fail(new Error("Unauthorized"), { status: 401 });
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const body = await req.json().catch(() => ({}));
|
||||
const { userId, ...updateData } = body;
|
||||
|
||||
if (!userId) return fail(new Error("userId is required"));
|
||||
|
||||
const parsed = UpdateUserSchema.safeParse(updateData);
|
||||
if (!parsed.success) return fail(new Error("Invalid update data"));
|
||||
|
||||
// Prevent disabling the current admin
|
||||
if (session.sub === userId && parsed.data.isActive === false) {
|
||||
return fail(new Error("Cannot disable your own account"), { status: 400 });
|
||||
}
|
||||
|
||||
// Prevent removing admin role from self
|
||||
if (session.sub === userId && parsed.data.role && parsed.data.role !== "ADMIN") {
|
||||
return fail(new Error("Cannot change your own role"), { status: 400 });
|
||||
}
|
||||
|
||||
const user = await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: {
|
||||
...(parsed.data.role && { role: parsed.data.role }),
|
||||
...(parsed.data.isActive !== undefined && { isActive: parsed.data.isActive }),
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
email: true,
|
||||
firstName: true,
|
||||
lastName: true,
|
||||
role: true,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
return ok({
|
||||
message:
|
||||
parsed.data.isActive === false
|
||||
? "User blocked successfully"
|
||||
: "User updated successfully",
|
||||
user,
|
||||
});
|
||||
}
|
||||
17
app/api/auth/[...route]/route.ts
Normal file
17
app/api/auth/[...route]/route.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { getAuth } from "@/lib/auth";
|
||||
import { NextRequest } from "next/server";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
async function handler(req: NextRequest) {
|
||||
const auth = await getAuth();
|
||||
return auth.handler(req);
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
return handler(req);
|
||||
}
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
return handler(req);
|
||||
}
|
||||
16
app/api/auth/captcha/route.ts
Normal file
16
app/api/auth/captcha/route.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { generateCaptcha } from "../../../../lib/captcha";
|
||||
import { ok } from "../../../../lib/http";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const { id, code } = generateCaptcha(Date.now().toString());
|
||||
|
||||
// Log the code to console for dev/demo purposes
|
||||
console.log(`CAPTCHA Code: ${code}`);
|
||||
|
||||
return ok({
|
||||
captchaId: id,
|
||||
captchaCode: code, // Return the code to display to user
|
||||
message: "CAPTCHA code generated. Enter it below to continue.",
|
||||
});
|
||||
}
|
||||
64
app/api/auth/change-password/route.ts
Normal file
64
app/api/auth/change-password/route.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { hashPassword, verifyPassword } from "../../../../lib/auth/password";
|
||||
import { isStrongPassword } from "../../../../lib/auth/validation";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
currentPassword: z.string().min(1),
|
||||
newPassword: z.string().min(8),
|
||||
confirmPassword: z.string().min(8),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503, isAdmin: session.role === "ADMIN" });
|
||||
|
||||
const parsed = Body.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) return fail(new Error("Invalid input"));
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: session.sub },
|
||||
include: { credential: true }
|
||||
});
|
||||
if (!user || !user.credential) return fail(new Error("Invalid user or no password set"));
|
||||
|
||||
if (parsed.data.newPassword !== parsed.data.confirmPassword) {
|
||||
return fail(new Error("Passwords do not match"));
|
||||
}
|
||||
if (!isStrongPassword(parsed.data.newPassword)) {
|
||||
return fail(new Error("Password is not strong enough"));
|
||||
}
|
||||
|
||||
// 🔥 Only verify current password if NOT forced reset
|
||||
if (!user.forcePasswordReset) {
|
||||
const okPw = await verifyPassword(
|
||||
parsed.data.currentPassword,
|
||||
user.credential.password
|
||||
);
|
||||
if (!okPw) return fail(new Error("Invalid password"));
|
||||
}
|
||||
|
||||
const newHash = await hashPassword(parsed.data.newPassword);
|
||||
await prisma.credential.update({
|
||||
where: { userId: user.id },
|
||||
data: { password: newHash },
|
||||
});
|
||||
|
||||
// Clear force password reset flag if it was set
|
||||
if (user.forcePasswordReset) {
|
||||
await prisma.user.update({
|
||||
where: { id: user.id },
|
||||
data: { forcePasswordReset: false },
|
||||
});
|
||||
}
|
||||
|
||||
return ok({ message: "Password updated" });
|
||||
}
|
||||
108
app/api/auth/login/route.ts
Normal file
108
app/api/auth/login/route.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { verifyPassword } from "../../../../lib/auth/password";
|
||||
import { signSession } from "../../../../lib/auth/jwt";
|
||||
import { verifyCaptcha } from "../../../../lib/captcha";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
email: z.string().email(),
|
||||
password: z.string().min(1),
|
||||
captchaId: z.string().optional(),
|
||||
captchaCode: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const prisma = await getPrisma();
|
||||
const body = Body.safeParse(await req.json().catch(() => ({})));
|
||||
|
||||
if (!body.success) {
|
||||
return NextResponse.json({ ok: false, message: "Invalid input" }, { status: 400 });
|
||||
}
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ ok: false, message: "Database not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
// Validate CAPTCHA if provided
|
||||
if (body.data.captchaId && body.data.captchaCode) {
|
||||
const captchaResult = verifyCaptcha(body.data.captchaId, body.data.captchaCode);
|
||||
if (!captchaResult.success) {
|
||||
return NextResponse.json({ ok: false, message: captchaResult.error || "CAPTCHA verification failed. Please try again." }, { status: 400 });
|
||||
}
|
||||
}
|
||||
|
||||
const email = body.data.email.toLowerCase();
|
||||
const password = body.data.password;
|
||||
|
||||
console.log("[LOGIN] Login attempt for:", email);
|
||||
|
||||
try {
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { email },
|
||||
include: { credential: true },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
console.log("[LOGIN] User not found:", email);
|
||||
return NextResponse.json({ ok: false, message: "Invalid email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.credential || !user.credential.password) {
|
||||
console.log("[LOGIN] No password credential for user:", email);
|
||||
return NextResponse.json({ ok: false, message: "Please use Google sign-in for this account" }, { status: 401 });
|
||||
}
|
||||
|
||||
const valid = await verifyPassword(password, user.credential.password);
|
||||
|
||||
if (!valid) {
|
||||
console.log("[LOGIN] Invalid password for:", email);
|
||||
return NextResponse.json({ ok: false, message: "Invalid email or password" }, { status: 401 });
|
||||
}
|
||||
|
||||
if (!user.isActive) {
|
||||
console.log("[LOGIN] Inactive account:", email);
|
||||
return NextResponse.json({ ok: false, message: "Account is inactive. Please contact support." }, { status: 403 });
|
||||
}
|
||||
|
||||
// Create session token
|
||||
const token = await signSession({
|
||||
sub: user.id,
|
||||
role: user.role as "ADMIN" | "USER",
|
||||
email: user.email,
|
||||
forcePasswordReset: user.forcePasswordReset || false,
|
||||
});
|
||||
|
||||
console.log("[LOGIN] Session created for:", email, "with role:", user.role);
|
||||
|
||||
// Set cookie
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set("ep_session", token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
maxAge: 7 * 24 * 60 * 60, // 7 days
|
||||
path: "/",
|
||||
});
|
||||
|
||||
console.log("[LOGIN] Login successful for:", email);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
user: {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
emailVerified: user.emailVerified,
|
||||
forcePasswordReset: user.forcePasswordReset,
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
console.error("[LOGIN] Error:", e);
|
||||
return NextResponse.json({ ok: false, message: "Server error: Unable to process request" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
23
app/api/auth/logout/route.ts
Normal file
23
app/api/auth/logout/route.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { SESSION_COOKIE } from "../../../../lib/auth/session";
|
||||
import { ok } from "../../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
console.log("[LOGOUT] User logout");
|
||||
|
||||
const res = ok({ message: "Logged out successfully" });
|
||||
|
||||
// Clear the session cookie
|
||||
res.cookies.set(SESSION_COOKIE, "", {
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
path: "/",
|
||||
maxAge: 0,
|
||||
});
|
||||
|
||||
console.log("[LOGOUT] Session cleared");
|
||||
return res;
|
||||
}
|
||||
27
app/api/auth/me/route.ts
Normal file
27
app/api/auth/me/route.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { ok } from "../../../../lib/http";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET() {
|
||||
const session = await getSession();
|
||||
if (!session) return ok({ session: null });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return ok({ session });
|
||||
|
||||
const user = await prisma.user.findUnique({ where: { id: session.sub } });
|
||||
return ok({
|
||||
session,
|
||||
user: user
|
||||
? {
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
avatarUrl: user.image,
|
||||
role: user.role,
|
||||
email: user.email,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
124
app/api/auth/register/route.ts
Normal file
124
app/api/auth/register/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { hashPassword } from "../../../../lib/auth/password";
|
||||
import { isStrongPassword, sanitizeText } from "../../../../lib/auth/validation";
|
||||
import { ok, fail } from "../../../../lib/http";
|
||||
import { loadSystemConfig } from "../../../../lib/system-config";
|
||||
import { sendEmail } from "../../../../lib/email";
|
||||
import { verifyCaptcha } from "../../../../lib/captcha";
|
||||
import crypto from "crypto";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
firstName: z.string().min(1),
|
||||
lastName: z.string().min(1),
|
||||
gender: z.string().optional(),
|
||||
dob: z.string().optional(),
|
||||
address: z.string().optional(),
|
||||
email: z.string().email(),
|
||||
password: z.string().min(8),
|
||||
confirmPassword: z.string().min(8),
|
||||
captchaId: z.string().optional(),
|
||||
captchaCode: z.string().optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const cfg = await loadSystemConfig();
|
||||
const prisma = await getPrisma();
|
||||
const body = Body.safeParse(await req.json().catch(() => ({})));
|
||||
|
||||
if (!body.success) return fail(new Error("Invalid input"));
|
||||
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
// Validate CAPTCHA if provided
|
||||
if (body.data.captchaId && body.data.captchaCode) {
|
||||
const captchaResult = verifyCaptcha(body.data.captchaId, body.data.captchaCode);
|
||||
if (!captchaResult.success) {
|
||||
return fail(new Error(captchaResult.error || "CAPTCHA verification failed. Please try again."));
|
||||
}
|
||||
}
|
||||
|
||||
const email = body.data.email.toLowerCase();
|
||||
const password = body.data.password;
|
||||
const confirmPassword = body.data.confirmPassword;
|
||||
|
||||
if (password !== confirmPassword) return fail(new Error("Passwords do not match"));
|
||||
if (!isStrongPassword(password)) return fail(new Error("Password is not strong enough"));
|
||||
|
||||
// Validate birth date if provided
|
||||
if (body.data.dob) {
|
||||
const birthDate = new Date(body.data.dob);
|
||||
const today = new Date();
|
||||
const minDate = new Date(today.getFullYear() - 100, today.getMonth(), today.getDate());
|
||||
const maxDate = new Date(today.getFullYear() - 18, today.getMonth(), today.getDate());
|
||||
if (birthDate < minDate || birthDate > maxDate) {
|
||||
return fail(new Error("Birth date must be between 18 and 100 years old"));
|
||||
}
|
||||
}
|
||||
|
||||
const passwordHash = await hashPassword(password);
|
||||
|
||||
try {
|
||||
const user = await prisma.user.create({
|
||||
data: {
|
||||
email,
|
||||
role: "USER",
|
||||
name: `${body.data.firstName} ${body.data.lastName}`,
|
||||
firstName: sanitizeText(body.data.firstName),
|
||||
lastName: sanitizeText(body.data.lastName),
|
||||
gender: body.data.gender ? sanitizeText(body.data.gender) : null,
|
||||
dob: body.data.dob ? new Date(body.data.dob) : null,
|
||||
address: body.data.address ? sanitizeText(body.data.address) : null,
|
||||
// If email is enabled, require verification; otherwise mark as verified
|
||||
emailVerified: !cfg.email?.enabled,
|
||||
forcePasswordReset: false,
|
||||
isActive: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Create credential for email/password authentication
|
||||
await prisma.credential.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
password: passwordHash,
|
||||
},
|
||||
});
|
||||
|
||||
// Only create verification token and send email if email is enabled
|
||||
if (cfg.email?.enabled) {
|
||||
const token = crypto.randomBytes(32).toString("hex");
|
||||
const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);
|
||||
await prisma.verification.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
identifier: email,
|
||||
value: token,
|
||||
expiresAt,
|
||||
},
|
||||
});
|
||||
|
||||
const baseUrl = process.env.APP_BASE_URL || "http://localhost:3000";
|
||||
const link = `${baseUrl}/auth/verify?token=${token}`;
|
||||
await sendEmail({
|
||||
to: email,
|
||||
subject: "Verify your email",
|
||||
html: `<p>Welcome! Please verify your email:</p><p><a href="${link}">Verify Email</a></p>`,
|
||||
});
|
||||
return ok({ message: "Account created. Please check your email to verify your account." });
|
||||
} else {
|
||||
return ok({ message: "Account created successfully. You can now log in." });
|
||||
}
|
||||
} catch (e: any) {
|
||||
// Check if the error is due to unique constraint on email
|
||||
if (e.code === 'P2002' && e.meta?.target?.includes('email')) {
|
||||
return fail(new Error("This email is already taken"));
|
||||
}
|
||||
console.error("Registration error:", e);
|
||||
return fail(new Error("Account creation failed. Please try again or contact support."));
|
||||
}
|
||||
|
||||
return ok({ message: "Account created. Please check your email to verify your account." });
|
||||
}
|
||||
63
app/api/contact/route.ts
Normal file
63
app/api/contact/route.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../lib/db";
|
||||
import { ok, fail } from "../../../lib/http";
|
||||
|
||||
const ContactSchema = z.object({
|
||||
firstName: z.string().min(1, "First name is required"),
|
||||
lastName: z.string().min(1, "Last name is required"),
|
||||
email: z.string().email("Invalid email address"),
|
||||
subject: z.string().min(1, "Subject is required"),
|
||||
message: z.string().min(10, "Message must be at least 10 characters"),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const parsed = ContactSchema.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) {
|
||||
console.error("[CONTACT] Validation error:", parsed.error);
|
||||
const firstError = parsed.error.errors[0];
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: firstError.message },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { firstName, lastName, email, subject, message } = parsed.data;
|
||||
const name = `${firstName} ${lastName}`;
|
||||
console.log("[CONTACT] New message from:", email);
|
||||
|
||||
try {
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
console.error("[CONTACT] Database not configured");
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Service temporarily unavailable. Please try again later." },
|
||||
{ status: 503 }
|
||||
);
|
||||
}
|
||||
|
||||
// Store contact message in database
|
||||
await prisma.contactMessage.create({
|
||||
data: {
|
||||
name,
|
||||
email,
|
||||
subject,
|
||||
message,
|
||||
status: "NEW",
|
||||
},
|
||||
});
|
||||
|
||||
console.log("[CONTACT] Message saved from:", email);
|
||||
|
||||
// TODO: Send email notification to admin
|
||||
// TODO: Send confirmation email to user
|
||||
|
||||
return ok({ message: "Message received. We'll get back to you soon." });
|
||||
} catch (error) {
|
||||
console.error("[CONTACT] Error saving message:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to send message. Please try again." },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
59
app/api/public/app-setup/route.ts
Normal file
59
app/api/public/app-setup/route.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
import { loadSystemConfig } from "@/lib/system-config";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const prisma = new PrismaClient();
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const appSetup = await prisma.appSetup.findUnique({
|
||||
where: { id: 1 },
|
||||
select: {
|
||||
googleAuthEnabled: true,
|
||||
socials: true,
|
||||
},
|
||||
});
|
||||
|
||||
const systemConfig = await loadSystemConfig();
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
setup: {
|
||||
data: {
|
||||
googleAuthEnabled: appSetup?.googleAuthEnabled || false,
|
||||
googleClientId: systemConfig.oauth?.google?.clientId || "",
|
||||
socials: appSetup?.socials || {},
|
||||
smtp: {
|
||||
enabled: systemConfig.email?.enabled || false,
|
||||
},
|
||||
oauth: {
|
||||
google: {
|
||||
enabled: systemConfig.oauth?.google?.enabled || false,
|
||||
clientId: systemConfig.oauth?.google?.clientId || "",
|
||||
},
|
||||
github: {
|
||||
enabled: systemConfig.oauth?.github?.enabled || false,
|
||||
clientId: systemConfig.oauth?.github?.clientId || "",
|
||||
},
|
||||
facebook: {
|
||||
enabled: systemConfig.oauth?.facebook?.enabled || false,
|
||||
clientId: systemConfig.oauth?.facebook?.clientId || "",
|
||||
},
|
||||
discord: {
|
||||
enabled: systemConfig.oauth?.discord?.enabled || false,
|
||||
clientId: systemConfig.oauth?.discord?.clientId || "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Error fetching app setup:", error);
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Failed to fetch configuration" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
124
app/api/registrations/route.ts
Normal file
124
app/api/registrations/route.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getSession } from "../../../lib/auth/session";
|
||||
import { getPrisma } from "../../../lib/db";
|
||||
import { ok, fail } from "../../../lib/http";
|
||||
import { getStripe } from "../../../lib/stripe";
|
||||
import { loadSystemConfig } from "../../../lib/system-config";
|
||||
import { sendEmail } from "../../../lib/email";
|
||||
import { createWebinarCalendarEvent } from "../../../lib/calendar";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const Body = z.object({
|
||||
webinarId: z.string().uuid(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session) return fail(new Error("Unauthorized"), { status: 401 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
// Check if user is admin - prevent admin registration for webinars
|
||||
const user = await prisma.user.findUnique({ where: { id: session.sub } });
|
||||
if (user?.role === "ADMIN") {
|
||||
return fail(new Error("Admins cannot register for webinars"), { status: 403 });
|
||||
}
|
||||
|
||||
const parsed = Body.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) return fail(new Error("Invalid input"));
|
||||
|
||||
const webinar = await prisma.webinar.findUnique({ where: { id: parsed.data.webinarId } });
|
||||
if (!webinar || webinar.visibility !== "PUBLIC" || !webinar.isActive) return fail(new Error("Not found"), { status: 404 });
|
||||
|
||||
const count = await prisma.webinarRegistration.count({
|
||||
where: { webinarId: webinar.id, status: { not: "CANCELLED" } },
|
||||
});
|
||||
if (count >= webinar.capacity) return fail(new Error("Webinar is full"), { status: 409 });
|
||||
|
||||
// Upsert registration
|
||||
if (webinar.priceCents <= 0) {
|
||||
const reg = await prisma.webinarRegistration.upsert({
|
||||
where: { userId_webinarId: { userId: session.sub, webinarId: webinar.id } },
|
||||
update: { status: "CONFIRMED" },
|
||||
create: { userId: session.sub, webinarId: webinar.id, status: "CONFIRMED" },
|
||||
});
|
||||
|
||||
// Send confirmation email with calendar invite for free webinars
|
||||
const cfg = await loadSystemConfig();
|
||||
if (cfg.email?.enabled && user) {
|
||||
try {
|
||||
const { icsContent } = await createWebinarCalendarEvent(webinar, user.email);
|
||||
|
||||
const meetingInfo = webinar.meetingInfo as any;
|
||||
const meetingLink = meetingInfo?.meetingLink || "TBD";
|
||||
const htmlContent = `
|
||||
<h2>Webinar Registration Confirmed</h2>
|
||||
<p>Hi ${user.firstName},</p>
|
||||
<p>Thank you for registering for <strong>${webinar.title}</strong>.</p>
|
||||
<p><strong>Date & Time:</strong> ${new Date(webinar.startAt).toLocaleString()}</p>
|
||||
<p><strong>Duration:</strong> ${webinar.duration} minutes</p>
|
||||
<p><strong>Join Link:</strong> <a href="${meetingLink}">${meetingLink}</a></p>
|
||||
<p>A calendar invitation is attached to this email.</p>
|
||||
<p>See you there!</p>
|
||||
`;
|
||||
|
||||
await sendEmail({
|
||||
to: user.email,
|
||||
subject: `Registration Confirmed: ${webinar.title}`,
|
||||
html: htmlContent,
|
||||
attachments: [
|
||||
{
|
||||
filename: "webinar-invite.ics",
|
||||
content: icsContent,
|
||||
contentType: "text/calendar",
|
||||
},
|
||||
],
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[REGISTRATION] Failed to send email:", error);
|
||||
// Don't fail the registration if email fails
|
||||
}
|
||||
}
|
||||
|
||||
return ok({ registration: reg, next: "CONFIRMED" });
|
||||
}
|
||||
|
||||
const cfg = await loadSystemConfig();
|
||||
const stripe = await getStripe();
|
||||
if (!stripe) return fail(new Error("Stripe not configured"), { status: 503 });
|
||||
|
||||
const baseUrl = cfg.app?.initialized ? (process.env.APP_BASE_URL || "http://localhost:3000") : (process.env.APP_BASE_URL || "http://localhost:3000");
|
||||
|
||||
const reg = await prisma.webinarRegistration.upsert({
|
||||
where: { userId_webinarId: { userId: session.sub, webinarId: webinar.id } },
|
||||
update: { status: "PAYMENT_PENDING" },
|
||||
create: { userId: session.sub, webinarId: webinar.id, status: "PAYMENT_PENDING" },
|
||||
});
|
||||
|
||||
const checkout = await stripe.checkout.sessions.create({
|
||||
mode: "payment",
|
||||
success_url: `${baseUrl}/webinars/${webinar.id}?payment=success`,
|
||||
cancel_url: `${baseUrl}/webinars/${webinar.id}?payment=cancel`,
|
||||
line_items: [
|
||||
{
|
||||
quantity: 1,
|
||||
price_data: {
|
||||
currency: "usd",
|
||||
unit_amount: webinar.priceCents,
|
||||
product_data: { name: webinar.title },
|
||||
},
|
||||
},
|
||||
],
|
||||
metadata: { registrationId: reg.id, userId: session.sub, webinarId: webinar.id },
|
||||
});
|
||||
|
||||
await prisma.webinarRegistration.update({
|
||||
where: { id: reg.id },
|
||||
data: { stripeCheckoutSessionId: checkout.id },
|
||||
});
|
||||
|
||||
return ok({ next: "STRIPE_CHECKOUT", url: checkout.url });
|
||||
}
|
||||
94
app/api/stripe/webhook/route.ts
Normal file
94
app/api/stripe/webhook/route.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { headers } from "next/headers";
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { loadSystemConfig } from "../../../../lib/system-config";
|
||||
import { getStripe } from "../../../../lib/stripe";
|
||||
import { sendEmail } from "../../../../lib/email";
|
||||
import { createWebinarCalendarEvent } from "../../../../lib/calendar";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const prisma = await getPrisma();
|
||||
const stripe = await getStripe();
|
||||
const cfg = await loadSystemConfig();
|
||||
|
||||
const secret = cfg.stripe?.webhookSecret || process.env.STRIPE_WEBHOOK_SECRET;
|
||||
|
||||
if (!prisma || !stripe || !secret) {
|
||||
// must still 200 to avoid retries in misconfigured env
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
const headersList = await headers();
|
||||
const sig = headersList.get("stripe-signature");
|
||||
const body = await req.text();
|
||||
|
||||
if (!sig) return NextResponse.json({ ok: true });
|
||||
|
||||
let event: any;
|
||||
try {
|
||||
event = stripe.webhooks.constructEvent(body, sig, secret);
|
||||
} catch {
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
|
||||
if (event.type === "checkout.session.completed") {
|
||||
const session = event.data.object as any;
|
||||
const registrationId = session.metadata?.registrationId as string | undefined;
|
||||
const paymentIntent = session.payment_intent as string | undefined;
|
||||
|
||||
if (registrationId) {
|
||||
try {
|
||||
await prisma.webinarRegistration.update({
|
||||
where: { id: registrationId },
|
||||
data: { status: "PAID", stripePaymentIntentId: paymentIntent ?? null },
|
||||
});
|
||||
|
||||
// Send confirmation email with calendar invite
|
||||
const registration = await prisma.webinarRegistration.findUnique({
|
||||
where: { id: registrationId },
|
||||
include: { user: true, webinar: true },
|
||||
});
|
||||
|
||||
if (registration && cfg.email?.enabled) {
|
||||
const { icsContent } = await createWebinarCalendarEvent(
|
||||
registration.webinar,
|
||||
registration.user.email
|
||||
);
|
||||
|
||||
const meetingInfo = registration.webinar.meetingInfo as any;
|
||||
const meetingLink = meetingInfo?.meetingLink || "TBD";
|
||||
const htmlContent = `
|
||||
<h2>Webinar Registration Confirmed</h2>
|
||||
<p>Hi ${registration.user.firstName},</p>
|
||||
<p>Thank you for registering for <strong>${registration.webinar.title}</strong>.</p>
|
||||
<p><strong>Date & Time:</strong> ${new Date(registration.webinar.startAt).toLocaleString()}</p>
|
||||
<p><strong>Duration:</strong> ${registration.webinar.duration} minutes</p>
|
||||
<p><strong>Join Link:</strong> <a href="${meetingLink}">${meetingLink}</a></p>
|
||||
<p>A calendar invitation is attached to this email.</p>
|
||||
<p>See you there!</p>
|
||||
`;
|
||||
|
||||
await sendEmail({
|
||||
to: registration.user.email,
|
||||
subject: `Registration Confirmed: ${registration.webinar.title}`,
|
||||
html: htmlContent,
|
||||
attachments: [
|
||||
{
|
||||
filename: "webinar-invite.ics",
|
||||
content: icsContent,
|
||||
contentType: "text/calendar",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[WEBHOOK] Error processing payment:", error);
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
}
|
||||
142
app/api/webinars/[id]/route.ts
Normal file
142
app/api/webinars/[id]/route.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../../lib/db";
|
||||
import { getSession } from "../../../../lib/auth/session";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
const UpdateBody = z.object({
|
||||
title: z.string().min(3).optional(),
|
||||
description: z.string().min(1).optional(),
|
||||
speaker: z.string().min(1).optional(),
|
||||
startAt: z.string().optional(),
|
||||
duration: z.number().int().positive().optional(),
|
||||
bannerUrl: z.string().url().optional().or(z.literal("")).optional(),
|
||||
category: z.string().min(1).optional(),
|
||||
visibility: z.enum(["PUBLIC", "PRIVATE"]).optional(),
|
||||
isActive: z.boolean().optional(),
|
||||
capacity: z.number().int().positive().optional(),
|
||||
priceCents: z.number().int().min(0).optional(),
|
||||
learningPoints: z.array(z.string()).optional(),
|
||||
meetingInfo: z.any().optional(),
|
||||
});
|
||||
|
||||
export async function GET(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ ok: false, message: "Database not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
const session = await getSession();
|
||||
const isAdmin = session?.role === "ADMIN";
|
||||
|
||||
try {
|
||||
const webinar = await prisma.webinar.findUnique({
|
||||
where: { id: id },
|
||||
include: {
|
||||
_count: {
|
||||
select: { registrations: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!webinar) {
|
||||
return NextResponse.json({ ok: false, message: "Webinar not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
// Check visibility
|
||||
if (!isAdmin && (webinar.visibility !== "PUBLIC" || !webinar.isActive)) {
|
||||
return NextResponse.json({ ok: false, message: "Webinar not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true, webinar });
|
||||
} catch (error) {
|
||||
console.error("Error fetching webinar:", error);
|
||||
return NextResponse.json({ ok: false, message: "Failed to fetch webinar" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return NextResponse.json({ ok: false, message: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ ok: false, message: "Database not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const parsed = UpdateBody.safeParse(body);
|
||||
|
||||
if (!parsed.success) {
|
||||
return NextResponse.json(
|
||||
{ ok: false, message: "Invalid input", errors: parsed.error.errors },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const updateData: any = { ...parsed.data };
|
||||
|
||||
if (parsed.data.startAt) {
|
||||
updateData.startAt = new Date(parsed.data.startAt);
|
||||
}
|
||||
|
||||
if (parsed.data.learningPoints) {
|
||||
updateData.learningPoints = parsed.data.learningPoints;
|
||||
}
|
||||
|
||||
const webinar = await prisma.webinar.update({
|
||||
where: { id: id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, webinar, message: "Webinar updated successfully" });
|
||||
} catch (error: any) {
|
||||
console.error("Error updating webinar:", error);
|
||||
if (error.code === "P2025") {
|
||||
return NextResponse.json({ ok: false, message: "Webinar not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: false, message: "Failed to update webinar" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
const { id } = await params;
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") {
|
||||
return NextResponse.json({ ok: false, message: "Unauthorized" }, { status: 403 });
|
||||
}
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) {
|
||||
return NextResponse.json({ ok: false, message: "Database not configured" }, { status: 503 });
|
||||
}
|
||||
|
||||
try {
|
||||
await prisma.webinar.delete({
|
||||
where: { id: id },
|
||||
});
|
||||
|
||||
return NextResponse.json({ ok: true, message: "Webinar deleted successfully" });
|
||||
} catch (error: any) {
|
||||
console.error("Error deleting webinar:", error);
|
||||
if (error.code === "P2025") {
|
||||
return NextResponse.json({ ok: false, message: "Webinar not found" }, { status: 404 });
|
||||
}
|
||||
return NextResponse.json({ ok: false, message: "Failed to delete webinar" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
72
app/api/webinars/route.ts
Normal file
72
app/api/webinars/route.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { getPrisma } from "../../../lib/db";
|
||||
import { getSession } from "../../../lib/auth/session";
|
||||
import { ok, fail } from "../../../lib/http";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503 });
|
||||
|
||||
const session = await getSession();
|
||||
const isAdmin = session?.role === "ADMIN";
|
||||
|
||||
const url = new URL(req.url);
|
||||
const page = parseInt(url.searchParams.get("page") || "0");
|
||||
const limit = parseInt(url.searchParams.get("limit") || "20");
|
||||
const offset = page * limit;
|
||||
|
||||
const where = isAdmin ? {} : { visibility: "PUBLIC" as const, isActive: true };
|
||||
|
||||
const [webinars, total] = await Promise.all([
|
||||
prisma.webinar.findMany({
|
||||
where,
|
||||
orderBy: { startAt: "asc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
}),
|
||||
prisma.webinar.count({ where }),
|
||||
]);
|
||||
|
||||
return ok({ webinars, total, page, limit });
|
||||
}
|
||||
|
||||
const CreateBody = z.object({
|
||||
title: z.string().min(3),
|
||||
description: z.string().min(1),
|
||||
speaker: z.string().min(1),
|
||||
startAt: z.string(),
|
||||
duration: z.number().int().positive(),
|
||||
bannerUrl: z.string().url().optional().or(z.literal("")),
|
||||
category: z.string().min(1),
|
||||
visibility: z.enum(["PUBLIC", "PRIVATE"]),
|
||||
isActive: z.boolean(),
|
||||
capacity: z.number().int().positive(),
|
||||
priceCents: z.number().int().min(0),
|
||||
learningPoints: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
const session = await getSession();
|
||||
if (!session || session.role !== "ADMIN") return fail(new Error("Forbidden"), { status: 403 });
|
||||
|
||||
const prisma = await getPrisma();
|
||||
if (!prisma) return fail(new Error("Database not configured"), { status: 503, isAdmin: true });
|
||||
|
||||
const parsed = CreateBody.safeParse(await req.json().catch(() => ({})));
|
||||
if (!parsed.success) return fail(new Error("Invalid input"), { isAdmin: true });
|
||||
|
||||
const w = await prisma.webinar.create({
|
||||
data: {
|
||||
...parsed.data,
|
||||
bannerUrl: parsed.data.bannerUrl ? parsed.data.bannerUrl : null,
|
||||
startAt: new Date(parsed.data.startAt),
|
||||
meetingInfo: {},
|
||||
learningPoints: parsed.data.learningPoints || [],
|
||||
},
|
||||
});
|
||||
|
||||
return ok({ webinar: w });
|
||||
}
|
||||
Reference in New Issue
Block a user