Skip to content
Merged
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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ OPENAI_API_KEY=
OPENROUTER_API_KEY=
ANTHROPIC_API_KEY=

# Google Cloud Translation Basic (v2, server-side only)
GOOGLE_CLOUD_TRANSLATION_API_KEY=

# Stripe Billing
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
396 changes: 258 additions & 138 deletions README.md

Large diffs are not rendered by default.

Binary file added docs/screenshots/admin-analytics.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 1 addition & 1 deletion src/app/ai-tutor/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@ export const metadata: Metadata = {

export default async function AiTutorPage() {
const viewer = await requireViewer();
return <AiTutor initial={await getTutorData(viewer)} />;
return <AiTutor viewer={viewer} initial={await getTutorData(viewer)} />;
}
39 changes: 31 additions & 8 deletions src/app/api/admin/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,12 @@ const createSchema = z.discriminatedUnion("kind", [
slug: z.string().trim().min(2).max(80).regex(/^[a-z0-9-]+$/),
titleVi: z.string().trim().min(2).max(160),
titleEn: z.string().trim().max(160).optional(),
titleJa: z.string().trim().max(160).optional(),
titleTh: z.string().trim().max(160).optional(),
descriptionVi: z.string().trim().max(1000).optional(),
descriptionEn: z.string().trim().max(1000).optional(),
descriptionJa: z.string().trim().max(1000).optional(),
descriptionTh: z.string().trim().max(1000).optional(),
targetLevel: z.string().trim().min(1).max(20),
estimatedHours: z.coerce.number().int().min(1).max(1000),
published: z.boolean(),
Expand All @@ -32,7 +37,13 @@ const createSchema = z.discriminatedUnion("kind", [
pathId: z.uuid(),
position: z.coerce.number().int().min(1).max(1000),
titleVi: z.string().trim().min(2).max(160),
titleEn: z.string().trim().max(160).optional(),
titleJa: z.string().trim().max(160).optional(),
titleTh: z.string().trim().max(160).optional(),
descriptionVi: z.string().trim().max(1000).optional(),
descriptionEn: z.string().trim().max(1000).optional(),
descriptionJa: z.string().trim().max(1000).optional(),
descriptionTh: z.string().trim().max(1000).optional(),
skill,
level: z.string().trim().min(1).max(20),
estimatedMinutes: z.coerce.number().int().min(1).max(300),
Expand All @@ -41,7 +52,13 @@ const createSchema = z.discriminatedUnion("kind", [
kind: z.literal("question"),
unitId: z.uuid(),
promptVi: z.string().trim().min(2).max(3000),
promptEn: z.string().trim().max(3000).optional(),
promptJa: z.string().trim().max(3000).optional(),
promptTh: z.string().trim().max(3000).optional(),
explanationVi: z.string().trim().max(3000).optional(),
explanationEn: z.string().trim().max(3000).optional(),
explanationJa: z.string().trim().max(3000).optional(),
explanationTh: z.string().trim().max(3000).optional(),
passage: z.string().trim().max(10000).optional(),
audioUrl: z.string().trim().url().optional().or(z.literal("")),
skill,
Expand All @@ -55,7 +72,13 @@ const createSchema = z.discriminatedUnion("kind", [
kind: z.literal("challenge"),
slug: z.string().trim().min(2).max(80).regex(/^[a-z0-9-]+$/),
titleVi: z.string().trim().min(2).max(160),
titleEn: z.string().trim().max(160).optional(),
titleJa: z.string().trim().max(160).optional(),
titleTh: z.string().trim().max(160).optional(),
descriptionVi: z.string().trim().max(1000),
descriptionEn: z.string().trim().max(1000).optional(),
descriptionJa: z.string().trim().max(1000).optional(),
descriptionTh: z.string().trim().max(1000).optional(),
challengeType: z.enum(["daily", "weekly", "boss", "community"]),
difficulty: z.enum(["easy", "normal", "hard", "legendary"]),
eventType: z.string().trim().max(80).optional(),
Expand Down Expand Up @@ -89,8 +112,8 @@ export async function POST(request: Request) {
if (input.kind === "path") {
result = await admin.from("learning_paths").insert({
slug: input.slug,
title: { vi: input.titleVi, en: input.titleEn || input.titleVi },
description: { vi: input.descriptionVi || "" },
title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi },
description: { vi: input.descriptionVi || "", en: input.descriptionEn || input.descriptionVi || "", ja: input.descriptionJa || input.descriptionEn || input.descriptionVi || "", th: input.descriptionTh || input.descriptionEn || input.descriptionVi || "" },
target_level: input.targetLevel,
estimated_hours: input.estimatedHours,
is_published: input.published,
Expand All @@ -100,8 +123,8 @@ export async function POST(request: Request) {
result = await admin.from("learning_units").insert({
path_id: input.pathId,
position: input.position,
title: { vi: input.titleVi, en: input.titleVi },
description: { vi: input.descriptionVi || "" },
title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi },
description: { vi: input.descriptionVi || "", en: input.descriptionEn || input.descriptionVi || "", ja: input.descriptionJa || input.descriptionEn || input.descriptionVi || "", th: input.descriptionTh || input.descriptionEn || input.descriptionVi || "" },
skill: input.skill,
level: input.level,
estimated_minutes: input.estimatedMinutes,
Expand Down Expand Up @@ -133,8 +156,8 @@ export async function POST(request: Request) {
owner_id: null,
skill: input.skill,
question_type: input.questionType,
prompt: { vi: input.promptVi, en: input.promptVi },
explanation: { vi: input.explanationVi || "" },
prompt: { vi: input.promptVi, en: input.promptEn || input.promptVi, ja: input.promptJa || input.promptEn || input.promptVi, th: input.promptTh || input.promptEn || input.promptVi },
explanation: { vi: input.explanationVi || "", en: input.explanationEn || input.explanationVi || "", ja: input.explanationJa || input.explanationEn || input.explanationVi || "", th: input.explanationTh || input.explanationEn || input.explanationVi || "" },
passage: input.passage || null,
audio_url: input.audioUrl || null,
options: optionObjects.length ? optionObjects : null,
Expand All @@ -148,8 +171,8 @@ export async function POST(request: Request) {
ends.setDate(ends.getDate() + input.durationDays);
result = await admin.from("learning_challenges").insert({
slug: input.slug,
title: { vi: input.titleVi, en: input.titleVi },
description: { vi: input.descriptionVi, en: input.descriptionVi },
title: { vi: input.titleVi, en: input.titleEn || input.titleVi, ja: input.titleJa || input.titleEn || input.titleVi, th: input.titleTh || input.titleEn || input.titleVi },
description: { vi: input.descriptionVi, en: input.descriptionEn || input.descriptionVi, ja: input.descriptionJa || input.descriptionEn || input.descriptionVi, th: input.descriptionTh || input.descriptionEn || input.descriptionVi },
challenge_type: input.challengeType,
difficulty: input.difficulty,
event_type: input.eventType || null,
Expand Down
156 changes: 116 additions & 40 deletions src/app/api/practice/attempt/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,12 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getOptionalViewer } from "@/lib/auth";
import {
answerWords,
normalizeAnswer,
orderedSentenceScore,
wordSimilarity,
} from "@/lib/practice-scoring";
import { createAdminClient } from "@/lib/supabase/admin";

const schema = z.object({
Expand All @@ -9,33 +15,15 @@ const schema = z.object({
durationSeconds: z.number().int().min(0).max(14_400).default(0),
module: z.enum(["practice", "quiz", "competition"]).default("practice"),
challengeId: z.uuid().optional(),
unitId: z.uuid().optional(),
unitSessionId: z.uuid().optional(),
idempotencyKey: z.uuid(),
});

function normalize(value: unknown) {
return String(value ?? "").trim().toLocaleLowerCase("en");
}

function words(value: unknown) {
return normalize(value)
.replace(/[^\p{L}\p{N}\s]/gu, "")
.split(/\s+/)
.filter(Boolean);
}

function similarity(expectedValue: unknown, actualValue: unknown) {
const expected = words(expectedValue);
const actual = words(actualValue);
if (!expected.length) return 0;
const remaining = [...actual];
const matched = expected.filter((word) => {
const index = remaining.indexOf(word);
if (index < 0) return false;
remaining.splice(index, 1);
return true;
}).length;
return Math.round((matched / expected.length) * 100);
}
}).refine(
(value) =>
(!value.unitId && !value.unitSessionId) ||
Boolean(value.unitId && value.unitSessionId),
{ message: "unitId và unitSessionId phải được gửi cùng nhau." },
);

export async function POST(request: Request) {
const viewer = await getOptionalViewer();
Expand All @@ -44,12 +32,73 @@ export async function POST(request: Request) {
const admin = createAdminClient();
const { data: question, error: questionError } = await admin
.from("practice_questions")
.select("id,skill,question_type,answer_key,explanation,options")
.select("id,unit_id,skill,question_type,answer_key,explanation,options")
.eq("id", input.questionId)
.single();
if (questionError || !question) {
return NextResponse.json({ error: "Không tìm thấy câu hỏi." }, { status: 404 });
}
if (input.unitId && question.unit_id !== input.unitId) {
return NextResponse.json(
{ error: "Câu hỏi không thuộc chặng học hiện tại." },
{ status: 403 },
);
}
if (input.unitId && input.unitSessionId) {
const { data: activeSession } = await admin
.from("learning_unit_sessions")
.select("id")
.eq("id", input.unitSessionId)
.eq("user_id", viewer.id)
.eq("unit_id", input.unitId)
.eq("status", "active")
.maybeSingle();
if (!activeSession) {
return NextResponse.json(
{ error: "Phiên học không còn hoạt động. Hãy mở lại chặng." },
{ status: 403 },
);
}
}
if (question.unit_id && input.module !== "competition") {
const { data: unit } = await admin
.from("learning_units")
.select("path_id,position")
.eq("id", question.unit_id)
.maybeSingle();
if (unit) {
const [{ data: enrollment }, { data: previous }] = await Promise.all([
admin
.from("user_path_enrollments")
.select("id")
.eq("user_id", viewer.id)
.eq("path_id", unit.path_id)
.maybeSingle(),
admin
.from("learning_units")
.select("id")
.eq("path_id", unit.path_id)
.lt("position", unit.position)
.order("position", { ascending: false })
.limit(1)
.maybeSingle(),
]);
if (enrollment && previous) {
const { data: previousProgress } = await admin
.from("user_unit_progress")
.select("completed_at")
.eq("user_id", viewer.id)
.eq("unit_id", previous.id)
.maybeSingle();
if (!previousProgress?.completed_at) {
return NextResponse.json(
{ error: "Hãy hoàn thành checkpoint trước để mở bài học này." },
{ status: 403 },
);
}
}
}
}

const key = (question.answer_key ?? {}) as {
value?: unknown;
Expand All @@ -61,37 +110,39 @@ export async function POST(request: Request) {
const options = (question.options ?? []) as Array<{ id?: string; text?: string }>;
let score: number | null = null;
if (question.question_type === "multiple_choice" || question.question_type === "true_false") {
const selected = options.find((option) => normalize(option.id) === normalize(input.answer));
const selected = options.find((option) => normalizeAnswer(option.id) === normalizeAnswer(input.answer));
score =
normalize(input.answer) === normalize(key.value) ||
normalize(selected?.text) === normalize(key.value)
normalizeAnswer(input.answer) === normalizeAnswer(key.value) ||
normalizeAnswer(selected?.text) === normalizeAnswer(key.value)
? 100
: 0;
} else if (question.question_type === "dictation") {
score = similarity(key.text ?? key.value, input.answer);
score = wordSimilarity(key.text ?? key.value, input.answer);
} else if (question.question_type === "short_answer") {
score = similarity(key.value ?? key.text, input.answer);
score = wordSimilarity(key.value ?? key.text, input.answer);
} else if (question.question_type === "fill_blank") {
score = normalize(key.value ?? key.text) === normalize(input.answer) ? 100 : similarity(key.value ?? key.text, input.answer);
score = normalizeAnswer(key.value ?? key.text) === normalizeAnswer(input.answer)
? 100
: wordSimilarity(key.value ?? key.text, input.answer);
} else if (question.question_type === "match_meaning") {
const selected = options.find((option) => normalize(option.id) === normalize(input.answer));
const selected = options.find((option) => normalizeAnswer(option.id) === normalizeAnswer(input.answer));
score =
normalize(input.answer) === normalize(key.value) ||
normalize(selected?.text) === normalize(key.value)
normalizeAnswer(input.answer) === normalizeAnswer(key.value) ||
normalizeAnswer(selected?.text) === normalizeAnswer(key.value)
? 100
: 0;
} else if (question.question_type === "sentence_order") {
const ordered = Array.isArray(input.answer)
? input.answer.join(" ")
: input.answer;
score = similarity(key.text ?? key.value, ordered);
score = orderedSentenceScore(key.text ?? key.value, ordered);
} else if (question.question_type === "speaking") {
const keywords = key.keywords ?? [];
score = keywords.length
? similarity(keywords.join(" "), input.answer)
: Math.min(100, words(input.answer).length * 10);
? wordSimilarity(keywords.join(" "), input.answer)
: Math.min(100, answerWords(input.answer).length * 10);
} else if (question.question_type === "essay") {
const count = words(input.answer).length;
const count = answerWords(input.answer).length;
const minimum = key.min_words ?? 60;
const maximum = key.max_words ?? 180;
score =
Expand Down Expand Up @@ -128,6 +179,21 @@ export async function POST(request: Request) {
if (!secured) {
return NextResponse.json({ error: "Không thể ghi nhận lượt học." }, { status: 500 });
}
if (input.unitId && input.unitSessionId) {
const { error: sessionError } = await admin.rpc(
"attach_attempt_to_unit_session",
{
target_user_id: viewer.id,
target_session_id: input.unitSessionId,
target_unit_id: input.unitId,
target_question_id: question.id,
target_attempt_id: secured.attempt_id,
},
);
if (sessionError) {
return NextResponse.json({ error: sessionError.message }, { status: 403 });
}
}
if (input.module === "quiz" && score !== null) {
await admin.from("quiz_results").insert({
user_id: viewer.id,
Expand All @@ -147,5 +213,15 @@ export async function POST(request: Request) {
},
rewardEligible: secured.reward_eligible,
cooldownSeconds: secured.cooldown_seconds,
correctAnswer:
question.question_type === "multiple_choice" ||
question.question_type === "true_false" ||
question.question_type === "match_meaning"
? options.find(
(option) =>
normalizeAnswer(option.id) === normalizeAnswer(key.value) ||
normalizeAnswer(option.text) === normalizeAnswer(key.value),
)?.text ?? String(key.value ?? "")
: String(key.text ?? key.value ?? ""),
});
}
30 changes: 30 additions & 0 deletions src/app/api/practice/review/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { getOptionalViewer } from "@/lib/auth";
import { createAdminClient } from "@/lib/supabase/admin";

const schema = z.object({
questionId: z.uuid(),
difficult: z.boolean(),
score: z.number().min(0).max(100).default(0),
});

export async function POST(request: Request) {
const viewer = await getOptionalViewer();
if (!viewer) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}

const input = schema.parse(await request.json());
const admin = createAdminClient();
const { error } = await admin.rpc("set_question_review_state", {
target_user_id: viewer.id,
target_question_id: input.questionId,
target_difficult: input.difficult,
target_score: input.score,
});
if (error) {
return NextResponse.json({ error: error.message }, { status: 400 });
}
return NextResponse.json({ saved: true });
}
Loading
Loading