Boost Azure Demo

This commit is contained in:
MangoPig
2026-05-25 17:05:06 +01:00
parent 675285e99d
commit 4f79137d89
230 changed files with 43275 additions and 2644 deletions

6
Backend/db/embed.go Normal file
View File

@@ -0,0 +1,6 @@
package db
import "embed"
//go:embed migrations/*.sql
var Migrations embed.FS

View File

@@ -0,0 +1,160 @@
-- +goose Up
CREATE TYPE user_role AS ENUM ('student', 'teacher');
CREATE TYPE question_status AS ENUM ('draft', 'published', 'archived');
CREATE TYPE assignment_status AS ENUM ('draft', 'assigned', 'closed');
CREATE TYPE answer_status AS ENUM ('not_started', 'in_progress', 'submitted', 'reviewed');
CREATE TABLE users (
id BIGSERIAL PRIMARY KEY,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT,
role user_role NOT NULL,
full_name VARCHAR(255) NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE classrooms (
id BIGSERIAL PRIMARY KEY,
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
code VARCHAR(50) UNIQUE,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE classroom_students (
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (classroom_id, student_id)
);
CREATE TABLE questions (
id BIGSERIAL PRIMARY KEY,
author_teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
title VARCHAR(255) NOT NULL,
prompt TEXT NOT NULL,
subject VARCHAR(100),
source TEXT,
status question_status NOT NULL DEFAULT 'draft',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE tags (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL UNIQUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE question_tags (
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
tag_id BIGINT NOT NULL REFERENCES tags(id) ON DELETE CASCADE,
PRIMARY KEY (question_id, tag_id)
);
CREATE TABLE assignments (
id BIGSERIAL PRIMARY KEY,
classroom_id BIGINT NOT NULL REFERENCES classrooms(id) ON DELETE CASCADE,
teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE RESTRICT,
title VARCHAR(255) NOT NULL,
instructions TEXT,
status assignment_status NOT NULL DEFAULT 'draft',
due_at TIMESTAMPTZ,
published_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE TABLE assignment_assignees (
assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
assigned_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (assignment_id, student_id)
);
CREATE TABLE assignment_questions (
assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT,
position INTEGER NOT NULL,
PRIMARY KEY (assignment_id, question_id),
CONSTRAINT assignment_questions_assignment_position_key UNIQUE (assignment_id, position)
);
CREATE TABLE student_answers (
id BIGSERIAL PRIMARY KEY,
assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE RESTRICT,
student_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
answer_text TEXT,
ai_feedback TEXT,
teacher_feedback TEXT,
status answer_status NOT NULL DEFAULT 'not_started',
submitted_at TIMESTAMPTZ,
reviewed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT student_answers_assignment_question_student_key UNIQUE (assignment_id, question_id, student_id)
);
CREATE INDEX idx_users_role ON users(role);
CREATE INDEX idx_classrooms_teacher_id ON classrooms(teacher_id);
CREATE INDEX idx_questions_author_teacher_id ON questions(author_teacher_id);
CREATE INDEX idx_questions_status ON questions(status);
CREATE INDEX idx_assignments_classroom_id ON assignments(classroom_id);
CREATE INDEX idx_assignments_teacher_id ON assignments(teacher_id);
CREATE INDEX idx_assignment_assignees_student_id ON assignment_assignees(student_id);
CREATE INDEX idx_student_answers_student_id ON student_answers(student_id);
CREATE INDEX idx_student_answers_assignment_id ON student_answers(assignment_id);
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE TRIGGER users_updated_at BEFORE UPDATE ON users
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER classrooms_updated_at BEFORE UPDATE ON classrooms
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER questions_updated_at BEFORE UPDATE ON questions
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER assignments_updated_at BEFORE UPDATE ON assignments
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER student_answers_updated_at BEFORE UPDATE ON student_answers
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- +goose Down
DROP TRIGGER IF EXISTS student_answers_updated_at ON student_answers;
DROP TRIGGER IF EXISTS assignments_updated_at ON assignments;
DROP TRIGGER IF EXISTS questions_updated_at ON questions;
DROP TRIGGER IF EXISTS classrooms_updated_at ON classrooms;
DROP TRIGGER IF EXISTS users_updated_at ON users;
DROP FUNCTION IF EXISTS update_updated_at();
DROP TABLE IF EXISTS student_answers;
DROP TABLE IF EXISTS assignment_questions;
DROP TABLE IF EXISTS assignment_assignees;
DROP TABLE IF EXISTS assignments;
DROP TABLE IF EXISTS question_tags;
DROP TABLE IF EXISTS tags;
DROP TABLE IF EXISTS questions;
DROP TABLE IF EXISTS classroom_students;
DROP TABLE IF EXISTS classrooms;
DROP TABLE IF EXISTS users;
DROP TYPE IF EXISTS answer_status;
DROP TYPE IF EXISTS assignment_status;
DROP TYPE IF EXISTS question_status;
DROP TYPE IF EXISTS user_role;

View File

@@ -0,0 +1,44 @@
-- +goose Up
CREATE TABLE profiles (
user_id BIGINT PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
preferred_name VARCHAR(100),
profile_icon_url TEXT,
headline VARCHAR(255),
bio TEXT,
timezone VARCHAR(100),
locale VARCHAR(20),
grade_level VARCHAR(100),
learning_goal TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
INSERT INTO profiles (user_id)
SELECT id
FROM users
ON CONFLICT (user_id) DO NOTHING;
-- +goose StatementBegin
CREATE OR REPLACE FUNCTION ensure_profile_for_user()
RETURNS TRIGGER AS $$
BEGIN
INSERT INTO profiles (user_id)
VALUES (NEW.id)
ON CONFLICT (user_id) DO NOTHING;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- +goose StatementEnd
CREATE TRIGGER profiles_updated_at BEFORE UPDATE ON profiles
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER users_ensure_profile_after_insert AFTER INSERT ON users
FOR EACH ROW EXECUTE FUNCTION ensure_profile_for_user();
-- +goose Down
DROP TRIGGER IF EXISTS users_ensure_profile_after_insert ON users;
DROP TRIGGER IF EXISTS profiles_updated_at ON profiles;
DROP FUNCTION IF EXISTS ensure_profile_for_user();
DROP TABLE IF EXISTS profiles;

View File

@@ -0,0 +1,48 @@
-- +goose Up
CREATE TABLE message_threads (
id BIGSERIAL PRIMARY KEY,
created_by_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
subject VARCHAR(255) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT message_threads_subject_not_blank CHECK (length(btrim(subject)) > 0)
);
CREATE TABLE message_thread_participants (
thread_id BIGINT NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE,
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
joined_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
last_read_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
PRIMARY KEY (thread_id, user_id)
);
CREATE TABLE messages (
id BIGSERIAL PRIMARY KEY,
thread_id BIGINT NOT NULL REFERENCES message_threads(id) ON DELETE CASCADE,
sender_user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
body TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT messages_body_not_blank CHECK (length(btrim(body)) > 0)
);
CREATE INDEX idx_message_threads_created_by_user_id ON message_threads(created_by_user_id);
CREATE INDEX idx_message_threads_updated_at ON message_threads(updated_at DESC);
CREATE INDEX idx_message_thread_participants_user_id ON message_thread_participants(user_id);
CREATE INDEX idx_messages_thread_id_created_at ON messages(thread_id, created_at DESC);
CREATE INDEX idx_messages_sender_user_id ON messages(sender_user_id);
CREATE TRIGGER message_threads_updated_at BEFORE UPDATE ON message_threads
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER messages_updated_at BEFORE UPDATE ON messages
FOR EACH ROW EXECUTE FUNCTION update_updated_at();
-- +goose Down
DROP TRIGGER IF EXISTS messages_updated_at ON messages;
DROP TRIGGER IF EXISTS message_threads_updated_at ON message_threads;
DROP TABLE IF EXISTS messages;
DROP TABLE IF EXISTS message_thread_participants;
DROP TABLE IF EXISTS message_threads;

View File

@@ -0,0 +1,12 @@
-- +goose Up
ALTER TABLE student_answers
ADD COLUMN solve_mode TEXT NOT NULL DEFAULT 'just_answer',
ADD COLUMN working_steps TEXT,
ADD CONSTRAINT student_answers_solve_mode_check
CHECK (solve_mode IN ('just_answer', 'step_by_step', 'solve_together', 'handwritten'));
-- +goose Down
ALTER TABLE student_answers
DROP CONSTRAINT IF EXISTS student_answers_solve_mode_check,
DROP COLUMN IF EXISTS working_steps,
DROP COLUMN IF EXISTS solve_mode;

View File

@@ -0,0 +1,13 @@
-- +goose Up
ALTER TABLE questions
ADD COLUMN correct_answer TEXT;
ALTER TABLE student_answers
ADD COLUMN is_correct BOOLEAN;
-- +goose Down
ALTER TABLE student_answers
DROP COLUMN IF EXISTS is_correct;
ALTER TABLE questions
DROP COLUMN IF EXISTS correct_answer;

View File

@@ -0,0 +1,53 @@
-- +goose Up
ALTER TABLE assignment_assignees
ADD COLUMN ai_feedback TEXT,
ADD COLUMN teacher_feedback TEXT;
UPDATE assignment_assignees aa
SET ai_feedback = aggregated.ai_feedback
FROM (
SELECT
sa.assignment_id,
sa.student_id,
string_agg(
format('Question %s: %s', aq.position, btrim(sa.ai_feedback)),
E'\n\n'
ORDER BY aq.position ASC
) AS ai_feedback
FROM student_answers sa
JOIN assignment_questions aq
ON aq.assignment_id = sa.assignment_id
AND aq.question_id = sa.question_id
WHERE NULLIF(btrim(sa.ai_feedback), '') IS NOT NULL
GROUP BY sa.assignment_id, sa.student_id
) AS aggregated
WHERE aa.assignment_id = aggregated.assignment_id
AND aa.student_id = aggregated.student_id;
UPDATE assignment_assignees aa
SET teacher_feedback = aggregated.teacher_feedback
FROM (
SELECT
sa.assignment_id,
sa.student_id,
string_agg(
format('Question %s: %s', aq.position, btrim(sa.teacher_feedback)),
E'\n\n'
ORDER BY aq.position ASC
) AS teacher_feedback
FROM student_answers sa
JOIN assignment_questions aq
ON aq.assignment_id = sa.assignment_id
AND aq.question_id = sa.question_id
WHERE NULLIF(btrim(sa.teacher_feedback), '') IS NOT NULL
GROUP BY sa.assignment_id, sa.student_id
) AS aggregated
WHERE aa.assignment_id = aggregated.assignment_id
AND aa.student_id = aggregated.student_id;
-- +goose Down
ALTER TABLE assignment_assignees
DROP COLUMN IF EXISTS teacher_feedback,
DROP COLUMN IF EXISTS ai_feedback;

View File

@@ -0,0 +1,66 @@
-- +goose Up
CREATE TYPE assignment_pass_status AS ENUM ('pending', 'pass', 'no_pass');
ALTER TABLE student_answers
ADD COLUMN review_needs_attention BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN review_issue_reason TEXT,
ADD COLUMN review_correctness_score NUMERIC(4,3),
ADD COLUMN review_understanding_score NUMERIC(4,3),
ADD COLUMN review_question_score NUMERIC(4,3),
ADD COLUMN review_confidence NUMERIC(4,3),
ADD COLUMN review_tags TEXT[] NOT NULL DEFAULT ARRAY[]::TEXT[],
ADD CONSTRAINT student_answers_review_correctness_score_range_check
CHECK (review_correctness_score IS NULL OR (review_correctness_score >= 0 AND review_correctness_score <= 1)),
ADD CONSTRAINT student_answers_review_understanding_score_range_check
CHECK (review_understanding_score IS NULL OR (review_understanding_score >= 0 AND review_understanding_score <= 1)),
ADD CONSTRAINT student_answers_review_question_score_range_check
CHECK (review_question_score IS NULL OR (review_question_score >= 0 AND review_question_score <= 1)),
ADD CONSTRAINT student_answers_review_confidence_range_check
CHECK (review_confidence IS NULL OR (review_confidence >= 0 AND review_confidence <= 1));
UPDATE student_answers
SET review_correctness_score = CASE
WHEN is_correct IS TRUE THEN 1.000
WHEN is_correct IS FALSE THEN 0.000
ELSE NULL
END,
review_question_score = CASE
WHEN is_correct IS TRUE THEN 1.000
WHEN is_correct IS FALSE THEN 0.000
ELSE NULL
END
WHERE is_correct IS NOT NULL;
ALTER TABLE assignment_assignees
ADD COLUMN overall_score NUMERIC(5,2),
ADD COLUMN pass_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.00,
ADD COLUMN pass_status assignment_pass_status NOT NULL DEFAULT 'pending',
ADD CONSTRAINT assignment_assignees_overall_score_range_check
CHECK (overall_score IS NULL OR (overall_score >= 0 AND overall_score <= 10)),
ADD CONSTRAINT assignment_assignees_pass_threshold_range_check
CHECK (pass_threshold >= 0 AND pass_threshold <= 10);
-- +goose Down
ALTER TABLE assignment_assignees
DROP CONSTRAINT IF EXISTS assignment_assignees_pass_threshold_range_check,
DROP CONSTRAINT IF EXISTS assignment_assignees_overall_score_range_check,
DROP COLUMN IF EXISTS pass_status,
DROP COLUMN IF EXISTS pass_threshold,
DROP COLUMN IF EXISTS overall_score;
ALTER TABLE student_answers
DROP CONSTRAINT IF EXISTS student_answers_review_confidence_range_check,
DROP CONSTRAINT IF EXISTS student_answers_review_question_score_range_check,
DROP CONSTRAINT IF EXISTS student_answers_review_understanding_score_range_check,
DROP CONSTRAINT IF EXISTS student_answers_review_correctness_score_range_check,
DROP COLUMN IF EXISTS review_tags,
DROP COLUMN IF EXISTS review_confidence,
DROP COLUMN IF EXISTS review_question_score,
DROP COLUMN IF EXISTS review_understanding_score,
DROP COLUMN IF EXISTS review_correctness_score,
DROP COLUMN IF EXISTS review_issue_reason,
DROP COLUMN IF EXISTS review_needs_attention;
DROP TYPE IF EXISTS assignment_pass_status;

View File

@@ -0,0 +1,22 @@
-- +goose Up
ALTER TABLE assignments
ADD COLUMN pass_threshold NUMERIC(5,2) NOT NULL DEFAULT 8.00,
ADD CONSTRAINT assignments_pass_threshold_range_check
CHECK (pass_threshold >= 0 AND pass_threshold <= 10);
UPDATE assignments a
SET pass_threshold = COALESCE(
(
SELECT MAX(aa.pass_threshold)
FROM assignment_assignees aa
WHERE aa.assignment_id = a.id
),
8.00
);
-- +goose Down
ALTER TABLE assignments
DROP CONSTRAINT IF EXISTS assignments_pass_threshold_range_check,
DROP COLUMN IF EXISTS pass_threshold;

View File

@@ -0,0 +1,9 @@
-- +goose Up
ALTER TABLE assignment_assignees
ADD COLUMN pass_status_override assignment_pass_status;
-- +goose Down
ALTER TABLE assignment_assignees
DROP COLUMN IF EXISTS pass_status_override;

View File

@@ -0,0 +1,13 @@
-- +goose Up
CREATE TYPE assignment_next_step_outcome AS ENUM ('redo', 'accept', 'support');
ALTER TABLE assignment_assignees
ADD COLUMN next_step_outcome assignment_next_step_outcome;
-- +goose Down
ALTER TABLE assignment_assignees
DROP COLUMN IF EXISTS next_step_outcome;
DROP TYPE IF EXISTS assignment_next_step_outcome;

View File

@@ -0,0 +1,80 @@
-- +goose Up
UPDATE questions AS q
SET correct_answer = seeded.correct_answer
FROM (
VALUES
(1001, '700'),
(1002, '25000'),
(1003, '7/100'),
(1004, '0.37'),
(1101, '383'),
(1102, '456'),
(1103, '2627'),
(1104, '196'),
(1105, '24'),
(1106, '3744'),
(1201, '3'),
(1202, '-5'),
(1203, '-4'),
(1301, '11'),
(1302, '22'),
(1303, '19'),
(1401, '1/2'),
(1402, '2/3'),
(1403, '5/8'),
(1411, '5/6'),
(1412, '3/4'),
(1413, '11/15'),
(1414, '11/12'),
(1415, '1/2'),
(1416, '29/24'),
(1421, '1/6'),
(1422, '1/2'),
(1423, '28'),
(1501, '5'),
(1502, '7'),
(1503, '6'),
(1511, '14'),
(1512, '31'),
(1513, '3n+2'),
(1601, '28'),
(1602, '40'),
(1603, '27'),
(1611, '70'),
(1612, '75'),
(1613, '720'),
(1701, '8'),
(1702, '5'),
(1703, '7'),
(1711, '3/8'),
(1712, '1/3'),
(1801, '15'),
(1802, '3/8'),
(1803, '51'),
(1804, '23'),
(1805, '96')
) AS seeded(id, correct_answer)
WHERE q.id = seeded.id
AND (q.correct_answer IS NULL OR BTRIM(q.correct_answer) = '');
-- +goose Down
UPDATE questions
SET correct_answer = NULL
WHERE id IN (
1001, 1002, 1003, 1004,
1101, 1102, 1103, 1104, 1105, 1106,
1201, 1202, 1203,
1301, 1302, 1303,
1401, 1402, 1403,
1411, 1412, 1413, 1414, 1415, 1416,
1421, 1422, 1423,
1501, 1502, 1503,
1511, 1512, 1513,
1601, 1602, 1603,
1611, 1612, 1613,
1701, 1702, 1703,
1711, 1712,
1801, 1802, 1803, 1804, 1805
);

View File

@@ -0,0 +1,123 @@
-- +goose Up
CREATE TYPE question_topic AS ENUM (
'place_value',
'arithmetic',
'negative_numbers',
'bidmas',
'fractions',
'algebra',
'geometry',
'data'
);
CREATE TYPE question_difficulty AS ENUM ('easy', 'medium', 'hard');
ALTER TABLE questions
ADD COLUMN topic question_topic,
ADD COLUMN difficulty question_difficulty;
UPDATE questions
SET topic = CASE BTRIM(COALESCE(subject, ''))
WHEN 'Place Value' THEN 'place_value'::question_topic
WHEN 'Arithmetic' THEN 'arithmetic'::question_topic
WHEN 'Negative Numbers' THEN 'negative_numbers'::question_topic
WHEN 'BIDMAS' THEN 'bidmas'::question_topic
WHEN 'Fractions' THEN 'fractions'::question_topic
WHEN 'Algebra' THEN 'algebra'::question_topic
WHEN 'Geometry' THEN 'geometry'::question_topic
WHEN 'Data' THEN 'data'::question_topic
ELSE NULL
END
WHERE topic IS NULL;
UPDATE questions AS q
SET difficulty = seeded.difficulty::question_difficulty
FROM (
VALUES
(1001, 'easy'),
(1002, 'medium'),
(1003, 'medium'),
(1004, 'hard'),
(1101, 'easy'),
(1102, 'medium'),
(1103, 'hard'),
(1104, 'medium'),
(1105, 'medium'),
(1106, 'hard'),
(1201, 'easy'),
(1202, 'medium'),
(1203, 'hard'),
(1301, 'easy'),
(1302, 'medium'),
(1303, 'hard'),
(1401, 'easy'),
(1402, 'medium'),
(1403, 'hard'),
(1411, 'easy'),
(1412, 'easy'),
(1413, 'medium'),
(1414, 'medium'),
(1415, 'medium'),
(1416, 'hard'),
(1421, 'easy'),
(1422, 'medium'),
(1423, 'hard'),
(1501, 'easy'),
(1502, 'medium'),
(1503, 'hard'),
(1511, 'easy'),
(1512, 'medium'),
(1513, 'hard'),
(1601, 'easy'),
(1602, 'medium'),
(1603, 'hard'),
(1611, 'easy'),
(1612, 'medium'),
(1613, 'hard'),
(1701, 'easy'),
(1702, 'medium'),
(1703, 'easy'),
(1711, 'medium'),
(1712, 'hard'),
(1801, 'easy'),
(1802, 'medium'),
(1803, 'medium'),
(1804, 'hard'),
(1805, 'hard')
) AS seeded(id, difficulty)
WHERE q.id = seeded.id
AND q.difficulty IS NULL;
ALTER TABLE assignments
ALTER COLUMN pass_threshold SET DEFAULT 6.00;
UPDATE assignments
SET pass_threshold = 6.00;
ALTER TABLE assignment_assignees
ALTER COLUMN pass_threshold SET DEFAULT 6.00;
UPDATE assignment_assignees
SET pass_threshold = 6.00;
-- +goose Down
UPDATE assignment_assignees
SET pass_threshold = 8.00;
ALTER TABLE assignment_assignees
ALTER COLUMN pass_threshold SET DEFAULT 8.00;
UPDATE assignments
SET pass_threshold = 8.00;
ALTER TABLE assignments
ALTER COLUMN pass_threshold SET DEFAULT 8.00;
ALTER TABLE questions
DROP COLUMN IF EXISTS difficulty,
DROP COLUMN IF EXISTS topic;
DROP TYPE IF EXISTS question_difficulty;
DROP TYPE IF EXISTS question_topic;

View File

@@ -0,0 +1,11 @@
-- +goose Up
ALTER TABLE assignment_assignees
ADD COLUMN redo_plan TEXT,
ADD COLUMN redo_plan_generated_at TIMESTAMPTZ;
-- +goose Down
ALTER TABLE assignment_assignees
DROP COLUMN IF EXISTS redo_plan_generated_at,
DROP COLUMN IF EXISTS redo_plan;

View File

@@ -0,0 +1,30 @@
-- +goose Up
CREATE TABLE assignment_student_questions (
id BIGSERIAL PRIMARY KEY,
assignment_id BIGINT NOT NULL,
student_id BIGINT NOT NULL,
question_id BIGINT NOT NULL REFERENCES questions(id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK (position > 0),
source_bucket TEXT NOT NULL CHECK (btrim(source_bucket) <> ''),
source_topic question_topic,
source_difficulty question_difficulty,
generator_seed BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT assignment_student_questions_assignment_student_fkey
FOREIGN KEY (assignment_id, student_id)
REFERENCES assignment_assignees(assignment_id, student_id)
ON DELETE CASCADE,
CONSTRAINT assignment_student_questions_assignment_student_question_key
UNIQUE (assignment_id, student_id, question_id),
CONSTRAINT assignment_student_questions_assignment_student_position_key
UNIQUE (assignment_id, student_id, position)
);
CREATE INDEX idx_assignment_student_questions_assignment_student
ON assignment_student_questions (assignment_id, student_id);
-- +goose Down
DROP INDEX IF EXISTS idx_assignment_student_questions_assignment_student;
DROP TABLE IF EXISTS assignment_student_questions;

View File

@@ -0,0 +1,391 @@
-- name: CreateAssignment :one
INSERT INTO assignments (
classroom_id,
teacher_id,
title,
instructions,
status,
due_at,
published_at,
pass_threshold
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8
)
RETURNING *;
-- name: AssignStudentToAssignment :exec
INSERT INTO assignment_assignees (
assignment_id,
student_id
) VALUES (
$1,
$2
)
ON CONFLICT (assignment_id, student_id) DO NOTHING;
-- name: DeleteAssignmentAssignee :exec
DELETE FROM assignment_assignees
WHERE assignment_id = $1
AND student_id = $2;
-- name: GetAssignmentAssignee :one
SELECT *
FROM assignment_assignees
WHERE assignment_id = $1
AND student_id = $2;
-- name: UpdateAssignmentAIReview :one
UPDATE assignment_assignees
SET ai_feedback = $3,
next_step_outcome = NULLIF($4::text, '')::assignment_next_step_outcome
WHERE assignment_id = $1
AND student_id = $2
RETURNING *;
-- name: UpdateAssignmentRedoPlan :one
UPDATE assignment_assignees
SET redo_plan = NULLIF($3::text, ''),
redo_plan_generated_at = CASE
WHEN NULLIF($3::text, '') IS NULL THEN NULL
ELSE NOW()
END
WHERE assignment_id = $1
AND student_id = $2
RETURNING *;
-- name: GetAssignmentRedoPlan :one
SELECT
assignment_id,
student_id,
redo_plan,
redo_plan_generated_at
FROM assignment_assignees
WHERE assignment_id = $1
AND student_id = $2;
-- name: UpdateAssignmentTeacherFeedback :one
WITH student_question_set AS (
SELECT asq.assignment_id, asq.question_id, asq.position
FROM assignment_student_questions asq
WHERE asq.assignment_id = $1
AND asq.student_id = $2
), selected_questions AS (
SELECT assignment_id, question_id, position
FROM student_question_set
UNION ALL
SELECT aq.assignment_id, aq.question_id, aq.position
FROM assignment_questions aq
WHERE aq.assignment_id = $1
AND NOT EXISTS (SELECT 1 FROM student_question_set)
), score_summary AS (
SELECT CASE
WHEN COUNT(sa.id) = 0 THEN NULL
ELSE ROUND((AVG(
CASE
WHEN sa.is_correct IS NULL THEN COALESCE(sa.review_understanding_score, 0)::NUMERIC
ELSE (
((CASE WHEN sa.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa.review_understanding_score, 0)::NUMERIC
) / 2
END
) * 10)::NUMERIC, 2)
END AS overall_score
FROM selected_questions aq
LEFT JOIN student_answers sa
ON sa.assignment_id = aq.assignment_id
AND sa.question_id = aq.question_id
AND sa.student_id = $2
WHERE aq.assignment_id = $1
), updated AS (
UPDATE assignment_assignees aa
SET teacher_feedback = $3,
pass_status_override = NULLIF($4::text, '')::assignment_pass_status,
next_step_outcome = NULLIF($5::text, '')::assignment_next_step_outcome,
overall_score = (SELECT overall_score FROM score_summary),
pass_status = COALESCE(
NULLIF($4::text, '')::assignment_pass_status,
CASE
WHEN (SELECT overall_score FROM score_summary) IS NULL THEN 'pending'::assignment_pass_status
WHEN (SELECT overall_score FROM score_summary) >= a.pass_threshold THEN 'pass'::assignment_pass_status
ELSE 'no_pass'::assignment_pass_status
END
)
FROM assignments a
WHERE aa.assignment_id = $1
AND aa.student_id = $2
AND a.id = aa.assignment_id
RETURNING aa.*
)
SELECT *
FROM updated;
-- name: AddQuestionToAssignment :exec
INSERT INTO assignment_questions (
assignment_id,
question_id,
position
) VALUES (
$1,
$2,
$3
)
ON CONFLICT (assignment_id, question_id) DO UPDATE
SET position = EXCLUDED.position;
-- name: DeleteAssignmentStudentQuestions :exec
DELETE FROM assignment_student_questions
WHERE assignment_id = $1
AND student_id = $2;
-- name: AddAssignmentStudentQuestion :one
INSERT INTO assignment_student_questions (
assignment_id,
student_id,
question_id,
position,
source_bucket,
source_topic,
source_difficulty,
generator_seed
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8
)
RETURNING *;
-- name: ListAssignmentStudentQuestions :many
SELECT *
FROM assignment_student_questions
WHERE assignment_id = $1
AND student_id = $2
ORDER BY position ASC, id ASC;
-- name: ListGeneratedQuestionsForAssignmentStudent :many
SELECT
asq.id,
asq.assignment_id,
asq.student_id,
asq.question_id,
asq.position,
asq.source_bucket,
asq.source_topic,
asq.source_difficulty,
asq.generator_seed,
asq.created_at,
q.author_teacher_id,
q.title,
q.prompt,
q.subject,
q.source,
q.status,
q.created_at AS question_created_at,
q.updated_at AS question_updated_at,
q.correct_answer,
q.topic,
q.difficulty
FROM assignment_student_questions asq
JOIN questions q ON q.id = asq.question_id
WHERE asq.assignment_id = $1
AND asq.student_id = $2
ORDER BY asq.position ASC, asq.id ASC;
-- name: ListAssignmentsByTeacher :many
SELECT *
FROM assignments
WHERE teacher_id = $1
ORDER BY created_at DESC;
-- name: ListAssignmentsForStudent :many
SELECT a.*
FROM assignment_assignees aa
JOIN assignments a ON a.id = aa.assignment_id
WHERE aa.student_id = $1
ORDER BY a.created_at DESC;
-- name: GetAssignmentByID :one
SELECT *
FROM assignments
WHERE id = $1;
-- name: UpdateAssignmentDraft :one
UPDATE assignments
SET classroom_id = $2,
title = $3,
instructions = $4,
due_at = $5,
pass_threshold = $6,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: CloseAssignment :one
UPDATE assignments
SET status = 'closed'::assignment_status,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: ListQuestionsForAssignment :many
SELECT
aq.assignment_id,
aq.question_id,
aq.position,
q.author_teacher_id,
q.title,
q.prompt,
q.subject,
q.source,
q.status,
q.created_at,
q.updated_at
FROM assignment_questions aq
JOIN questions q ON q.id = aq.question_id
WHERE aq.assignment_id = $1
ORDER BY aq.position ASC, aq.question_id ASC;
-- name: GetAssignmentReviewSummary :one
WITH student_question_set AS (
SELECT asq.student_id, asq.question_id, asq.position
FROM assignment_student_questions asq
WHERE asq.assignment_id = $1
),
students_with_personalized AS (
SELECT DISTINCT student_id
FROM student_question_set
),
selected_questions AS (
SELECT student_id, question_id, position
FROM student_question_set
UNION ALL
SELECT aa.student_id, aq.question_id, aq.position
FROM assignment_assignees aa
JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id
WHERE aa.assignment_id = $1
AND NOT EXISTS (
SELECT 1
FROM students_with_personalized swp
WHERE swp.student_id = aa.student_id
)
),
student_states AS (
SELECT
aa.student_id,
COUNT(sq.question_id)::BIGINT AS total_questions,
CASE
WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status
WHEN COUNT(sq.question_id) > 0
AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id)
THEN 'reviewed'::answer_status
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status
ELSE 'not_started'::answer_status
END AS review_status
FROM assignment_assignees aa
LEFT JOIN selected_questions sq
ON sq.student_id = aa.student_id
LEFT JOIN student_answers sa
ON sa.assignment_id = aa.assignment_id
AND sa.question_id = sq.question_id
AND sa.student_id = aa.student_id
WHERE aa.assignment_id = $1
GROUP BY aa.student_id
)
SELECT
$1::BIGINT AS assignment_id,
COALESCE(MAX(student_states.total_questions), 0)::BIGINT AS total_questions,
COUNT(*)::BIGINT AS total_assigned,
COUNT(*) FILTER (WHERE review_status = 'not_started')::BIGINT AS not_started,
COUNT(*) FILTER (WHERE review_status = 'in_progress')::BIGINT AS in_progress,
COUNT(*) FILTER (WHERE review_status = 'submitted')::BIGINT AS submitted,
COUNT(*) FILTER (WHERE review_status = 'reviewed')::BIGINT AS reviewed
FROM student_states;
-- name: ListAssignmentReviewQueue :many
WITH student_question_set AS (
SELECT asq.student_id, asq.question_id, asq.position
FROM assignment_student_questions asq
WHERE asq.assignment_id = $1
),
students_with_personalized AS (
SELECT DISTINCT student_id
FROM student_question_set
),
selected_questions AS (
SELECT student_id, question_id, position
FROM student_question_set
UNION ALL
SELECT aa.student_id, aq.question_id, aq.position
FROM assignment_assignees aa
JOIN assignment_questions aq ON aq.assignment_id = aa.assignment_id
WHERE aa.assignment_id = $1
AND NOT EXISTS (
SELECT 1
FROM students_with_personalized swp
WHERE swp.student_id = aa.student_id
)
),
student_states AS (
SELECT
aa.assignment_id,
aa.student_id,
aa.next_step_outcome,
u.full_name AS student_name,
u.email AS student_email,
COUNT(sq.question_id)::BIGINT AS total_questions,
COUNT(sa.id)::BIGINT AS answered_questions,
COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed')::BIGINT AS reviewed_questions,
COUNT(sa.id) FILTER (WHERE sa.status = 'submitted')::BIGINT AS submitted_questions,
COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress')::BIGINT AS in_progress_questions,
MAX(sa.submitted_at)::timestamptz AS latest_submitted_at,
MAX(sa.reviewed_at)::timestamptz AS latest_reviewed_at,
CASE
WHEN COUNT(sa.id) = 0 THEN 'not_started'::answer_status
WHEN COUNT(sq.question_id) > 0
AND COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') = COUNT(sq.question_id)
THEN 'reviewed'::answer_status
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'submitted') > 0 THEN 'submitted'::answer_status
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'in_progress') > 0 THEN 'in_progress'::answer_status
WHEN COUNT(sa.id) FILTER (WHERE sa.status = 'reviewed') > 0 THEN 'in_progress'::answer_status
ELSE 'not_started'::answer_status
END AS review_status
FROM assignment_assignees aa
JOIN users u ON u.id = aa.student_id
LEFT JOIN selected_questions sq
ON sq.student_id = aa.student_id
LEFT JOIN student_answers sa
ON sa.assignment_id = aa.assignment_id
AND sa.question_id = sq.question_id
AND sa.student_id = aa.student_id
WHERE aa.assignment_id = $1
GROUP BY aa.assignment_id, aa.student_id, aa.next_step_outcome, u.full_name, u.email
)
SELECT
student_states.assignment_id,
student_states.student_id,
student_states.next_step_outcome,
student_states.student_name,
student_states.student_email,
student_states.total_questions,
student_states.answered_questions,
student_states.reviewed_questions,
student_states.submitted_questions,
student_states.in_progress_questions,
student_states.review_status,
student_states.latest_submitted_at,
student_states.latest_reviewed_at
FROM student_states
WHERE ($2::text = '' OR review_status::text = $2::text)
ORDER BY student_states.student_name ASC, student_states.student_id ASC;

View File

@@ -0,0 +1,36 @@
-- name: CreateClassroom :one
INSERT INTO classrooms (
teacher_id,
name,
code,
description
) VALUES (
$1,
$2,
$3,
$4
)
RETURNING *;
-- name: ListClassroomsByTeacher :many
SELECT *
FROM classrooms
WHERE teacher_id = $1
ORDER BY created_at DESC;
-- name: AddStudentToClassroom :exec
INSERT INTO classroom_students (
classroom_id,
student_id
) VALUES (
$1,
$2
)
ON CONFLICT (classroom_id, student_id) DO NOTHING;
-- name: ListStudentsForClassroom :many
SELECT u.*
FROM classroom_students cs
JOIN users u ON u.id = cs.student_id
WHERE cs.classroom_id = $1
ORDER BY u.full_name ASC;

View File

@@ -0,0 +1,266 @@
-- name: ListMessageRecipientsForUser :many
SELECT
u.id AS user_id,
u.email AS user_email,
u.role AS user_role,
u.full_name AS user_full_name,
p.preferred_name,
p.profile_icon_url,
p.headline
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.id <> $1
AND u.is_active = TRUE
AND (
EXISTS (
SELECT 1
FROM classrooms c
JOIN classroom_students cs ON cs.classroom_id = c.id
WHERE c.teacher_id = u.id
AND cs.student_id = $1
)
OR EXISTS (
SELECT 1
FROM classrooms c
JOIN classroom_students cs ON cs.classroom_id = c.id
WHERE c.teacher_id = $1
AND cs.student_id = u.id
)
)
ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC;
-- name: GetMessageRecipientByIDForUser :one
SELECT
u.id AS user_id,
u.email AS user_email,
u.role AS user_role,
u.full_name AS user_full_name,
p.preferred_name,
p.profile_icon_url,
p.headline
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.id = $2
AND u.id <> $1
AND u.is_active = TRUE
AND (
EXISTS (
SELECT 1
FROM classrooms c
JOIN classroom_students cs ON cs.classroom_id = c.id
WHERE c.teacher_id = u.id
AND cs.student_id = $1
)
OR EXISTS (
SELECT 1
FROM classrooms c
JOIN classroom_students cs ON cs.classroom_id = c.id
WHERE c.teacher_id = $1
AND cs.student_id = u.id
)
)
LIMIT 1;
-- name: ListMessageThreadsForUser :many
SELECT
t.id AS thread_id,
t.subject,
t.created_by_user_id,
t.created_at AS thread_created_at,
t.updated_at AS thread_updated_at,
COALESCE(last_message.id, 0)::bigint AS last_message_id,
COALESCE(last_message.body, '') AS last_message_body,
last_message.created_at AS last_message_created_at,
COALESCE(last_message.sender_user_id, 0)::bigint AS last_message_sender_user_id,
sender.full_name AS last_message_sender_full_name,
sender_profile.preferred_name AS last_message_sender_preferred_name,
sender_profile.profile_icon_url AS last_message_sender_profile_icon_url,
COALESCE((
SELECT COUNT(*)::bigint
FROM messages unread
WHERE unread.thread_id = t.id
AND unread.sender_user_id <> $1
AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at)
), 0)::bigint AS unread_count
FROM message_thread_participants participant
JOIN message_threads t ON t.id = participant.thread_id
LEFT JOIN LATERAL (
SELECT m.id, m.body, m.created_at, m.sender_user_id
FROM messages m
WHERE m.thread_id = t.id
ORDER BY m.created_at DESC, m.id DESC
LIMIT 1
) AS last_message ON TRUE
LEFT JOIN users sender ON sender.id = last_message.sender_user_id
LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id
WHERE participant.user_id = $1
AND participant.archived_at IS NULL
ORDER BY COALESCE(last_message.created_at, t.updated_at) DESC, t.id DESC;
-- name: ListMessageThreadParticipantsForUser :many
SELECT
mtp.thread_id,
u.id AS user_id,
u.email AS user_email,
u.role AS user_role,
u.full_name AS user_full_name,
p.preferred_name,
p.profile_icon_url,
p.headline,
mtp.joined_at,
mtp.last_read_at,
mtp.archived_at
FROM message_thread_participants mtp
JOIN users u ON u.id = mtp.user_id
LEFT JOIN profiles p ON p.user_id = u.id
WHERE mtp.thread_id IN (
SELECT participant.thread_id
FROM message_thread_participants participant
WHERE participant.user_id = $1
AND participant.archived_at IS NULL
)
ORDER BY mtp.thread_id ASC, COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC;
-- name: GetMessageThreadForUser :one
SELECT
t.id,
t.subject,
t.created_by_user_id,
t.created_at,
t.updated_at,
participant.last_read_at,
COALESCE((
SELECT COUNT(*)::bigint
FROM messages unread
WHERE unread.thread_id = t.id
AND unread.sender_user_id <> $2
AND (participant.last_read_at IS NULL OR unread.created_at > participant.last_read_at)
), 0)::bigint AS unread_count
FROM message_threads t
JOIN message_thread_participants participant ON participant.thread_id = t.id
WHERE t.id = $1
AND participant.user_id = $2
AND participant.archived_at IS NULL;
-- name: ListMessagesForThreadForUser :many
SELECT
m.id,
m.thread_id,
m.sender_user_id,
m.body,
m.created_at,
m.updated_at,
sender.email AS sender_email,
sender.role AS sender_role,
sender.full_name AS sender_full_name,
sender_profile.preferred_name AS sender_preferred_name,
sender_profile.profile_icon_url AS sender_profile_icon_url,
sender_profile.headline AS sender_headline
FROM messages m
JOIN message_thread_participants participant ON participant.thread_id = m.thread_id
JOIN users sender ON sender.id = m.sender_user_id
LEFT JOIN profiles sender_profile ON sender_profile.user_id = sender.id
WHERE m.thread_id = $1
AND participant.user_id = $2
AND participant.archived_at IS NULL
ORDER BY m.created_at ASC, m.id ASC;
-- name: ListParticipantsForThreadForUser :many
SELECT
mtp.thread_id,
u.id AS user_id,
u.email AS user_email,
u.role AS user_role,
u.full_name AS user_full_name,
p.preferred_name,
p.profile_icon_url,
p.headline,
mtp.joined_at,
mtp.last_read_at,
mtp.archived_at
FROM message_thread_participants mtp
JOIN users u ON u.id = mtp.user_id
LEFT JOIN profiles p ON p.user_id = u.id
WHERE mtp.thread_id = $1
AND EXISTS (
SELECT 1
FROM message_thread_participants participant
WHERE participant.thread_id = mtp.thread_id
AND participant.user_id = $2
AND participant.archived_at IS NULL
)
ORDER BY COALESCE(NULLIF(p.preferred_name, ''), u.full_name) ASC, u.id ASC;
-- name: CreateMessageThread :one
INSERT INTO message_threads (
created_by_user_id,
subject
) VALUES (
$1,
$2
)
RETURNING *;
-- name: AddMessageThreadParticipant :exec
INSERT INTO message_thread_participants (
thread_id,
user_id,
last_read_at
) VALUES (
$1,
$2,
$3
)
ON CONFLICT (thread_id, user_id) DO NOTHING;
-- name: CreateThreadMessage :one
INSERT INTO messages (
thread_id,
sender_user_id,
body
) VALUES (
$1,
$2,
$3
)
RETURNING *;
-- name: TouchMessageThread :exec
UPDATE message_threads
SET updated_at = NOW()
WHERE id = $1;
-- name: UpdateMessageThreadSubject :one
UPDATE message_threads
SET subject = sqlc.arg(subject),
updated_at = NOW()
WHERE id = sqlc.arg(thread_id)
RETURNING *;
-- name: UpdateThreadMessageBody :one
UPDATE messages
SET body = sqlc.arg(body),
updated_at = NOW()
WHERE id = sqlc.arg(message_id)
AND thread_id = sqlc.arg(thread_id)
AND sender_user_id = sqlc.arg(user_id)
RETURNING *;
-- name: DeleteThreadMessage :one
DELETE FROM messages
WHERE id = sqlc.arg(message_id)
AND thread_id = sqlc.arg(thread_id)
AND sender_user_id = sqlc.arg(user_id)
RETURNING *;
-- name: DeleteMessageThread :one
DELETE FROM message_threads
WHERE id = sqlc.arg(thread_id)
RETURNING *;
-- name: MarkMessageThreadRead :one
UPDATE message_thread_participants
SET last_read_at = COALESCE((SELECT MAX(m.created_at) FROM messages m WHERE m.thread_id = $1), NOW())
WHERE message_thread_participants.thread_id = $1
AND message_thread_participants.user_id = $2
RETURNING *;

View File

@@ -0,0 +1,55 @@
-- name: CreateQuestion :one
INSERT INTO questions (
author_teacher_id,
title,
prompt,
topic,
subject,
difficulty,
source,
status,
correct_answer
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
RETURNING *;
-- name: ListQuestionsByTeacher :many
SELECT *
FROM questions
WHERE author_teacher_id = $1
ORDER BY created_at DESC;
-- name: GetQuestionByID :one
SELECT *
FROM questions
WHERE id = $1;
-- name: CreateTag :one
INSERT INTO tags (name)
VALUES ($1)
ON CONFLICT (name) DO UPDATE SET name = EXCLUDED.name
RETURNING *;
-- name: AttachTagToQuestion :exec
INSERT INTO question_tags (
question_id,
tag_id
) VALUES (
$1,
$2
)
ON CONFLICT (question_id, tag_id) DO NOTHING;
-- name: ListTags :many
SELECT *
FROM tags
ORDER BY name ASC;

View File

@@ -0,0 +1,228 @@
-- name: UpsertStudentAnswer :one
INSERT INTO student_answers (
assignment_id,
question_id,
student_id,
answer_text,
solve_mode,
working_steps,
ai_feedback,
teacher_feedback,
status,
submitted_at,
reviewed_at,
is_correct
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9,
$10,
$11,
$12
)
ON CONFLICT (assignment_id, question_id, student_id) DO UPDATE
SET
answer_text = EXCLUDED.answer_text,
solve_mode = EXCLUDED.solve_mode,
working_steps = EXCLUDED.working_steps,
ai_feedback = EXCLUDED.ai_feedback,
teacher_feedback = EXCLUDED.teacher_feedback,
status = EXCLUDED.status,
submitted_at = EXCLUDED.submitted_at,
reviewed_at = EXCLUDED.reviewed_at,
is_correct = EXCLUDED.is_correct,
updated_at = NOW()
RETURNING *;
-- name: UpdateAnswerAIReview :one
UPDATE student_answers
SET
ai_feedback = $2,
review_needs_attention = $3,
review_issue_reason = $4,
review_correctness_score = $5,
review_understanding_score = $6,
review_question_score = $7,
review_confidence = $8,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: ListAnswersForAssignment :many
SELECT *
FROM student_answers
WHERE assignment_id = $1
ORDER BY created_at ASC;
-- name: ListAnswersForStudent :many
SELECT *
FROM student_answers
WHERE student_id = $1
ORDER BY created_at DESC;
-- name: ListQuestionDetailsForAssignmentStudent :many
WITH student_question_set AS (
SELECT
asq.assignment_id,
asq.question_id,
asq.position
FROM assignment_student_questions asq
WHERE asq.assignment_id = $1
AND asq.student_id = $2
),
selected_questions AS (
SELECT
sq.assignment_id,
sq.question_id,
sq.position
FROM student_question_set sq
UNION ALL
SELECT
aq.assignment_id,
aq.question_id,
aq.position
FROM assignment_questions aq
WHERE aq.assignment_id = $1
AND NOT EXISTS (SELECT 1 FROM student_question_set)
)
SELECT
aq.assignment_id,
aq.question_id,
aq.position,
q.title,
q.prompt,
q.subject,
q.source,
COALESCE(
ARRAY(
SELECT t.name
FROM question_tags qt
JOIN tags t ON t.id = qt.tag_id
WHERE qt.question_id = aq.question_id
ORDER BY t.name ASC
),
ARRAY[]::TEXT[]
)::TEXT[] AS question_tags,
q.status AS question_status,
q.correct_answer,
aa.ai_feedback AS assignment_ai_feedback,
aa.teacher_feedback AS assignment_teacher_feedback,
review_summary.overall_score,
a.pass_threshold,
aa.next_step_outcome,
aa.pass_status_override,
COALESCE(
aa.pass_status_override,
CASE
WHEN review_summary.overall_score IS NULL THEN 'pending'::assignment_pass_status
WHEN review_summary.overall_score >= a.pass_threshold THEN 'pass'::assignment_pass_status
ELSE 'no_pass'::assignment_pass_status
END
) AS pass_status,
sa.id AS answer_id,
sa.student_id,
sa.answer_text,
sa.solve_mode,
sa.working_steps,
sa.is_correct,
sa.ai_feedback,
sa.teacher_feedback,
sa.status AS answer_status,
sa.review_needs_attention,
sa.review_issue_reason,
sa.review_correctness_score,
sa.review_understanding_score,
sa.review_question_score,
sa.review_confidence,
sa.review_tags,
sa.submitted_at,
sa.reviewed_at,
sa.created_at AS answer_created_at,
sa.updated_at AS answer_updated_at
FROM selected_questions aq
JOIN assignments a ON a.id = aq.assignment_id
JOIN questions q ON q.id = aq.question_id
LEFT JOIN assignment_assignees aa
ON aa.assignment_id = aq.assignment_id
AND aa.student_id = $2
LEFT JOIN LATERAL (
SELECT CASE
WHEN COUNT(sa2.id) = 0 THEN NULL::NUMERIC(5,2)
ELSE ROUND((AVG(
CASE
WHEN sa2.is_correct IS NULL THEN COALESCE(sa2.review_understanding_score, 0)::NUMERIC
ELSE (
((CASE WHEN sa2.is_correct THEN 1 ELSE 0 END)::NUMERIC) + COALESCE(sa2.review_understanding_score, 0)::NUMERIC
) / 2
END
) * 10)::NUMERIC, 2)::NUMERIC(5,2)
END AS overall_score
FROM selected_questions aq2
LEFT JOIN student_answers sa2
ON sa2.assignment_id = aq2.assignment_id
AND sa2.question_id = aq2.question_id
AND sa2.student_id = $2
WHERE aq2.assignment_id = aq.assignment_id
) review_summary ON TRUE
LEFT JOIN student_answers sa
ON sa.assignment_id = aq.assignment_id
AND sa.question_id = aq.question_id
AND sa.student_id = $2
WHERE aq.assignment_id = $1
ORDER BY aq.position ASC, aq.question_id ASC;
-- name: UpdateAnswerReview :one
UPDATE student_answers
SET
status = $2,
review_needs_attention = $3,
review_issue_reason = $4,
review_correctness_score = $5,
review_understanding_score = $6,
review_question_score = $7,
review_confidence = $8,
review_tags = $9,
reviewed_at = CASE
WHEN $2::answer_status = 'reviewed' THEN NOW()
ELSE NULL
END,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: ListStudentPlanningPerformance :many
SELECT
sa.assignment_id,
sa.question_id,
q.topic,
q.subject,
q.difficulty,
COALESCE(
ARRAY(
SELECT t.name
FROM question_tags qt
JOIN tags t ON t.id = qt.tag_id
WHERE qt.question_id = sa.question_id
ORDER BY t.name ASC
),
ARRAY[]::TEXT[]
)::TEXT[] AS question_tags,
sa.is_correct,
sa.review_understanding_score,
sa.review_needs_attention,
sa.review_issue_reason,
sa.status,
sa.submitted_at,
sa.reviewed_at,
sa.updated_at
FROM student_answers sa
JOIN questions q ON q.id = sa.question_id
WHERE sa.student_id = $1
AND sa.status IN ('submitted'::answer_status, 'reviewed'::answer_status)
ORDER BY COALESCE(sa.reviewed_at, sa.submitted_at, sa.updated_at) DESC, sa.id DESC;

View File

@@ -0,0 +1,178 @@
-- name: CreateUser :one
INSERT INTO users (
email,
password_hash,
role,
full_name
) VALUES (
$1,
$2,
$3,
$4
)
RETURNING *;
-- name: GetUserByID :one
SELECT *
FROM users
WHERE id = $1;
-- name: GetAuthUserByID :one
SELECT
u.id AS user_id,
u.email AS user_email,
u.role AS user_role,
u.full_name AS user_full_name,
u.is_active AS user_is_active,
u.created_at AS user_created_at,
u.updated_at AS user_updated_at,
p.user_id AS profile_user_id,
p.preferred_name,
p.profile_icon_url,
p.headline,
p.bio,
p.timezone,
p.locale,
p.grade_level,
p.learning_goal,
p.created_at AS profile_created_at,
p.updated_at AS profile_updated_at
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.id = $1;
-- name: GetUserByEmail :one
SELECT *
FROM users
WHERE email = $1;
-- name: GetAuthUserByEmail :one
SELECT
u.id AS user_id,
u.email AS user_email,
u.role AS user_role,
u.full_name AS user_full_name,
u.is_active AS user_is_active,
u.password_hash AS user_password_hash,
u.created_at AS user_created_at,
u.updated_at AS user_updated_at,
p.user_id AS profile_user_id,
p.preferred_name,
p.profile_icon_url,
p.headline,
p.bio,
p.timezone,
p.locale,
p.grade_level,
p.learning_goal,
p.created_at AS profile_created_at,
p.updated_at AS profile_updated_at
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.email = $1;
-- name: ListUsersByRole :many
SELECT *
FROM users
WHERE role = $1
ORDER BY full_name ASC;
-- name: ListUsersWithProfileByRole :many
SELECT
u.id AS user_id,
u.email AS user_email,
u.role AS user_role,
u.full_name AS user_full_name,
u.is_active AS user_is_active,
u.created_at AS user_created_at,
u.updated_at AS user_updated_at,
p.user_id AS profile_user_id,
p.preferred_name,
p.profile_icon_url,
p.headline,
p.bio,
p.timezone,
p.locale,
p.grade_level,
p.learning_goal,
p.created_at AS profile_created_at,
p.updated_at AS profile_updated_at
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.role = $1
ORDER BY u.full_name ASC;
-- name: GetUserWithProfileByID :one
SELECT
u.id AS user_id,
u.email AS user_email,
u.role AS user_role,
u.full_name AS user_full_name,
u.is_active AS user_is_active,
u.created_at AS user_created_at,
u.updated_at AS user_updated_at,
p.user_id AS profile_user_id,
p.preferred_name,
p.profile_icon_url,
p.headline,
p.bio,
p.timezone,
p.locale,
p.grade_level,
p.learning_goal,
p.created_at AS profile_created_at,
p.updated_at AS profile_updated_at
FROM users u
LEFT JOIN profiles p ON p.user_id = u.id
WHERE u.id = $1;
-- name: UpdateUserActiveStatus :one
UPDATE users
SET
is_active = $2,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: UpdateUserFullName :one
UPDATE users
SET
full_name = $2,
updated_at = NOW()
WHERE id = $1
RETURNING *;
-- name: UpsertUserProfile :one
INSERT INTO profiles (
user_id,
preferred_name,
profile_icon_url,
headline,
bio,
timezone,
locale,
grade_level,
learning_goal
) VALUES (
$1,
$2,
$3,
$4,
$5,
$6,
$7,
$8,
$9
)
ON CONFLICT (user_id) DO UPDATE
SET
preferred_name = EXCLUDED.preferred_name,
profile_icon_url = EXCLUDED.profile_icon_url,
headline = EXCLUDED.headline,
bio = EXCLUDED.bio,
timezone = EXCLUDED.timezone,
locale = EXCLUDED.locale,
grade_level = EXCLUDED.grade_level,
learning_goal = EXCLUDED.learning_goal,
updated_at = NOW()
RETURNING *;

12
Backend/db/sqlc.yaml Normal file
View File

@@ -0,0 +1,12 @@
version: "2"
sql:
- engine: "postgresql"
queries: "queries/"
schema: "migrations/"
gen:
go:
package: "sqlc"
out: "../internal/sqlc"
sql_package: "pgx/v5"
emit_json_tags: true
emit_empty_slices: true