This commit is contained in:
Marcelo
2026-02-17 00:07:00 +00:00
parent b7a86a2d1c
commit be4ca2ed78
92 changed files with 6850 additions and 1188 deletions

36
lib/auth/clientAuth.ts Normal file
View File

@@ -0,0 +1,36 @@
import { DEMO_AUTH_EMAIL_COOKIE, DEMO_AUTH_ROLE_COOKIE } from "@/lib/auth/demoAuth";
export type ClientAuthSnapshot = {
userId: string;
userEmail: string | null;
isAuthed: boolean;
isTeacher: boolean;
};
const readCookieMap = (): Map<string, string> =>
new Map(
document.cookie
.split(";")
.map((entry) => entry.trim())
.filter(Boolean)
.map((entry) => {
const [key, ...rest] = entry.split("=");
return [key, decodeURIComponent(rest.join("="))] as const;
}),
);
export const readDemoClientAuth = (): ClientAuthSnapshot => {
if (typeof document === "undefined") {
return { userId: "guest", userEmail: null, isAuthed: false, isTeacher: false };
}
const cookies = readCookieMap();
const userEmail = cookies.get(DEMO_AUTH_EMAIL_COOKIE) ?? null;
const role = cookies.get(DEMO_AUTH_ROLE_COOKIE) ?? "";
return {
userId: userEmail ?? "guest",
userEmail,
isAuthed: Boolean(userEmail),
isTeacher: role === "teacher",
};
};

34
lib/auth/demoAuth.ts Normal file
View File

@@ -0,0 +1,34 @@
export const DEMO_AUTH_EMAIL_COOKIE = "acve_demo_email";
export const DEMO_AUTH_ROLE_COOKIE = "acve_demo_role";
type DemoAccount = {
email: string;
password: string;
role: "teacher" | "learner";
};
const demoAccounts: DemoAccount[] = [
{
email: "teacher@acve.local",
password: "teacher123",
role: "teacher",
},
{
email: "learner@acve.local",
password: "learner123",
role: "learner",
},
];
export const demoCredentialsHint = demoAccounts
.map((account) => `${account.email} / ${account.password}`)
.join(" | ");
export const findDemoAccount = (email: string, password: string): DemoAccount | null => {
const cleanEmail = email.trim().toLowerCase();
return (
demoAccounts.find(
(account) => account.email.toLowerCase() === cleanEmail && account.password === password,
) ?? null
);
};

67
lib/auth/requireTeacher.ts Normal file → Executable file
View File

@@ -1,26 +1,53 @@
import { redirect } from "next/navigation";
import { requireUser } from "@/lib/auth/requireUser";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { cookies } from "next/headers";
import { db } from "@/lib/prisma";
import { UserRole } from "@prisma/client";
import { logger } from "@/lib/logger";
const readTeacherEmails = (): string[] =>
(process.env.TEACHER_EMAILS ?? "")
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
export async function requireTeacher() {
export const requireTeacher = async () => {
const user = await requireUser("/teacher");
if (!user?.email) {
redirect("/");
const cookieStore = await cookies();
// 1. Get Supabase Session
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{
cookies: {
getAll() { return cookieStore.getAll() },
setAll(cookiesToSet: { name: string; value: string; options?: CookieOptions }[]) {
try {
cookiesToSet.forEach(({ name, value, options }) =>
cookieStore.set(name, value, options)
)
} catch (error) {
// This is expected in Server Components, but let's log it just in case
logger.warn("Failed to set cookies in Server Component context (expected behavior)", error);
}
},
},
}
);
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return null; // Let the caller handle the redirect
}
const allowed = readTeacherEmails();
if (allowed.length === 0) {
redirect("/");
// 2. Check Role in Database
const profile = await db.profile.findUnique({
where: { id: user.id },
}
);
console.log("AUTH_USER_ID:", user.id);
console.log("DB_PROFILE:", profile);
if (!profile || (profile.role !== UserRole.TEACHER && profile.role !== UserRole.SUPER_ADMIN)) {
// You can decide to return null or throw an error here
return null;
}
if (!allowed.includes(user.email.toLowerCase())) {
redirect("/");
}
return user;
};
return profile;
}

28
lib/auth/requireUser.ts Normal file → Executable file
View File

@@ -1,16 +1,18 @@
import { redirect } from "next/navigation";
import { supabaseServer } from "@/lib/supabase/server";
import { createServerClient } from "@supabase/ssr";
import { cookies } from "next/headers";
import { db } from "@/lib/prisma";
export const requireUser = async (redirectTo: string) => {
const supabase = await supabaseServer();
if (!supabase) {
return null;
}
export async function requireUser() {
const cookieStore = await cookies();
const supabase = createServerClient(
process.env.NEXT_PUBLIC_SUPABASE_URL!,
process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY!,
{ cookies: { getAll() { return cookieStore.getAll() } } }
);
const { data } = await supabase.auth.getUser();
if (!data.user) {
redirect(`/auth/login?redirectTo=${encodeURIComponent(redirectTo)}`);
}
const { data: { user } } = await supabase.auth.getUser();
if (!user) return null;
return data.user;
};
const profile = await db.profile.findUnique({ where: { id: user.id } });
return profile;
}

View File

@@ -0,0 +1,16 @@
const parseTeacherEmails = (source: string | undefined): string[] =>
(source ?? "")
.split(",")
.map((email) => email.trim().toLowerCase())
.filter(Boolean);
export const readTeacherEmailsServer = (): string[] => parseTeacherEmails(process.env.TEACHER_EMAILS);
export const readTeacherEmailsBrowser = (): string[] =>
parseTeacherEmails(process.env.NEXT_PUBLIC_TEACHER_EMAILS);
export const isTeacherEmailAllowed = (email: string | null, allowed: string[]): boolean => {
if (!email) return false;
if (allowed.length === 0) return false;
return allowed.includes(email.toLowerCase());
};

14
lib/data/courseCatalog.ts Normal file → Executable file
View File

@@ -2,12 +2,14 @@ import { mockCourses } from "@/lib/data/mockCourses";
import { getTeacherCourseBySlug, getTeacherCourses } from "@/lib/data/teacherCourses";
import type { Course } from "@/types/course";
const isPublished = (course: Course) => course.status === "Published";
export const getAllCourses = (): Course[] => {
const teacherCourses = getTeacherCourses();
if (teacherCourses.length === 0) return mockCourses;
const teacherCourses = getTeacherCourses().filter(isPublished);
if (teacherCourses.length === 0) return mockCourses.filter(isPublished);
const teacherSlugs = new Set(teacherCourses.map((course) => course.slug));
const baseCourses = mockCourses.filter((course) => !teacherSlugs.has(course.slug));
const baseCourses = mockCourses.filter((course) => isPublished(course) && !teacherSlugs.has(course.slug));
return [...baseCourses, ...teacherCourses];
};
@@ -17,4 +19,8 @@ export const getCourseBySlug = (slug: string): Course | undefined => {
return mockCourses.find((course) => course.slug === slug);
};
export const getAllCourseSlugs = (): string[] => getAllCourses().map((course) => course.slug);
export const getAllCourseSlugs = (): string[] => {
const teacherCourses = getTeacherCourses();
const all = [...mockCourses, ...teacherCourses];
return all.map((course) => course.slug);
};

0
lib/data/mockCaseStudies.ts Normal file → Executable file
View File

4
lib/data/mockCourses.ts Normal file → Executable file
View File

@@ -5,6 +5,7 @@ export const mockCourses: Course[] = [
slug: "legal-english-foundations",
title: "Legal English Foundations",
level: "Beginner",
status: "Published",
summary: "Build legal vocabulary, core writing patterns, and court terminology.",
rating: 4.8,
weeks: 4,
@@ -24,6 +25,7 @@ export const mockCourses: Course[] = [
slug: "contract-analysis-practice",
title: "Contract Analysis Practice",
level: "Intermediate",
status: "Published",
summary: "Analyze contract sections and identify risk-heavy clauses quickly.",
rating: 4.7,
weeks: 5,
@@ -44,6 +46,7 @@ export const mockCourses: Course[] = [
slug: "litigation-brief-writing",
title: "Litigation Brief Writing",
level: "Advanced",
status: "Published",
summary: "Craft persuasive briefs with strong authority usage and argument flow.",
rating: 4.9,
weeks: 6,
@@ -65,6 +68,7 @@ export const mockCourses: Course[] = [
slug: "cross-border-ip-strategy",
title: "Cross-Border IP Strategy",
level: "Advanced",
status: "Published",
summary: "Understand IP enforcement strategy across multiple jurisdictions.",
rating: 4.6,
weeks: 4,

0
lib/data/mockPractice.ts Normal file → Executable file
View File

1
lib/data/teacherCourses.ts Normal file → Executable file
View File

@@ -92,6 +92,7 @@ export const createTeacherCourse = (input: TeacherCourseInput, reservedSlugs: st
slug,
title: input.title,
level: input.level,
status: "Draft",
summary: input.summary,
rating: 5,
weeks: Math.max(1, input.weeks),

21
lib/logger.ts Normal file
View File

@@ -0,0 +1,21 @@
const getTimestamp = () => new Date().toISOString();
export const logger = {
info: (message: string, ...args: unknown[]) => {
console.log(`[INFO] [${getTimestamp()}] ${message}`, ...args);
},
warn: (message: string, ...args: unknown[]) => {
console.warn(`[WARN] [${getTimestamp()}] ${message}`, ...args);
},
error: (message: string, error?: unknown) => {
console.error(`[ERROR] [${getTimestamp()}] ${message}`, error);
if (error instanceof Error) {
console.error(error.stack);
}
},
debug: (message: string, ...args: unknown[]) => {
if (process.env.NODE_ENV === 'development') {
console.debug(`[DEBUG] [${getTimestamp()}] ${message}`, ...args);
}
}
};

24
lib/prisma.ts Executable file
View File

@@ -0,0 +1,24 @@
import { Pool } from 'pg'
import { PrismaPg } from '@prisma/adapter-pg'
import { PrismaClient } from '@prisma/client'
// Use the POOLER URL (6543) for the app to handle high traffic
const connectionString = process.env.DATABASE_URL
const createPrismaClient = () => {
// 1. Create the Pool
const pool = new Pool({ connectionString })
// 2. Create the Adapter
const adapter = new PrismaPg(pool)
// 3. Init the Client
return new PrismaClient({ adapter })
}
// Prevent multiple instances during development hot-reloading
declare global {
var prisma: ReturnType<typeof createPrismaClient> | undefined
}
export const db = globalThis.prisma || createPrismaClient()
if (process.env.NODE_ENV !== 'production') globalThis.prisma = db

0
lib/progress/localProgress.ts Normal file → Executable file
View File

9
lib/supabase/browser.ts Normal file → Executable file
View File

@@ -1,17 +1,16 @@
import { createClient, type SupabaseClient } from "@supabase/supabase-js";
import { readSupabasePublicConfig } from "@/lib/supabase/config";
let browserClient: SupabaseClient | null = null;
export const supabaseBrowser = (): SupabaseClient | null => {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
const config = readSupabasePublicConfig();
if (!config) {
return null;
}
if (!browserClient) {
browserClient = createClient(url, anonKey);
browserClient = createClient(config.url, config.anonKey);
}
return browserClient;

28
lib/supabase/config.ts Normal file
View File

@@ -0,0 +1,28 @@
const isValidHttpUrl = (value: string): boolean => {
try {
const parsed = new URL(value);
return parsed.protocol === "http:" || parsed.protocol === "https:";
} catch {
return false;
}
};
export type SupabasePublicConfig = {
url: string;
anonKey: string;
};
export const readSupabasePublicConfig = (): SupabasePublicConfig | null => {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL?.trim();
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY?.trim();
if (!url || !anonKey) {
return null;
}
if (!isValidHttpUrl(url)) {
return null;
}
return { url, anonKey };
};

8
lib/supabase/middleware.ts Normal file → Executable file
View File

@@ -1,5 +1,6 @@
import { NextResponse, type NextRequest } from "next/server";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { readSupabasePublicConfig } from "@/lib/supabase/config";
export type SessionSnapshot = {
response: NextResponse;
@@ -11,9 +12,8 @@ export type SessionSnapshot = {
export const updateSession = async (req: NextRequest): Promise<SessionSnapshot> => {
const response = NextResponse.next({ request: req });
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
const config = readSupabasePublicConfig();
if (!config) {
return {
response,
isAuthed: false,
@@ -22,7 +22,7 @@ export const updateSession = async (req: NextRequest): Promise<SessionSnapshot>
};
}
const supabase = createServerClient(url, anonKey, {
const supabase = createServerClient(config.url, config.anonKey, {
cookies: {
getAll: () => req.cookies.getAll(),
setAll: (

9
lib/supabase/server.ts Normal file → Executable file
View File

@@ -1,17 +1,16 @@
import { cookies } from "next/headers";
import { createServerClient, type CookieOptions } from "@supabase/ssr";
import { readSupabasePublicConfig } from "@/lib/supabase/config";
export const supabaseServer = async () => {
const url = process.env.NEXT_PUBLIC_SUPABASE_URL;
const anonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY;
if (!url || !anonKey) {
const config = readSupabasePublicConfig();
if (!config) {
return null;
}
const cookieStore = await cookies();
return createServerClient(url, anonKey, {
return createServerClient(config.url, config.anonKey, {
cookies: {
getAll() {
return cookieStore.getAll();

6
lib/utils.ts Executable file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

12
lib/validations/course.ts Executable file
View File

@@ -0,0 +1,12 @@
import { z } from "zod";
const translationSchema = z.object({
en: z.string().min(1, "English version is required"),
es: z.string().min(1, "Spanish version is required"),
});
export const courseSchema = z.object({
title: translationSchema,
description: translationSchema,
tags: z.array(z.string()).default([]),
});