From 5da58889cfbf8f38f619df6a8d5ae6abc0a6a0cd Mon Sep 17 00:00:00 2001 From: Darius Kassi Date: Tue, 13 Jan 2026 21:15:44 +0000 Subject: [PATCH 1/4] feat: add admin user type and parent alert persistence schema --- .../data-ops/src/drizzle/0011_real_pyro.sql | 10 + .../src/drizzle/meta/0011_snapshot.json | 2663 +++++++++++++++++ .../data-ops/src/drizzle/meta/_journal.json | 7 + packages/data-ops/src/drizzle/schema.ts | 17 +- packages/data-ops/src/zod-schema/profile.ts | 2 +- tasks/prd-core-refinement.md | 72 + tasks/tasks-core-refinement.md | 46 + 7 files changed, 2815 insertions(+), 2 deletions(-) create mode 100644 packages/data-ops/src/drizzle/0011_real_pyro.sql create mode 100644 packages/data-ops/src/drizzle/meta/0011_snapshot.json create mode 100644 tasks/prd-core-refinement.md create mode 100644 tasks/tasks-core-refinement.md 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/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/_journal.json b/packages/data-ops/src/drizzle/meta/_journal.json index 5fd43d7..61fee0c 100644 --- a/packages/data-ops/src/drizzle/meta/_journal.json +++ b/packages/data-ops/src/drizzle/meta/_journal.json @@ -78,6 +78,13 @@ "when": 1767815658417, "tag": "0010_real_master_mold", "breakpoints": true + }, + { + "idx": 11, + "version": "7", + "when": 1768338859476, + "tag": "0011_real_pyro", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/data-ops/src/drizzle/schema.ts b/packages/data-ops/src/drizzle/schema.ts index 0291596..9566ed5 100644 --- a/packages/data-ops/src/drizzle/schema.ts +++ b/packages/data-ops/src/drizzle/schema.ts @@ -200,7 +200,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(), @@ -371,6 +371,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/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..bf8525e --- /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 +- [ ] 2.0 Parent Alerts Persistence Logic + - [ ] 2.1 Implement `markAlertAsRead` and `markAllAlertsAsRead` server functions in `parent.ts` + - [ ] 2.2 Update `useParentAlerts` hook to use actual server functions instead of `console.warn` + - [ ] 2.3 Refactor `getParentAlerts` to exclude or mark read alerts based on DB state +- [ ] 3.0 Achievement Notification Tracking + - [ ] 3.1 Implement `markAchievementsNotified` server function + - [ ] 3.2 Update `AchievementUnlockToast` or its parent to call this function after display + - [ ] 3.3 Ensure the progress page only triggers celebrations for unnotified achievements +- [ ] 4.0 Public Legal Pages & Navigation Links + - [ ] 4.1 Create `/_public/terms` route and component with placeholder content + - [ ] 4.2 Create `/_public/privacy` route and component with placeholder content + - [ ] 4.3 Update `AuthScreen` and `UserTypeSelection` links to point to these new routes +- [ ] 5.0 Admin Access Security Implementation + - [ ] 5.1 Update admin middleware to verify `userType === 'admin'` + - [ ] 5.2 Add a security check in `kurama-admin` to prevent non-admin access + - [ ] 5.3 Final cleanup of all identified "TODO" comments in the codebase From 3e480f7e3ff326b62364fac3a735a253112996d0 Mon Sep 17 00:00:00 2001 From: Darius Kassi Date: Tue, 13 Jan 2026 21:35:16 +0000 Subject: [PATCH 2/4] feat: finalize core refinement (persistence, legal pages, admin security) --- .../src/core/middleware/admin-auth.ts | 14 +- .../src/components/auth/auth-screen.tsx | 27 +- .../onboarding/user-type-selection.tsx | 19 +- .../src/core/functions/parent.ts | 94 +- .../src/core/functions/progress.ts | 17 +- .../src/hooks/use-parent-dashboard.ts | 29 +- apps/user-application/src/routeTree.gen.ts | 42 + .../src/routes/_auth/app/progress.tsx | 39 +- .../src/routes/_auth/app/referrals.tsx | 3 +- .../src/routes/_public/privacy.tsx | 84 + .../src/routes/_public/terms.tsx | 84 + .../src/drizzle/0012_burly_karnak.sql | 1 + .../src/drizzle/meta/0012_snapshot.json | 2669 +++++++++++++++++ .../data-ops/src/drizzle/meta/_journal.json | 7 + packages/data-ops/src/drizzle/relations.ts | 9 +- packages/data-ops/src/drizzle/schema.ts | 1 + packages/data-ops/src/queries/achievements.ts | 13 +- tasks/tasks-core-refinement.md | 32 +- 18 files changed, 3072 insertions(+), 112 deletions(-) create mode 100644 apps/user-application/src/routes/_public/privacy.tsx create mode 100644 apps/user-application/src/routes/_public/terms.tsx create mode 100644 packages/data-ops/src/drizzle/0012_burly_karnak.sql create mode 100644 packages/data-ops/src/drizzle/meta/0012_snapshot.json diff --git a/apps/kurama-admin/src/core/middleware/admin-auth.ts b/apps/kurama-admin/src/core/middleware/admin-auth.ts index 943bb54..2342cea 100644 --- a/apps/kurama-admin/src/core/middleware/admin-auth.ts +++ b/apps/kurama-admin/src/core/middleware/admin-auth.ts @@ -1,3 +1,6 @@ +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 { getAuth } from '@kurama/data-ops/auth/server' 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/user-application/src/components/auth/auth-screen.tsx b/apps/user-application/src/components/auth/auth-screen.tsx index ac60e50..ac1c129 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' @@ -59,11 +60,11 @@ export function AuthScreen() { > {step === 'email' ? ( - - ) + + ) : ( - - )} + + )} {/* Divider */} @@ -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/core/functions/parent.ts b/apps/user-application/src/core/functions/parent.ts index 68fd334..36d964a 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,79 @@ 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}` // Simplified ID for persistence + 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, + }) + } } } 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..4b22338 100644 --- a/apps/user-application/src/core/functions/progress.ts +++ b/apps/user-application/src/core/functions/progress.ts @@ -1,7 +1,7 @@ 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 { 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' @@ -130,3 +130,18 @@ 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 } + }) 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/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/progress.tsx b/apps/user-application/src/routes/_auth/app/progress.tsx index ba3f18e..c6c8080 100644 --- a/apps/user-application/src/routes/_auth/app/progress.tsx +++ b/apps/user-application/src/routes/_auth/app/progress.tsx @@ -1,5 +1,5 @@ import type { AchievementWithProgress } from '@kurama/data-ops/queries/achievements' -import { useQuery } from '@tanstack/react-query' +import { useMutation, useQuery } from '@tanstack/react-query' import { createFileRoute } from '@tanstack/react-router' import { Award, @@ -33,7 +33,7 @@ import { DialogTitle, } from '@/components/ui/dialog' import { LogoLoader } from '@/components/ui/logo-loader' -import { getProgressStats } from '@/core/functions/progress' +import { getProgressStats, markAchievementsAsNotified } from '@/core/functions/progress' import { authClient, isSigningOut } from '@/lib/auth-client' import { trackRouteLoad } from '@/lib/performance-monitor' import { cn } from '@/lib/utils' @@ -103,38 +103,19 @@ function ProgressPage() { }) // Handle newly unlocked achievements with localStorage filtering + const markNotifiedMutation = useMutation({ + mutationFn: (ids: string[]) => 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/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. +

+
+ +
+ Dernière mise à jour : + {' '} + {new Date().toLocaleDateString('fr-FR')} +
+
+
+
+ ) +} 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/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 61fee0c..b873469 100644 --- a/packages/data-ops/src/drizzle/meta/_journal.json +++ b/packages/data-ops/src/drizzle/meta/_journal.json @@ -85,6 +85,13 @@ "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 9566ed5..56817bc 100644 --- a/packages/data-ops/src/drizzle/schema.ts +++ b/packages/data-ops/src/drizzle/schema.ts @@ -359,6 +359,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({ 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/tasks/tasks-core-refinement.md b/tasks/tasks-core-refinement.md index bf8525e..594832b 100644 --- a/tasks/tasks-core-refinement.md +++ b/tasks/tasks-core-refinement.md @@ -28,19 +28,19 @@ - [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 -- [ ] 2.0 Parent Alerts Persistence Logic - - [ ] 2.1 Implement `markAlertAsRead` and `markAllAlertsAsRead` server functions in `parent.ts` - - [ ] 2.2 Update `useParentAlerts` hook to use actual server functions instead of `console.warn` - - [ ] 2.3 Refactor `getParentAlerts` to exclude or mark read alerts based on DB state -- [ ] 3.0 Achievement Notification Tracking - - [ ] 3.1 Implement `markAchievementsNotified` server function - - [ ] 3.2 Update `AchievementUnlockToast` or its parent to call this function after display - - [ ] 3.3 Ensure the progress page only triggers celebrations for unnotified achievements -- [ ] 4.0 Public Legal Pages & Navigation Links - - [ ] 4.1 Create `/_public/terms` route and component with placeholder content - - [ ] 4.2 Create `/_public/privacy` route and component with placeholder content - - [ ] 4.3 Update `AuthScreen` and `UserTypeSelection` links to point to these new routes -- [ ] 5.0 Admin Access Security Implementation - - [ ] 5.1 Update admin middleware to verify `userType === 'admin'` - - [ ] 5.2 Add a security check in `kurama-admin` to prevent non-admin access - - [ ] 5.3 Final cleanup of all identified "TODO" comments in the codebase +- [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 From 0bd7f53c5ed94c85092718fa8a82f38c943460c8 Mon Sep 17 00:00:00 2001 From: Darius Kassi Date: Tue, 13 Jan 2026 22:05:35 +0000 Subject: [PATCH 3/4] feat: enhance study sessions and admin card management - Refactored study session API to support subject-level and lesson-level review. - Updated database schema for elective lessonId and new subjectId in study_sessions. - Enhanced gamification persistence with server-side XP and streak calculations. - Improved kurama-admin lesson detail page with card list, CRUD, and bulk JSON import. - Fixed numerous TypeScript and linting errors across the monorepo. - Updated CLAUDE.md with new project guidelines and workflows. --- .../src/core/middleware/admin-auth.ts | 2 +- .../src/routes/_admin/dashboard.tsx | 522 +++++++++--------- .../src/routes/_admin/lessons.$lessonId.tsx | 424 ++++++++++---- .../src/routes/_admin/users.index.tsx | 19 +- .../src/components/auth/auth-screen.tsx | 8 +- .../components/profile/profile-edit-form.tsx | 24 +- .../src/core/functions/learning.ts | 35 +- .../src/core/functions/stats.ts | 50 +- .../src/lib/atoms/user-profile.ts | 2 +- .../_auth/app/lesson-summary.$lessonId.tsx | 15 +- .../src/routes/api/study/$sessionId.ts | 28 +- packages/data-ops/src/drizzle/schema.ts | 12 +- 12 files changed, 724 insertions(+), 417 deletions(-) diff --git a/apps/kurama-admin/src/core/middleware/admin-auth.ts b/apps/kurama-admin/src/core/middleware/admin-auth.ts index 2342cea..0a7ac3e 100644 --- a/apps/kurama-admin/src/core/middleware/admin-auth.ts +++ b/apps/kurama-admin/src/core/middleware/admin-auth.ts @@ -1,7 +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 { getAuth } from '@kurama/data-ops/auth/server' import { createMiddleware } from '@tanstack/react-start' import { getRequest } from '@tanstack/react-start/server' diff --git a/apps/kurama-admin/src/routes/_admin/dashboard.tsx b/apps/kurama-admin/src/routes/_admin/dashboard.tsx index b850e24..c1fe726 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 @@ -183,73 +183,73 @@ function DashboardPage() {
{statsLoading ? ( - <> - - - - - - ) + <> + + + + + + ) : ( - <> - } - trend={ - stats?.users.newThisWeek - ? { value: stats.users.newThisWeek, label: 'cette semaine' } - : undefined - } - /> - } - /> - } - /> - } - /> - - )} + <> + } + trend={ + stats?.users.newThisWeek + ? { value: stats.users.newThisWeek, label: 'cette semaine' } + : undefined + } + /> + } + /> + } + /> + } + /> + + )}
{/* Secondary Stats */}
{statsLoading ? ( - <> - - - - ) + <> + + + + ) : ( - <> - } - /> - } - /> - - )} + <> + } + /> + } + /> + + )}
{/* Charts */} @@ -265,69 +265,69 @@ function DashboardPage() { {userGrowthLoading ? ( - - ) + + ) : userGrowth && userGrowth.length > 0 ? ( - - - - - - - - - - - new Date(value).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'short', - })} - className="text-xs text-muted-foreground" - axisLine={false} - tickLine={false} - tickMargin={10} - /> - - - new Date(value).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'long', - year: 'numeric', - })} - formatter={(value: number) => [value, 'Inscriptions']} - /> - - - - ) + + + + + + + + + + + new Date(value).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })} + className="text-xs text-muted-foreground" + axisLine={false} + tickLine={false} + tickMargin={10} + /> + + + new Date(value).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + formatter={(value: number) => [value, 'Inscriptions']} + /> + + + + ) : ( -

- Aucune donnée disponible -

- )} +

+ Aucune donnée disponible +

+ )}
@@ -342,65 +342,65 @@ function DashboardPage() { {sessionGrowthLoading ? ( - - ) + + ) : sessionGrowth && sessionGrowth.length > 0 ? ( - - - - - - - - - - - new Date(value).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'short', - })} - className="text-xs text-muted-foreground" - axisLine={false} - tickLine={false} - tickMargin={10} - /> - - - new Date(value).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'long', - year: 'numeric', - })} - formatter={(value: number, name: string) => [ - value, - name === 'count' ? 'Sessions' : 'Cartes révisées', - ]} - /> - - - - ) + + + + + + + + + + + new Date(value).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })} + className="text-xs text-muted-foreground" + axisLine={false} + tickLine={false} + tickMargin={10} + /> + + + new Date(value).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'long', + year: 'numeric', + })} + formatter={(value: number, name: string) => [ + value, + name === 'count' ? 'Sessions' : 'Cartes révisées', + ]} + /> + + + + ) : ( -

- Aucune donnée disponible -

- )} +

+ Aucune donnée disponible +

+ )}
@@ -417,49 +417,49 @@ function DashboardPage() { {contentLoading ? ( -
- {[...Array.from({ length: 5 })].map(() => ( -
- - +
+ {[...Array.from({ length: 5 })].map(() => ( +
+ + +
+ ))} +
+ ) + : contentStats && contentStats.length > 0 + ? ( +
+ {contentStats.map((subject: SubjectStat) => ( +
+
+ + {subject.abbreviation} + + {subject.name} +
+
+ + + {subject.lessonCount} + + + + + {subject.cardCount} + +
))}
) - : contentStats && contentStats.length > 0 - ? ( -
- {contentStats.map((subject: SubjectStat) => ( -
-
- - {subject.abbreviation} - - {subject.name} -
-
- - - {subject.lessonCount} - - - - - {subject.cardCount} - -
-
- ))} -
- ) : ( -

- Aucune matière trouvée -

- )} +

+ Aucune matière trouvée +

+ )} @@ -474,59 +474,59 @@ function DashboardPage() { {activityLoading ? ( -
- {[...Array.from({ length: 5 })].map(() => ( -
- -
- - -
+
+ {[...Array.from({ length: 5 })].map(() => ( +
+ +
+ +
- ))} -
- ) +
+ ))} +
+ ) : recentActivity && recentActivity.length > 0 ? ( -
- {recentActivity.map((session: RecentSession) => ( -
-
- -
-
-

- {session.userName || session.userEmail || 'Utilisateur'} -

-

- {session.lessonTitle || 'Leçon'} - - {session.cardsReviewed} - {' '} - cartes -

-
-
-
- - {new Date(session.startedAt).toLocaleDateString('fr-FR', { - day: 'numeric', - month: 'short', - })} -
+
+ {recentActivity.map((session: RecentSession) => ( +
+
+ +
+
+

+ {session.userName || session.userEmail || 'Utilisateur'} +

+

+ {session.lessonTitle || 'Leçon'} + + {session.cardsReviewed} + {' '} + cartes +

+
+
+
+ + {new Date(session.startedAt).toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + })}
- ))} -
- ) +
+ ))} +
+ ) : ( -

- Aucune activité récente -

- )} +

+ Aucune activité récente +

+ )} diff --git a/apps/kurama-admin/src/routes/_admin/lessons.$lessonId.tsx b/apps/kurama-admin/src/routes/_admin/lessons.$lessonId.tsx index 6be14ad..71220c0 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', @@ -396,71 +519,71 @@ function LessonDetailPage() { {isEditing ? ( -
-