feat: implement structured grading system with predefined assessment types
- Add structured grading policy with predefined types (Continuous Assessment, Midterm, Final Test, Exam) - Replace free-text category input with combobox selection in Grading Policy page - Update Lesson Editor to use dropdown selector for grading category assignment - Fix create_grading_category handler to capture organization context - Fix update_course handler to set audit context in database transaction - Implement getImageUrl helper for proper asset path resolution - Add unoptimized prop to organization logo images to bypass Next.js optimization - Add database migrations for organization_id in content tables - Seed default tutorial courses for Admin, Instructor, and Student roles - Fix audit log constraints and content schema issues
This commit is contained in:
@@ -0,0 +1,37 @@
|
||||
-- Migration: Add organization_id to content tables
|
||||
-- Scope: modules, lessons, grading_categories
|
||||
|
||||
-- 1. Add organization_id to modules
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
|
||||
-- Backfill modules based on course
|
||||
UPDATE modules m SET organization_id = c.organization_id
|
||||
FROM courses c WHERE m.course_id = c.id AND m.organization_id IS NULL;
|
||||
|
||||
-- Make organization_id NOT NULL after backfill
|
||||
ALTER TABLE modules ALTER COLUMN organization_id SET NOT NULL;
|
||||
|
||||
-- 2. Add organization_id to lessons
|
||||
-- Note: lessons are children of modules, which now have organization_id
|
||||
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
|
||||
-- Backfill lessons based on module
|
||||
UPDATE lessons l SET organization_id = m.organization_id
|
||||
FROM modules m WHERE l.module_id = m.id AND l.organization_id IS NULL;
|
||||
|
||||
-- Make organization_id NOT NULL after backfill
|
||||
ALTER TABLE lessons ALTER COLUMN organization_id SET NOT NULL;
|
||||
|
||||
-- 3. Add organization_id to grading_categories
|
||||
ALTER TABLE grading_categories ADD COLUMN IF NOT EXISTS organization_id UUID REFERENCES organizations(id) ON DELETE CASCADE;
|
||||
|
||||
-- Backfill grading_categories based on course
|
||||
UPDATE grading_categories g SET organization_id = c.organization_id
|
||||
FROM courses c WHERE g.course_id = c.id AND g.organization_id IS NULL;
|
||||
|
||||
-- Make organization_id NOT NULL after backfill
|
||||
ALTER TABLE grading_categories ALTER COLUMN organization_id SET NOT NULL;
|
||||
|
||||
-- 4. Re-create triggers to ensure they pick up the new columns for audit logs
|
||||
-- (Triggers were already defined in 20260111000002_advanced_auditing.sql)
|
||||
-- No changes needed to the triggers themselves as they use NEW.organization_id dynamically.
|
||||
@@ -0,0 +1,95 @@
|
||||
-- Migration: Fix Audit Logs Constraint and Trigger
|
||||
-- 1. Make changes column nullable (legacy compatibility)
|
||||
ALTER TABLE audit_logs ALTER COLUMN changes DROP NOT NULL;
|
||||
|
||||
-- 2. Update fn_trigger_audit_log to populate changes if needed or just handle NULLs
|
||||
CREATE OR REPLACE FUNCTION fn_trigger_audit_log()
|
||||
RETURNS TRIGGER AS $$
|
||||
DECLARE
|
||||
v_user_id UUID;
|
||||
v_org_id UUID;
|
||||
v_ip INET;
|
||||
v_user_agent TEXT;
|
||||
v_event_type VARCHAR(50);
|
||||
v_old_data JSONB := NULL;
|
||||
v_new_data JSONB := NULL;
|
||||
v_action VARCHAR(50);
|
||||
BEGIN
|
||||
-- Try to get context from session variables
|
||||
BEGIN
|
||||
v_user_id := current_setting('app.current_user_id', true)::UUID;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_user_id := NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
v_org_id := current_setting('app.current_org_id', true)::UUID;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_org_id := NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
v_ip := current_setting('app.client_ip', true)::INET;
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_ip := NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
v_user_agent := current_setting('app.user_agent', true);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_user_agent := NULL;
|
||||
END;
|
||||
|
||||
BEGIN
|
||||
v_event_type := current_setting('app.event_type', true);
|
||||
EXCEPTION WHEN OTHERS THEN
|
||||
v_event_type := 'USER_EVENT';
|
||||
END;
|
||||
|
||||
-- Handle different operations
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
v_old_data := to_jsonb(OLD);
|
||||
v_action := 'DELETE';
|
||||
ELSIF (TG_OP = 'UPDATE') THEN
|
||||
v_old_data := to_jsonb(OLD);
|
||||
v_new_data := to_jsonb(NEW);
|
||||
v_action := 'UPDATE';
|
||||
ELSIF (TG_OP = 'INSERT') THEN
|
||||
v_new_data := to_jsonb(NEW);
|
||||
v_action := 'INSERT';
|
||||
END IF;
|
||||
|
||||
-- Insert into audit_logs
|
||||
INSERT INTO audit_logs (
|
||||
organization_id,
|
||||
user_id,
|
||||
action,
|
||||
entity_type,
|
||||
entity_id,
|
||||
event_type,
|
||||
old_data,
|
||||
new_data,
|
||||
ip_address,
|
||||
user_agent,
|
||||
changes -- Populate legacy column with new_data or old_data for compatibility
|
||||
)
|
||||
VALUES (
|
||||
COALESCE(v_org_id, (CASE WHEN TG_OP = 'DELETE' THEN OLD.organization_id ELSE NEW.organization_id END)),
|
||||
v_user_id,
|
||||
v_action,
|
||||
TG_TABLE_NAME,
|
||||
CASE WHEN TG_OP = 'DELETE' THEN OLD.id ELSE NEW.id END,
|
||||
COALESCE(v_event_type, 'USER_EVENT'),
|
||||
v_old_data,
|
||||
v_new_data,
|
||||
v_ip,
|
||||
v_user_agent,
|
||||
COALESCE(v_new_data, v_old_data)
|
||||
);
|
||||
|
||||
IF (TG_OP = 'DELETE') THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
@@ -0,0 +1,28 @@
|
||||
-- Migration: Final Content Schema Alignment
|
||||
-- Scope: lessons, modules
|
||||
|
||||
-- 1. Add missing columns to lessons
|
||||
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS content_blocks JSONB DEFAULT '[]';
|
||||
ALTER TABLE lessons ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
|
||||
-- 2. Add missing columns to modules
|
||||
ALTER TABLE modules ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW();
|
||||
|
||||
-- 3. Create Update Trigger for updated_at (if not already exists)
|
||||
-- Function update_updated_at_column was defined in initial_schema.sql
|
||||
|
||||
-- Attach triggers to lessons and modules
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_lessons_updated_at') THEN
|
||||
CREATE TRIGGER trg_lessons_updated_at
|
||||
BEFORE UPDATE ON lessons
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
|
||||
IF NOT EXISTS (SELECT 1 FROM pg_trigger WHERE tgname = 'trg_modules_updated_at') THEN
|
||||
CREATE TRIGGER trg_modules_updated_at
|
||||
BEFORE UPDATE ON modules
|
||||
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -0,0 +1,111 @@
|
||||
-- Migration: Seed Tutorials
|
||||
-- Description: Creates default courses for Admin, Instructor, and Student roles using system functions.
|
||||
|
||||
DO $$
|
||||
DECLARE
|
||||
v_admin_id UUID;
|
||||
v_org_id UUID;
|
||||
v_course_id UUID;
|
||||
v_module_id UUID;
|
||||
v_lesson_id UUID;
|
||||
v_blocks JSONB;
|
||||
BEGIN
|
||||
-- 1. Get an Author (First Admin)
|
||||
SELECT id, organization_id INTO v_admin_id, v_org_id FROM users WHERE role = 'admin' LIMIT 1;
|
||||
|
||||
IF v_admin_id IS NULL THEN
|
||||
RAISE NOTICE 'No admin user found. Skipping tutorial seeding.';
|
||||
RETURN;
|
||||
END IF;
|
||||
|
||||
-- SIMULATE SESSION for Audit Logs
|
||||
-- This ensures fn_trigger_audit_log finds a user_id
|
||||
PERFORM set_config('app.current_user_id', v_admin_id::TEXT, true);
|
||||
PERFORM set_config('app.org_id', v_org_id::TEXT, true);
|
||||
PERFORM set_config('app.event_type', 'SYSTEM_SEED', true);
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- COURSE 1: SYSTEM ADMINISTRATION
|
||||
---------------------------------------------------------------------------
|
||||
-- Check if exists to avoid duplicates (simple check by title)
|
||||
PERFORM id FROM courses WHERE title = 'System Administration 101' AND organization_id = v_org_id;
|
||||
IF NOT FOUND THEN
|
||||
SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'System Administration 101', 'self_paced');
|
||||
|
||||
-- Module 1: Organization & Users
|
||||
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Organization & User Management', 1);
|
||||
|
||||
-- Lesson 1.1: Managing Users
|
||||
v_blocks := '[
|
||||
{"id": "1", "type": "description", "content": "Learn how to invite, manage, and remove users from your organization."},
|
||||
{"id": "2", "type": "media", "media_type": "video", "title": "User Management Demo", "url": "https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4"}
|
||||
]';
|
||||
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Managing Users & Roles', 'video', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||
|
||||
-- Lesson 1.2: Audit Logs
|
||||
v_blocks := '[
|
||||
{"id": "1", "type": "description", "content": "OpenCCB automatically logs all critical actions. Access the Audit Log from the settings menu to view a timeline of all system changes."}
|
||||
]';
|
||||
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Using the Audit Log', 'article', NULL, 2, NULL, jsonb_build_object('blocks', v_blocks));
|
||||
|
||||
-- Module 2: System Settings
|
||||
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'System Integrations', 2);
|
||||
|
||||
-- Lesson 2.1: Webhooks
|
||||
v_blocks := '[
|
||||
{"id": "1", "type": "description", "content": "Configure webhooks to receive real-time updates for student enrollments and completions."}
|
||||
]';
|
||||
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Configuring Webhooks', 'article', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||
END IF;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- COURSE 2: INSTRUCTOR ESSENTIALS
|
||||
---------------------------------------------------------------------------
|
||||
PERFORM id FROM courses WHERE title = 'Instructor Essentials' AND organization_id = v_org_id;
|
||||
IF NOT FOUND THEN
|
||||
SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'Instructor Essentials', 'self_paced');
|
||||
|
||||
-- Module 1: The Lesson Builder
|
||||
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Mastering the Lesson Builder', 1);
|
||||
|
||||
-- Lesson 1.1: Content Blocks
|
||||
v_blocks := '[
|
||||
{"id": "1", "type": "description", "content": "The Lesson Builder uses a block-based approach. You can mix and match text, video, and quizzes."},
|
||||
{"id": "2", "type": "fill-in-the-blanks", "title": "Knowledge Check", "content": "The [[Lesson]] Builder allows you to create [[engaging]] content."}
|
||||
]';
|
||||
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Content Blocks Deep Dive', 'activity', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||
|
||||
-- Module 2: Grading
|
||||
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Grading & Analytics', 2);
|
||||
|
||||
-- Lesson 2.1: Grading Policy
|
||||
v_blocks := '[
|
||||
{"id": "1", "type": "description", "content": "Set up grading categories like Exams and Assignments to weight student performance."}
|
||||
]';
|
||||
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Setting up Grading Policies', 'article', NULL, 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||
END IF;
|
||||
|
||||
---------------------------------------------------------------------------
|
||||
-- COURSE 3: STUDENT EXPERIENCE
|
||||
---------------------------------------------------------------------------
|
||||
PERFORM id FROM courses WHERE title = 'Student Onboarding' AND organization_id = v_org_id;
|
||||
IF NOT FOUND THEN
|
||||
SELECT id INTO v_course_id FROM fn_create_course(v_org_id, v_admin_id, 'Student Onboarding', 'self_paced');
|
||||
|
||||
-- Module 1: Getting Started
|
||||
SELECT id INTO v_module_id FROM fn_create_module(v_org_id, v_course_id, 'Welcome to OpenCCB', 1);
|
||||
|
||||
-- Lesson 1.1: Navigation
|
||||
v_blocks := '[
|
||||
{"id": "1", "type": "description", "content": "Navigate through your courses using the sidebar. Track your progress on the dashboard."}
|
||||
]';
|
||||
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Platform Navigation', 'video', 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/TearsOfSteel.mp4', 1, NULL, jsonb_build_object('blocks', v_blocks));
|
||||
|
||||
-- Lesson 1.2: Gamification
|
||||
v_blocks := '[
|
||||
{"id": "1", "type": "description", "content": "Earn XP and badges as you complete lessons. Check the leaderboard to see how you stack up!"}
|
||||
]';
|
||||
SELECT id INTO v_lesson_id FROM fn_create_lesson(v_org_id, v_module_id, 'Understanding XP & Badges', 'article', NULL, 2, NULL, jsonb_build_object('blocks', v_blocks));
|
||||
END IF;
|
||||
|
||||
END $$;
|
||||
@@ -262,6 +262,22 @@ pub async fn update_course(
|
||||
.and_then(|s| s.parse::<DateTime<Utc>>().ok())
|
||||
.or(existing.end_date);
|
||||
|
||||
// BEGIN TRANSACTION
|
||||
let mut tx = pool
|
||||
.begin()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
// Set auditing context
|
||||
sqlx::query(
|
||||
"SELECT set_config('app.current_user_id', $1, true), set_config('app.org_id', $2, true)",
|
||||
)
|
||||
.bind(claims.sub.to_string())
|
||||
.bind(org_ctx.id.to_string())
|
||||
.execute(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
let course = sqlx::query_as::<_, Course>(
|
||||
"SELECT * FROM fn_update_course($1, $2, $3, $4, $5, $6, $7, $8, $9)",
|
||||
)
|
||||
@@ -274,7 +290,7 @@ pub async fn update_course(
|
||||
.bind(start_date)
|
||||
.bind(end_date)
|
||||
.bind(certificate_template)
|
||||
.fetch_one(&pool)
|
||||
.fetch_one(&mut *tx)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
(
|
||||
@@ -283,6 +299,10 @@ pub async fn update_course(
|
||||
)
|
||||
})?;
|
||||
|
||||
tx.commit()
|
||||
.await
|
||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||
|
||||
Ok(Json(course))
|
||||
}
|
||||
|
||||
@@ -944,13 +964,15 @@ pub async fn get_grading_categories(
|
||||
|
||||
pub async fn create_grading_category(
|
||||
State(pool): State<PgPool>,
|
||||
Org(org_ctx): Org,
|
||||
Json(payload): Json<GradingPayload>,
|
||||
) -> Result<Json<common::models::GradingCategory>, (StatusCode, String)> {
|
||||
let category = sqlx::query_as::<_, common::models::GradingCategory>(
|
||||
"INSERT INTO grading_categories (course_id, name, weight, drop_count)
|
||||
VALUES ($1, $2, $3, $4)
|
||||
"INSERT INTO grading_categories (organization_id, course_id, name, weight, drop_count)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *",
|
||||
)
|
||||
.bind(org_ctx.id)
|
||||
.bind(payload.course_id)
|
||||
.bind(payload.name)
|
||||
.bind(payload.weight)
|
||||
|
||||
Reference in New Issue
Block a user