From 4c366315a61708ca5bf4b3963894e8df77383d0f Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:40:52 +0300 Subject: [PATCH 01/57] Add document OCR and exam assessment schema --- .../0044_document_assessment_hub.sql | 684 ++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 supabase/migrations/0044_document_assessment_hub.sql diff --git a/supabase/migrations/0044_document_assessment_hub.sql b/supabase/migrations/0044_document_assessment_hub.sql new file mode 100644 index 0000000..b2e3cbe --- /dev/null +++ b/supabase/migrations/0044_document_assessment_hub.sql @@ -0,0 +1,684 @@ +-- Document Assessment Hub: OCR writing imports, reusable school assignments, +-- general exam reading/grading, teacher approval, and student appeals. + +-- --------------------------------------------------------------------------- +-- Writing assignment extensions +-- --------------------------------------------------------------------------- + +alter table public.assignments + add column if not exists vocabulary_requirements text, + add column if not exists pattern_requirements text, + add column if not exists max_points numeric not null default 100, + add column if not exists scoring_breakdown jsonb not null default '{"rubric":100,"vocabulary":0,"patterns":0}'::jsonb, + add column if not exists shared_with_school boolean not null default true; + +do $$ +begin + if not exists ( + select 1 from pg_constraint where conname = 'assignments_max_points_check' + ) then + alter table public.assignments + add constraint assignments_max_points_check check (max_points > 0 and max_points <= 1000); + end if; +end $$; + +create table if not exists public.assignment_reuses ( + id uuid primary key default gen_random_uuid(), + assignment_id uuid not null references public.assignments(id) on delete cascade, + teacher_id uuid not null references public.profiles(id) on delete cascade, + class_id uuid not null references public.school_classes(id) on delete cascade, + created_at timestamptz not null default now(), + unique (assignment_id, teacher_id, class_id) +); + +alter table public.assignment_reuses enable row level security; + +create policy "assignment reuses visible to linked school teachers" +on public.assignment_reuses for select to authenticated +using ( + public.is_current_account_active() + and exists ( + select 1 + from public.assignments a + where a.id = assignment_id + and (public.is_admin() or public.is_teacher_of_school(a.school_id)) + ) +); + +-- Teachers can attach a shared assignment created by another teacher in the +-- same school to classes they themselves teach. The original assignment is +-- reused instead of copied, so the school has one canonical definition. +create or replace function public.adopt_shared_assignment( + p_assignment_id uuid, + p_class_ids uuid[] +) +returns integer +language plpgsql +security definer +set search_path = public, pg_temp +as $$ +declare + v_school_id uuid; + v_count integer := 0; + v_class_id uuid; +begin + if public.app_current_role() <> 'teacher' or not public.is_current_account_active() then + raise exception 'teacher_required' using errcode = '42501'; + end if; + + select school_id into v_school_id + from public.assignments + where id = p_assignment_id + and status = 'published' + and shared_with_school = true; + + if not found or not public.is_teacher_of_school(v_school_id) then + raise exception 'assignment_not_shareable' using errcode = '42501'; + end if; + + foreach v_class_id in array coalesce(p_class_ids, array[]::uuid[]) + loop + if exists ( + select 1 + from public.school_classes c + join public.teacher_classes tc on tc.class_id = c.id + where c.id = v_class_id + and c.school_id = v_school_id + and tc.teacher_id = auth.uid() + ) then + insert into public.assignment_classes (assignment_id, class_id) + values (p_assignment_id, v_class_id) + on conflict do nothing; + + insert into public.assignment_reuses (assignment_id, teacher_id, class_id) + values (p_assignment_id, auth.uid(), v_class_id) + on conflict do nothing; + + v_count := v_count + 1; + end if; + end loop; + + return v_count; +end; +$$; + +revoke all on function public.adopt_shared_assignment(uuid, uuid[]) from public, anon; +grant execute on function public.adopt_shared_assignment(uuid, uuid[]) to authenticated; + +-- --------------------------------------------------------------------------- +-- Secure provider integration secrets +-- --------------------------------------------------------------------------- + +create table if not exists app_private.integration_secrets ( + provider text primary key, + encrypted_value text not null, + updated_by uuid, + updated_at timestamptz not null default now() +); + +revoke all on table app_private.integration_secrets from public, anon, authenticated; + +create or replace function public.admin_set_integration_secret( + p_provider text, + p_encrypted_value text +) +returns void +language plpgsql +security definer +set search_path = app_private, public, pg_temp +as $$ +begin + if not public.is_admin() then + raise exception 'Forbidden' using errcode = '42501'; + end if; + + if length(trim(coalesce(p_provider, ''))) = 0 + or length(trim(coalesce(p_encrypted_value, ''))) < 16 then + raise exception 'invalid_secret_payload' using errcode = '22023'; + end if; + + insert into app_private.integration_secrets (provider, encrypted_value, updated_by, updated_at) + values (lower(trim(p_provider)), p_encrypted_value, auth.uid(), now()) + on conflict (provider) do update + set encrypted_value = excluded.encrypted_value, + updated_by = excluded.updated_by, + updated_at = now(); +end; +$$; + +create or replace function public.integration_secret_status(p_provider text) +returns boolean +language plpgsql +stable +security definer +set search_path = app_private, public, pg_temp +as $$ +begin + if not public.is_current_account_active() then + return false; + end if; + + if public.app_current_role() not in ('teacher', 'super_admin') then + return false; + end if; + + return exists ( + select 1 from app_private.integration_secrets + where provider = lower(trim(p_provider)) + ); +end; +$$; + +create or replace function public.server_get_integration_secret( + p_provider text, + p_server_secret text +) +returns text +language plpgsql +security definer +set search_path = app_private, public, extensions, pg_temp +as $$ +declare + v_value text; +begin + if p_server_secret is null or not exists ( + select 1 + from app_private.server_secret_hashes + where name = 'grading' + and sha256_hex = encode(extensions.digest(p_server_secret, 'sha256'), 'hex') + ) then + raise exception 'Forbidden' using errcode = '42501'; + end if; + + if auth.uid() is null or not public.is_current_account_active() then + raise exception 'Unauthorized' using errcode = '42501'; + end if; + + select encrypted_value into v_value + from app_private.integration_secrets + where provider = lower(trim(p_provider)); + + return v_value; +end; +$$; + +revoke all on function public.admin_set_integration_secret(text, text) from public, anon; +revoke all on function public.integration_secret_status(text) from public, anon; +revoke all on function public.server_get_integration_secret(text, text) from public, anon; +grant execute on function public.admin_set_integration_secret(text, text) to authenticated; +grant execute on function public.integration_secret_status(text) to authenticated; +grant execute on function public.server_get_integration_secret(text, text) to authenticated; + +-- --------------------------------------------------------------------------- +-- General exams +-- --------------------------------------------------------------------------- + +create table if not exists public.exam_definitions ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null references public.schools(id) on delete cascade, + title text not null, + subject text, + instructions text, + max_points numeric not null default 100 check (max_points > 0 and max_points <= 1000), + scoring_notes text, + master_ocr_text text, + master_ocr_markdown text, + master_structure jsonb not null default '{}'::jsonb, + shared_with_school boolean not null default true, + feedback_visible_default boolean not null default false, + status text not null default 'draft' check (status in ('draft', 'ready', 'archived')), + created_by uuid not null references public.profiles(id) on delete cascade, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index if not exists exam_definitions_school_idx on public.exam_definitions(school_id); + +create table if not exists public.document_import_batches ( + id uuid primary key default gen_random_uuid(), + school_id uuid not null references public.schools(id) on delete cascade, + created_by uuid not null references public.profiles(id) on delete cascade, + kind text not null check (kind in ('writing', 'exam_template', 'exam_attempt')), + assignment_id uuid references public.assignments(id) on delete cascade, + exam_id uuid references public.exam_definitions(id) on delete cascade, + title text not null, + source_mode text not null check (source_mode in ('camera', 'gallery', 'zip', 'file', 'cloud_url')), + status text not null default 'open' check (status in ('open', 'processing', 'ready', 'closed', 'failed')), + created_at timestamptz not null default now() +); +create index if not exists document_import_batches_school_idx on public.document_import_batches(school_id); + +create table if not exists public.document_import_items ( + id uuid primary key default gen_random_uuid(), + batch_id uuid not null references public.document_import_batches(id) on delete cascade, + student_id uuid references public.profiles(id) on delete set null, + original_filename text not null, + mime_type text not null, + source_url text, + ocr_text text, + ocr_markdown text, + ocr_payload jsonb not null default '{}'::jsonb, + suggested_student_name text, + suggested_student_identifier text, + confidence numeric, + page_count integer, + ocr_status text not null default 'queued' check (ocr_status in ('queued', 'processing', 'ready', 'failed')), + review_status text not null default 'needs_match' check (review_status in ('needs_match', 'ready_for_grading', 'teacher_review_pending', 'approved', 'returned')), + linked_submission_id uuid references public.submissions(id) on delete set null, + linked_exam_attempt_id uuid, + error_message text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); +create index if not exists document_import_items_batch_idx on public.document_import_items(batch_id); +create index if not exists document_import_items_student_idx on public.document_import_items(student_id); + +create table if not exists public.exam_attempts ( + id uuid primary key default gen_random_uuid(), + exam_id uuid not null references public.exam_definitions(id) on delete cascade, + school_id uuid not null references public.schools(id) on delete cascade, + student_id uuid not null references public.profiles(id) on delete cascade, + document_item_id uuid references public.document_import_items(id) on delete set null, + ocr_text text not null default '', + ocr_markdown text, + status text not null default 'grading_pending' check (status in ('grading_pending', 'analyzing', 'teacher_review_pending', 'approved', 'returned', 'grading_failed')), + ai_score numeric, + final_score numeric, + ai_feedback jsonb not null default '{}'::jsonb, + teacher_feedback text, + feedback_visible boolean not null default false, + reviewed_by uuid references public.profiles(id) on delete set null, + reviewed_at timestamptz, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + unique (exam_id, student_id, document_item_id) +); +create index if not exists exam_attempts_exam_idx on public.exam_attempts(exam_id); +create index if not exists exam_attempts_student_idx on public.exam_attempts(student_id); + +alter table public.document_import_items + drop constraint if exists document_import_items_linked_exam_attempt_id_fkey; +alter table public.document_import_items + add constraint document_import_items_linked_exam_attempt_id_fkey + foreign key (linked_exam_attempt_id) references public.exam_attempts(id) on delete set null; + +create table if not exists public.exam_question_scores ( + id uuid primary key default gen_random_uuid(), + attempt_id uuid not null references public.exam_attempts(id) on delete cascade, + question_key text not null, + question_label text not null, + max_score numeric not null, + ai_score numeric not null, + teacher_score numeric, + explanation text not null default '', + evidence_quote text, + feedback text, + sort_order integer not null default 0, + unique (attempt_id, question_key) +); + +create table if not exists public.exam_appeals ( + id uuid primary key default gen_random_uuid(), + attempt_id uuid not null references public.exam_attempts(id) on delete cascade, + student_id uuid not null references public.profiles(id) on delete cascade, + reason text not null, + status text not null default 'submitted' check (status in ('submitted', 'reviewing', 'accepted', 'rejected')), + teacher_response text, + resolved_by uuid references public.profiles(id) on delete set null, + resolved_at timestamptz, + created_at timestamptz not null default now() +); +create index if not exists exam_appeals_attempt_idx on public.exam_appeals(attempt_id); + +alter table public.exam_definitions enable row level security; +alter table public.document_import_batches enable row level security; +alter table public.document_import_items enable row level security; +alter table public.exam_attempts enable row level security; +alter table public.exam_question_scores enable row level security; +alter table public.exam_appeals enable row level security; + +create policy "exam definitions readable by school teachers" +on public.exam_definitions for select to authenticated +using ( + public.is_current_account_active() + and (public.is_admin() or public.is_teacher_of_school(school_id)) +); +create policy "exam definitions writable by school teachers" +on public.exam_definitions for all to authenticated +using (public.is_admin() or public.is_teacher_of_school(school_id)) +with check (public.is_admin() or (public.is_teacher_of_school(school_id) and created_by = auth.uid())); + +create policy "document batches readable by school teachers" +on public.document_import_batches for select to authenticated +using (public.is_admin() or public.is_teacher_of_school(school_id)); +create policy "document batches insertable by school teachers" +on public.document_import_batches for insert to authenticated +with check (public.is_admin() or (public.is_teacher_of_school(school_id) and created_by = auth.uid())); +create policy "document batches updateable by school teachers" +on public.document_import_batches for update to authenticated +using (public.is_admin() or public.is_teacher_of_school(school_id)) +with check (public.is_admin() or public.is_teacher_of_school(school_id)); + +create policy "document items readable by batch school teachers" +on public.document_import_items for select to authenticated +using ( + exists ( + select 1 from public.document_import_batches b + where b.id = batch_id and (public.is_admin() or public.is_teacher_of_school(b.school_id)) + ) +); +create policy "document items insertable by batch school teachers" +on public.document_import_items for insert to authenticated +with check ( + exists ( + select 1 from public.document_import_batches b + where b.id = batch_id and (public.is_admin() or public.is_teacher_of_school(b.school_id)) + ) +); +create policy "document items updateable by batch school teachers" +on public.document_import_items for update to authenticated +using ( + exists ( + select 1 from public.document_import_batches b + where b.id = batch_id and (public.is_admin() or public.is_teacher_of_school(b.school_id)) + ) +) +with check ( + exists ( + select 1 from public.document_import_batches b + where b.id = batch_id and (public.is_admin() or public.is_teacher_of_school(b.school_id)) + ) +); + +create policy "exam attempts visible to teachers or permitted student" +on public.exam_attempts for select to authenticated +using ( + public.is_admin() + or public.is_teacher_of_school(school_id) + or (student_id = auth.uid() and status = 'approved' and feedback_visible = true) +); +create policy "exam attempts writable by school teachers" +on public.exam_attempts for all to authenticated +using (public.is_admin() or public.is_teacher_of_school(school_id)) +with check (public.is_admin() or public.is_teacher_of_school(school_id)); + +create policy "exam question scores visible through attempt" +on public.exam_question_scores for select to authenticated +using ( + exists ( + select 1 from public.exam_attempts a + where a.id = attempt_id + and ( + public.is_admin() + or public.is_teacher_of_school(a.school_id) + or (a.student_id = auth.uid() and a.status = 'approved' and a.feedback_visible = true) + ) + ) +); +create policy "exam question scores writable by school teachers" +on public.exam_question_scores for all to authenticated +using ( + exists ( + select 1 from public.exam_attempts a + where a.id = attempt_id and (public.is_admin() or public.is_teacher_of_school(a.school_id)) + ) +) +with check ( + exists ( + select 1 from public.exam_attempts a + where a.id = attempt_id and (public.is_admin() or public.is_teacher_of_school(a.school_id)) + ) +); + +create policy "exam appeals visible to owner and school teachers" +on public.exam_appeals for select to authenticated +using ( + student_id = auth.uid() + or exists ( + select 1 from public.exam_attempts a + where a.id = attempt_id and (public.is_admin() or public.is_teacher_of_school(a.school_id)) + ) +); +create policy "students may appeal visible approved exams" +on public.exam_appeals for insert to authenticated +with check ( + student_id = auth.uid() + and length(trim(reason)) >= 5 + and exists ( + select 1 from public.exam_attempts a + where a.id = attempt_id + and a.student_id = auth.uid() + and a.status = 'approved' + and a.feedback_visible = true + ) +); +create policy "school teachers may resolve appeals" +on public.exam_appeals for update to authenticated +using ( + exists ( + select 1 from public.exam_attempts a + where a.id = attempt_id and (public.is_admin() or public.is_teacher_of_school(a.school_id)) + ) +) +with check ( + exists ( + select 1 from public.exam_attempts a + where a.id = attempt_id and (public.is_admin() or public.is_teacher_of_school(a.school_id)) + ) +); + +-- --------------------------------------------------------------------------- +-- OCR writing -> existing submission pipeline +-- --------------------------------------------------------------------------- + +create table if not exists app_private.server_import_write_context ( + transaction_id bigint not null, + student_id uuid not null, + primary key (transaction_id, student_id) +); +revoke all on table app_private.server_import_write_context from public, anon, authenticated; + +create or replace function public.guard_submission_insert() +returns trigger +language plpgsql +security definer +set search_path = public, auth, app_private, pg_temp +as $$ +declare + expected_school_id uuid; + expected_writing_type text; + expected_level text; + expected_score_visibility boolean; + expected_custom_rubric boolean; +begin + if exists ( + select 1 from app_private.server_import_write_context ctx + where ctx.transaction_id = txid_current() + and ctx.student_id = new.student_id + ) then + return new; + end if; + + if auth.role() = 'service_role' or public.is_admin() then + return new; + end if; + + if not public.is_current_account_active() + or public.app_current_role() <> 'student' + or new.student_id <> auth.uid() then + raise exception 'submission_insert_forbidden' using errcode = '42501'; + end if; + + if new.status not in ('not_started', 'in_progress') then + raise exception 'invalid_initial_submission_status' using errcode = '42501'; + end if; + + if new.ai_score is not null + or new.final_score is not null + or new.teacher_feedback is not null + or new.reviewed_by is not null + or new.reviewed_at is not null then + raise exception 'server_managed_submission_fields' using errcode = '42501'; + end if; + + if new.assignment_id is null then + if not new.is_practice or new.school_id <> public.current_school_id() then + raise exception 'invalid_practice_submission' using errcode = '42501'; + end if; + new.score_visible_to_student := true; + new.uses_custom_rubric := false; + else + select + a.school_id, + a.writing_type_id, + a.level, + a.show_ai_score_immediately, + r.is_custom + into + expected_school_id, + expected_writing_type, + expected_level, + expected_score_visibility, + expected_custom_rubric + from public.assignments a + join public.assignment_rubrics r on r.id = a.rubric_id + where a.id = new.assignment_id + and a.status = 'published' + and a.school_id = public.current_school_id() + and exists ( + select 1 + from public.assignment_classes ac + join public.student_classes sc on sc.class_id = ac.class_id + where ac.assignment_id = a.id and sc.student_id = auth.uid() + ); + + if not found + or new.is_practice + or new.school_id <> expected_school_id + or new.writing_type_id <> expected_writing_type + or new.level <> expected_level then + raise exception 'invalid_assignment_submission' using errcode = '42501'; + end if; + + new.score_visible_to_student := expected_score_visibility; + new.uses_custom_rubric := expected_custom_rubric; + end if; + + return new; +end; +$$; + +create or replace function public.create_ocr_submission( + p_item_id uuid, + p_assignment_id uuid, + p_student_id uuid +) +returns uuid +language plpgsql +security definer +set search_path = public, app_private, pg_temp +as $$ +declare + v_item public.document_import_items%rowtype; + v_batch public.document_import_batches%rowtype; + v_assignment public.assignments%rowtype; + v_submission_id uuid; + v_existing_id uuid; +begin + if public.app_current_role() <> 'teacher' or not public.is_current_account_active() then + raise exception 'teacher_required' using errcode = '42501'; + end if; + + select * into v_item from public.document_import_items where id = p_item_id for update; + if not found or v_item.ocr_status <> 'ready' or length(trim(coalesce(v_item.ocr_text, ''))) = 0 then + raise exception 'ocr_item_not_ready' using errcode = '22023'; + end if; + + select * into v_batch from public.document_import_batches where id = v_item.batch_id; + if not found or v_batch.kind <> 'writing' or v_batch.assignment_id <> p_assignment_id then + raise exception 'invalid_writing_batch' using errcode = '22023'; + end if; + + if not public.is_teacher_of_school(v_batch.school_id) then + raise exception 'Forbidden' using errcode = '42501'; + end if; + + select * into v_assignment + from public.assignments + where id = p_assignment_id + and school_id = v_batch.school_id + and status = 'published'; + if not found then + raise exception 'assignment_not_found' using errcode = 'P0002'; + end if; + + if not exists ( + select 1 + from public.profiles p + where p.id = p_student_id + and p.role = 'student' + and p.school_id = v_batch.school_id + and p.status = 'active' + ) or not exists ( + select 1 + from public.assignment_classes ac + join public.student_classes sc on sc.class_id = ac.class_id + where ac.assignment_id = p_assignment_id + and sc.student_id = p_student_id + ) then + raise exception 'student_not_assigned' using errcode = '42501'; + end if; + + select id into v_existing_id + from public.submissions + where assignment_id = p_assignment_id and student_id = p_student_id + order by created_at desc + limit 1; + + if v_existing_id is not null then + update public.document_import_items + set student_id = p_student_id, + linked_submission_id = v_existing_id, + review_status = 'ready_for_grading', + updated_at = now() + where id = p_item_id; + return v_existing_id; + end if; + + insert into app_private.server_import_write_context (transaction_id, student_id) + values (txid_current(), p_student_id) + on conflict do nothing; + + insert into public.submissions ( + assignment_id, is_practice, student_id, school_id, writing_type_id, level, + topic_title, text, word_count, status, submitted_at, last_saved_at, + score_visible_to_student, uses_custom_rubric + ) + values ( + p_assignment_id, false, p_student_id, v_batch.school_id, v_assignment.writing_type_id, + v_assignment.level, v_assignment.title, v_item.ocr_text, + case when length(trim(v_item.ocr_text)) = 0 then 0 else array_length(regexp_split_to_array(trim(v_item.ocr_text), '\\s+'), 1) end, + 'submitted', now(), now(), false, true + ) + returning id into v_submission_id; + + delete from app_private.server_import_write_context + where transaction_id = txid_current() and student_id = p_student_id; + + update public.document_import_items + set student_id = p_student_id, + linked_submission_id = v_submission_id, + review_status = 'ready_for_grading', + updated_at = now() + where id = p_item_id; + + return v_submission_id; +end; +$$; + +revoke all on function public.create_ocr_submission(uuid, uuid, uuid) from public, anon; +grant execute on function public.create_ocr_submission(uuid, uuid, uuid) to authenticated; + +comment on table public.document_import_batches is 'Teacher-created OCR import batches for handwritten writings and exams.'; +comment on table public.document_import_items is 'OCR results and human matching state for each imported document.'; +comment on table public.exam_attempts is 'AI-graded general exam attempts that require teacher approval before release.'; +comment on function public.create_ocr_submission(uuid, uuid, uuid) is 'Converts a teacher-reviewed OCR writing into the existing submission pipeline without granting broad teacher insert rights.'; From 1777d95ea9f018e9c1a23a012643e292a6934946 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:42:53 +0300 Subject: [PATCH 02/57] Add secure Mistral OCR and exam grading service --- server/documentAssessment.ts | 542 +++++++++++++++++++++++++++++++++++ 1 file changed, 542 insertions(+) create mode 100644 server/documentAssessment.ts diff --git a/server/documentAssessment.ts b/server/documentAssessment.ts new file mode 100644 index 0000000..09ba07a --- /dev/null +++ b/server/documentAssessment.ts @@ -0,0 +1,542 @@ +import { createHash, createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; +import { createRequesterClient } from './submissionLookup.ts'; + +interface DocumentAssessmentDeps { + supabaseUrl: string; + anonKey: string; + gradingServerSecret: string; + geminiApiKey: string; + integrationEncryptionKey?: string; +} + +interface RequesterProfile { + id: string; + role: 'student' | 'teacher' | 'super_admin'; + status: string; + expires_at?: string | null; +} + +interface OcrInput { + itemId: string; + dataBase64?: string; + mimeType?: string; + sourceUrl?: string; +} + +interface OcrPage { + markdown?: string; + confidence?: number; +} + +interface OcrResponse { + pages?: OcrPage[]; + document_annotation?: string | Record | null; + model?: string; +} + +interface OcrAnnotation { + studentName?: string; + studentIdentifier?: string; + answerText?: string; + documentType?: string; +} + +interface ExamModelQuestion { + key: string; + label: string; + maxPoints: number; + score: number; + explanation: string; + evidenceQuote?: string; + feedback?: string; +} + +interface ExamModelResult { + overallPercent: number; + summary: string; + questions: ExamModelQuestion[]; +} + +const ALLOWED_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/webp', + 'image/avif', + 'application/pdf', +]); +const MAX_DOCUMENT_BYTES = 10 * 1024 * 1024; +const MISTRAL_OCR_URL = 'https://api.mistral.ai/v1/ocr'; +const MISTRAL_MODELS_URL = 'https://api.mistral.ai/v1/models'; +const GEMINI_MODEL = process.env.GEMINI_MODEL?.trim() || 'gemma-4-31b-it'; +const GEMINI_URL = `https://generativelanguage.googleapis.com/v1beta/models/${GEMINI_MODEL}:generateContent`; + +const OCR_ANNOTATION_SCHEMA = { + type: 'object', + properties: { + studentName: { type: 'string' }, + studentIdentifier: { type: 'string' }, + answerText: { type: 'string' }, + documentType: { type: 'string' }, + }, + required: ['answerText'], + additionalProperties: false, +}; + +const EXAM_RESPONSE_SCHEMA = { + type: 'OBJECT', + properties: { + overallPercent: { type: 'NUMBER' }, + summary: { type: 'STRING' }, + questions: { + type: 'ARRAY', + minItems: 1, + items: { + type: 'OBJECT', + properties: { + key: { type: 'STRING' }, + label: { type: 'STRING' }, + maxPoints: { type: 'NUMBER' }, + score: { type: 'NUMBER' }, + explanation: { type: 'STRING' }, + evidenceQuote: { type: 'STRING' }, + feedback: { type: 'STRING' }, + }, + required: ['key', 'label', 'maxPoints', 'score', 'explanation'], + }, + }, + }, + required: ['overallPercent', 'summary', 'questions'], +} as const; + +function bearerToken(authHeader: string | undefined): string | null { + return authHeader?.startsWith('Bearer ') ? authHeader.slice('Bearer '.length) : null; +} + +async function requesterProfile(token: string, deps: DocumentAssessmentDeps): Promise { + const client = createRequesterClient(token, deps); + const { data: authData, error: authError } = await client.auth.getUser(token); + if (authError || !authData.user) return null; + + const { data, error } = await client + .from('profiles') + .select('id, role, status, expires_at') + .eq('id', authData.user.id) + .maybeSingle(); + if (error || !data || data.status !== 'active') return null; + if (data.expires_at && new Date(data.expires_at).getTime() <= Date.now()) return null; + return data; +} + +function encryptionKey(deps: DocumentAssessmentDeps): Buffer { + const seed = deps.integrationEncryptionKey?.trim() || deps.gradingServerSecret; + return createHash('sha256').update(seed, 'utf8').digest(); +} + +function encryptSecret(value: string, deps: DocumentAssessmentDeps): string { + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', encryptionKey(deps), iv); + const ciphertext = Buffer.concat([cipher.update(value, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return `${iv.toString('base64url')}.${tag.toString('base64url')}.${ciphertext.toString('base64url')}`; +} + +function decryptSecret(value: string, deps: DocumentAssessmentDeps): string { + const [ivPart, tagPart, ciphertextPart] = value.split('.'); + if (!ivPart || !tagPart || !ciphertextPart) throw new Error('integration_secret_corrupt'); + const decipher = createDecipheriv('aes-256-gcm', encryptionKey(deps), Buffer.from(ivPart, 'base64url')); + decipher.setAuthTag(Buffer.from(tagPart, 'base64url')); + return Buffer.concat([ + decipher.update(Buffer.from(ciphertextPart, 'base64url')), + decipher.final(), + ]).toString('utf8'); +} + +async function testMistralKey(apiKey: string): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 12_000); + try { + const response = await fetch(MISTRAL_MODELS_URL, { + headers: { Authorization: `Bearer ${apiKey}` }, + signal: controller.signal, + }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + +async function loadMistralKey(token: string, deps: DocumentAssessmentDeps): Promise { + const client = createRequesterClient(token, deps); + const { data, error } = await client.rpc('server_get_integration_secret', { + p_provider: 'mistral', + p_server_secret: deps.gradingServerSecret, + }); + if (error || typeof data !== 'string' || !data) throw new Error('mistral_not_configured'); + return decryptSecret(data, deps); +} + +function safeCloudUrl(raw: string): string { + const url = new URL(raw); + if (url.protocol !== 'https:') throw new Error('cloud_url_must_be_https'); + const host = url.hostname.toLowerCase(); + const allowed = [ + 'drive.google.com', + 'docs.google.com', + 'storage.googleapis.com', + 'dropbox.com', + 'www.dropbox.com', + '1drv.ms', + 'onedrive.live.com', + ]; + if (!allowed.includes(host)) throw new Error('cloud_host_not_allowed'); + + if (host === 'drive.google.com') { + const match = url.pathname.match(/\/file\/d\/([^/]+)/); + if (match?.[1]) return `https://drive.google.com/uc?export=download&id=${encodeURIComponent(match[1])}`; + const id = url.searchParams.get('id'); + if (id) return `https://drive.google.com/uc?export=download&id=${encodeURIComponent(id)}`; + } + if (host.endsWith('dropbox.com')) { + url.searchParams.set('dl', '1'); + } + return url.toString(); +} + +function parseAnnotation(raw: OcrResponse['document_annotation']): OcrAnnotation { + if (!raw) return {}; + if (typeof raw === 'object') return raw as OcrAnnotation; + try { + return JSON.parse(raw) as OcrAnnotation; + } catch { + return {}; + } +} + +async function runMistralOcr(input: OcrInput, apiKey: string): Promise<{ + text: string; + markdown: string; + payload: OcrResponse; + annotation: OcrAnnotation; + pageCount: number; + confidence?: number; +}> { + let document: Record; + if (input.sourceUrl) { + document = { type: 'document_url', document_url: safeCloudUrl(input.sourceUrl) }; + } else { + const mime = (input.mimeType || '').toLowerCase(); + if (!ALLOWED_MIME_TYPES.has(mime)) throw new Error('unsupported_document_type'); + if (!input.dataBase64) throw new Error('document_data_missing'); + const byteLength = Buffer.byteLength(input.dataBase64, 'base64'); + if (byteLength <= 0 || byteLength > MAX_DOCUMENT_BYTES) throw new Error('document_too_large'); + const dataUrl = `data:${mime};base64,${input.dataBase64}`; + document = mime.startsWith('image/') + ? { type: 'image_url', image_url: dataUrl } + : { type: 'document_url', document_url: dataUrl }; + } + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 75_000); + try { + const response = await fetch(MISTRAL_OCR_URL, { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + signal: controller.signal, + body: JSON.stringify({ + model: 'mistral-ocr-latest', + document, + include_blocks: true, + confidence_scores_granularity: 'page', + document_annotation_format: { + type: 'json_schema', + json_schema: { + name: 'student_document', + strict: true, + schema: OCR_ANNOTATION_SCHEMA, + }, + }, + document_annotation_prompt: + 'Extract the main handwritten or typed student response. If a student name or student number is visible, return it. Do not invent missing identity information. Preserve the response text faithfully.', + }), + }); + if (response.status === 429 || response.status === 503) throw new Error('mistral_temporarily_unavailable'); + if (!response.ok) { + const body = await response.text(); + throw new Error(`mistral_ocr_failed_${response.status}:${body.slice(0, 240)}`); + } + const payload = (await response.json()) as OcrResponse; + const markdown = (payload.pages ?? []).map((page) => page.markdown ?? '').filter(Boolean).join('\n\n').trim(); + const annotation = parseAnnotation(payload.document_annotation); + const text = (annotation.answerText || markdown).trim(); + const confidences = (payload.pages ?? []) + .map((page) => page.confidence) + .filter((value): value is number => typeof value === 'number' && Number.isFinite(value)); + const confidence = confidences.length + ? confidences.reduce((sum, value) => sum + value, 0) / confidences.length + : undefined; + return { text, markdown, payload, annotation, pageCount: payload.pages?.length ?? 0, confidence }; + } finally { + clearTimeout(timeout); + } +} + +export async function getIntegrationStatus( + authHeader: string | undefined, + deps: DocumentAssessmentDeps, +): Promise<{ mistral: boolean }> { + const token = bearerToken(authHeader); + if (!token || !(await requesterProfile(token, deps))) throw new Error('Unauthorized'); + const client = createRequesterClient(token, deps); + const { data } = await client.rpc('integration_secret_status', { p_provider: 'mistral' }); + return { mistral: data === true }; +} + +export async function configureMistralKey( + authHeader: string | undefined, + apiKey: string, + deps: DocumentAssessmentDeps, +): Promise<{ ok: true }> { + const token = bearerToken(authHeader); + if (!token) throw new Error('Unauthorized'); + const profile = await requesterProfile(token, deps); + if (!profile || profile.role !== 'super_admin') throw new Error('Forbidden'); + const trimmed = apiKey.trim(); + if (trimmed.length < 16) throw new Error('invalid_mistral_key'); + if (!(await testMistralKey(trimmed))) throw new Error('mistral_key_rejected'); + + const client = createRequesterClient(token, deps); + const { error } = await client.rpc('admin_set_integration_secret', { + p_provider: 'mistral', + p_encrypted_value: encryptSecret(trimmed, deps), + }); + if (error) throw new Error(`integration_save_failed:${error.message}`); + return { ok: true }; +} + +export async function processDocumentOcr( + authHeader: string | undefined, + input: OcrInput, + deps: DocumentAssessmentDeps, +): Promise<{ itemId: string; textLength: number; pageCount: number }> { + const token = bearerToken(authHeader); + if (!token) throw new Error('Unauthorized'); + const profile = await requesterProfile(token, deps); + if (!profile || (profile.role !== 'teacher' && profile.role !== 'super_admin')) throw new Error('Forbidden'); + + const client = createRequesterClient(token, deps); + const { data: item, error: itemError } = await client + .from('document_import_items') + .select('id, batch_id, original_filename') + .eq('id', input.itemId) + .maybeSingle(); + if (itemError || !item) throw new Error('document_item_not_found'); + + await client.from('document_import_items').update({ ocr_status: 'processing', error_message: null }).eq('id', input.itemId); + try { + const apiKey = await loadMistralKey(token, deps); + const result = await runMistralOcr(input, apiKey); + if (!result.text) throw new Error('ocr_returned_empty_text'); + + const { data: batch } = await client + .from('document_import_batches') + .select('kind, exam_id') + .eq('id', item.batch_id) + .maybeSingle(); + + const { error: updateError } = await client + .from('document_import_items') + .update({ + ocr_text: result.text, + ocr_markdown: result.markdown, + ocr_payload: result.payload, + suggested_student_name: result.annotation.studentName || null, + suggested_student_identifier: result.annotation.studentIdentifier || null, + confidence: result.confidence ?? null, + page_count: result.pageCount, + ocr_status: 'ready', + review_status: 'needs_match', + updated_at: new Date().toISOString(), + }) + .eq('id', input.itemId); + if (updateError) throw new Error(`ocr_persist_failed:${updateError.message}`); + + if (batch?.kind === 'exam_template' && batch.exam_id) { + await client + .from('exam_definitions') + .update({ + master_ocr_text: result.text, + master_ocr_markdown: result.markdown, + master_structure: result.payload, + status: 'ready', + updated_at: new Date().toISOString(), + }) + .eq('id', batch.exam_id); + } + + return { itemId: input.itemId, textLength: result.text.length, pageCount: result.pageCount }; + } catch (error) { + const message = error instanceof Error ? error.message : 'ocr_failed'; + await client + .from('document_import_items') + .update({ ocr_status: 'failed', error_message: message.slice(0, 500), updated_at: new Date().toISOString() }) + .eq('id', input.itemId); + throw error; + } +} + +function parseGeminiJson(raw: string): ExamModelResult { + const clean = raw.trim().replace(/^```(?:json)?\s*/i, '').replace(/```\s*$/, ''); + const parsed = JSON.parse(clean) as ExamModelResult; + if (!Array.isArray(parsed.questions) || parsed.questions.length === 0) throw new Error('exam_grading_invalid_questions'); + if (!Number.isFinite(parsed.overallPercent)) throw new Error('exam_grading_invalid_score'); + return parsed; +} + +async function gradeExamWithGemini(params: { + blankText: string; + studentText: string; + examTitle: string; + maxPoints: number; + scoringNotes?: string | null; + geminiApiKey: string; +}): Promise { + const prompt = `You are an expert teacher grading a scanned student exam using a blank exam template and the student's OCR transcript. + +Important rules: +- The blank template defines the questions, instructions, answer areas, and any printed reference text. Printed text appearing in both documents is NOT a student answer. +- Infer question boundaries conservatively. Do not invent questions that are not present in the blank template. +- Grade only what can be supported by the student's OCR transcript. +- If OCR is ambiguous, lower confidence in the explanation instead of inventing an answer. +- Return question-level feedback explaining what is correct, what is wrong or missing, and what the expected answer/approach was. +- evidenceQuote, when present, must be copied verbatim from the STUDENT OCR text. +- Scores must be non-negative and must not exceed each question's maxPoints. +- The requested exam total is ${params.maxPoints} points. Your question maxPoints should sum approximately to that total; the server will normalize precisely. +${params.scoringNotes ? `Teacher scoring notes: ${params.scoringNotes}\n` : ''} + +EXAM: ${params.examTitle} +--- BLANK EXAM OCR --- +${params.blankText} +--- END BLANK EXAM OCR --- + +--- STUDENT FILLED EXAM OCR --- +${params.studentText} +--- END STUDENT FILLED EXAM OCR ---`; + + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 90_000); + try { + const response = await fetch(GEMINI_URL, { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-goog-api-key': params.geminiApiKey }, + signal: controller.signal, + body: JSON.stringify({ + contents: [{ role: 'user', parts: [{ text: prompt }] }], + generationConfig: { + maxOutputTokens: 6144, + responseMimeType: 'application/json', + responseSchema: EXAM_RESPONSE_SCHEMA, + }, + }), + }); + if (!response.ok) { + const body = await response.text(); + throw new Error(`exam_grading_model_failed_${response.status}:${body.slice(0, 300)}`); + } + const json = (await response.json()) as { candidates?: { content?: { parts?: { text?: string }[] } }[] }; + const raw = json.candidates?.[0]?.content?.parts?.map((part) => part.text || '').join('') || ''; + if (!raw) throw new Error('exam_grading_model_empty'); + return parseGeminiJson(raw); + } finally { + clearTimeout(timeout); + } +} + +export async function gradeExamAttempt( + authHeader: string | undefined, + attemptId: string, + deps: DocumentAssessmentDeps, +): Promise<{ attemptId: string; score: number }> { + const token = bearerToken(authHeader); + if (!token) throw new Error('Unauthorized'); + const profile = await requesterProfile(token, deps); + if (!profile || (profile.role !== 'teacher' && profile.role !== 'super_admin')) throw new Error('Forbidden'); + + const client = createRequesterClient(token, deps); + const { data: attempt, error: attemptError } = await client + .from('exam_attempts') + .select('id, exam_id, school_id, student_id, document_item_id, ocr_text, status') + .eq('id', attemptId) + .maybeSingle(); + if (attemptError || !attempt) throw new Error('exam_attempt_not_found'); + + const { data: exam, error: examError } = await client + .from('exam_definitions') + .select('id, title, max_points, scoring_notes, master_ocr_text') + .eq('id', attempt.exam_id) + .maybeSingle(); + if (examError || !exam || !exam.master_ocr_text) throw new Error('exam_template_not_ready'); + if (!attempt.ocr_text?.trim()) throw new Error('exam_attempt_ocr_empty'); + + await client.from('exam_attempts').update({ status: 'analyzing', updated_at: new Date().toISOString() }).eq('id', attemptId); + try { + const result = await gradeExamWithGemini({ + blankText: exam.master_ocr_text, + studentText: attempt.ocr_text, + examTitle: exam.title, + maxPoints: Number(exam.max_points), + scoringNotes: exam.scoring_notes, + geminiApiKey: deps.geminiApiKey, + }); + + const rawMax = result.questions.reduce((sum, question) => sum + Math.max(0, Number(question.maxPoints) || 0), 0) || 1; + const targetMax = Number(exam.max_points); + const normalized = result.questions.map((question, index) => { + const qMax = Math.max(0.01, Number(question.maxPoints) || 0.01); + const maxScore = (qMax / rawMax) * targetMax; + const boundedRawScore = Math.max(0, Math.min(qMax, Number(question.score) || 0)); + const score = (boundedRawScore / qMax) * maxScore; + return { + attempt_id: attemptId, + question_key: question.key || `q_${index + 1}`, + question_label: question.label || `Question ${index + 1}`, + max_score: Number(maxScore.toFixed(2)), + ai_score: Number(score.toFixed(2)), + explanation: question.explanation || '', + evidence_quote: question.evidenceQuote || null, + feedback: question.feedback || null, + sort_order: index, + }; + }); + const finalScore = Number(normalized.reduce((sum, question) => sum + question.ai_score, 0).toFixed(2)); + + await client.from('exam_question_scores').delete().eq('attempt_id', attemptId); + const { error: insertError } = await client.from('exam_question_scores').insert(normalized); + if (insertError) throw new Error(`exam_scores_persist_failed:${insertError.message}`); + + const { error: updateError } = await client + .from('exam_attempts') + .update({ + ai_score: finalScore, + final_score: finalScore, + ai_feedback: { summary: result.summary, rawOverallPercent: result.overallPercent }, + status: 'teacher_review_pending', + feedback_visible: false, + updated_at: new Date().toISOString(), + }) + .eq('id', attemptId); + if (updateError) throw new Error(`exam_attempt_persist_failed:${updateError.message}`); + + if (attempt.document_item_id) { + await client.from('document_import_items').update({ review_status: 'teacher_review_pending' }).eq('id', attempt.document_item_id); + } + return { attemptId, score: finalScore }; + } catch (error) { + await client.from('exam_attempts').update({ status: 'grading_failed', updated_at: new Date().toISOString() }).eq('id', attemptId); + throw error; + } +} From 64d1f41c20cdbfd0aec997e27e705e52afc21a90 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:43:39 +0300 Subject: [PATCH 03/57] Expose OCR, integration and exam grading endpoints --- server/documentAssessmentRoutes.ts | 116 +++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) create mode 100644 server/documentAssessmentRoutes.ts diff --git a/server/documentAssessmentRoutes.ts b/server/documentAssessmentRoutes.ts new file mode 100644 index 0000000..139c9d5 --- /dev/null +++ b/server/documentAssessmentRoutes.ts @@ -0,0 +1,116 @@ +import type express from 'express'; +import rateLimit, { ipKeyGenerator } from 'express-rate-limit'; +import { z, ZodError } from 'zod'; +import { + configureMistralKey, + getIntegrationStatus, + gradeExamAttempt, + processDocumentOcr, +} from './documentAssessment.ts'; + +interface RouteDeps { + supabaseUrl: string; + anonKey: string; + gradingServerSecret: string; + geminiApiKey: string; + integrationEncryptionKey?: string; +} + +const authenticatedKey = (req: express.Request) => + req.headers.authorization ?? ipKeyGenerator(req.ip ?? 'unknown'); + +const documentLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 60, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: authenticatedKey, + message: { error: 'Too many document-processing requests. Please wait a few minutes and try again.' }, +}); + +const integrationLimiter = rateLimit({ + windowMs: 15 * 60 * 1000, + limit: 10, + standardHeaders: true, + legacyHeaders: false, + keyGenerator: authenticatedKey, + message: { error: 'Too many integration requests. Please wait a few minutes and try again.' }, +}); + +const ocrSchema = z.object({ + itemId: z.string().uuid(), + dataBase64: z.string().max(14_500_000).optional(), + mimeType: z.string().max(100).optional(), + sourceUrl: z.string().url().max(2_000).optional(), +}).refine((value) => Boolean(value.sourceUrl || (value.dataBase64 && value.mimeType)), { + message: 'Either sourceUrl or dataBase64 + mimeType is required', +}); + +const mistralKeySchema = z.object({ apiKey: z.string().trim().min(16).max(500) }); +const attemptIdSchema = z.string().uuid(); + +function errorStatus(message: string): number { + if (message === 'Unauthorized') return 401; + if (message === 'Forbidden') return 403; + if (message.includes('not_found')) return 404; + if (message.includes('not_configured') || message.includes('template_not_ready')) return 503; + if (message.includes('temporarily_unavailable')) return 503; + if (message.includes('too_large')) return 413; + if (message.includes('unsupported') || message.includes('invalid') || message.includes('must_be_https') || message.includes('not_allowed')) return 400; + return 502; +} + +export function registerDocumentAssessmentRoutes(app: express.Express, deps: RouteDeps): void { + app.get('/api/integrations/status', integrationLimiter, async (req, res) => { + try { + res.json(await getIntegrationStatus(req.headers.authorization, deps)); + } catch (error) { + const message = error instanceof Error ? error.message : 'integration_status_failed'; + res.status(errorStatus(message)).json({ error: message }); + } + }); + + app.post('/api/admin/integrations/mistral', integrationLimiter, async (req, res) => { + try { + const body = mistralKeySchema.parse(req.body); + res.json(await configureMistralKey(req.headers.authorization, body.apiKey, deps)); + } catch (error) { + if (error instanceof ZodError) { + res.status(400).json({ error: 'Invalid request body', details: error.issues }); + return; + } + const message = error instanceof Error ? error.message : 'integration_config_failed'; + res.status(errorStatus(message)).json({ error: message }); + } + }); + + app.post('/api/documents/ocr', documentLimiter, async (req, res) => { + try { + const body = ocrSchema.parse(req.body); + res.json(await processDocumentOcr(req.headers.authorization, body, deps)); + } catch (error) { + if (error instanceof ZodError) { + res.status(400).json({ error: 'Invalid request body', details: error.issues }); + return; + } + const message = error instanceof Error ? error.message : 'ocr_failed'; + console.error('[ocr] request failed:', message); + res.status(errorStatus(message)).json({ error: message }); + } + }); + + app.post('/api/exam-attempts/:attemptId/grade', documentLimiter, async (req, res) => { + try { + const attemptId = attemptIdSchema.parse(req.params.attemptId); + res.json(await gradeExamAttempt(req.headers.authorization, attemptId, deps)); + } catch (error) { + if (error instanceof ZodError) { + res.status(400).json({ error: 'Invalid attempt ID' }); + return; + } + const message = error instanceof Error ? error.message : 'exam_grading_failed'; + console.error('[exam-grading] request failed:', message); + res.status(errorStatus(message)).json({ error: message }); + } + }); +} From 0fbddb94cd4f4ab66c91ad5a0e5c468d4645a1c8 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:44:42 +0300 Subject: [PATCH 04/57] Register document assessment API routes --- server/index.ts | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/server/index.ts b/server/index.ts index 07e28bb..9bbfb52 100644 --- a/server/index.ts +++ b/server/index.ts @@ -4,6 +4,7 @@ import { z, ZodError } from 'zod'; import { requestPasswordReset } from './passwordReset.ts'; import { deleteUserAccount } from './adminActions.ts'; import { gradeAndPersistSubmission, SubmissionGradingError } from './submissionGrading.ts'; +import { registerDocumentAssessmentRoutes } from './documentAssessmentRoutes.ts'; try { process.loadEnvFile(); @@ -17,6 +18,7 @@ const SUPABASE_URL = process.env.SUPABASE_URL; const SUPABASE_ANON_KEY = process.env.SUPABASE_ANON_KEY; const SUPABASE_SERVICE_ROLE_KEY = process.env.SUPABASE_SERVICE_ROLE_KEY; const GRADING_SERVER_SECRET = process.env.GRADING_SERVER_SECRET; +const INTEGRATION_ENCRYPTION_KEY = process.env.INTEGRATION_ENCRYPTION_KEY; const RESEND_API_KEY = process.env.RESEND_API_KEY; const RESEND_FROM_EMAIL = process.env.RESEND_FROM_EMAIL; const APP_ORIGIN = process.env.APP_ORIGIN; @@ -36,13 +38,14 @@ const PASIFIC_VERCEL_ORIGIN = /^https:\/\/pasific(?:-[a-z0-9-]+)?\.vercel\.app$/ if (!GEMINI_API_KEY) console.warn('[server] GEMINI_API_KEY is not configured'); if (!SUPABASE_URL || !SUPABASE_ANON_KEY) console.warn('[server] Supabase public configuration is incomplete'); if (!GRADING_SERVER_SECRET) console.warn('[server] GRADING_SERVER_SECRET is not configured'); +if (!INTEGRATION_ENCRYPTION_KEY) console.warn('[server] INTEGRATION_ENCRYPTION_KEY is not configured; grading secret will be used as the integration encryption seed'); if (!SUPABASE_SERVICE_ROLE_KEY) console.warn('[server] SUPABASE_SERVICE_ROLE_KEY is not configured'); if (!RESEND_API_KEY || !RESEND_FROM_EMAIL) console.warn('[server] Resend configuration is incomplete'); const app = express(); app.disable('x-powered-by'); app.set('trust proxy', 1); -app.use(express.json({ limit: '1mb' })); +app.use(express.json({ limit: '16mb' })); app.use((req, res, next) => { req.url = req.url.replace(/\/{2,}/g, '/'); res.setHeader('X-Content-Type-Options', 'nosniff'); @@ -174,6 +177,16 @@ app.post('/api/submissions/:submissionId/grade', gradeLimiter, async (req, res) } }); +if (GEMINI_API_KEY && SUPABASE_URL && SUPABASE_ANON_KEY && GRADING_SERVER_SECRET) { + registerDocumentAssessmentRoutes(app, { + supabaseUrl: SUPABASE_URL, + anonKey: SUPABASE_ANON_KEY, + gradingServerSecret: GRADING_SERVER_SECRET, + geminiApiKey: GEMINI_API_KEY, + integrationEncryptionKey: INTEGRATION_ENCRYPTION_KEY, + }); +} + const passwordResetRequestSchema = z.object({ username: z.string().trim().min(1).max(100), method: z.enum(['email', 'phone']), @@ -269,4 +282,4 @@ app.use((error: unknown, _req: express.Request, res: express.Response, _next: ex app.listen(PORT, () => { console.log(`[server] listening on http://localhost:${PORT}`); -}); +}); \ No newline at end of file From a92f593d3399a40e4a71b9f257493794d419b14a Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:45:08 +0300 Subject: [PATCH 05/57] Allow explicit custom rubric descriptions in grading --- server/gradingSchema.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/server/gradingSchema.ts b/server/gradingSchema.ts index f3ce48f..5f6bbf8 100644 --- a/server/gradingSchema.ts +++ b/server/gradingSchema.ts @@ -4,6 +4,7 @@ export const criterionInputSchema = z.object({ id: z.string(), key: z.string(), nameKey: z.string(), + description: z.string().optional(), weight: z.number(), maxScore: z.number().positive(), }); @@ -48,4 +49,4 @@ export const modelOutputSchema = z.object({ export type ModelOutput = z.infer; export type ModelCriterionScore = z.infer; -export type ModelAnnotation = z.infer; +export type ModelAnnotation = z.infer; \ No newline at end of file From d24f317b3a568605e4c3181088a7cd6506d7b342 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:45:51 +0300 Subject: [PATCH 06/57] Add assessment hub domain types --- src/types/assessment.ts | 125 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 125 insertions(+) create mode 100644 src/types/assessment.ts diff --git a/src/types/assessment.ts b/src/types/assessment.ts new file mode 100644 index 0000000..0099d92 --- /dev/null +++ b/src/types/assessment.ts @@ -0,0 +1,125 @@ +import type { CefrLevel, WritingTypeId } from './entities'; + +export type DocumentImportKind = 'writing' | 'exam_template' | 'exam_attempt'; +export type DocumentSourceMode = 'camera' | 'gallery' | 'zip' | 'file' | 'cloud_url'; +export type DocumentOcrStatus = 'queued' | 'processing' | 'ready' | 'failed'; +export type DocumentReviewStatus = 'needs_match' | 'ready_for_grading' | 'teacher_review_pending' | 'approved' | 'returned'; + +export interface AdvancedScoringBreakdown { + rubric: number; + vocabulary: number; + patterns: number; +} + +export interface SchoolAssignmentTemplate { + id: string; + title: string; + prompt: string; + writingTypeId: WritingTypeId; + level: CefrLevel; + maxPoints: number; + createdBy: string; + creatorName: string; + createdAt: string; + vocabularyRequirements?: string; + patternRequirements?: string; +} + +export interface DocumentImportBatch { + id: string; + schoolId: string; + createdBy: string; + kind: DocumentImportKind; + assignmentId?: string; + examId?: string; + title: string; + sourceMode: DocumentSourceMode; + status: 'open' | 'processing' | 'ready' | 'closed' | 'failed'; + createdAt: string; +} + +export interface DocumentImportItem { + id: string; + batchId: string; + studentId?: string; + originalFilename: string; + mimeType: string; + sourceUrl?: string; + ocrText?: string; + ocrMarkdown?: string; + suggestedStudentName?: string; + suggestedStudentIdentifier?: string; + confidence?: number; + pageCount?: number; + ocrStatus: DocumentOcrStatus; + reviewStatus: DocumentReviewStatus; + linkedSubmissionId?: string; + linkedExamAttemptId?: string; + errorMessage?: string; + createdAt: string; +} + +export interface ExamDefinition { + id: string; + schoolId: string; + title: string; + subject?: string; + instructions?: string; + maxPoints: number; + scoringNotes?: string; + masterOcrText?: string; + masterOcrMarkdown?: string; + sharedWithSchool: boolean; + feedbackVisibleDefault: boolean; + status: 'draft' | 'ready' | 'archived'; + createdBy: string; + createdAt: string; +} + +export type ExamAttemptStatus = 'grading_pending' | 'analyzing' | 'teacher_review_pending' | 'approved' | 'returned' | 'grading_failed'; + +export interface ExamQuestionScore { + id: string; + attemptId: string; + questionKey: string; + questionLabel: string; + maxScore: number; + aiScore: number; + teacherScore?: number; + explanation: string; + evidenceQuote?: string; + feedback?: string; + sortOrder: number; +} + +export interface ExamAttempt { + id: string; + examId: string; + schoolId: string; + studentId: string; + documentItemId?: string; + ocrText: string; + ocrMarkdown?: string; + status: ExamAttemptStatus; + aiScore?: number; + finalScore?: number; + aiFeedback: Record; + teacherFeedback?: string; + feedbackVisible: boolean; + reviewedBy?: string; + reviewedAt?: string; + createdAt: string; + questionScores: ExamQuestionScore[]; +} + +export interface ExamAppeal { + id: string; + attemptId: string; + studentId: string; + reason: string; + status: 'submitted' | 'reviewing' | 'accepted' | 'rejected'; + teacherResponse?: string; + resolvedBy?: string; + resolvedAt?: string; + createdAt: string; +} From a6a49dcd7c0618c0704704bfbbfb4006016553c9 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:47:06 +0300 Subject: [PATCH 07/57] Add assessment hub data service --- src/services/assessmentData.ts | 556 +++++++++++++++++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 src/services/assessmentData.ts diff --git a/src/services/assessmentData.ts b/src/services/assessmentData.ts new file mode 100644 index 0000000..a1d1aff --- /dev/null +++ b/src/services/assessmentData.ts @@ -0,0 +1,556 @@ +import { supabase } from './supabaseClient'; +import { API_BASE } from './apiBase'; +import type { AssignmentRubric, CefrLevel, WritingTypeId } from '../types/entities'; +import type { + AdvancedScoringBreakdown, + DocumentImportBatch, + DocumentImportItem, + DocumentImportKind, + DocumentSourceMode, + ExamAppeal, + ExamAttempt, + ExamDefinition, + ExamQuestionScore, + SchoolAssignmentTemplate, +} from '../types/assessment'; + +function mapBatch(row: Record): DocumentImportBatch { + return { + id: row.id as string, + schoolId: row.school_id as string, + createdBy: row.created_by as string, + kind: row.kind as DocumentImportKind, + assignmentId: (row.assignment_id as string | null) ?? undefined, + examId: (row.exam_id as string | null) ?? undefined, + title: row.title as string, + sourceMode: row.source_mode as DocumentSourceMode, + status: row.status as DocumentImportBatch['status'], + createdAt: row.created_at as string, + }; +} + +function mapItem(row: Record): DocumentImportItem { + return { + id: row.id as string, + batchId: row.batch_id as string, + studentId: (row.student_id as string | null) ?? undefined, + originalFilename: row.original_filename as string, + mimeType: row.mime_type as string, + sourceUrl: (row.source_url as string | null) ?? undefined, + ocrText: (row.ocr_text as string | null) ?? undefined, + ocrMarkdown: (row.ocr_markdown as string | null) ?? undefined, + suggestedStudentName: (row.suggested_student_name as string | null) ?? undefined, + suggestedStudentIdentifier: (row.suggested_student_identifier as string | null) ?? undefined, + confidence: row.confidence == null ? undefined : Number(row.confidence), + pageCount: row.page_count == null ? undefined : Number(row.page_count), + ocrStatus: row.ocr_status as DocumentImportItem['ocrStatus'], + reviewStatus: row.review_status as DocumentImportItem['reviewStatus'], + linkedSubmissionId: (row.linked_submission_id as string | null) ?? undefined, + linkedExamAttemptId: (row.linked_exam_attempt_id as string | null) ?? undefined, + errorMessage: (row.error_message as string | null) ?? undefined, + createdAt: row.created_at as string, + }; +} + +function mapExam(row: Record): ExamDefinition { + return { + id: row.id as string, + schoolId: row.school_id as string, + title: row.title as string, + subject: (row.subject as string | null) ?? undefined, + instructions: (row.instructions as string | null) ?? undefined, + maxPoints: Number(row.max_points), + scoringNotes: (row.scoring_notes as string | null) ?? undefined, + masterOcrText: (row.master_ocr_text as string | null) ?? undefined, + masterOcrMarkdown: (row.master_ocr_markdown as string | null) ?? undefined, + sharedWithSchool: row.shared_with_school as boolean, + feedbackVisibleDefault: row.feedback_visible_default as boolean, + status: row.status as ExamDefinition['status'], + createdBy: row.created_by as string, + createdAt: row.created_at as string, + }; +} + +function mapQuestion(row: Record): ExamQuestionScore { + return { + id: row.id as string, + attemptId: row.attempt_id as string, + questionKey: row.question_key as string, + questionLabel: row.question_label as string, + maxScore: Number(row.max_score), + aiScore: Number(row.ai_score), + teacherScore: row.teacher_score == null ? undefined : Number(row.teacher_score), + explanation: row.explanation as string, + evidenceQuote: (row.evidence_quote as string | null) ?? undefined, + feedback: (row.feedback as string | null) ?? undefined, + sortOrder: Number(row.sort_order), + }; +} + +async function mapAttempt(row: Record): Promise { + const { data: scoreRows } = await supabase + .from('exam_question_scores') + .select('*') + .eq('attempt_id', row.id) + .order('sort_order'); + return { + id: row.id as string, + examId: row.exam_id as string, + schoolId: row.school_id as string, + studentId: row.student_id as string, + documentItemId: (row.document_item_id as string | null) ?? undefined, + ocrText: row.ocr_text as string, + ocrMarkdown: (row.ocr_markdown as string | null) ?? undefined, + status: row.status as ExamAttempt['status'], + aiScore: row.ai_score == null ? undefined : Number(row.ai_score), + finalScore: row.final_score == null ? undefined : Number(row.final_score), + aiFeedback: (row.ai_feedback as Record | null) ?? {}, + teacherFeedback: (row.teacher_feedback as string | null) ?? undefined, + feedbackVisible: row.feedback_visible as boolean, + reviewedBy: (row.reviewed_by as string | null) ?? undefined, + reviewedAt: (row.reviewed_at as string | null) ?? undefined, + createdAt: row.created_at as string, + questionScores: (scoreRows ?? []).map(mapQuestion), + }; +} + +function mapAppeal(row: Record): ExamAppeal { + return { + id: row.id as string, + attemptId: row.attempt_id as string, + studentId: row.student_id as string, + reason: row.reason as string, + status: row.status as ExamAppeal['status'], + teacherResponse: (row.teacher_response as string | null) ?? undefined, + resolvedBy: (row.resolved_by as string | null) ?? undefined, + resolvedAt: (row.resolved_at as string | null) ?? undefined, + createdAt: row.created_at as string, + }; +} + +async function authToken(): Promise { + const { data } = await supabase.auth.getSession(); + const token = data.session?.access_token; + if (!token) throw new Error('not_authenticated'); + return token; +} + +async function apiJson(path: string, init?: RequestInit): Promise { + const token = await authToken(); + const response = await fetch(`${API_BASE}${path}`, { + ...init, + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + ...(init?.headers ?? {}), + }, + }); + const body = (await response.json().catch(() => null)) as (T & { error?: string }) | null; + if (!response.ok) throw new Error(body?.error ?? `request_failed_${response.status}`); + return body as T; +} + +export async function createAdvancedWritingAssignment(input: { + title: string; + prompt: string; + writingTypeId: WritingTypeId; + level: CefrLevel; + minWords: number; + maxWords: number; + dueAt: string; + timeLimitMinutes?: number; + classIds: string[]; + instructions?: string; + referenceText?: string; + vocabularyRequirements?: string; + patternRequirements?: string; + maxPoints: number; + scoringBreakdown: AdvancedScoringBreakdown; + rubric: AssignmentRubric; + aiSupportMode: string; + showAiScoreImmediately: boolean; + sharedWithSchool: boolean; + status: 'draft' | 'published'; + createdBy: string; + schoolId: string; + topicId?: string; +}): Promise { + const { data: rubricRow, error: rubricError } = await supabase + .from('assignment_rubrics') + .insert({ is_custom: input.rubric.isCustom }) + .select('id') + .single(); + if (rubricError || !rubricRow) throw rubricError ?? new Error('rubric_insert_failed'); + + const { error: criteriaError } = await supabase.from('rubric_criteria').insert( + input.rubric.criteria.map((criterion, index) => ({ + rubric_id: rubricRow.id, + key: criterion.key, + name_key: criterion.nameKey, + description_key: criterion.descriptionKey, + weight: criterion.weight, + max_score: criterion.maxScore, + is_custom: criterion.isCustom, + is_core: criterion.isCore, + enabled: criterion.enabled, + sort_order: index, + })), + ); + if (criteriaError) throw criteriaError; + + const { data: assignment, error: assignmentError } = await supabase + .from('assignments') + .insert({ + title: input.title, + prompt: input.prompt, + writing_type_id: input.writingTypeId, + level: input.level, + min_words: input.minWords, + max_words: input.maxWords, + suggested_min_words: input.minWords, + suggested_max_words: input.maxWords, + due_at: input.dueAt, + time_limit_minutes: input.timeLimitMinutes, + instructions: input.instructions, + reference_text: input.referenceText, + vocabulary_requirements: input.vocabularyRequirements, + pattern_requirements: input.patternRequirements, + max_points: input.maxPoints, + scoring_breakdown: input.scoringBreakdown, + shared_with_school: input.sharedWithSchool, + ai_support_mode: input.aiSupportMode, + rubric_id: rubricRow.id, + show_ai_score_immediately: input.showAiScoreImmediately, + status: input.status, + created_by: input.createdBy, + school_id: input.schoolId, + topic_id: input.topicId, + }) + .select('id') + .single(); + if (assignmentError || !assignment) throw assignmentError ?? new Error('assignment_insert_failed'); + + if (input.classIds.length) { + const { error: classError } = await supabase + .from('assignment_classes') + .insert(input.classIds.map((classId) => ({ assignment_id: assignment.id, class_id: classId }))); + if (classError) throw classError; + } + return assignment.id as string; +} + +export async function fetchSchoolSharedAssignments(schoolId: string): Promise { + const { data: rows, error } = await supabase + .from('assignments') + .select('id, title, prompt, writing_type_id, level, max_points, created_by, created_at, vocabulary_requirements, pattern_requirements') + .eq('school_id', schoolId) + .eq('status', 'published') + .eq('shared_with_school', true) + .order('created_at', { ascending: false }); + if (error) throw error; + const creatorIds = [...new Set((rows ?? []).map((row) => row.created_by as string).filter(Boolean))]; + const { data: creators } = creatorIds.length + ? await supabase.from('profiles').select('id, display_name').in('id', creatorIds) + : { data: [] as Record[] }; + return (rows ?? []).map((row) => ({ + id: row.id as string, + title: row.title as string, + prompt: row.prompt as string, + writingTypeId: row.writing_type_id as WritingTypeId, + level: row.level as CefrLevel, + maxPoints: Number(row.max_points ?? 100), + createdBy: row.created_by as string, + creatorName: (creators ?? []).find((creator) => creator.id === row.created_by)?.display_name as string || '', + createdAt: row.created_at as string, + vocabularyRequirements: (row.vocabulary_requirements as string | null) ?? undefined, + patternRequirements: (row.pattern_requirements as string | null) ?? undefined, + })); +} + +export async function adoptSharedAssignment(assignmentId: string, classIds: string[]): Promise { + const { data, error } = await supabase.rpc('adopt_shared_assignment', { + p_assignment_id: assignmentId, + p_class_ids: classIds, + }); + if (error) throw error; + return Number(data ?? 0); +} + +export async function fetchIntegrationStatus(): Promise<{ mistral: boolean }> { + return apiJson('/api/integrations/status'); +} + +export async function configureMistralApiKey(apiKey: string): Promise { + await apiJson('/api/admin/integrations/mistral', { + method: 'POST', + body: JSON.stringify({ apiKey }), + }); +} + +export async function createDocumentBatch(input: { + schoolId: string; + createdBy: string; + kind: DocumentImportKind; + assignmentId?: string; + examId?: string; + title: string; + sourceMode: DocumentSourceMode; +}): Promise { + const { data, error } = await supabase + .from('document_import_batches') + .insert({ + school_id: input.schoolId, + created_by: input.createdBy, + kind: input.kind, + assignment_id: input.assignmentId, + exam_id: input.examId, + title: input.title, + source_mode: input.sourceMode, + status: 'open', + }) + .select('*') + .single(); + if (error || !data) throw error ?? new Error('document_batch_create_failed'); + return mapBatch(data); +} + +export async function createDocumentItem(input: { + batchId: string; + filename: string; + mimeType: string; + sourceUrl?: string; +}): Promise { + const { data, error } = await supabase + .from('document_import_items') + .insert({ + batch_id: input.batchId, + original_filename: input.filename, + mime_type: input.mimeType, + source_url: input.sourceUrl, + ocr_status: 'queued', + review_status: 'needs_match', + }) + .select('*') + .single(); + if (error || !data) throw error ?? new Error('document_item_create_failed'); + return mapItem(data); +} + +export async function processDocumentItem(input: { + itemId: string; + dataBase64?: string; + mimeType?: string; + sourceUrl?: string; +}): Promise { + await apiJson('/api/documents/ocr', { method: 'POST', body: JSON.stringify(input) }); +} + +export async function fetchDocumentBatches(schoolId: string, kind?: DocumentImportKind): Promise { + let query = supabase.from('document_import_batches').select('*').eq('school_id', schoolId).order('created_at', { ascending: false }); + if (kind) query = query.eq('kind', kind); + const { data, error } = await query; + if (error) throw error; + return (data ?? []).map(mapBatch); +} + +export async function fetchDocumentItems(batchId: string): Promise { + const { data, error } = await supabase + .from('document_import_items') + .select('*') + .eq('batch_id', batchId) + .order('created_at'); + if (error) throw error; + return (data ?? []).map(mapItem); +} + +export async function createAndGradeOcrWriting(itemId: string, assignmentId: string, studentId: string): Promise { + const { data, error } = await supabase.rpc('create_ocr_submission', { + p_item_id: itemId, + p_assignment_id: assignmentId, + p_student_id: studentId, + }); + if (error || typeof data !== 'string') throw error ?? new Error('ocr_submission_create_failed'); + await apiJson(`/api/submissions/${encodeURIComponent(data)}/grade`, { method: 'POST' }); + return data; +} + +export async function createExamDefinition(input: { + schoolId: string; + title: string; + subject?: string; + instructions?: string; + maxPoints: number; + scoringNotes?: string; + sharedWithSchool: boolean; + feedbackVisibleDefault: boolean; + createdBy: string; +}): Promise { + const { data, error } = await supabase + .from('exam_definitions') + .insert({ + school_id: input.schoolId, + title: input.title, + subject: input.subject, + instructions: input.instructions, + max_points: input.maxPoints, + scoring_notes: input.scoringNotes, + shared_with_school: input.sharedWithSchool, + feedback_visible_default: input.feedbackVisibleDefault, + status: 'draft', + created_by: input.createdBy, + }) + .select('*') + .single(); + if (error || !data) throw error ?? new Error('exam_create_failed'); + return mapExam(data); +} + +export async function fetchExamDefinitions(schoolId: string): Promise { + const { data, error } = await supabase.from('exam_definitions').select('*').eq('school_id', schoolId).order('created_at', { ascending: false }); + if (error) throw error; + return (data ?? []).map(mapExam); +} + +export async function fetchExamDefinition(examId: string): Promise { + const { data, error } = await supabase.from('exam_definitions').select('*').eq('id', examId).maybeSingle(); + if (error) throw error; + return data ? mapExam(data) : null; +} + +export async function createExamAttemptFromItem(itemId: string, examId: string, studentId: string): Promise { + const { data: item, error: itemError } = await supabase + .from('document_import_items') + .select('id, ocr_text, ocr_markdown, batch_id') + .eq('id', itemId) + .maybeSingle(); + if (itemError || !item?.ocr_text) throw itemError ?? new Error('ocr_item_not_ready'); + const { data: exam, error: examError } = await supabase.from('exam_definitions').select('school_id').eq('id', examId).maybeSingle(); + if (examError || !exam) throw examError ?? new Error('exam_not_found'); + const { data, error } = await supabase + .from('exam_attempts') + .insert({ + exam_id: examId, + school_id: exam.school_id, + student_id: studentId, + document_item_id: itemId, + ocr_text: item.ocr_text, + ocr_markdown: item.ocr_markdown, + status: 'grading_pending', + feedback_visible: false, + }) + .select('*') + .single(); + if (error || !data) throw error ?? new Error('exam_attempt_create_failed'); + await supabase + .from('document_import_items') + .update({ student_id: studentId, linked_exam_attempt_id: data.id, review_status: 'ready_for_grading' }) + .eq('id', itemId); + return mapAttempt(data); +} + +export async function gradeExamAttempt(attemptId: string): Promise { + await apiJson(`/api/exam-attempts/${encodeURIComponent(attemptId)}/grade`, { method: 'POST' }); +} + +export async function fetchExamAttempts(examId: string): Promise { + const { data, error } = await supabase.from('exam_attempts').select('*').eq('exam_id', examId).order('created_at', { ascending: false }); + if (error) throw error; + return Promise.all((data ?? []).map(mapAttempt)); +} + +export async function fetchExamAttempt(attemptId: string): Promise { + const { data, error } = await supabase.from('exam_attempts').select('*').eq('id', attemptId).maybeSingle(); + if (error) throw error; + return data ? mapAttempt(data) : null; +} + +export async function setExamQuestionTeacherScore(questionId: string, score: number): Promise { + const { data: question, error: loadError } = await supabase + .from('exam_question_scores') + .select('attempt_id, max_score') + .eq('id', questionId) + .maybeSingle(); + if (loadError || !question) throw loadError ?? new Error('question_not_found'); + const bounded = Math.max(0, Math.min(Number(question.max_score), score)); + const { error } = await supabase.from('exam_question_scores').update({ teacher_score: bounded }).eq('id', questionId); + if (error) throw error; + const { data: rows } = await supabase.from('exam_question_scores').select('ai_score, teacher_score').eq('attempt_id', question.attempt_id); + const finalScore = (rows ?? []).reduce((sum, row) => sum + Number(row.teacher_score ?? row.ai_score ?? 0), 0); + await supabase.from('exam_attempts').update({ final_score: Number(finalScore.toFixed(2)), updated_at: new Date().toISOString() }).eq('id', question.attempt_id); +} + +export async function approveExamAttempt(input: { + attemptId: string; + teacherId: string; + feedback?: string; + feedbackVisible: boolean; +}): Promise { + const { error } = await supabase + .from('exam_attempts') + .update({ + status: 'approved', + teacher_feedback: input.feedback || null, + feedback_visible: input.feedbackVisible, + reviewed_by: input.teacherId, + reviewed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq('id', input.attemptId); + if (error) throw error; +} + +export async function returnExamAttempt(attemptId: string, teacherId: string, feedback: string): Promise { + const { error } = await supabase + .from('exam_attempts') + .update({ + status: 'returned', + teacher_feedback: feedback, + feedback_visible: false, + reviewed_by: teacherId, + reviewed_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + }) + .eq('id', attemptId); + if (error) throw error; +} + +export async function fetchStudentVisibleExamAttempts(studentId: string): Promise { + const { data, error } = await supabase + .from('exam_attempts') + .select('*') + .eq('student_id', studentId) + .eq('status', 'approved') + .eq('feedback_visible', true) + .order('created_at', { ascending: false }); + if (error) throw error; + return Promise.all((data ?? []).map(mapAttempt)); +} + +export async function createExamAppeal(attemptId: string, studentId: string, reason: string): Promise { + const { error } = await supabase.from('exam_appeals').insert({ attempt_id: attemptId, student_id: studentId, reason: reason.trim() }); + if (error) throw error; +} + +export async function fetchExamAppeals(attemptId: string): Promise { + const { data, error } = await supabase.from('exam_appeals').select('*').eq('attempt_id', attemptId).order('created_at', { ascending: false }); + if (error) throw error; + return (data ?? []).map(mapAppeal); +} + +export async function resolveExamAppeal(input: { + appealId: string; + status: 'accepted' | 'rejected'; + response: string; + teacherId: string; +}): Promise { + const { error } = await supabase + .from('exam_appeals') + .update({ + status: input.status, + teacher_response: input.response, + resolved_by: input.teacherId, + resolved_at: new Date().toISOString(), + }) + .eq('id', input.appealId); + if (error) throw error; +} From 03af25fb0f315d2218bf7036fbce17da15b5d0d8 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:49:28 +0300 Subject: [PATCH 08/57] Add safe camera, gallery and ZIP import helpers --- src/utils/documentImport.ts | 180 ++++++++++++++++++++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 src/utils/documentImport.ts diff --git a/src/utils/documentImport.ts b/src/utils/documentImport.ts new file mode 100644 index 0000000..6626876 --- /dev/null +++ b/src/utils/documentImport.ts @@ -0,0 +1,180 @@ +const MAX_ITEM_BYTES = 10 * 1024 * 1024; +const MAX_ZIP_ITEMS = 80; +const MAX_ZIP_TOTAL_BYTES = 100 * 1024 * 1024; +const IMAGE_MAX_DIMENSION = 2200; + +const SUPPORTED_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp', 'avif', 'pdf']); + +function extensionOf(name: string): string { + return name.split('.').pop()?.toLowerCase() ?? ''; +} + +function mimeForName(name: string): string { + switch (extensionOf(name)) { + case 'jpg': + case 'jpeg': return 'image/jpeg'; + case 'png': return 'image/png'; + case 'webp': return 'image/webp'; + case 'avif': return 'image/avif'; + case 'pdf': return 'application/pdf'; + default: return 'application/octet-stream'; + } +} + +export function isSupportedDocument(file: Pick): boolean { + const extension = extensionOf(file.name); + const supportedMime = file.type.startsWith('image/') || file.type === 'application/pdf'; + return file.size > 0 && file.size <= MAX_ITEM_BYTES && SUPPORTED_EXTENSIONS.has(extension) && supportedMime; +} + +export async function fileToBase64(file: Blob): Promise { + const buffer = await file.arrayBuffer(); + const bytes = new Uint8Array(buffer); + const chunkSize = 0x8000; + let binary = ''; + for (let offset = 0; offset < bytes.length; offset += chunkSize) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)); + } + return btoa(binary); +} + +export async function optimizeImageForOcr(file: File): Promise { + if (!file.type.startsWith('image/') || file.type === 'image/avif') return file; + const bitmap = await createImageBitmap(file); + try { + const scale = Math.min(1, IMAGE_MAX_DIMENSION / Math.max(bitmap.width, bitmap.height)); + if (scale === 1 && file.size <= 5 * 1024 * 1024) return file; + + const width = Math.max(1, Math.round(bitmap.width * scale)); + const height = Math.max(1, Math.round(bitmap.height * scale)); + const canvas = document.createElement('canvas'); + canvas.width = width; + canvas.height = height; + const context = canvas.getContext('2d', { alpha: false }); + if (!context) return file; + context.fillStyle = '#ffffff'; + context.fillRect(0, 0, width, height); + context.drawImage(bitmap, 0, 0, width, height); + + const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/jpeg', 0.9)); + if (!blob || blob.size >= file.size || blob.size > MAX_ITEM_BYTES) return file; + return new File([blob], file.name.replace(/\.[^.]+$/, '.jpg'), { type: 'image/jpeg', lastModified: Date.now() }); + } finally { + bitmap.close(); + } +} + +function u16(view: DataView, offset: number): number { + return view.getUint16(offset, true); +} + +function u32(view: DataView, offset: number): number { + return view.getUint32(offset, true); +} + +function findEndOfCentralDirectory(view: DataView): number { + const minimum = Math.max(0, view.byteLength - 65_557); + for (let offset = view.byteLength - 22; offset >= minimum; offset -= 1) { + if (u32(view, offset) === 0x06054b50) return offset; + } + throw new Error('zip_directory_not_found'); +} + +async function inflateRaw(bytes: Uint8Array): Promise { + if (typeof DecompressionStream === 'undefined') throw new Error('zip_decompression_not_supported'); + const input = new Blob([bytes.slice().buffer]).stream(); + const output = input.pipeThrough(new DecompressionStream('deflate-raw')); + return new Uint8Array(await new Response(output).arrayBuffer()); +} + +interface ZipEntryMeta { + name: string; + method: number; + compressedSize: number; + uncompressedSize: number; + localHeaderOffset: number; + flags: number; +} + +function parseZipEntries(buffer: ArrayBuffer): ZipEntryMeta[] { + const view = new DataView(buffer); + const eocd = findEndOfCentralDirectory(view); + const entryCount = u16(view, eocd + 10); + const centralDirectoryOffset = u32(view, eocd + 16); + if (entryCount > MAX_ZIP_ITEMS) throw new Error('zip_too_many_files'); + + const decoder = new TextDecoder('utf-8'); + const entries: ZipEntryMeta[] = []; + let offset = centralDirectoryOffset; + let totalUncompressed = 0; + + for (let index = 0; index < entryCount; index += 1) { + if (offset + 46 > view.byteLength || u32(view, offset) !== 0x02014b50) throw new Error('zip_central_directory_invalid'); + const flags = u16(view, offset + 8); + const method = u16(view, offset + 10); + const compressedSize = u32(view, offset + 20); + const uncompressedSize = u32(view, offset + 24); + const fileNameLength = u16(view, offset + 28); + const extraLength = u16(view, offset + 30); + const commentLength = u16(view, offset + 32); + const localHeaderOffset = u32(view, offset + 42); + const nameBytes = new Uint8Array(buffer, offset + 46, fileNameLength); + const name = decoder.decode(nameBytes); + offset += 46 + fileNameLength + extraLength + commentLength; + + if (name.endsWith('/')) continue; + if ((flags & 0x1) !== 0) throw new Error('zip_encrypted_files_not_supported'); + if (method !== 0 && method !== 8) throw new Error('zip_compression_not_supported'); + if (uncompressedSize > MAX_ITEM_BYTES) throw new Error('zip_file_too_large'); + totalUncompressed += uncompressedSize; + if (totalUncompressed > MAX_ZIP_TOTAL_BYTES) throw new Error('zip_unpacked_size_too_large'); + if (!SUPPORTED_EXTENSIONS.has(extensionOf(name))) continue; + + entries.push({ name, method, compressedSize, uncompressedSize, localHeaderOffset, flags }); + } + + if (entries.length === 0) throw new Error('zip_has_no_supported_documents'); + return entries; +} + +function safeBaseName(path: string): string { + const normalized = path.replace(/\\/g, '/'); + return normalized.split('/').filter(Boolean).pop()?.replace(/[\u0000-\u001f]/g, '') || 'document'; +} + +export async function extractSupportedFilesFromZip(zipFile: File): Promise { + if (zipFile.size > MAX_ZIP_TOTAL_BYTES) throw new Error('zip_file_too_large'); + const buffer = await zipFile.arrayBuffer(); + const view = new DataView(buffer); + const entries = parseZipEntries(buffer); + const files: File[] = []; + + for (const entry of entries) { + const local = entry.localHeaderOffset; + if (local + 30 > view.byteLength || u32(view, local) !== 0x04034b50) throw new Error('zip_local_header_invalid'); + const fileNameLength = u16(view, local + 26); + const extraLength = u16(view, local + 28); + const dataOffset = local + 30 + fileNameLength + extraLength; + const dataEnd = dataOffset + entry.compressedSize; + if (dataEnd > buffer.byteLength) throw new Error('zip_entry_out_of_bounds'); + + const compressed = new Uint8Array(buffer, dataOffset, entry.compressedSize); + const content = entry.method === 0 ? compressed.slice() : await inflateRaw(compressed); + if (content.byteLength !== entry.uncompressedSize || content.byteLength > MAX_ITEM_BYTES) throw new Error('zip_entry_size_mismatch'); + + files.push(new File([content.slice().buffer], safeBaseName(entry.name), { + type: mimeForName(entry.name), + lastModified: Date.now(), + })); + } + return files; +} + +export async function prepareDocumentFiles(files: File[]): Promise { + const prepared: File[] = []; + for (const file of files) { + if (!isSupportedDocument(file)) throw new Error(`unsupported_document:${file.name}`); + prepared.push(file.type.startsWith('image/') ? await optimizeImageForOcr(file) : file); + } + return prepared; +} From 1688c23e7d4fbaf249e2a5ea7ac5e4ca40f7111c Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:50:31 +0300 Subject: [PATCH 09/57] Grade advanced writing criteria and hide OCR scores pending review --- server/submissionGrading.ts | 40 ++++++++++++++++++++++++++++--------- 1 file changed, 31 insertions(+), 9 deletions(-) diff --git a/server/submissionGrading.ts b/server/submissionGrading.ts index fedeac9..adbd1ef 100644 --- a/server/submissionGrading.ts +++ b/server/submissionGrading.ts @@ -21,6 +21,8 @@ interface AssignmentRow { max_words: number | null; rubric_id: string; show_ai_score_immediately: boolean; + vocabulary_requirements: string | null; + pattern_requirements: string | null; } interface ProfileRow { @@ -120,13 +122,22 @@ async function loadCriteria( } return { - criteria: rows.map((row) => ({ - id: row.id as string, - key: row.key as string, - nameKey: row.name_key as string, - weight: Number(row.weight), - maxScore: Number(row.max_score), - })), + criteria: rows.map((row) => { + const key = row.key as string; + const description = key === 'required_vocabulary' + ? assignment.vocabulary_requirements ?? undefined + : key === 'required_patterns' + ? assignment.pattern_requirements ?? undefined + : undefined; + return { + id: row.id as string, + key, + nameKey: row.name_key as string, + description, + weight: Number(row.weight), + maxScore: Number(row.max_score), + }; + }), usesCustomRubric: Boolean(rubric?.is_custom), }; } @@ -171,7 +182,7 @@ export async function gradeAndPersistSubmission( if (submission.assignment_id) { const { data, error } = await requester .from('assignments') - .select('prompt, min_words, max_words, rubric_id, show_ai_score_immediately') + .select('prompt, min_words, max_words, rubric_id, show_ai_score_immediately, vocabulary_requirements, pattern_requirements') .eq('id', submission.assignment_id) .maybeSingle(); if (error || !data) throw new SubmissionGradingError('Assignment not found', 404); @@ -186,6 +197,13 @@ export async function gradeAndPersistSubmission( .maybeSingle(); if (descriptorError) throw new SubmissionGradingError('Level descriptor is unavailable', 500, true); + const { data: ocrImport } = await requester + .from('document_import_items') + .select('id') + .eq('linked_submission_id', submissionId) + .maybeSingle(); + const importedFromOcr = Boolean(ocrImport); + const { error: analyzingError } = await requester.rpc('begin_server_submission_grading', { p_submission_id: submissionId, p_server_secret: deps.gradingServerSecret, @@ -212,7 +230,11 @@ export async function gradeAndPersistSubmission( const finalScore = recomputeFinalScore(result.criterionScores); const nextStatus = submission.assignment_id ? 'teacher_review_pending' : 'result_ready'; - const scoreVisible = assignment ? assignment.show_ai_score_immediately : submission.score_visible_to_student; + const scoreVisible = importedFromOcr + ? false + : assignment + ? assignment.show_ai_score_immediately + : submission.score_visible_to_student; const { error: finishError } = await requester.rpc('complete_server_submission_grading', { p_submission_id: submissionId, From 61db8d7a158a40793a39fa8c2587f7c6afff1021 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:51:38 +0300 Subject: [PATCH 10/57] Include advanced criterion requirements in AI prompt --- server/gemini.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/server/gemini.ts b/server/gemini.ts index 8a306ef..529f8b2 100644 --- a/server/gemini.ts +++ b/server/gemini.ts @@ -130,8 +130,8 @@ function buildPrompt(input: GradeRequest, levelDescriptor?: string): { systemIns const criteriaLines = input.criteria .map((c) => { const label = resolveLabel(c.nameKey); - const description = resolveDescription(c.nameKey); - return `- id="${c.id}" name="${label}"${description ? ` description="${description}"` : ''} maxScore=${c.maxScore} weight=${c.weight}`; + const description = c.description ?? resolveDescription(c.nameKey); + return `- id="${c.id}" name="${label}"${description ? ` description="${description.replace(/"/g, '\\"')}"` : ''} maxScore=${c.maxScore} weight=${c.weight}`; }) .join('\n'); From 0b2d216867a6940ba560c58b9bcc102ce6c9ab52 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:52:04 +0300 Subject: [PATCH 11/57] Refine OCR submission security and rubric metadata --- .../0045_document_assessment_refinements.sql | 141 ++++++++++++++++++ 1 file changed, 141 insertions(+) create mode 100644 supabase/migrations/0045_document_assessment_refinements.sql diff --git a/supabase/migrations/0045_document_assessment_refinements.sql b/supabase/migrations/0045_document_assessment_refinements.sql new file mode 100644 index 0000000..aadd920 --- /dev/null +++ b/supabase/migrations/0045_document_assessment_refinements.sql @@ -0,0 +1,141 @@ +-- Refinements for Document Assessment Hub. +-- Keep OCR-imported writings inside the normal submission/grading pipeline, +-- while preserving the assignment rubric's actual custom flag. + +create or replace function public.create_ocr_submission( + p_item_id uuid, + p_assignment_id uuid, + p_student_id uuid +) +returns uuid +language plpgsql +security definer +set search_path = public, app_private, pg_temp +as $$ +declare + v_item public.document_import_items%rowtype; + v_batch public.document_import_batches%rowtype; + v_assignment public.assignments%rowtype; + v_uses_custom_rubric boolean; + v_submission_id uuid; + v_existing_id uuid; +begin + if public.app_current_role() <> 'teacher' or not public.is_current_account_active() then + raise exception 'teacher_required' using errcode = '42501'; + end if; + + select * into v_item + from public.document_import_items + where id = p_item_id + for update; + + if not found or v_item.ocr_status <> 'ready' or length(trim(coalesce(v_item.ocr_text, ''))) = 0 then + raise exception 'ocr_item_not_ready' using errcode = '22023'; + end if; + + select * into v_batch + from public.document_import_batches + where id = v_item.batch_id; + + if not found or v_batch.kind <> 'writing' or v_batch.assignment_id <> p_assignment_id then + raise exception 'invalid_writing_batch' using errcode = '22023'; + end if; + + if not public.is_teacher_of_school(v_batch.school_id) then + raise exception 'Forbidden' using errcode = '42501'; + end if; + + select a.*, r.is_custom + into v_assignment, v_uses_custom_rubric + from public.assignments a + join public.assignment_rubrics r on r.id = a.rubric_id + where a.id = p_assignment_id + and a.school_id = v_batch.school_id + and a.status = 'published'; + + if not found then + raise exception 'assignment_not_found' using errcode = 'P0002'; + end if; + + if not exists ( + select 1 + from public.profiles p + where p.id = p_student_id + and p.role = 'student' + and p.school_id = v_batch.school_id + and p.status = 'active' + and (p.expires_at is null or p.expires_at > now()) + ) or not exists ( + select 1 + from public.assignment_classes ac + join public.student_classes sc on sc.class_id = ac.class_id + where ac.assignment_id = p_assignment_id + and sc.student_id = p_student_id + ) then + raise exception 'student_not_assigned' using errcode = '42501'; + end if; + + select id into v_existing_id + from public.submissions + where assignment_id = p_assignment_id + and student_id = p_student_id + order by created_at desc + limit 1; + + if v_existing_id is not null then + update public.document_import_items + set student_id = p_student_id, + linked_submission_id = v_existing_id, + review_status = 'ready_for_grading', + updated_at = now() + where id = p_item_id; + return v_existing_id; + end if; + + insert into app_private.server_import_write_context (transaction_id, student_id) + values (txid_current(), p_student_id) + on conflict do nothing; + + insert into public.submissions ( + assignment_id, is_practice, student_id, school_id, writing_type_id, level, + topic_title, text, word_count, status, submitted_at, last_saved_at, + score_visible_to_student, uses_custom_rubric + ) + values ( + p_assignment_id, + false, + p_student_id, + v_batch.school_id, + v_assignment.writing_type_id, + v_assignment.level, + v_assignment.title, + v_item.ocr_text, + case + when length(trim(v_item.ocr_text)) = 0 then 0 + else array_length(regexp_split_to_array(trim(v_item.ocr_text), '\\s+'), 1) + end, + 'submitted', + now(), + now(), + false, + coalesce(v_uses_custom_rubric, false) + ) + returning id into v_submission_id; + + delete from app_private.server_import_write_context + where transaction_id = txid_current() + and student_id = p_student_id; + + update public.document_import_items + set student_id = p_student_id, + linked_submission_id = v_submission_id, + review_status = 'ready_for_grading', + updated_at = now() + where id = p_item_id; + + return v_submission_id; +end; +$$; + +revoke all on function public.create_ocr_submission(uuid, uuid, uuid) from public, anon; +grant execute on function public.create_ocr_submission(uuid, uuid, uuid) to authenticated; From 6fa0699ae903a01d56f52035bcfa2dde2c98a8d4 Mon Sep 17 00:00:00 2001 From: Nedim Mutlu Sezer <154451479+NMSOfficial@users.noreply.github.com> Date: Fri, 7 Aug 2026 16:54:12 +0300 Subject: [PATCH 12/57] Build advanced shared writing assignment creator --- .../teacher/AdvancedAssignmentBuilderPage.tsx | 413 ++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 src/features/teacher/AdvancedAssignmentBuilderPage.tsx diff --git a/src/features/teacher/AdvancedAssignmentBuilderPage.tsx b/src/features/teacher/AdvancedAssignmentBuilderPage.tsx new file mode 100644 index 0000000..c42cb0c --- /dev/null +++ b/src/features/teacher/AdvancedAssignmentBuilderPage.tsx @@ -0,0 +1,413 @@ +import { useEffect, useMemo, useState, type ChangeEvent } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { BookCopy, CheckCircle2, FileText, SlidersHorizontal, Upload } from 'lucide-react'; +import { useAuth } from '../../state/AuthContext'; +import { standardCriteria } from '../../mock/rubric'; +import { WRITING_TYPES } from '../../mock/writingTypes'; +import { CEFR_LEVELS } from '../../utils/cefr'; +import type { AssignmentRubric, CefrLevel, TeacherProfile, WritingTypeId } from '../../types/entities'; +import type { AdvancedScoringBreakdown, SchoolAssignmentTemplate } from '../../types/assessment'; +import { PageHeader } from '../../components/PageHeader'; +import { LoadingSkeleton } from '../../components/LoadingSkeleton'; +import { fetchVisibleCatalogForSchool } from '../../services/contentData'; +import { fetchTeacherClasses, type ClassMeta } from '../../services/teacherData'; +import { + adoptSharedAssignment, + createAdvancedWritingAssignment, + fetchSchoolSharedAssignments, +} from '../../services/assessmentData'; + +const QUICK_SCALES = [5, 10, 20, 50, 100]; + +function scaleRubric( + baseWeight: number, + vocabularyWeight: number, + patternWeight: number, + vocabularyRequirements: string, + patternRequirements: string, +): AssignmentRubric { + const baseCriteria = standardCriteria().map((criterion) => ({ + ...criterion, + id: crypto.randomUUID(), + weight: Number((baseWeight / 5).toFixed(2)), + enabled: baseWeight > 0, + isCustom: baseWeight !== 100, + })); + + const extras = []; + if (vocabularyWeight > 0 && vocabularyRequirements.trim()) { + extras.push({ + id: crypto.randomUUID(), + key: 'required_vocabulary', + nameKey: 'Required vocabulary usage', + descriptionKey: undefined, + weight: vocabularyWeight, + maxScore: 5, + isCustom: true, + isCore: false, + enabled: true, + }); + } + if (patternWeight > 0 && patternRequirements.trim()) { + extras.push({ + id: crypto.randomUUID(), + key: 'required_patterns', + nameKey: 'Required writing patterns', + descriptionKey: undefined, + weight: patternWeight, + maxScore: 5, + isCustom: true, + isCore: false, + enabled: true, + }); + } + + return { + id: crypto.randomUUID(), + isCustom: extras.length > 0 || baseWeight !== 100, + criteria: [...baseCriteria, ...extras], + }; +} + +function localTextFile(event: ChangeEvent, setter: (value: string) => void) { + const file = event.target.files?.[0]; + if (!file) return; + if (file.size > 512 * 1024 || !/\.(txt|md)$/i.test(file.name)) { + event.target.value = ''; + return; + } + void file.text().then(setter); + event.target.value = ''; +} + +export function AdvancedAssignmentBuilderPage() { + const { user } = useAuth(); + const navigate = useNavigate(); + const teacher = user as TeacherProfile; + const schoolId = teacher.schoolIds[0]; + + const [mode, setMode] = useState<'create' | 'shared'>('create'); + const [classes, setClasses] = useState(null); + const [shared, setShared] = useState(null); + const [topics, setTopics] = useState> | null>(null); + const [selectedShared, setSelectedShared] = useState(null); + const [selectedClasses, setSelectedClasses] = useState([]); + + const [topicId, setTopicId] = useState(''); + const [title, setTitle] = useState(''); + const [prompt, setPrompt] = useState(''); + const [writingTypeId, setWritingTypeId] = useState('argumentative_essay'); + const [level, setLevel] = useState('B2'); + const [minWords, setMinWords] = useState(180); + const [maxWords, setMaxWords] = useState(260); + const [dueAt, setDueAt] = useState(''); + const [timeLimitMinutes, setTimeLimitMinutes] = useState(); + const [instructions, setInstructions] = useState(''); + const [referenceText, setReferenceText] = useState(''); + const [vocabularyRequirements, setVocabularyRequirements] = useState(''); + const [patternRequirements, setPatternRequirements] = useState(''); + const [maxPoints, setMaxPoints] = useState(100); + const [scoring, setScoring] = useState({ rubric: 100, vocabulary: 0, patterns: 0 }); + const [sharedWithSchool, setSharedWithSchool] = useState(true); + const [showAiImmediately, setShowAiImmediately] = useState(false); + const [saving, setSaving] = useState(false); + const [message, setMessage] = useState(null); + + useEffect(() => { + if (!schoolId) return; + Promise.all([ + fetchTeacherClasses(teacher.id), + fetchSchoolSharedAssignments(schoolId), + fetchVisibleCatalogForSchool(schoolId), + ]).then(([classRows, sharedRows, topicRows]) => { + setClasses(classRows); + setShared(sharedRows); + setTopics(topicRows); + }); + }, [schoolId, teacher.id]); + + useEffect(() => { + if (!vocabularyRequirements.trim() && scoring.vocabulary !== 0) { + setScoring((current) => ({ ...current, rubric: current.rubric + current.vocabulary, vocabulary: 0 })); + } + }, [vocabularyRequirements, scoring.vocabulary]); + + useEffect(() => { + if (!patternRequirements.trim() && scoring.patterns !== 0) { + setScoring((current) => ({ ...current, rubric: current.rubric + current.patterns, patterns: 0 })); + } + }, [patternRequirements, scoring.patterns]); + + const totalWeight = scoring.rubric + scoring.vocabulary + scoring.patterns; + const canSave = title.trim() && prompt.trim() && dueAt && selectedClasses.length > 0 && totalWeight === 100 && maxPoints > 0; + const selectedTopic = useMemo(() => topics?.find((topic) => topic.id === topicId), [topics, topicId]); + + const useTopic = (id: string) => { + setTopicId(id); + const topic = topics?.find((candidate) => candidate.id === id); + if (!topic) return; + setTitle(topic.title); + setPrompt(topic.prompt); + setWritingTypeId(topic.writingTypeId); + setLevel(topic.level); + setMinWords(topic.minWords); + setMaxWords(topic.maxWords); + }; + + const toggleClass = (classId: string) => { + setSelectedClasses((current) => current.includes(classId) + ? current.filter((id) => id !== classId) + : [...current, classId]); + }; + + const setWeight = (key: keyof AdvancedScoringBreakdown, value: number) => { + const next = Math.max(0, Math.min(100, value)); + setScoring((current) => ({ ...current, [key]: next })); + }; + + const handleSave = async (status: 'draft' | 'published') => { + if (!schoolId || !canSave) return; + setSaving(true); + setMessage(null); + try { + const rubric = scaleRubric( + scoring.rubric, + scoring.vocabulary, + scoring.patterns, + vocabularyRequirements, + patternRequirements, + ); + await createAdvancedWritingAssignment({ + title: title.trim(), + prompt: prompt.trim(), + writingTypeId, + level, + minWords, + maxWords, + dueAt: new Date(dueAt).toISOString(), + timeLimitMinutes, + classIds: selectedClasses, + instructions: instructions.trim() || undefined, + referenceText: referenceText.trim() || undefined, + vocabularyRequirements: vocabularyRequirements.trim() || undefined, + patternRequirements: patternRequirements.trim() || undefined, + maxPoints, + scoringBreakdown: scoring, + rubric, + aiSupportMode: 'none', + showAiScoreImmediately: showAiImmediately, + sharedWithSchool, + status, + createdBy: teacher.id, + schoolId, + topicId: selectedTopic?.id, + }); + navigate('/teacher/assignments'); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Ödev kaydedilemedi.'); + } finally { + setSaving(false); + } + }; + + const handleAdopt = async () => { + if (!selectedShared || selectedClasses.length === 0) return; + setSaving(true); + setMessage(null); + try { + const count = await adoptSharedAssignment(selectedShared, selectedClasses); + setMessage(`${count} sınıfa atandı.`); + setSelectedClasses([]); + } catch (error) { + setMessage(error instanceof Error ? error.message : 'Ödev sınıflara atanamadı.'); + } finally { + setSaving(false); + } + }; + + if (!classes || !shared || !topics) return ; + + return ( + <> + + +
+ + +
+ + {message &&
{message}
} + + {mode === 'shared' ? ( +
+
+

Okuldaki paylaşılan ödevler

+
+ {shared.length === 0 &&

Henüz paylaşılan bir ödev yok.

} + {shared.map((assignment) => ( + + ))} +
+
+ +
+ +
+
+ ) : ( +
+
+

Konu ve temel bilgiler

+
+ + +
+
+
+ + setTitle(event.target.value)} /> +
+
+ +