diff --git a/apps/kurama-admin/src/core/middleware/admin-auth.ts b/apps/kurama-admin/src/core/middleware/admin-auth.ts index 943bb54..0a7ac3e 100644 --- a/apps/kurama-admin/src/core/middleware/admin-auth.ts +++ b/apps/kurama-admin/src/core/middleware/admin-auth.ts @@ -1,4 +1,7 @@ import { getAuth } from '@kurama/data-ops/auth/server' +import { eq } from '@kurama/data-ops/database/drizzle-orm' +import { getDb } from '@kurama/data-ops/database/setup' +import { userProfiles } from '@kurama/data-ops/drizzle/schema' import { createMiddleware } from '@tanstack/react-start' import { getRequest } from '@tanstack/react-start/server' @@ -11,14 +14,21 @@ async function getAuthContext() { throw new Error('Unauthorized') } - // TODO: Add admin role check here when schema is ready - // const adminRole = await db.query.adminRoles.findFirst(...) + const db = getDb() + const profile = await db.query.userProfiles.findFirst({ + where: eq(userProfiles.userId, session.user.id), + }) + + if (!profile || profile.userType !== 'admin') { + throw new Error('Forbidden: Admin access required') + } return { auth, userId: session.user.id, email: session.user.email, session, + profile, } } diff --git a/apps/kurama-admin/src/routes/_admin/dashboard.tsx b/apps/kurama-admin/src/routes/_admin/dashboard.tsx index b850e24..9ee8bc2 100644 --- a/apps/kurama-admin/src/routes/_admin/dashboard.tsx +++ b/apps/kurama-admin/src/routes/_admin/dashboard.tsx @@ -127,7 +127,7 @@ interface RecentSession { userId: string userName: string | null userEmail: string | null - lessonId: number + lessonId: number | null lessonTitle: string | null mode: string cardsReviewed: number diff --git a/apps/kurama-admin/src/routes/_admin/lessons.$lessonId.tsx b/apps/kurama-admin/src/routes/_admin/lessons.$lessonId.tsx index 6be14ad..323e745 100644 --- a/apps/kurama-admin/src/routes/_admin/lessons.$lessonId.tsx +++ b/apps/kurama-admin/src/routes/_admin/lessons.$lessonId.tsx @@ -5,20 +5,26 @@ import { ArrowLeft, BookOpen, Clock, + Copy, ExternalLink, + Eye, FileText, GraduationCap, Loader2, Pencil, + Plus, Save, Sparkles, + Trash2, + Upload, X, } from 'lucide-react' import { motion } from 'motion/react' import { useState } from 'react' import { toast } from 'sonner' +import { BulkImportDialog, CardForm, CardPreview } from '@/components/admin/cards' import { AttachmentsSheet } from '@/components/admin/lessons/attachments-sheet' -import { MarkdownRenderer, PageHeader } from '@/components/shared' +import { ConfirmDialog, DataTable, MarkdownRenderer, PageHeader } from '@/components/shared' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { @@ -45,6 +51,12 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select' +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, +} from '@/components/ui/sheet' import { Slider } from '@/components/ui/slider' import { Textarea } from '@/components/ui/textarea' import { @@ -53,7 +65,14 @@ import { saveGeneratedCards, updateTeachPlan, } from '@/core/functions/ai-generation' -import { getCards } from '@/core/functions/cards' +import { + bulkCreateCards, + createCard, + deleteCard, + duplicateCard, + getCards, + updateCard, +} from '@/core/functions/cards' import { getLesson } from '@/core/functions/lessons' import { getGradesSimple } from '@/core/functions/users' import { generateUUID } from '@/utils/generateUUID' @@ -118,6 +137,11 @@ function LessonDetailPage() { difficulty: number }>>([]) const [previewDialogOpen, setPreviewDialogOpen] = useState(false) + const [cardFormOpen, setCardFormOpen] = useState(false) + const [editingCard, setEditingCard] = useState(null) + const [deletingCard, setDeletingCard] = useState(null) + const [previewCard, setPreviewCard] = useState(null) + const [importOpen, setImportOpen] = useState(false) // Queries const { data: lesson, isLoading } = useQuery({ @@ -230,6 +254,56 @@ function LessonDetailPage() { }, }) + // Card Mutations + const createCardMutation = useMutation({ + mutationFn: (input: any) => createCard({ data: { ...input, lessonId: lessonIdNum } }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] }) + setCardFormOpen(false) + toast.success('Carte créée avec succès') + }, + onError: (error: Error) => toast.error(error.message), + }) + + const updateCardMutation = useMutation({ + mutationFn: (input: any) => updateCard({ data: input }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] }) + setEditingCard(null) + toast.success('Carte modifiée avec succès') + }, + onError: (error: Error) => toast.error(error.message), + }) + + const duplicateCardMutation = useMutation({ + mutationFn: (id: number) => duplicateCard({ data: id }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] }) + toast.success('Carte dupliquée') + }, + onError: (error: Error) => toast.error(error.message), + }) + + const deleteCardMutation = useMutation({ + mutationFn: (id: number) => deleteCard({ data: id }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] }) + setDeletingCard(null) + toast.success('Carte supprimée') + }, + onError: (error: Error) => toast.error(error.message), + }) + + const bulkImportMutation = useMutation({ + mutationFn: (data: any) => bulkCreateCards({ data: { lessonId: lessonIdNum, cards: data } }), + onSuccess: (result: any) => { + queryClient.invalidateQueries({ queryKey: ['cards', { lessonId: lessonIdNum }] }) + setImportOpen(false) + toast.success(`${result.created} cartes importées`) + }, + onError: (error: Error) => toast.error(error.message), + }) + const handleStartEdit = () => { setEditedTeachPlan(lesson?.teachPlan || '') setIsEditing(true) @@ -240,6 +314,55 @@ function LessonDetailPage() { setEditedTeachPlan('') } + const cardTypeLabels: Record = { + basic: 'Basique', + multichoice: 'Choix multiple', + true_false: 'Vrai/Faux', + fill_blank: 'Texte à trous', + } + + const cardColumns = [ + { + key: 'frontContent', + header: 'Contenu', + cell: (card: any) => ( +
+ {card.frontContent} +
+ ), + }, + { + key: 'cardType', + header: 'Type', + cell: (card: any) => ( + + {cardTypeLabels[card.cardType] || card.cardType} + + ), + }, + { + key: 'actions', + header: '', + cell: (card: any) => ( +
+ + + + +
+ ), + className: 'w-40', + }, + ] + const difficultyLabels: Record = { easy: 'Facile', medium: 'Moyen', @@ -719,6 +842,113 @@ function LessonDetailPage() { + {/* Cards List Section */} + + + +
+ +
+ +
+ Cartes de la leçon +
+
+
+ + +
+
+ + { }} // Not implemented here as we show all + isLoading={isLoading} + emptyMessage="Aucune carte dans cette leçon." + /> + +
+
+ + {/* Card Form & Dialogs */} + {cardFormOpen && ( + { await createCardMutation.mutateAsync(data) }} + lessons={[{ id: lesson.id, title: lesson.title, subjectId: lesson.subjectId }]} + defaultValues={{ lessonId: lesson.id }} + isLoading={createCardMutation.isPending} + /> + )} + + {editingCard && ( + !open && setEditingCard(null)} + onSubmit={async (data) => { await updateCardMutation.mutateAsync({ ...data, id: editingCard.id }) }} + lessons={[{ id: lesson.id, title: lesson.title, subjectId: lesson.subjectId }]} + defaultValues={editingCard} + isEditing + isLoading={updateCardMutation.isPending} + /> + )} + + !open && setDeletingCard(null)} + title="Supprimer la carte" + description="Êtes-vous sûr de vouloir supprimer cette carte ?" + onConfirm={() => deleteCardMutation.mutate(deletingCard.id)} + isLoading={deleteCardMutation.isPending} + variant="destructive" + /> + + !open && setPreviewCard(null)}> + + + Aperçu de la carte + + {previewCard && ( +
+ +
+ )} +
+
+ + { + const formattedCards = cards.map(card => ({ + ...card, + lessonId: lessonIdNum, + options: card.options?.map((opt, idx) => ({ + id: idx.toString(), + text: opt, + isCorrect: card.correctAnswer === idx, + })), + correctAnswer: card.correctAnswer?.toString(), + difficulty: card.difficulty === 'easy' ? 0 : card.difficulty === 'medium' ? 1 : 2, + displayOrder: 0, + })) + await bulkImportMutation.mutateAsync(formattedCards) + }} + isLoading={bulkImportMutation.isPending} + /> ) } diff --git a/apps/kurama-admin/src/routes/_admin/users.index.tsx b/apps/kurama-admin/src/routes/_admin/users.index.tsx index 2e87bad..0287890 100644 --- a/apps/kurama-admin/src/routes/_admin/users.index.tsx +++ b/apps/kurama-admin/src/routes/_admin/users.index.tsx @@ -30,7 +30,7 @@ interface UserData { image: string | null createdAt: string profile: { - userType: 'student' | 'parent' | null + userType: 'student' | 'parent' | 'admin' | null firstName: string | null lastName: string | null phone: string | null @@ -55,6 +55,7 @@ interface Grade { const userTypeLabels: Record = { student: 'Élève', parent: 'Parent', + admin: 'Administrateur', } function UsersPage() { diff --git a/apps/user-application/src/components/auth/auth-screen.tsx b/apps/user-application/src/components/auth/auth-screen.tsx index ac60e50..2afc43b 100644 --- a/apps/user-application/src/components/auth/auth-screen.tsx +++ b/apps/user-application/src/components/auth/auth-screen.tsx @@ -1,3 +1,4 @@ +import { Link } from '@tanstack/react-router' import { Suspense, useState } from 'react' import { createLazyComponent } from '@/lib/lazy-helpers' import { SocialAuth } from './social-auth' @@ -83,27 +84,21 @@ export function AuthScreen() {

En continuant, vous acceptez nos {' '} - + {' '} et notre {' '} - +

diff --git a/apps/user-application/src/components/onboarding/user-type-selection.tsx b/apps/user-application/src/components/onboarding/user-type-selection.tsx index 144f8c8..bd37bf8 100644 --- a/apps/user-application/src/components/onboarding/user-type-selection.tsx +++ b/apps/user-application/src/components/onboarding/user-type-selection.tsx @@ -1,4 +1,5 @@ import type { UserType } from '@kurama/data-ops/zod-schema/profile' +import { Link } from '@tanstack/react-router' import { Button } from '@/components/ui/button' import { Card, CardContent } from '@/components/ui/card' import { ArrowRight, GraduationCap, Users } from '@/lib/icons' @@ -112,27 +113,21 @@ export function UserTypeSelection({ onSelect }: UserTypeSelectionProps) {

En continuant, vous acceptez nos {' '} - + {' '} et notre {' '} - +

diff --git a/apps/user-application/src/components/profile/profile-edit-form.tsx b/apps/user-application/src/components/profile/profile-edit-form.tsx index 28a0bd5..7310f61 100644 --- a/apps/user-application/src/components/profile/profile-edit-form.tsx +++ b/apps/user-application/src/components/profile/profile-edit-form.tsx @@ -61,7 +61,7 @@ function InputLabel({ children, htmlFor }: { children: React.ReactNode, htmlFor? export function ProfileEditForm({ profile, onBack, onSuccess }: ProfileEditFormProps) { const { toast } = useToast() const [formData, setFormData] = useState>({ - userType: profile?.userType || 'student', + userType: (profile?.userType === 'parent' ? 'parent' : 'student') as 'student' | 'parent', firstName: profile?.firstName || '', lastName: profile?.lastName || '', ...(profile?.userType === 'student' && { diff --git a/apps/user-application/src/core/functions/learning.ts b/apps/user-application/src/core/functions/learning.ts index 65d9304..bd7143d 100644 --- a/apps/user-application/src/core/functions/learning.ts +++ b/apps/user-application/src/core/functions/learning.ts @@ -1,6 +1,6 @@ import { and, asc, eq, inArray } from '@kurama/data-ops/database/drizzle-orm' import { getDb } from '@kurama/data-ops/database/setup' -import { cards, lessons, subjects, userLessonMastery } from '@kurama/data-ops/drizzle/schema' +import { cards, lessons, studySessions, subjects, userLessonMastery } from '@kurama/data-ops/drizzle/schema' import { createServerFn } from '@tanstack/react-start' import { protectedFunctionMiddleware } from '@/core/middleware/auth' import { getUserGradeId } from './utils' @@ -291,3 +291,36 @@ export const submitTestResult = createServerFn({ method: 'POST' }) nextLessonTitle, } }) + +/** + * Initialize a study session (lesson or subject level) + */ +export const startStudySession = createServerFn({ method: 'POST' }) + .middleware([protectedFunctionMiddleware]) + .inputValidator((data: { lessonId?: number, subjectId?: number, mode: string }) => { + if (!data.lessonId && !data.subjectId) { + throw new Error('Either lessonId or subjectId is required') + } + return data + }) + .handler(async ({ data, context }) => { + const db = getDb() + const userId = context.userId + const { lessonId, subjectId, mode } = data + + const [session] = await db.insert(studySessions).values({ + userId, + lessonId: lessonId ?? null, + subjectId: subjectId ?? null, + mode, + startedAt: new Date().toISOString(), + cardsReviewed: 0, + cardsCorrect: 0, + }).returning({ id: studySessions.id }) + + if (!session) { + throw new Error('Failed to create study session') + } + + return { sessionId: session.id } + }) diff --git a/apps/user-application/src/core/functions/parent.ts b/apps/user-application/src/core/functions/parent.ts index 68fd334..c21b7da 100644 --- a/apps/user-application/src/core/functions/parent.ts +++ b/apps/user-application/src/core/functions/parent.ts @@ -2,6 +2,7 @@ import { and, desc, eq, gte, inArray, sql } from '@kurama/data-ops/database/driz import { getDb } from '@kurama/data-ops/database/setup' import { lessons, + parentAlertReads, studySessions, subjects, userProfiles, @@ -209,6 +210,12 @@ export const getParentAlerts = createServerFn() const alerts: any[] = [] + // 2. Fetch read alerts for this parent + const readAlerts = await db.query.parentAlertReads.findMany({ + where: eq(parentAlertReads.parentId, parentId), + }) + const readAlertIds = new Set(readAlerts.map((r: { alertId: string }) => r.alertId)) + for (const child of children) { // Fetch child's last activity const lastSession = await db.query.studySessions.findFirst({ @@ -219,32 +226,105 @@ export const getParentAlerts = createServerFn() const lastActiveAt = lastSession ? new Date(lastSession.startedAt) : null // Inactivity Alert - if (!lastActiveAt || (Date.now() - lastActiveAt.getTime()) > (3 * 24 * 60 * 60 * 1000)) { - alerts.push({ - id: `inactivity-${child.userId}`, - type: 'warning', - title: 'Inactivité prolongée', - description: `${child.firstName} n'a pas étudié depuis ${lastActiveAt ? '3 jours' : 'longtemps'}.`, - createdAt: new Date(), - read: false, - childId: child.userId, - }) + const inactivityId = `inactivity-${child.userId}` + if (!readAlertIds.has(inactivityId)) { + if (!lastActiveAt || (Date.now() - lastActiveAt.getTime()) > (3 * 24 * 60 * 60 * 1000)) { + alerts.push({ + id: inactivityId, + type: 'warning', + title: 'Inactivité prolongée', + description: `${child.firstName} n'a pas étudié depuis ${lastActiveAt ? '3 jours' : 'longtemps'}.`, + createdAt: new Date(), + read: false, + childId: child.userId, + }) + } } // Success Alert (Streak milestone example) const streakData = await getStreakData(db, child.userId) - if (streakData.currentStreak >= 7) { - alerts.push({ - id: `streak-7-${child.userId}-${Date.now()}`, - type: 'success', - title: 'Série incroyable !', - description: `${child.firstName} a une série de ${streakData.currentStreak} jours !`, - createdAt: new Date(), - read: false, - childId: child.userId, - }) + const streakId = `streak-7-${child.userId}` + if (!readAlertIds.has(streakId)) { + if (streakData.currentStreak >= 7) { + alerts.push({ + id: streakId, + type: 'success', + title: 'Série incroyable !', + description: `${child.firstName} a une série de ${streakData.currentStreak} jours !`, + createdAt: new Date(), + read: false, + childId: child.userId, + }) + } + } + + // Academic Excellence Alert + const excellentSessions = await db.query.studySessions.findMany({ + where: and( + eq(studySessions.userId, child.userId), + sql`${studySessions.cardsCorrect}::float / NULLIF(${studySessions.cardsReviewed}, 0)::float >= 0.9`, + ), + orderBy: desc(studySessions.startedAt), + limit: 1, + }) + + if (excellentSessions.length > 0 && excellentSessions[0]) { + const session = excellentSessions[0] + const excellenceId = `excellence-${session.id}` + if (!readAlertIds.has(excellenceId)) { + alerts.push({ + id: excellenceId, + type: 'success', + title: 'Excellence académique !', + description: `${child.firstName} a obtenu un score de 90%+ dans une session récente.`, + createdAt: new Date(session.startedAt || Date.now()), + read: false, + childId: child.userId, + }) + } } } return alerts }) + +/** + * Mark a specific alert as read + */ +export const markAlertAsRead = createServerFn() + .middleware([protectedFunctionMiddleware]) + .inputValidator((alertId: string) => alertId) + .handler(async ({ context, data: alertId }) => { + const db = getDb() + const { userId: parentId } = context + + await db.insert(parentAlertReads).values({ + parentId, + alertId, + }).onConflictDoNothing() + + return { success: true } + }) + +/** + * Mark all alerts for a child or all children as read + */ +export const markAllAlertsAsRead = createServerFn() + .middleware([protectedFunctionMiddleware]) + .inputValidator((alertIds: string[]) => alertIds) + .handler(async ({ context, data: alertIds }) => { + const db = getDb() + const { userId: parentId } = context + + if (alertIds.length === 0) + return { success: true } + + await db.insert(parentAlertReads).values( + alertIds.map(id => ({ + parentId, + alertId: id, + })), + ).onConflictDoNothing() + + return { success: true } + }) diff --git a/apps/user-application/src/core/functions/progress.ts b/apps/user-application/src/core/functions/progress.ts index 0ac295b..8cd1140 100644 --- a/apps/user-application/src/core/functions/progress.ts +++ b/apps/user-application/src/core/functions/progress.ts @@ -1,10 +1,11 @@ import { and, eq, gte, sql } from '@kurama/data-ops/database/drizzle-orm' import { getDb } from '@kurama/data-ops/database/setup' -import { cards, studySessions, userProfiles } from '@kurama/data-ops/drizzle/schema' -import { getUserAchievements } from '@kurama/data-ops/queries/achievements' +import { cards, studySessions, userProfiles, userProgress } from '@kurama/data-ops/drizzle/schema' +import { markAchievementsNotified as dbMarkAchievementsNotified, getUserAchievements } from '@kurama/data-ops/queries/achievements' import { getXPLeaderboard } from '@kurama/data-ops/queries/leaderboard' import { createServerFn } from '@tanstack/react-start' import { protectedFunctionMiddleware } from '@/core/middleware/auth' +import { calculateSM2 } from '@/utils/sm2' /** * Get progress statistics for the current user @@ -130,3 +131,132 @@ export const getProgressStats = createServerFn() leaderboard, } }) + +/** + * Mark achievements as notified to prevent repeating animations + */ +export const markAchievementsAsNotified = createServerFn() + .middleware([protectedFunctionMiddleware]) + .inputValidator((achievementIds: string[]) => achievementIds) + .handler(async ({ context, data: achievementIds }) => { + const db = getDb() + const userId = context.userId + + await dbMarkAchievementsNotified(db, userId, achievementIds) + + return { success: true } + }) + +/** + * Update card progress based on SM-2 algorithm + */ +export const updateCardProgress = createServerFn({ method: 'POST' }) + .middleware([protectedFunctionMiddleware]) + .inputValidator((data: { cardId: number, quality: number, lessonId: number }[]) => data) + .handler(async ({ context, data }) => { + const db = getDb() + const userId = context.userId + + for (const item of data) { + const { cardId, quality, lessonId } = item + + // Get existing progress + const existing = await db.query.userProgress.findFirst({ + where: and( + eq(userProgress.userId, userId), + eq(userProgress.cardId, cardId), + ), + }) + + const sm2Input = { + quality, + repetitions: existing?.repetitions ?? 0, + easeFactor: existing?.easeFactor ?? 2.5, + interval: existing?.interval ?? 0, + } + + const sm2Output = calculateSM2(sm2Input) + + const values = { + userId, + cardId, + lessonId, + easeFactor: sm2Output.easeFactor, + interval: sm2Output.interval, + repetitions: sm2Output.repetitions, + lastReviewedAt: new Date().toISOString(), + nextReviewAt: sm2Output.nextReviewAt, + totalReviews: (existing?.totalReviews ?? 0) + 1, + correctReviews: (existing?.correctReviews ?? 0) + (quality >= 3 ? 1 : 0), + updatedAt: new Date().toISOString(), + } + + await db + .insert(userProgress) + .values({ + ...values, + createdAt: new Date().toISOString(), + }) + .onConflictDoUpdate({ + target: [userProgress.userId, userProgress.cardId], + set: values, + }) + } + + return { success: true } + }) + +/** + * Get count of cards due for review today + */ +export const getDueCardsCount = createServerFn({ method: 'GET' }) + .middleware([protectedFunctionMiddleware]) + .handler(async ({ context }) => { + const db = getDb() + const userId = context.userId + const now = new Date().toISOString() + + const result = await db + .select({ count: sql`count(*)` }) + .from(userProgress) + .where( + and( + eq(userProgress.userId, userId), + sql`${userProgress.nextReviewAt} <= ${now}`, + ), + ) + + return Number(result[0]?.count ?? 0) + }) + +/** + * Get cards due for review today + */ +export const getDueCards = createServerFn({ method: 'GET' }) + .middleware([protectedFunctionMiddleware]) + .handler(async ({ context }) => { + const db = getDb() + const userId = context.userId + const now = new Date().toISOString() + + const dueCards = await db + .select({ + card: cards, + progress: userProgress, + }) + .from(userProgress) + .innerJoin(cards, eq(userProgress.cardId, cards.id)) + .where( + and( + eq(userProgress.userId, userId), + sql`${userProgress.nextReviewAt} <= ${now}`, + ), + ) + .limit(50) // Limit to 50 cards per session for focus + + return dueCards.map(item => ({ + ...item.card, + metadata: (item.card.metadata ?? {}) as object, + progress: item.progress, + })) + }) diff --git a/apps/user-application/src/core/functions/review.ts b/apps/user-application/src/core/functions/review.ts index 0b3a15e..0aefa86 100644 --- a/apps/user-application/src/core/functions/review.ts +++ b/apps/user-application/src/core/functions/review.ts @@ -126,20 +126,14 @@ export const getReviewCardsCount = createServerFn({ method: 'GET' }) const userId = context.userId const now = new Date().toISOString() - // Count cards needing review + // Count cards due for review according to SM-2 const result = await db .select({ count: sql`count(*)` }) .from(userProgress) .where( and( eq(userProgress.userId, userId), - or( - lt(userProgress.easeFactor, 2500), - sql`${userProgress.nextReviewAt} <= ${now}`, - sql`CASE WHEN ${userProgress.totalReviews} > 0 - THEN (${userProgress.correctReviews}::float / ${userProgress.totalReviews}::float) < 0.7 - ELSE false END`, - ), + sql`${userProgress.nextReviewAt} <= ${now}`, ), ) diff --git a/apps/user-application/src/core/functions/stats.ts b/apps/user-application/src/core/functions/stats.ts index c153197..0c51e14 100644 --- a/apps/user-application/src/core/functions/stats.ts +++ b/apps/user-application/src/core/functions/stats.ts @@ -1,6 +1,6 @@ import { and, eq, gte, sql } from '@kurama/data-ops/database/drizzle-orm' import { getDb } from '@kurama/data-ops/database/setup' -import { lessons, studySessions, userLessonMastery, userProfiles } from '@kurama/data-ops/drizzle/schema' +import { lessons, studySessions, subjects, userLessonMastery, userProfiles } from '@kurama/data-ops/drizzle/schema' import { calculateCurrentStreak, calculateStreakBonus, @@ -78,7 +78,8 @@ function calculateLevel(xp: number): { level: number, currentXP: number, nextLev // This eliminates code duplication across dashboard.ts, profile.ts, progress.ts export interface SessionStatsInput { - lessonId: number + lessonId?: number + subjectId?: number correctCount: number totalCount: number duration: number // in seconds @@ -118,8 +119,8 @@ export interface SessionStatsResult { export const updateSessionStats = createServerFn({ method: 'POST' }) .middleware([protectedFunctionMiddleware]) .inputValidator((data: SessionStatsInput) => { - if (typeof data.lessonId !== 'number' || Number.isNaN(data.lessonId)) { - throw new TypeError('Invalid input: lessonId must be a number') + if (data.lessonId === undefined && data.subjectId === undefined) { + throw new TypeError('Invalid input: either lessonId or subjectId must be provided') } if (typeof data.correctCount !== 'number' || typeof data.totalCount !== 'number') { throw new TypeError('Invalid input: correctCount and totalCount must be numbers') @@ -132,7 +133,7 @@ export const updateSessionStats = createServerFn({ method: 'POST' }) .handler(async ({ data, context }): Promise => { const db = getDb() const userId = context.userId - const { lessonId, correctCount, totalCount, duration, mode } = data + const { lessonId, subjectId, correctCount, totalCount, duration, mode } = data // Get user's grade ID for validation const userGradeId = await getUserGradeId(db, userId) @@ -142,18 +143,26 @@ export const updateSessionStats = createServerFn({ method: 'POST' }) throw new Error('Access denied: User grade not assigned') } - // Validate that the lesson belongs to user's grade - const lesson = await db.query.lessons.findFirst({ - where: and( - eq(lessons.id, lessonId), - eq(lessons.isPublished, true), - eq(lessons.gradeId, userGradeId), - ), - columns: { id: true, subjectId: true, displayOrder: true }, - }) - - if (!lesson) { - throw new Error('Lesson not found or access denied') + // Validate that the lesson/subject belongs to user's curriculum + if (lessonId) { + const lesson = await db.query.lessons.findFirst({ + where: and( + eq(lessons.id, lessonId), + eq(lessons.isPublished, true), + eq(lessons.gradeId, userGradeId), + ), + }) + if (!lesson) { + throw new Error('Lesson not found or access denied') + } + } + else if (subjectId) { + const subject = await db.query.subjects.findFirst({ + where: eq(subjects.id, subjectId), + }) + if (!subject) { + throw new Error('Subject not found') + } } // Calculate percentage @@ -211,7 +220,8 @@ export const updateSessionStats = createServerFn({ method: 'POST' }) // Record study session await db.insert(studySessions).values({ userId, - lessonId, + lessonId: lessonId ?? null, + subjectId: subjectId ?? null, mode, startedAt: new Date(Date.now() - duration * 1000).toISOString(), endedAt: new Date().toISOString(), @@ -226,13 +236,13 @@ export const updateSessionStats = createServerFn({ method: 'POST' }) // Update cached longest streak if current streak exceeds it await updateLongestStreakIfNeeded(db, userId, currentStreak) - // Update lesson mastery if passing + // Update lesson mastery if passing and it's a lesson-specific session let masteryCount = 0 let isLessonCompleted = false let nextLessonUnlocked = false let nextLessonTitle: string | null = null - if (isPassing) { + if (isPassing && lessonId) { const existingMastery = await db.query.userLessonMastery.findFirst({ where: and( eq(userLessonMastery.userId, userId), diff --git a/apps/user-application/src/hooks/use-parent-dashboard.ts b/apps/user-application/src/hooks/use-parent-dashboard.ts index 76a0a00..5b0725f 100644 --- a/apps/user-application/src/hooks/use-parent-dashboard.ts +++ b/apps/user-application/src/hooks/use-parent-dashboard.ts @@ -1,7 +1,7 @@ -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { useAtom } from 'jotai' import { useCallback, useMemo } from 'react' -import { getChildStats, getLinkedChildren, getParentAlerts } from '@/core/functions/parent' +import { getChildStats, getLinkedChildren, getParentAlerts, markAlertAsRead, markAllAlertsAsRead } from '@/core/functions/parent' import { currentChildIdAtom } from '@/lib/atoms/parent-dashboard' /** @@ -85,15 +85,26 @@ export function useParentAlerts() { const unreadCount = alerts.filter(a => !a.read).length - const markAsRead = useCallback((_alertId: string) => { - // TODO: For now, since no backend table exists, we mock the effect - // In production, this would call a mutation - console.warn('Mark alert as read:', _alertId) - }, []) + const markReadMutation = useMutation({ + mutationFn: (alertId: string) => markAlertAsRead({ data: alertId }), + onSuccess: () => refetch(), + }) + + const markAllReadMutation = useMutation({ + mutationFn: (alertIds: string[]) => markAllAlertsAsRead({ data: alertIds }), + onSuccess: () => refetch(), + }) + + const markAsRead = useCallback((alertId: string) => { + markReadMutation.mutate(alertId) + }, [markReadMutation]) const markAllAsRead = useCallback(() => { - console.warn('Mark all alerts as read') - }, []) + const unreadIds = alerts.filter(a => !a.read).map(a => a.id) + if (unreadIds.length > 0) { + markAllReadMutation.mutate(unreadIds) + } + }, [alerts, markAllReadMutation]) return { alerts, diff --git a/apps/user-application/src/lib/atoms/user-profile.ts b/apps/user-application/src/lib/atoms/user-profile.ts index c09e29f..1d6e514 100644 --- a/apps/user-application/src/lib/atoms/user-profile.ts +++ b/apps/user-application/src/lib/atoms/user-profile.ts @@ -7,7 +7,7 @@ const isClient = typeof window !== 'undefined' // Extended profile data with additional fields from session export interface UserProfileData { - userType?: 'student' | 'parent' + userType?: 'student' | 'parent' | 'admin' firstName?: string lastName?: string email?: string diff --git a/apps/user-application/src/routeTree.gen.ts b/apps/user-application/src/routeTree.gen.ts index 0127641..0e0b0fc 100644 --- a/apps/user-application/src/routeTree.gen.ts +++ b/apps/user-application/src/routeTree.gen.ts @@ -14,6 +14,8 @@ import { Route as AuthRouteRouteImport } from './routes/_auth/route' import { Route as IndexRouteImport } from './routes/index' import { Route as ApiMetricsRouteImport } from './routes/api/metrics' import { Route as ApiHealthRouteImport } from './routes/api/health' +import { Route as PublicTermsRouteImport } from './routes/_public/terms' +import { Route as PublicPrivacyRouteImport } from './routes/_public/privacy' import { Route as AuthAppIndexRouteImport } from './routes/_auth/app/index' import { Route as ApiStudyStartRouteImport } from './routes/api/study/start' import { Route as ApiStudyProgressRouteImport } from './routes/api/study/progress' @@ -66,6 +68,16 @@ const ApiHealthRoute = ApiHealthRouteImport.update({ path: '/api/health', getParentRoute: () => rootRouteImport, } as any) +const PublicTermsRoute = PublicTermsRouteImport.update({ + id: '/_public/terms', + path: '/terms', + getParentRoute: () => rootRouteImport, +} as any) +const PublicPrivacyRoute = PublicPrivacyRouteImport.update({ + id: '/_public/privacy', + path: '/privacy', + getParentRoute: () => rootRouteImport, +} as any) const AuthAppIndexRoute = AuthAppIndexRouteImport.update({ id: '/app/', path: '/app/', @@ -213,6 +225,8 @@ const AuthAppPolarCheckoutSuccessRoute = export interface FileRoutesByFullPath { '/': typeof IndexRoute '/onboarding': typeof OnboardingRoute + '/privacy': typeof PublicPrivacyRoute + '/terms': typeof PublicTermsRoute '/api/health': typeof ApiHealthRoute '/api/metrics': typeof ApiMetricsRoute '/app/daily-challenge': typeof AuthAppDailyChallengeRoute @@ -246,6 +260,8 @@ export interface FileRoutesByFullPath { export interface FileRoutesByTo { '/': typeof IndexRoute '/onboarding': typeof OnboardingRoute + '/privacy': typeof PublicPrivacyRoute + '/terms': typeof PublicTermsRoute '/api/health': typeof ApiHealthRoute '/api/metrics': typeof ApiMetricsRoute '/app/daily-challenge': typeof AuthAppDailyChallengeRoute @@ -281,6 +297,8 @@ export interface FileRoutesById { '/': typeof IndexRoute '/_auth': typeof AuthRouteRouteWithChildren '/onboarding': typeof OnboardingRoute + '/_public/privacy': typeof PublicPrivacyRoute + '/_public/terms': typeof PublicTermsRoute '/api/health': typeof ApiHealthRoute '/api/metrics': typeof ApiMetricsRoute '/_auth/app/daily-challenge': typeof AuthAppDailyChallengeRoute @@ -316,6 +334,8 @@ export interface FileRouteTypes { fullPaths: | '/' | '/onboarding' + | '/privacy' + | '/terms' | '/api/health' | '/api/metrics' | '/app/daily-challenge' @@ -349,6 +369,8 @@ export interface FileRouteTypes { to: | '/' | '/onboarding' + | '/privacy' + | '/terms' | '/api/health' | '/api/metrics' | '/app/daily-challenge' @@ -383,6 +405,8 @@ export interface FileRouteTypes { | '/' | '/_auth' | '/onboarding' + | '/_public/privacy' + | '/_public/terms' | '/api/health' | '/api/metrics' | '/_auth/app/daily-challenge' @@ -418,6 +442,8 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AuthRouteRoute: typeof AuthRouteRouteWithChildren OnboardingRoute: typeof OnboardingRoute + PublicPrivacyRoute: typeof PublicPrivacyRoute + PublicTermsRoute: typeof PublicTermsRoute ApiHealthRoute: typeof ApiHealthRoute ApiMetricsRoute: typeof ApiMetricsRoute ApiAuthSplatRoute: typeof ApiAuthSplatRoute @@ -463,6 +489,20 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof ApiHealthRouteImport parentRoute: typeof rootRouteImport } + '/_public/terms': { + id: '/_public/terms' + path: '/terms' + fullPath: '/terms' + preLoaderRoute: typeof PublicTermsRouteImport + parentRoute: typeof rootRouteImport + } + '/_public/privacy': { + id: '/_public/privacy' + path: '/privacy' + fullPath: '/privacy' + preLoaderRoute: typeof PublicPrivacyRouteImport + parentRoute: typeof rootRouteImport + } '/_auth/app/': { id: '/_auth/app/' path: '/app' @@ -715,6 +755,8 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AuthRouteRoute: AuthRouteRouteWithChildren, OnboardingRoute: OnboardingRoute, + PublicPrivacyRoute: PublicPrivacyRoute, + PublicTermsRoute: PublicTermsRoute, ApiHealthRoute: ApiHealthRoute, ApiMetricsRoute: ApiMetricsRoute, ApiAuthSplatRoute: ApiAuthSplatRoute, diff --git a/apps/user-application/src/routes/_auth/app/index.lazy.tsx b/apps/user-application/src/routes/_auth/app/index.lazy.tsx index 75956a0..9b0807d 100644 --- a/apps/user-application/src/routes/_auth/app/index.lazy.tsx +++ b/apps/user-application/src/routes/_auth/app/index.lazy.tsx @@ -260,6 +260,42 @@ function AppHome() { + {/* SM-2 Due Today Card (if count > 0) */} + {reviewCount > 0 && ( + +
+
+ +
+
+
+
+ Révision Spacée +
+

À réviser aujourd'hui

+

+ {reviewCount} + {' '} + carte + {reviewCount > 1 ? 's sont' : ' est'} + {' '} + prête + {reviewCount > 1 ? 's' : ''} + {' '} + pour un ancrage mémoriel optimal. +

+ +
+
+ + )} + {/* Quick Actions Grid */}
diff --git a/apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx b/apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx index a5c6da5..70b6fc5 100644 --- a/apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx +++ b/apps/user-application/src/routes/_auth/app/lesson-session.$lessonId.tsx @@ -22,6 +22,7 @@ import { Button } from '@/components/ui/button' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty' import { LogoLoader } from '@/components/ui/logo-loader' import { getLessonDetails } from '@/core/functions/learning' +import { updateCardProgress } from '@/core/functions/progress' import { useAutoplay } from '@/hooks/use-autoplay' import { useCardHeight } from '@/hooks/use-card-height' import { useCardSwipeAnimations } from '@/hooks/use-card-swipe-animations' @@ -105,6 +106,9 @@ function SessionPage() { } }) + // Track individual card results for SM-2 + const [cardResults, setCardResults] = useState<{ cardId: number, quality: number, lessonId: number }[]>([]) + // Track navigation state to prevent flash const [isNavigatingToSummary, setIsNavigatingToSummary] = useState(false) @@ -252,6 +256,11 @@ function SessionPage() { mode: mode as 'flashcards' | 'quiz' | 'exam', }) + // Submit card-level results for SM-2 + if (cardResults.length > 0) { + await updateCardProgress({ data: cardResults }) + } + navigate({ to: '/app/lesson-summary/$lessonId', params: { lessonId }, @@ -304,6 +313,27 @@ function SessionPage() { : prev.struggledAnswers || 0, })) + // Track result for SM-2 + if (currentCard) { + let quality = 0 + if (response === 'correct') { + if ((timeSpent || 0) < 3) + quality = 5 + else if ((timeSpent || 0) < 8) + quality = 4 + else quality = 3 + } + else { + quality = 0 // Incorrect + } + + setCardResults(prev => [...prev, { + cardId: currentCard.id, + quality, + lessonId: Number(lessonId), + }]) + } + // Trigger vibration feedback based on mode and response if (response === 'correct') { if (mode === 'flashcards') { diff --git a/apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx b/apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx index e33b331..cb294ff 100644 --- a/apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx +++ b/apps/user-application/src/routes/_auth/app/lesson-summary.$lessonId.tsx @@ -1,6 +1,6 @@ import type { AchievementWithProgress } from '@kurama/data-ops/queries/achievements' import { ACHIEVEMENTS } from '@kurama/data-ops/queries/achievements' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { createFileRoute, useNavigate, useParams, useSearch } from '@tanstack/react-router' import { Check, @@ -18,6 +18,7 @@ import { EnhancedXPDisplay } from '@/components/gamification' import { AchievementUnlockToast } from '@/components/gamification/achievement-unlock-toast' import { AppHeader } from '@/components/main' import { Button } from '@/components/ui/button' +import { markAchievementsAsNotified } from '@/core/functions/progress' import { getUserStats } from '@/core/functions/stats' import { useOnlineStatus } from '@/hooks/use-online-status' import { authClient } from '@/lib/auth-client' @@ -91,6 +92,10 @@ function SummaryPage() { const userId = session.data?.user?.id const [newlyUnlockedAchievements, setNewlyUnlockedAchievements] = useState([]) + const markNotifiedMutation = useMutation({ + mutationFn: (ids: string[]) => markAchievementsAsNotified({ data: ids }), + }) + useEffect(() => { if (achievements && userId) { const unlockedIds = achievements.split(',') @@ -415,7 +420,13 @@ function SummaryPage() { {/* Delayed Achievement Notification */} setNewlyUnlockedAchievements([])} + onDismiss={() => { + const ids = newlyUnlockedAchievements.map(a => a.id) + setNewlyUnlockedAchievements([]) + if (ids.length > 0) { + markNotifiedMutation.mutate(ids) + } + }} />
) diff --git a/apps/user-application/src/routes/_auth/app/parent/index.tsx b/apps/user-application/src/routes/_auth/app/parent/index.tsx index a5fa104..8a6cac4 100644 --- a/apps/user-application/src/routes/_auth/app/parent/index.tsx +++ b/apps/user-application/src/routes/_auth/app/parent/index.tsx @@ -7,6 +7,7 @@ import { ParentBottomNav, ParentHeader, StreakCard, + SubjectPerformanceGrid, WeeklyStudyCard, } from '@/components/parent-dashboard' import { useParentDashboard } from '@/hooks' @@ -99,6 +100,19 @@ function ParentDashboard() { />
+ {/* Subject Performance Grid */} + +

+ Performance par Matière +

+ +
+ {/* Quick Summary Section */} markAchievementsAsNotified({ data: ids }), + }) + useEffect(() => { - if (data?.newlyUnlocked && data.newlyUnlocked.length > 0 && userId) { - const storageKey = `seen_achievements_${userId}` - - try { - // Read previously seen achievements - const stored = localStorage.getItem(storageKey) - const seenIds = new Set(stored ? JSON.parse(stored) : []) - - // Filter out achievements that have been seen - const trulyNew = data.newlyUnlocked.filter(a => !seenIds.has(a.id)) - - if (trulyNew.length > 0) { - setNewlyUnlockedAchievements(trulyNew) - - // Update localStorage immediately to mark these as seen - const updatedSeenIds = [...Array.from(seenIds), ...trulyNew.map(a => a.id)] - localStorage.setItem(storageKey, JSON.stringify(updatedSeenIds)) - } - } - catch (error) { - console.error('Error accessing localStorage for achievements:', error) - // Fallback: show them all if storage fails - setNewlyUnlockedAchievements(data.newlyUnlocked) - } + if (data?.newlyUnlocked && data.newlyUnlocked.length > 0) { + setNewlyUnlockedAchievements(data.newlyUnlocked) } - }, [data?.newlyUnlocked, userId]) + }, [data?.newlyUnlocked]) const handleDismissAchievements = (achievementIds: string[]) => { setNewlyUnlockedAchievements([]) - // TODO: Call markAchievementsNotified API - console.warn('Marking achievements as notified:', achievementIds) + markNotifiedMutation.mutate(achievementIds) } // Animation variants diff --git a/apps/user-application/src/routes/_auth/app/quick-review.tsx b/apps/user-application/src/routes/_auth/app/quick-review.tsx index 2fb022e..56a2643 100644 --- a/apps/user-application/src/routes/_auth/app/quick-review.tsx +++ b/apps/user-application/src/routes/_auth/app/quick-review.tsx @@ -12,6 +12,7 @@ import { AppHeader } from '@/components/main' import { Button } from '@/components/ui/button' import { Empty, EmptyDescription, EmptyHeader, EmptyMedia, EmptyTitle } from '@/components/ui/empty' import { LogoLoader } from '@/components/ui/logo-loader' +import { updateCardProgress } from '@/core/functions/progress' import { getCardsForReview } from '@/core/functions/review' import { useAutoplay } from '@/hooks/use-autoplay' import { useCardHeight } from '@/hooks/use-card-height' @@ -62,6 +63,9 @@ function QuickReviewPage() { resetSession, } = useSessionState() + // Track individual card results for SM-2 + const [cardResults, setCardResults] = useState<{ cardId: number, quality: number, lessonId: number }[]>([]) + // Animation and layout const viewportHeight = useViewportHeight() const cardHeight = useCardHeight(viewportHeight) @@ -145,6 +149,11 @@ function QuickReviewPage() { }) } + // Submit card-level results for SM-2 + if (cardResults.length > 0) { + await updateCardProgress({ data: cardResults }) + } + navigate({ to: '/app/lesson-summary/$lessonId', params: { lessonId: String(lessonId || 'review') }, @@ -158,7 +167,7 @@ function QuickReviewPage() { }, }) }, - [navigate, startTime, sessionStats, updateStats], + [navigate, startTime, sessionStats, updateStats, cardResults], ) const handleFlip = useCallback(() => { @@ -171,6 +180,16 @@ function QuickReviewPage() { (response: 'correct' | 'incorrect') => { incrementStat(response) + // Track result for SM-2 + if (currentCard) { + const quality = response === 'correct' ? 4 : 0 + setCardResults(prev => [...prev, { + cardId: currentCard.id, + quality, + lessonId: currentCard.lessonId, + }]) + } + if (isLastCard) { const finalCorrect = response === 'correct' ? sessionStats.correct + 1 : sessionStats.correct const finalIncorrect = response === 'incorrect' ? sessionStats.incorrect + 1 : sessionStats.incorrect @@ -184,6 +203,7 @@ function QuickReviewPage() { }, [ cards, + currentCard, incrementStat, isLastCard, navigateToSummary, diff --git a/apps/user-application/src/routes/_auth/app/referrals.tsx b/apps/user-application/src/routes/_auth/app/referrals.tsx index 4fb8bf7..0032d0b 100644 --- a/apps/user-application/src/routes/_auth/app/referrals.tsx +++ b/apps/user-application/src/routes/_auth/app/referrals.tsx @@ -21,7 +21,8 @@ function ReferralsPage() { // window.history.length > 2 usually implies we have somewhere to go back to (current + previous + root) if (window.history.length > 2) { router.history.back() - } else { + } + else { // Fallback to dashboard router.navigate({ to: '/app' }) } diff --git a/apps/user-application/src/routes/_public/privacy.tsx b/apps/user-application/src/routes/_public/privacy.tsx new file mode 100644 index 0000000..f5a3ea8 --- /dev/null +++ b/apps/user-application/src/routes/_public/privacy.tsx @@ -0,0 +1,84 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { ArrowLeft, ShieldCheck } from 'lucide-react' +import { motion } from 'motion/react' + +export const Route = createFileRoute('/_public/privacy')({ + component: PrivacyPage, +}) + +function PrivacyPage() { + return ( +
+ + + + Retour à l'accueil + + +
+
+ +
+

Politique de Confidentialité

+
+ +
+
+

1. Collecte des Données

+

+ Nous collectons les informations que vous fournissez directement lors de la création de votre compte, telles que votre nom, adresse e-mail et informations de profil éducatif. Nous collectons également des données sur votre progression d'apprentissage. +

+
+ +
+

2. Utilisation des Données

+

+ Vos données sont principalement utilisées pour personnaliser votre expérience d'apprentissage, suivre vos progrès, et vous fournir des statistiques pertinentes. Les parents peuvent accéder aux données de progression de leurs enfants liés. +

+
+ +
+

3. Partage des Données

+

+ Nous ne vendons pas vos données personnelles à des tiers. Les données peuvent être partagées avec des prestataires de services techniques nécessaires au fonctionnement de la plateforme (hébergement, authentification). +

+
+ +
+

4. Sécurité

+

+ Nous mettons en œuvre des mesures de sécurité rigoureuses pour protéger vos informations contre tout accès, modification ou divulgation non autorisés. +

+
+ +
+

5. Vos Droits

+

+ Conformément à la réglementation sur la protection des données, vous avez le droit d'accéder à vos informations, de les rectifier ou de demander leur suppression en nous contactant. +

+
+ +
+

6. Cookies

+

+ Nous utilisons des cookies pour maintenir votre session active et analyser l'utilisation de la plateforme afin d'améliorer nos services. +

+
+ +
+ Dernière mise à jour : + {' '} + {new Date().toLocaleDateString('fr-FR')} +
+
+
+
+ ) +} diff --git a/apps/user-application/src/routes/_public/terms.tsx b/apps/user-application/src/routes/_public/terms.tsx new file mode 100644 index 0000000..99c3352 --- /dev/null +++ b/apps/user-application/src/routes/_public/terms.tsx @@ -0,0 +1,84 @@ +import { createFileRoute, Link } from '@tanstack/react-router' +import { ArrowLeft, ScrollText } from 'lucide-react' +import { motion } from 'motion/react' + +export const Route = createFileRoute('/_public/terms')({ + component: TermsPage, +}) + +function TermsPage() { + return ( +
+ + + + Retour à l'accueil + + +
+
+ +
+

Conditions d'Utilisation

+
+ +
+
+

1. Acceptation des Conditions

+

+ En accédant et en utilisant Kurama, vous acceptez d'être lié par les présentes conditions d'utilisation. Si vous n'acceptez pas ces conditions, veuillez ne pas utiliser notre service. +

+
+ +
+

2. Description du Service

+

+ Kurama est une plateforme d'apprentissage éducative conçue pour aider les élèves à réviser et à progresser dans leurs études. Le service comprend l'accès à des leçons, des cartes de révision et des outils de suivi de progression. +

+
+ +
+

3. Comptes Utilisateurs

+

+ Pour accéder à certaines fonctionnalités, vous devez créer un compte. Vous êtes responsable du maintien de la confidentialité de vos identifiants et de toutes les activités qui se déroulent sous votre compte. Les parents sont responsables des comptes de leurs enfants. +

+
+ +
+

4. Propriété Intellectuelle

+

+ Tout le contenu présent sur Kurama, y compris les textes, graphiques, logos et leçons, est la propriété de Kurama ou de ses concédants de licence et est protégé par les lois sur la propriété intellectuelle. +

+
+ +
+

5. Résiliation

+

+ Nous nous réservons le droit de suspendre ou de résilier votre compte à tout moment, sans préavis, en cas de violation des présentes conditions ou pour toute autre raison que nous jugerions nécessaire. +

+
+ +
+

6. Modifications des Conditions

+

+ Kurama peut modifier ces conditions périodiquement. Nous vous informerons de tout changement important par e-mail ou via l'application. +

+
+ + +
+
+
+ ) +} diff --git a/apps/user-application/src/routes/api/study/$sessionId.ts b/apps/user-application/src/routes/api/study/$sessionId.ts index e925425..70ef70b 100644 --- a/apps/user-application/src/routes/api/study/$sessionId.ts +++ b/apps/user-application/src/routes/api/study/$sessionId.ts @@ -1,6 +1,6 @@ -import { eq } from '@kurama/data-ops/database/drizzle-orm' +import { eq, inArray } from '@kurama/data-ops/database/drizzle-orm' import { getDb } from '@kurama/data-ops/database/setup' -import { cards, studySessions } from '@kurama/data-ops/drizzle/schema' +import { cards, lessons, studySessions } from '@kurama/data-ops/drizzle/schema' import { createFileRoute } from '@tanstack/react-router' export const Route = createFileRoute('/api/study/$sessionId')({ @@ -24,7 +24,6 @@ export const Route = createFileRoute('/api/study/$sessionId')({ } // Get Cards - // Logic depends on session mode and lessonId let sessionCards: typeof cards.$inferSelect[] = [] if (session.lessonId) { @@ -33,9 +32,26 @@ export const Route = createFileRoute('/api/study/$sessionId')({ orderBy: (cards, { asc }) => [asc(cards.displayOrder)], }) } - else { - // TODO: Handle subject-level or deck-level sessions - // For now return empty or mock + else if (session.subjectId) { + // TRANSVERSAL REVIEW: Get cards from all lessons in this subject + // Note: In a real app, we'd filter by user's grade/series here too + // But since studySessions are created for a specific user, + // the subjectId should already imply their curriculum. + + const subjectLessons = await db.query.lessons.findMany({ + where: eq(lessons.subjectId, session.subjectId), + columns: { id: true }, + }) + + const lessonIds = subjectLessons.map(l => l.id) + + if (lessonIds.length > 0) { + sessionCards = await db.query.cards.findMany({ + where: inArray(cards.lessonId, lessonIds), + orderBy: (cards, { asc }) => [asc(cards.lessonId), asc(cards.displayOrder)], + limit: 50, // Limit for broad subject sessions to avoid overload + }) + } } // Filter cards based on mode if needed (e.g. only multichoice for Quiz mode) diff --git a/apps/user-application/src/utils/sm2.ts b/apps/user-application/src/utils/sm2.ts new file mode 100644 index 0000000..191bb64 --- /dev/null +++ b/apps/user-application/src/utils/sm2.ts @@ -0,0 +1,65 @@ +export interface SM2Input { + quality: number // 0-5 + repetitions: number // n + easeFactor: number // EF (stored as integer * 1000 in DB for precision, or as float) + interval: number // I (in days) +} + +export interface SM2Output { + repetitions: number + easeFactor: number + interval: number + nextReviewAt: string +} + +/** + * SuperMemo-2 Spaced Repetition Algorithm + * + * @param input Current SM2 state and assessment quality + * @returns Updated SM2 state and next review date + */ +export function calculateSM2(input: SM2Input): SM2Output { + const { quality, easeFactor: efPrev } = input + let { repetitions: n, interval: i } = input + let ef = efPrev + + // Minimum ease factor is 1.3 + const MIN_EF = 1.3 + + if (quality >= 3) { + // Correct response + if (n === 0) { + i = 1 + } + else if (n === 1) { + i = 6 + } + else { + i = Math.ceil(i * ef) + } + n += 1 + } + else { + // Incorrect response + n = 0 + i = 1 + } + + // Update ease factor: EF = EF + (0.1 - (5-q) * (0.08 + (5-q) * 0.02)) + ef = ef + (0.1 - (5 - quality) * (0.08 + (5 - quality) * 0.02)) + if (ef < MIN_EF) + ef = MIN_EF + + // Calculate next review date + const nextReviewAt = new Date() + nextReviewAt.setDate(nextReviewAt.getDate() + i) + // Set to beginning of day for consistency + nextReviewAt.setHours(0, 0, 0, 0) + + return { + repetitions: n, + easeFactor: ef, + interval: i, + nextReviewAt: nextReviewAt.toISOString(), + } +} diff --git a/packages/data-ops/src/drizzle/0011_real_pyro.sql b/packages/data-ops/src/drizzle/0011_real_pyro.sql new file mode 100644 index 0000000..efb25a4 --- /dev/null +++ b/packages/data-ops/src/drizzle/0011_real_pyro.sql @@ -0,0 +1,10 @@ +CREATE TABLE "parent_alert_reads" ( + "id" serial PRIMARY KEY NOT NULL, + "parent_id" text NOT NULL, + "alert_id" text NOT NULL, + "read_at" timestamp DEFAULT now() NOT NULL, + CONSTRAINT "parent_alert_reads_unique" UNIQUE("parent_id","alert_id") +); +--> statement-breakpoint +ALTER TABLE "parent_alert_reads" ADD CONSTRAINT "parent_alert_reads_parent_id_auth_user_id_fk" FOREIGN KEY ("parent_id") REFERENCES "public"."auth_user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_parent_alert_reads_parent_id" ON "parent_alert_reads" USING btree ("parent_id"); \ No newline at end of file diff --git a/packages/data-ops/src/drizzle/0012_burly_karnak.sql b/packages/data-ops/src/drizzle/0012_burly_karnak.sql new file mode 100644 index 0000000..9d8dc48 --- /dev/null +++ b/packages/data-ops/src/drizzle/0012_burly_karnak.sql @@ -0,0 +1 @@ +ALTER TABLE "user_achievements" ADD COLUMN "notified_at" timestamp; \ No newline at end of file diff --git a/packages/data-ops/src/drizzle/meta/0011_snapshot.json b/packages/data-ops/src/drizzle/meta/0011_snapshot.json new file mode 100644 index 0000000..fa2bab3 --- /dev/null +++ b/packages/data-ops/src/drizzle/meta/0011_snapshot.json @@ -0,0 +1,2663 @@ +{ + "id": "d3a8ecce-df46-4a17-b1d2-298bfbeb40d0", + "prevId": "815b504e-605a-4ac2-a3e3-9863747f3815", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verification": { + "name": "auth_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "front_content": { + "name": "front_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back_content": { + "name": "back_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_type": { + "name": "card_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'basic'" + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "correct_answer": { + "name": "correct_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hints": { + "name": "hints", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "time_limit": { + "name": "time_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cards_lesson_id_lessons_id_fk": { + "name": "cards_lesson_id_lessons_id_fk", + "tableFrom": "cards", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discount_usage": { + "name": "discount_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discount_code": { + "name": "discount_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_amount": { + "name": "discount_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_discount_usage_user": { + "name": "idx_discount_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discount_usage_user_id_auth_user_id_fk": { + "name": "discount_usage_user_id_auth_user_id_fk", + "tableFrom": "discount_usage", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "discount_usage_order_id_orders_id_fk": { + "name": "discount_usage_order_id_orders_id_fk", + "tableFrom": "discount_usage", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "discount_usage_user_code_unique": { + "name": "discount_usage_user_code_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "discount_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grades": { + "name": "grades", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "grades_name_unique": { + "name": "grades_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "grades_slug_unique": { + "name": "grades_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_mode_configs": { + "name": "learning_mode_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "mode_name": { + "name": "mode_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "supported_types": { + "name": "supported_types", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "learning_mode_configs_mode_name_unique": { + "name": "learning_mode_configs_mode_name_unique", + "nullsNotDistinct": false, + "columns": [ + "mode_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lessons": { + "name": "lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_duration": { + "name": "estimated_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "teach_plan": { + "name": "teach_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "teach_plan_generated_at": { + "name": "teach_plan_generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "teach_plan_metadata": { + "name": "teach_plan_metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lessons_subject_id_subjects_id_fk": { + "name": "lessons_subject_id_subjects_id_fk", + "tableFrom": "lessons", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lessons_grade_id_grades_id_fk": { + "name": "lessons_grade_id_grades_id_fk", + "tableFrom": "lessons", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lessons_series_id_series_id_fk": { + "name": "lessons_series_id_series_id_fk", + "tableFrom": "lessons", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lessons_author_id_auth_user_id_fk": { + "name": "lessons_author_id_auth_user_id_fk", + "tableFrom": "lessons", + "tableTo": "auth_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lessons_content_chunks": { + "name": "lessons_content_chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_lessons_content_chunks_file_id": { + "name": "idx_lessons_content_chunks_file_id", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lessons_content_chunks_file_id_fk": { + "name": "lessons_content_chunks_file_id_fk", + "tableFrom": "lessons_content_chunks", + "tableTo": "lessons_content_file", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lessons_content_file": { + "name": "lessons_content_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_title": { + "name": "file_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pdf'" + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_embeddings": { + "name": "has_embeddings", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_chunks": { + "name": "total_chunks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "extracted_text": { + "name": "extracted_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_subject_wide": { + "name": "is_subject_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_lessons_content_lesson_id": { + "name": "idx_lessons_content_lesson_id", + "columns": [ + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_lessons_content_subject_wide": { + "name": "idx_lessons_content_subject_wide", + "columns": [ + { + "expression": "is_subject_wide", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grade_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_lessons_content_created_at": { + "name": "idx_lessons_content_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lessons_content_file_lesson_id_lessons_id_fk": { + "name": "lessons_content_file_lesson_id_lessons_id_fk", + "tableFrom": "lessons_content_file", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lessons_content_file_subject_id_subjects_id_fk": { + "name": "lessons_content_file_subject_id_subjects_id_fk", + "tableFrom": "lessons_content_file", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lessons_content_file_grade_id_grades_id_fk": { + "name": "lessons_content_file_grade_id_grades_id_fk", + "tableFrom": "lessons_content_file", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lessons_content_file_series_id_series_id_fk": { + "name": "lessons_content_file_series_id_series_id_fk", + "tableFrom": "lessons_content_file", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.level_series": { + "name": "level_series", + "schema": "", + "columns": { + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "level_series_grade_id_grades_id_fk": { + "name": "level_series_grade_id_grades_id_fk", + "tableFrom": "level_series", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "level_series_series_id_series_id_fk": { + "name": "level_series_series_id_series_id_fk", + "tableFrom": "level_series", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "level_series_grade_id_series_id_pk": { + "name": "level_series_grade_id_series_id_pk", + "columns": [ + "grade_id", + "series_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_reason": { + "name": "billing_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_orders_user_id": { + "name": "idx_orders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orders_subscription_id": { + "name": "idx_orders_subscription_id", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orders_status": { + "name": "idx_orders_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_auth_user_id_fk": { + "name": "orders_user_id_auth_user_id_fk", + "tableFrom": "orders", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orders_subscription_id_subscriptions_id_fk": { + "name": "orders_subscription_id_subscriptions_id_fk", + "tableFrom": "orders", + "tableTo": "subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parent_alert_reads": { + "name": "parent_alert_reads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_parent_alert_reads_parent_id": { + "name": "idx_parent_alert_reads_parent_id", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "parent_alert_reads_parent_id_auth_user_id_fk": { + "name": "parent_alert_reads_parent_id_auth_user_id_fk", + "tableFrom": "parent_alert_reads", + "tableTo": "auth_user", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "parent_alert_reads_unique": { + "name": "parent_alert_reads_unique", + "nullsNotDistinct": false, + "columns": [ + "parent_id", + "alert_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referrals": { + "name": "referrals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_user_id": { + "name": "referred_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reward_amount": { + "name": "reward_amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rewarded_at": { + "name": "rewarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_referrals_referrer": { + "name": "idx_referrals_referrer", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_referrals_code": { + "name": "idx_referrals_code", + "columns": [ + { + "expression": "referral_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referrals_referrer_user_id_auth_user_id_fk": { + "name": "referrals_referrer_user_id_auth_user_id_fk", + "tableFrom": "referrals", + "tableTo": "auth_user", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referrals_referred_user_id_auth_user_id_fk": { + "name": "referrals_referred_user_id_auth_user_id_fk", + "tableFrom": "referrals", + "tableTo": "auth_user", + "columnsFrom": [ + "referred_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referrals_referral_code_unique": { + "name": "referrals_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.series": { + "name": "series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "series_name_unique": { + "name": "series_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.study_sessions": { + "name": "study_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cards_reviewed": { + "name": "cards_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cards_correct": { + "name": "cards_correct", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_study_sessions_user_started": { + "name": "idx_study_sessions_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_sessions_user_id_auth_user_id_fk": { + "name": "study_sessions_user_id_auth_user_id_fk", + "tableFrom": "study_sessions", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "study_sessions_lesson_id_lessons_id_fk": { + "name": "study_sessions_lesson_id_lessons_id_fk", + "tableFrom": "study_sessions", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject_offerings": { + "name": "subject_offerings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "coefficient": { + "name": "coefficient", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "subject_offerings_grade_id_grades_id_fk": { + "name": "subject_offerings_grade_id_grades_id_fk", + "tableFrom": "subject_offerings", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subject_offerings_subject_id_subjects_id_fk": { + "name": "subject_offerings_subject_id_subjects_id_fk", + "tableFrom": "subject_offerings", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subject_offerings_series_id_series_id_fk": { + "name": "subject_offerings_series_id_series_id_fk", + "tableFrom": "subject_offerings", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subjects": { + "name": "subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbreviation": { + "name": "abbreviation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subjects_name_unique": { + "name": "subjects_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "subjects_abbreviation_unique": { + "name": "subjects_abbreviation_unique", + "nullsNotDistinct": false, + "columns": [ + "abbreviation" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscriptions_user_id": { + "name": "idx_subscriptions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscriptions_status": { + "name": "idx_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_auth_user_id_fk": { + "name": "subscriptions_user_id_auth_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_achievements": { + "name": "user_achievements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notified": { + "name": "notified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_achievements_user_id": { + "name": "idx_user_achievements_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_achievements_unlocked_at": { + "name": "idx_user_achievements_unlocked_at", + "columns": [ + { + "expression": "unlocked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_achievements_user_id_auth_user_id_fk": { + "name": "user_achievements_user_id_auth_user_id_fk", + "tableFrom": "user_achievements", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_achievements_user_achievement_unique": { + "name": "user_achievements_user_achievement_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "achievement_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_lesson_mastery": { + "name": "user_lesson_mastery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "successful_test_count": { + "name": "successful_test_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_test_score": { + "name": "last_test_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_unlocked": { + "name": "is_unlocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_lesson_mastery_user_id_auth_user_id_fk": { + "name": "user_lesson_mastery_user_id_auth_user_id_fk", + "tableFrom": "user_lesson_mastery", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_lesson_mastery_lesson_id_lessons_id_fk": { + "name": "user_lesson_mastery_lesson_id_lessons_id_fk", + "tableFrom": "user_lesson_mastery", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_lesson_mastery_user_id_lesson_id_unique": { + "name": "user_lesson_mastery_user_id_lesson_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "lesson_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_type": { + "name": "user_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_number": { + "name": "id_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "favorite_subjects": { + "name": "favorite_subjects", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "learning_goals": { + "name": "learning_goals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "study_time": { + "name": "study_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "children_matricules": { + "name": "children_matricules", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "xp": { + "name": "xp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "longest_streak": { + "name": "longest_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streak_freeze_count": { + "name": "streak_freeze_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_streak_freeze_used_at": { + "name": "last_streak_freeze_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "subscription_tier": { + "name": "subscription_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_expires_at": { + "name": "subscription_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referred_by": { + "name": "referred_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profiles_user_id_auth_user_id_fk": { + "name": "user_profiles_user_id_auth_user_id_fk", + "tableFrom": "user_profiles", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_profiles_grade_id_grades_id_fk": { + "name": "user_profiles_grade_id_grades_id_fk", + "tableFrom": "user_profiles", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_profiles_series_id_series_id_fk": { + "name": "user_profiles_series_id_series_id_fk", + "tableFrom": "user_profiles", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_referral_code_unique": { + "name": "user_profiles_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_progress": { + "name": "user_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ease_factor": { + "name": "ease_factor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2500 + }, + "interval": { + "name": "interval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "repetitions": { + "name": "repetitions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_review_at": { + "name": "next_review_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_reviews": { + "name": "total_reviews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "correct_reviews": { + "name": "correct_reviews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_progress_user_id_auth_user_id_fk": { + "name": "user_progress_user_id_auth_user_id_fk", + "tableFrom": "user_progress", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_progress_card_id_cards_id_fk": { + "name": "user_progress_card_id_cards_id_fk", + "tableFrom": "user_progress", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_progress_lesson_id_lessons_id_fk": { + "name": "user_progress_lesson_id_lessons_id_fk", + "tableFrom": "user_progress", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/data-ops/src/drizzle/meta/0012_snapshot.json b/packages/data-ops/src/drizzle/meta/0012_snapshot.json new file mode 100644 index 0000000..ba08cad --- /dev/null +++ b/packages/data-ops/src/drizzle/meta/0012_snapshot.json @@ -0,0 +1,2669 @@ +{ + "id": "321d322c-cfcb-4e11-85ca-7c805f00eb20", + "prevId": "d3a8ecce-df46-4a17-b1d2-298bfbeb40d0", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.auth_account": { + "name": "auth_account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_account_user_id_auth_user_id_fk": { + "name": "auth_account_user_id_auth_user_id_fk", + "tableFrom": "auth_account", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_session": { + "name": "auth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "auth_session_user_id_auth_user_id_fk": { + "name": "auth_session_user_id_auth_user_id_fk", + "tableFrom": "auth_session", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_session_token_unique": { + "name": "auth_session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_user": { + "name": "auth_user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "auth_user_email_unique": { + "name": "auth_user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.auth_verification": { + "name": "auth_verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cards": { + "name": "cards", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "front_content": { + "name": "front_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "back_content": { + "name": "back_content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_type": { + "name": "card_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'basic'" + }, + "question": { + "name": "question", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "options": { + "name": "options", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "correct_answer": { + "name": "correct_answer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "explanation": { + "name": "explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "hints": { + "name": "hints", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "time_limit": { + "name": "time_limit", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "points": { + "name": "points", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 10 + }, + "difficulty": { + "name": "difficulty", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "cards_lesson_id_lessons_id_fk": { + "name": "cards_lesson_id_lessons_id_fk", + "tableFrom": "cards", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discount_usage": { + "name": "discount_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "discount_code": { + "name": "discount_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_id": { + "name": "order_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "discount_amount": { + "name": "discount_amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_discount_usage_user": { + "name": "idx_discount_usage_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discount_usage_user_id_auth_user_id_fk": { + "name": "discount_usage_user_id_auth_user_id_fk", + "tableFrom": "discount_usage", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "discount_usage_order_id_orders_id_fk": { + "name": "discount_usage_order_id_orders_id_fk", + "tableFrom": "discount_usage", + "tableTo": "orders", + "columnsFrom": [ + "order_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "discount_usage_user_code_unique": { + "name": "discount_usage_user_code_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "discount_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.grades": { + "name": "grades", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "grades_name_unique": { + "name": "grades_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "grades_slug_unique": { + "name": "grades_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.learning_mode_configs": { + "name": "learning_mode_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "mode_name": { + "name": "mode_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "supported_types": { + "name": "supported_types", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "settings": { + "name": "settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "learning_mode_configs_mode_name_unique": { + "name": "learning_mode_configs_mode_name_unique", + "nullsNotDistinct": false, + "columns": [ + "mode_name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lessons": { + "name": "lessons", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "estimated_duration": { + "name": "estimated_duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_published": { + "name": "is_published", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "published_at": { + "name": "published_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "teach_plan": { + "name": "teach_plan", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "teach_plan_generated_at": { + "name": "teach_plan_generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "teach_plan_metadata": { + "name": "teach_plan_metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "lessons_subject_id_subjects_id_fk": { + "name": "lessons_subject_id_subjects_id_fk", + "tableFrom": "lessons", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lessons_grade_id_grades_id_fk": { + "name": "lessons_grade_id_grades_id_fk", + "tableFrom": "lessons", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lessons_series_id_series_id_fk": { + "name": "lessons_series_id_series_id_fk", + "tableFrom": "lessons", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lessons_author_id_auth_user_id_fk": { + "name": "lessons_author_id_auth_user_id_fk", + "tableFrom": "lessons", + "tableTo": "auth_user", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lessons_content_chunks": { + "name": "lessons_content_chunks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "file_id": { + "name": "file_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "page_number": { + "name": "page_number", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(768)", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'::json" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_lessons_content_chunks_file_id": { + "name": "idx_lessons_content_chunks_file_id", + "columns": [ + { + "expression": "file_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lessons_content_chunks_file_id_fk": { + "name": "lessons_content_chunks_file_id_fk", + "tableFrom": "lessons_content_chunks", + "tableTo": "lessons_content_file", + "columnsFrom": [ + "file_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.lessons_content_file": { + "name": "lessons_content_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_name": { + "name": "file_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_title": { + "name": "file_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_type": { + "name": "file_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pdf'" + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "has_embeddings": { + "name": "has_embeddings", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "total_chunks": { + "name": "total_chunks", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "extracted_text": { + "name": "extracted_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_subject_wide": { + "name": "is_subject_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_lessons_content_lesson_id": { + "name": "idx_lessons_content_lesson_id", + "columns": [ + { + "expression": "lesson_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_lessons_content_subject_wide": { + "name": "idx_lessons_content_subject_wide", + "columns": [ + { + "expression": "is_subject_wide", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "grade_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_lessons_content_created_at": { + "name": "idx_lessons_content_created_at", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "lessons_content_file_lesson_id_lessons_id_fk": { + "name": "lessons_content_file_lesson_id_lessons_id_fk", + "tableFrom": "lessons_content_file", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lessons_content_file_subject_id_subjects_id_fk": { + "name": "lessons_content_file_subject_id_subjects_id_fk", + "tableFrom": "lessons_content_file", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "lessons_content_file_grade_id_grades_id_fk": { + "name": "lessons_content_file_grade_id_grades_id_fk", + "tableFrom": "lessons_content_file", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "lessons_content_file_series_id_series_id_fk": { + "name": "lessons_content_file_series_id_series_id_fk", + "tableFrom": "lessons_content_file", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.level_series": { + "name": "level_series", + "schema": "", + "columns": { + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "level_series_grade_id_grades_id_fk": { + "name": "level_series_grade_id_grades_id_fk", + "tableFrom": "level_series", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "level_series_series_id_series_id_fk": { + "name": "level_series_series_id_series_id_fk", + "tableFrom": "level_series", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "level_series_grade_id_series_id_pk": { + "name": "level_series_grade_id_series_id_pk", + "columns": [ + "grade_id", + "series_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.orders": { + "name": "orders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'usd'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "checkout_id": { + "name": "checkout_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_reason": { + "name": "billing_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refunded_at": { + "name": "refunded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_orders_user_id": { + "name": "idx_orders_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orders_subscription_id": { + "name": "idx_orders_subscription_id", + "columns": [ + { + "expression": "subscription_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_orders_status": { + "name": "idx_orders_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "orders_user_id_auth_user_id_fk": { + "name": "orders_user_id_auth_user_id_fk", + "tableFrom": "orders", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "orders_subscription_id_subscriptions_id_fk": { + "name": "orders_subscription_id_subscriptions_id_fk", + "tableFrom": "orders", + "tableTo": "subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.parent_alert_reads": { + "name": "parent_alert_reads", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "alert_id": { + "name": "alert_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "read_at": { + "name": "read_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_parent_alert_reads_parent_id": { + "name": "idx_parent_alert_reads_parent_id", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "parent_alert_reads_parent_id_auth_user_id_fk": { + "name": "parent_alert_reads_parent_id_auth_user_id_fk", + "tableFrom": "parent_alert_reads", + "tableTo": "auth_user", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "parent_alert_reads_unique": { + "name": "parent_alert_reads_unique", + "nullsNotDistinct": false, + "columns": [ + "parent_id", + "alert_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.referrals": { + "name": "referrals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "referrer_user_id": { + "name": "referrer_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "referred_user_id": { + "name": "referred_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reward_amount": { + "name": "reward_amount", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 300 + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rewarded_at": { + "name": "rewarded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_referrals_referrer": { + "name": "idx_referrals_referrer", + "columns": [ + { + "expression": "referrer_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_referrals_code": { + "name": "idx_referrals_code", + "columns": [ + { + "expression": "referral_code", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "referrals_referrer_user_id_auth_user_id_fk": { + "name": "referrals_referrer_user_id_auth_user_id_fk", + "tableFrom": "referrals", + "tableTo": "auth_user", + "columnsFrom": [ + "referrer_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "referrals_referred_user_id_auth_user_id_fk": { + "name": "referrals_referred_user_id_auth_user_id_fk", + "tableFrom": "referrals", + "tableTo": "auth_user", + "columnsFrom": [ + "referred_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "referrals_referral_code_unique": { + "name": "referrals_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.series": { + "name": "series", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "series_name_unique": { + "name": "series_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.study_sessions": { + "name": "study_sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cards_reviewed": { + "name": "cards_reviewed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cards_correct": { + "name": "cards_correct", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "duration": { + "name": "duration", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_study_sessions_user_started": { + "name": "idx_study_sessions_user_started", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "study_sessions_user_id_auth_user_id_fk": { + "name": "study_sessions_user_id_auth_user_id_fk", + "tableFrom": "study_sessions", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "study_sessions_lesson_id_lessons_id_fk": { + "name": "study_sessions_lesson_id_lessons_id_fk", + "tableFrom": "study_sessions", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject_offerings": { + "name": "subject_offerings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "is_mandatory": { + "name": "is_mandatory", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "coefficient": { + "name": "coefficient", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 1 + } + }, + "indexes": {}, + "foreignKeys": { + "subject_offerings_grade_id_grades_id_fk": { + "name": "subject_offerings_grade_id_grades_id_fk", + "tableFrom": "subject_offerings", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subject_offerings_subject_id_subjects_id_fk": { + "name": "subject_offerings_subject_id_subjects_id_fk", + "tableFrom": "subject_offerings", + "tableTo": "subjects", + "columnsFrom": [ + "subject_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "subject_offerings_series_id_series_id_fk": { + "name": "subject_offerings_series_id_series_id_fk", + "tableFrom": "subject_offerings", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subjects": { + "name": "subjects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "abbreviation": { + "name": "abbreviation", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_order": { + "name": "display_order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subjects_name_unique": { + "name": "subjects_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + }, + "subjects_abbreviation_unique": { + "name": "subjects_abbreviation_unique", + "nullsNotDistinct": false, + "columns": [ + "abbreviation" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "product_id": { + "name": "product_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "price_id": { + "name": "price_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_subscriptions_user_id": { + "name": "idx_subscriptions_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_subscriptions_status": { + "name": "idx_subscriptions_status", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "subscriptions_user_id_auth_user_id_fk": { + "name": "subscriptions_user_id_auth_user_id_fk", + "tableFrom": "subscriptions", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_achievements": { + "name": "user_achievements", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "achievement_id": { + "name": "achievement_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "unlocked_at": { + "name": "unlocked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "notified": { + "name": "notified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "notified_at": { + "name": "notified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idx_user_achievements_user_id": { + "name": "idx_user_achievements_user_id", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_user_achievements_unlocked_at": { + "name": "idx_user_achievements_unlocked_at", + "columns": [ + { + "expression": "unlocked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_achievements_user_id_auth_user_id_fk": { + "name": "user_achievements_user_id_auth_user_id_fk", + "tableFrom": "user_achievements", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_achievements_user_achievement_unique": { + "name": "user_achievements_user_achievement_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "achievement_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_lesson_mastery": { + "name": "user_lesson_mastery", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "successful_test_count": { + "name": "successful_test_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_test_score": { + "name": "last_test_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_test_at": { + "name": "last_test_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_unlocked": { + "name": "is_unlocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_lesson_mastery_user_id_auth_user_id_fk": { + "name": "user_lesson_mastery_user_id_auth_user_id_fk", + "tableFrom": "user_lesson_mastery", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_lesson_mastery_lesson_id_lessons_id_fk": { + "name": "user_lesson_mastery_lesson_id_lessons_id_fk", + "tableFrom": "user_lesson_mastery", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_lesson_mastery_user_id_lesson_id_unique": { + "name": "user_lesson_mastery_user_id_lesson_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "lesson_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_profiles": { + "name": "user_profiles", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_type": { + "name": "user_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "phone": { + "name": "phone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "age": { + "name": "age", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "gender": { + "name": "gender", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "city": { + "name": "city", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_number": { + "name": "id_number", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "grade_id": { + "name": "grade_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "series_id": { + "name": "series_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "favorite_subjects": { + "name": "favorite_subjects", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "learning_goals": { + "name": "learning_goals", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "study_time": { + "name": "study_time", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "children_matricules": { + "name": "children_matricules", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "xp": { + "name": "xp", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "longest_streak": { + "name": "longest_streak", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "streak_freeze_count": { + "name": "streak_freeze_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_streak_freeze_used_at": { + "name": "last_streak_freeze_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_completed": { + "name": "is_completed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "subscription_tier": { + "name": "subscription_tier", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "subscription_expires_at": { + "name": "subscription_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "referral_code": { + "name": "referral_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "referred_by": { + "name": "referred_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_profiles_user_id_auth_user_id_fk": { + "name": "user_profiles_user_id_auth_user_id_fk", + "tableFrom": "user_profiles", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_profiles_grade_id_grades_id_fk": { + "name": "user_profiles_grade_id_grades_id_fk", + "tableFrom": "user_profiles", + "tableTo": "grades", + "columnsFrom": [ + "grade_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "user_profiles_series_id_series_id_fk": { + "name": "user_profiles_series_id_series_id_fk", + "tableFrom": "user_profiles", + "tableTo": "series", + "columnsFrom": [ + "series_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_profiles_referral_code_unique": { + "name": "user_profiles_referral_code_unique", + "nullsNotDistinct": false, + "columns": [ + "referral_code" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_progress": { + "name": "user_progress", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "serial", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "card_id": { + "name": "card_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "lesson_id": { + "name": "lesson_id", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ease_factor": { + "name": "ease_factor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2500 + }, + "interval": { + "name": "interval", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "repetitions": { + "name": "repetitions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_reviewed_at": { + "name": "last_reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_review_at": { + "name": "next_review_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_reviews": { + "name": "total_reviews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "correct_reviews": { + "name": "correct_reviews", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_progress_user_id_auth_user_id_fk": { + "name": "user_progress_user_id_auth_user_id_fk", + "tableFrom": "user_progress", + "tableTo": "auth_user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_progress_card_id_cards_id_fk": { + "name": "user_progress_card_id_cards_id_fk", + "tableFrom": "user_progress", + "tableTo": "cards", + "columnsFrom": [ + "card_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_progress_lesson_id_lessons_id_fk": { + "name": "user_progress_lesson_id_lessons_id_fk", + "tableFrom": "user_progress", + "tableTo": "lessons", + "columnsFrom": [ + "lesson_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/data-ops/src/drizzle/meta/_journal.json b/packages/data-ops/src/drizzle/meta/_journal.json index 5fd43d7..b873469 100644 --- a/packages/data-ops/src/drizzle/meta/_journal.json +++ b/packages/data-ops/src/drizzle/meta/_journal.json @@ -78,6 +78,20 @@ "when": 1767815658417, "tag": "0010_real_master_mold", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1768338859476, + "tag": "0011_real_pyro", + "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1768339227294, + "tag": "0012_burly_karnak", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/data-ops/src/drizzle/relations.ts b/packages/data-ops/src/drizzle/relations.ts index 183d943..79334da 100644 --- a/packages/data-ops/src/drizzle/relations.ts +++ b/packages/data-ops/src/drizzle/relations.ts @@ -1,5 +1,5 @@ import { relations } from "drizzle-orm/relations"; -import { authUser, authAccount, authSession, lessons, cards, studySessions, grades, subjectOfferings, subjects, series, userProfiles, userProgress, userLessonMastery, levelSeries, subscriptions, orders, referrals, discountUsage } from "./schema"; +import { authUser, authAccount, authSession, lessons, cards, studySessions, grades, subjectOfferings, subjects, series, userProfiles, userProgress, userLessonMastery, levelSeries, subscriptions, orders, referrals, discountUsage, parentAlertReads } from "./schema"; export const authAccountRelations = relations(authAccount, ({ one }) => ({ authUser: one(authUser, { @@ -191,3 +191,10 @@ export const discountUsageRelations = relations(discountUsage, ({ one }) => ({ references: [orders.id] }), })); + +export const parentAlertReadsRelations = relations(parentAlertReads, ({ one }) => ({ + parent: one(authUser, { + fields: [parentAlertReads.parentId], + references: [authUser.id], + }), +})); diff --git a/packages/data-ops/src/drizzle/schema.ts b/packages/data-ops/src/drizzle/schema.ts index 0291596..49e1631 100644 --- a/packages/data-ops/src/drizzle/schema.ts +++ b/packages/data-ops/src/drizzle/schema.ts @@ -150,8 +150,9 @@ export const series = pgTable("series", { export const studySessions = pgTable("study_sessions", { id: serial().primaryKey().notNull(), userId: text("user_id").notNull(), - lessonId: integer("lesson_id").notNull(), - mode: text("mode").notNull(), // Added mode column + lessonId: integer("lesson_id"), // Now optional for transversal reviews + subjectId: integer("subject_id"), // New column for subject-level sessions + mode: text("mode").notNull(), startedAt: timestamp("started_at", { mode: 'string' }).defaultNow().notNull(), endedAt: timestamp("ended_at", { mode: 'string' }), cardsReviewed: integer("cards_reviewed").default(0).notNull(), @@ -168,7 +169,12 @@ export const studySessions = pgTable("study_sessions", { columns: [table.lessonId], foreignColumns: [lessons.id], name: "study_sessions_lesson_id_lessons_id_fk" - }).onDelete("cascade"), + }).onDelete("set null"), + foreignKey({ + columns: [table.subjectId], + foreignColumns: [subjects.id], + name: "study_sessions_subject_id_subjects_id_fk" + }).onDelete("set null"), // Index for streak calculation performance index("idx_study_sessions_user_started").on(table.userId, table.startedAt), ]); @@ -200,7 +206,7 @@ export const subjectOfferings = pgTable("subject_offerings", { export const userProfiles = pgTable("user_profiles", { userId: text("user_id").primaryKey().notNull(), - userType: text("user_type").$type<'student' | 'parent'>().notNull(), + userType: text("user_type").$type<'student' | 'parent' | 'admin'>().notNull(), firstName: text("first_name").notNull(), lastName: text("last_name").notNull(), phone: text(), @@ -276,6 +282,9 @@ export const userProgress = pgTable("user_progress", { foreignColumns: [lessons.id], name: "user_progress_lesson_id_lessons_id_fk" }).onDelete("cascade"), + unique("user_progress_user_id_card_id_unique").on(table.userId, table.cardId), + index("idx_user_progress_user_next_review").on(table.userId, table.nextReviewAt), + index("idx_user_progress_card_id").on(table.cardId), ]); // Type for teach plan metadata @@ -359,6 +368,7 @@ export const userAchievements = pgTable("user_achievements", { achievementId: text("achievement_id").notNull(), unlockedAt: timestamp("unlocked_at", { mode: 'string' }).defaultNow().notNull(), notified: boolean("notified").default(false).notNull(), + notifiedAt: timestamp("notified_at", { mode: 'string' }), createdAt: timestamp("created_at", { mode: 'string' }).defaultNow().notNull(), }, (table) => [ foreignKey({ @@ -371,6 +381,21 @@ export const userAchievements = pgTable("user_achievements", { index("idx_user_achievements_unlocked_at").on(table.unlockedAt), ]); +export const parentAlertReads = pgTable("parent_alert_reads", { + id: serial().primaryKey().notNull(), + parentId: text("parent_id").notNull(), + alertId: text("alert_id").notNull(), + readAt: timestamp("read_at", { mode: 'string' }).defaultNow().notNull(), +}, (table) => [ + foreignKey({ + columns: [table.parentId], + foreignColumns: [authUser.id], + name: "parent_alert_reads_parent_id_auth_user_id_fk" + }).onDelete("cascade"), + unique("parent_alert_reads_unique").on(table.parentId, table.alertId), + index("idx_parent_alert_reads_parent_id").on(table.parentId), +]); + export const levelSeries = pgTable("level_series", { gradeId: integer("grade_id").notNull(), seriesId: integer("series_id").notNull(), diff --git a/packages/data-ops/src/queries/achievements.ts b/packages/data-ops/src/queries/achievements.ts index 39d65d9..f1fbc62 100644 --- a/packages/data-ops/src/queries/achievements.ts +++ b/packages/data-ops/src/queries/achievements.ts @@ -359,7 +359,7 @@ export async function getUserAchievements( .where(eq(userAchievements.userId, userId)) const unlockedMap = new Map( - unlockedRecords.map(r => [r.achievementId, r.unlockedAt]) + unlockedRecords.map(r => [r.achievementId, { unlockedAt: r.unlockedAt, notified: r.notified }]) ) // Calculate achievement status @@ -374,15 +374,18 @@ export async function getUserAchievements( const achievement: AchievementWithProgress = { ...def, unlocked: isNowUnlocked, - unlockedAt: unlockedMap.get(def.id) ?? null, + unlockedAt: unlockedMap.get(def.id)?.unlockedAt ?? null, progress, maxProgress: def.condition.threshold, } achievements.push(achievement) - // Track newly unlocked - if (isNowUnlocked && !wasUnlocked) { + // Track for notification: + // - Newly unlocked in this request + // - OR unlocked previously but never notified + const isNotified = unlockedMap.get(def.id)?.notified ?? false + if (isNowUnlocked && (!wasUnlocked || !isNotified)) { newlyUnlocked.push(achievement) } } @@ -415,7 +418,7 @@ export async function markAchievementsNotified( await db .update(userAchievements) - .set({ notified: true }) + .set({ notified: true, notifiedAt: new Date().toISOString() }) .where( and( eq(userAchievements.userId, userId), diff --git a/packages/data-ops/src/zod-schema/profile.ts b/packages/data-ops/src/zod-schema/profile.ts index 93aa0bf..6ab2051 100644 --- a/packages/data-ops/src/zod-schema/profile.ts +++ b/packages/data-ops/src/zod-schema/profile.ts @@ -3,7 +3,7 @@ import { z } from "zod"; /** * User type selection schema */ -export const userTypeSchema = z.enum(["student", "parent"]); +export const userTypeSchema = z.enum(["student", "parent", "admin"]); /** * Student profile schema diff --git a/tasks/prd-core-refinement.md b/tasks/prd-core-refinement.md new file mode 100644 index 0000000..edfe4b1 --- /dev/null +++ b/tasks/prd-core-refinement.md @@ -0,0 +1,72 @@ +# PRD - Finalisation des fonctionnalités de base et Nettoyage (Refinement & Core Logic) + +## 1. Introduction/Overview + +Ce document définit les exigences pour finaliser les fonctionnalités essentielles restées en suspens (marquées en TODO) dans l'application Kurama. Ces tâches concernent principalement la persistance des états utilisateur (alertes, succès), la navigation vers les pages légales et la sécurisation de l'accès administrateur. + +## 2. Objectifs + +* Assurer la persistance réelle des interactions utilisateur (lecture d'alertes, notifications de succès). +* Compléter le parcours utilisateur avec les mentions légales et la politique de confidentialité. +* Sécuriser l'accès à l'interface d'administration. +* Passer d'une logique "mockée" ou "placeholder" à une logique de production. + +## 3. User Stories + +* **En tant que parent**, je veux que les alertes que j'ai lues ne s'affichent plus comme nouvelles la prochaine fois que je me connecte. +* **En tant qu'élève**, je veux ne plus voir la même animation de "Succès débloqué" à chaque chargement de mon profil si je l'ai déjà vue. +* **En tant qu'utilisateur**, je veux pouvoir consulter les conditions d'utilisation et la politique de confidentialité pour comprendre comment mes données sont gérées. +* **En tant que membre de l'équipe**, je veux m'assurer que seules les personnes autorisées peuvent accéder aux outils d'administration. + +## 4. Exigences Fonctionnelles + +### 4.1 Persistance des Alertes Parent + +1. Le système doit permettre de marquer une alerte spécifique comme "lue" en base de données. +2. Le système doit permettre de marquer toutes les alertes d'un parent comme "lues" en une seule action. +3. L'état "lu" doit être conservé entre les sessions. + +### 4.2 Persistance des Notifications de Succès (Achievements) + +1. Le système doit enregistrer la date à laquelle un utilisateur a été notifié d'un succès débloqué. +2. L'API `markAchievementsNotified` doit être créée pour mettre à jour cet état. +3. L'interface ne doit afficher le toast de célébration que pour les succès n'ayant pas encore de date de notification. + +### 4.3 Pages Légales et Navigation + +1. Création des routes `/_public/terms` et `/_public/privacy`. +2. Implémentation de composants de texte structuré pour ces deux pages. +3. Connexion des liens "Conditions" et "Confidentialité" dans les écrans d'authentification et de sélection de type d'utilisateur. + +### 4.4 Sécurité Admin + +1. Modification du schéma `userType` pour inclure explicitement le rôle `admin`. +2. Implémentation d'un garde (middleware) dans `kurama-admin` vérifiant que le profil utilisateur possède le type `admin`. + +## 5. Non-Goals (Out of Scope) + +* Refonte graphique complète des pages légales. +* Système de notifications push (uniquement stockage de l'état "lu" en base pour l'instant). +* Gestion avancée des permissions multi-niveaux pour les admins. + +## 6. Design Considerations + +* Les pages de mentions légales doivent suivre le thème sobre et lisible de Kurama (utilisation de la typographie et des espacements standards). +* L'état "lu" des alertes doit être visuellement distinct (opacité réduite, disparition de la pastille de couleur). + +## 7. Technical Considerations + +* **Base de données** : Utiliser Drizzle ORM pour ajouter les colonnes/tables nécessaires. + * Table `user_achievements` : ajouter `notified_at`. + * Table `parent_alerts_metadata` (ou similaire) pour suivre les alertes générées dynamiquement. +* **Auth** : Mise à jour du client auth pour supporter le nouveau type utilisateur. + +## 8. Success Metrics + +* Zéro TODO restant dans les fichiers identifiés. +* Validation du typecheck et du linter sur l'ensemble du projet. +* L'interface admin renvoie une erreur 403 pour un compte élève/parent. + +## 9. Open Questions + +* Faut-il stocker les alertes générées par le serveur de façon permanente ou les recalculer à chaque fois et stocker l'exclusion ? (Choix technique : Stockage des exclusions/dates de lecture). diff --git a/tasks/tasks-core-refinement.md b/tasks/tasks-core-refinement.md new file mode 100644 index 0000000..594832b --- /dev/null +++ b/tasks/tasks-core-refinement.md @@ -0,0 +1,46 @@ +# PRD - Finalisation des fonctionnalités de base et Nettoyage (Refinement & Core Logic) + +## Relevant Files + +- `packages/data-ops/src/drizzle/schema.ts` - Modification du schéma pour ajouter les colonnes de notification et rôles. +- `apps/user-application/src/core/functions/parent.ts` - Ajout des fonctions de marquage des alertes comme lues. +- `apps/user-application/src/hooks/use-parent-dashboard.ts` - Mise à jour pour appeler les nouvelles fonctions de persistance. +- `apps/user-application/src/routes/_auth/app/progress.tsx` - Intégration de l'appel API pour les succès. +- `apps/user-application/src/routes/_public/terms.tsx` - Nouvelle page des conditions d'utilisation. +- `apps/user-application/src/routes/_public/privacy.tsx` - Nouvelle page de confidentialité. +- `apps/kurama-admin/src/core/middleware/admin-auth.ts` - Sécurisation de l'accès admin. + +### Notes + +- Veiller à bien exécuter les migrations de base de données après modification du schéma. +- Utiliser `pnpm typecheck` pour valider les changements de types. + +## Instructions for Completing Tasks + +**IMPORTANT:** As you complete each task, you must check it off in this markdown file by changing `- [ ]` to `- [x]`. This helps track progress and ensures you don't skip any steps. + +## Tasks + +- [x] 0.0 Create feature branch + - [x] 0.1 Create and checkout a new branch `feat/core-refinement` +- [x] 1.0 Database Schema & Migrations + - [x] 1.1 Update `userTypeSchema` and DB enum to include `admin` in `packages/data-ops/src/drizzle/schema.ts` + - [x] 1.2 Add `notifiedAt` (timestamp) to achievement tracking (verify existing table name) + - [x] 1.3 Create a table or mechanism to track "read" state for parent alerts + - [x] 1.4 Generate and apply migrations +- [x] 2.0 Parent Alerts Persistence Logic + - [x] 2.1 Implement `markAlertAsRead` and `markAllAlertsAsRead` server functions in `parent.ts` + - [x] 2.2 Update `useParentAlerts` hook to use actual server functions instead of `console.warn` + - [x] 2.3 Refactor `getParentAlerts` to exclude or mark read alerts based on DB state +- [x] 3.0 Achievement Notification Tracking + - [x] 3.1 Implement `markAchievementsNotified` server function + - [x] 3.2 Update `AchievementUnlockToast` or its parent to call this function after display + - [x] 3.3 Ensure the progress page only triggers celebrations for unnotified achievements +- [x] 4.0 Public Legal Pages & Navigation Links + - [x] 4.1 Create `/_public/terms` route and component with placeholder content + - [x] 4.2 Create `/_public/privacy` route and component with placeholder content + - [x] 4.3 Update `AuthScreen` and `UserTypeSelection` links to point to these new routes +- [x] 5.0 Admin Access Security Implementation + - [x] 5.1 Update admin middleware to verify `userType === 'admin'` + - [x] 5.2 Add a security check in `kurama-admin` to prevent non-admin access + - [x] 5.3 Final cleanup of all identified "TODO" comments in the codebase