> ## Documentation Index
> Fetch the complete documentation index at: https://docs.terang.ai/llms.txt
> Use this file to discover all available pages before exploring further.

# Courses API Guide

> Retrieve course listings, detailed course information, and recommended course bundles

## Overview

The Courses API provides three endpoints for browsing and retrieving course data from the LMS catalog. Together they power the public course catalog, the course detail dialog, and the bundle/paket view.

| Endpoint                       | Purpose                                                             |
| ------------------------------ | ------------------------------------------------------------------- |
| `GET /api/courses`             | Paginated course listing with search, category filter, sort         |
| `GET /api/courses/{id}`        | Full course detail with modules, chapters, quizzes, detail sections |
| `GET /api/recommended-courses` | Course bundle pairs with pricing and KUM values                     |

***

## Authentication

All three endpoints require authentication. Two methods are supported:

1. **API Key (Recommended for server-to-server)** — Pass your API key in the `x-api-key` header.
2. **Session Cookie (For frontend clients)** — Pass the browser session cookie.

### Method 1: Using an API Key (Recommended)

Generate an API Key in the **Kunci API** sidebar under the Teacher Dashboard. Once generated, include it in your HTTP requests:

```bash theme={null}
curl -H "x-api-key: lms_key_your_actual_key" \
     https://lms.terang.ai/api/courses?page=1&limit=10
```

API key users always see the published, buyer-visible dataset on `GET /api/courses` — equivalent to `?public=true`. For `GET /api/recommended-courses`, pass `?public=true` explicitly to filter to bundles where both courses are published.

### Method 2: Using Session Cookies (Development)

To test the endpoint locally or via curl using browser cookies:

1. Sign in to the LMS dashboard via your browser.
2. Open the browser's developer tools and copy the session cookie value.
3. Run the curl command below, replacing `<cookie-value>` with your token:

```bash theme={null}
curl -H "Cookie: better-auth.session_token=<cookie-value>" \
     https://lms.terang.ai/api/courses
```

Session-authenticated teachers see their own courses (including drafts).

### Quiz answer sanitization

For API key users and non-owner students, quiz questions are **sanitized** — answer keys are stripped before the response is sent. The following fields are removed from every question object:

* `correctAnswer` / `correct_answer`
* `essayAnswer` / `essay_answer`
* `explanation`
* `isCorrect` / `is_correct` / `correct` (from each option)

Only the course owner (teacher) or a `super_admin` can see answer keys, via session-based auth.

***

## Rate limiting

All three endpoints are rate-limited per API key to **100 requests per minute**. The limit is enforced using an in-memory sliding window tracked by the first 16 characters of the SHA-256 hash of your API key.

When the limit is exceeded, the API returns:

```text theme={null}
HTTP 429 Too Many Requests
Retry-After: 42

{
  "success": false,
  "error": "Rate limit exceeded"
}
```

The `Retry-After` header indicates the number of seconds to wait before retrying. Back off at least that long before making another request.

***

## Frontend integration guide

Use these TypeScript interfaces and React hooks to integrate the courses API into your frontend.

### TypeScript type definitions

```typescript theme={null}
// ── Shared types ──────────────────────────────────────────────────────

export interface Category {
  id: number;
  code: string;
  name: string;
}

export interface Pagination {
  page: number;
  limit: number;
  total: number;
  totalPages: number;
}

export interface CertificateInfo {
  isEligible: boolean;
  isGenerated: boolean;
}

export interface FinalExam {
  id: string;
  title: string;
  type: 'final';
  questions: unknown[];
  minimumScore: number;
  attempts: number;
  maxAttempts: number;
  isPassed: boolean;
}

// ── List Courses ──────────────────────────────────────────────────────

export interface ChapterSummary {
  id: string;
  title: string;
  order: number;
  contents: unknown[];
  quiz: unknown | null;
  isUnlocked: boolean;
  completionPercentage: number;
}

export interface ModuleSummary {
  id: string;
  title: string;
  description: string;
  order: number;
  chapterCount: number;
  chapters: ChapterSummary[];
  moduleQuiz: unknown | null;
  isUnlocked: boolean;
  completionPercentage: number;
}

export interface DetailSection {
  requirements?: string;
  applicationDeadline?: string;
  prerequisites?: string;
  credits?: string;
  workload?: string;
  assessment?: string;
  totalCost?: number;
  paymentOptions?: string;
  scholarships?: string;
  outcomes?: string;
  industries?: string;
  averageSalary?: string;
  testimonials?: string;
  facilities?: string;
  support?: string;
}

export interface PublicCourse {
  id: string;
  name: string;
  code: string;
  description: string;
  instructor: string;
  startDate: string;
  endDate: string;
  enrollmentType: string;
  enrollmentCode: string;
  isPurchasable: boolean;
  price: number;
  priceNonMember: number | null;
  priceMahasiswa: number | null;
  currency: string;
  previewMode: boolean;
  eventCode: string | null;
  nilaiKum: number | null;
  categoryId: number | null;
  category: Category | null;
  thumbnail?: string;
  moduleCount: number;
  moduleSummaries: Array<{
    id: string;
    title: string;
    order: number;
    chapterCount: number;
  }>;
  modules: ModuleSummary[];
  admissions: DetailSection | null;
  academics: DetailSection | null;
  tuitionAndFinancing: DetailSection | null;
  careers: DetailSection | null;
  studentExperience: DetailSection | null;
  finalExam: FinalExam;
  certificate: CertificateInfo;
  minPassingScore: number;
  totalProgress: number;
  status: string;
  studentCount: number;
}

export interface ListCoursesResponse {
  success: boolean;
  courses: PublicCourse[];
  pagination?: Pagination;
}

// ── Course Detail ─────────────────────────────────────────────────────

export interface Question {
  id: number;
  quizId: number;
  questionText: string;
  questionType: string;
  options: Array<{ id: string; text: string }>;
  orderIndex: number;
  points: number;
}

export interface Quiz {
  id: number;
  chapterId: number | null;
  name: string;
  description: string;
  quizType: string;
  minimumScore: number;
  timeLimit: number;
  questions: Question[];
}

export interface Chapter {
  id: number;
  moduleId: number;
  name: string;
  content: string;
  orderIndex: number;
  createdAt: string;
  updatedAt: string;
  quizzes: Quiz[];
}

export interface CourseModule {
  id: number;
  courseId: number;
  name: string;
  description: string;
  orderIndex: number;
  createdAt: string;
  updatedAt: string;
  chapters: Chapter[];
}

export interface CourseDetail {
  id: number;
  name: string;
  description: string;
  instructor: string;
  type: string;
  enrollmentType: string;
  startDate: string | null;
  endDate: string | null;
  teacherId: string;
  institutionId: number;
  categoryId: number | null;
  courseCode: string;
  eventCode: string | null;
  isPublished: boolean;
  nilaiKum: number | null;
  coverPicture: string | null;
  isPurchasable: boolean;
  price: string;
  priceNonMember: string | null;
  priceMahasiswa: string | null;
  currency: string;
  previewMode: boolean;
  createdAt: string;
  updatedAt: string;
  teacherName: string;
  teacherEmail: string;
  category: Category | null;
  modules: CourseModule[];
  moduleQuizzes: Quiz[];
  enrollmentCount: number;
  studentCount: number;
  admissions: DetailSection | null;
  academics: DetailSection | null;
  tuitionAndFinancing: DetailSection | null;
  careers: DetailSection | null;
  studentExperience: DetailSection | null;
}

export interface CourseDetailResponse {
  success: boolean;
  course: CourseDetail;
}

// ── Course Bundles ────────────────────────────────────────────────────

export interface BundleCourse {
  id: string;
  numericId: number;
  name: string;
  description: string;
  instructor: string;
  code: string;
  eventCode: string | null;
  nilaiKum: number | null;
  thumbnail?: string;
  price: number;
  priceNonMember: number | null;
  currency: string;
  categoryId: number | null;
  enrollmentType: string;
  isPurchasable: boolean;
  previewMode: boolean;
  startDate: string;
  endDate: string;
  modules: unknown[];
  certificate: CertificateInfo;
  minPassingScore: number;
  totalProgress: number;
  status: string;
  studentCount: number;
}

export interface CourseBundle {
  id: number;
  name: string;
  description: string;
  categoryId: number;
  category: Category;
  courseOne: BundleCourse;
  courseTwo: BundleCourse;
  totalPrice: number;
  price: number;
  priceNonMember: number;
  kum: number;
  currency: string;
  createdAt: string;
  updatedAt: string;
}

export interface CourseBundlesResponse {
  success: boolean;
  recommendedCourses: CourseBundle[];
}
```

### Code example: React hooks for course catalog

This example shows a React hook to fetch paginated courses with loading and error states:

```tsx theme={null}
import { useState, useEffect, useCallback } from 'react';
import { ListCoursesResponse, PublicCourse } from '@/types/courses';

interface UseCoursesOptions {
  page?: number;
  limit?: number;
  search?: string;
  categoryId?: string;
  sortBy?: 'name' | 'students' | 'modules';
}

interface UseCoursesResult {
  courses: PublicCourse[];
  pagination: { page: number; limit: number; total: number; totalPages: number } | null;
  loading: boolean;
  error: string | null;
  refetch: () => void;
}

export function useCourses(options: UseCoursesOptions = {}): UseCoursesResult {
  const { page = 1, limit = 12, search, categoryId, sortBy } = options;
  const [courses, setCourses] = useState<PublicCourse[]>([]);
  const [pagination, setPagination] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  const fetchCourses = useCallback(async () => {
    try {
      setLoading(true);
      setError(null);

      const params = new URLSearchParams();
      params.set('page', String(page));
      params.set('limit', String(limit));
      if (search) params.set('search', search);
      if (categoryId) params.set('categoryId', categoryId);
      if (sortBy) params.set('sortBy', sortBy);

      // For frontend use, include credentials for session cookie
      const res = await fetch(`/api/courses?${params}`, {
        credentials: 'include',
        headers: {
          'x-api-key': '...' // optional: use when calling from server-side
        }
      });

      if (!res.ok) {
        if (res.status === 401) throw new Error('Unauthorized. Provide a valid API key or log in.');
        if (res.status === 429) throw new Error('Rate limited. Wait before retrying.');
        throw new Error(`Server returned ${res.status}`);
      }

      const data: ListCoursesResponse = await res.json();
      if (!data.success) throw new Error('Failed to load courses.');

      setCourses(data.courses);
      setPagination(data.pagination ?? null);
    } catch (err: any) {
      setError(err.message || 'An unknown error occurred.');
    } finally {
      setLoading(false);
    }
  }, [page, limit, search, categoryId, sortBy]);

  useEffect(() => { fetchCourses(); }, [fetchCourses]);

  return { courses, pagination, loading, error, refetch: fetchCourses };
}
```

### Code example: course detail hook

```tsx theme={null}
import { useState, useEffect } from 'react';
import { CourseDetailResponse, CourseDetail } from '@/types/courses';

export function useCourseDetail(courseId: number | null) {
  const [course, setCourse] = useState<CourseDetail | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    if (!courseId) return;

    async function loadDetail() {
      try {
        setLoading(true);
        setError(null);
        const res = await fetch(`/api/courses/${courseId}`, {
          credentials: 'include',
        });

        if (!res.ok) {
          if (res.status === 404) throw new Error('Course not found.');
          if (res.status === 401) throw new Error('Unauthorized.');
          if (res.status === 429) throw new Error('Rate limited. Wait before retrying.');
          throw new Error(`Server returned ${res.status}`);
        }

        const data: CourseDetailResponse = await res.json();
        if (!data.success) throw new Error('Failed to load course detail.');

        setCourse(data.course);
      } catch (err: any) {
        setError(err.message || 'An unknown error occurred.');
      } finally {
        setLoading(false);
      }
    }

    loadDetail();
  }, [courseId]);

  return { course, loading, error };
}
```

### Code example: course bundles hook

```tsx theme={null}
import { useState, useEffect } from 'react';
import { CourseBundlesResponse, CourseBundle } from '@/types/courses';

export function useCourseBundles(publicOnly: boolean = true, categoryId?: number) {
  const [bundles, setBundles] = useState<CourseBundle[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    async function loadBundles() {
      try {
        setLoading(true);
        setError(null);

        const params = new URLSearchParams();
        if (publicOnly) params.set('public', 'true');
        if (categoryId) params.set('categoryId', String(categoryId));

        const res = await fetch(`/api/recommended-courses?${params}`, {
          credentials: 'include',
        });

        if (!res.ok) {
          if (res.status === 401) throw new Error('Unauthorized.');
          if (res.status === 429) throw new Error('Rate limited. Wait before retrying.');
          throw new Error(`Server returned ${res.status}`);
        }

        const data: CourseBundlesResponse = await res.json();
        if (!data.success) throw new Error('Failed to load bundles.');

        setBundles(data.recommendedCourses);
      } catch (err: any) {
        setError(err.message || 'An unknown error occurred.');
      } finally {
        setLoading(false);
      }
    }

    loadBundles();
  }, [publicOnly, categoryId]);

  return { bundles, loading, error };
}
```

***

## Technical details

### Public access filtering (buyer visibility)

A course is "buyer-visible" only when **both** conditions are met:

1. `is_published` is `true`
2. `event_code` is **not null** (the course has been registered with IAI)

This ensures only courses that are publish-ready and have received an IAI event code appear in the public catalog. Draft courses and courses pending IAI registration are hidden from API key users.

The SQL query for the public listing:

```sql theme={null}
SELECT c.*
FROM courses c
WHERE c.is_published = true
  AND c.event_code IS NOT NULL
ORDER BY c.id DESC;
```

### Server-side pagination

When `page` and/or `limit` query parameters are provided, the list endpoint uses a two-query pattern:

1. **Count query** — `SELECT count(*)::int` with the same WHERE clause to determine total results.
2. **Data query** — `SELECT ... LIMIT ${limit} OFFSET ${offset}` with the same WHERE clause.

Pagination metadata is returned alongside courses:

```json theme={null}
{
  "pagination": {
    "page": 1,
    "limit": 12,
    "total": 25,
    "totalPages": 3
  }
}
```

When pagination parameters are omitted, the endpoint returns all matching courses (up to the server limit).

### Category filtering

The `categoryId` query parameter accepts:

* A numeric ID (e.g. `1`) — filters to courses in that category.
* The string `uncategorized` — filters to courses with no category (`category_id IS NULL`).

### Search behavior

The `search` parameter performs a case-insensitive (`ILIKE`) search across six columns:

* `c.name`
* `c.description`
* `c.instructor`
* `c.course_code`
* `category.code`
* `category.name`

### Bundle pricing logic

Each course bundle has three price fields:

* `totalPrice` — Sum of the two individual course prices (what it would cost to buy separately).
* `price` — The bundle's own (discounted) price. Falls back to `totalPrice` when unset.
* `priceNonMember` — Price for non-IAI-member buyers. Falls back to `price` when unset.

The `kum` field represents the total KUM/SKPK points earned by completing both courses in the bundle.

### Quiz answer sanitization

The `sanitizeQuestionsForStudent` function strips answer keys from all question objects before sending them to API key users or non-owner students. This is applied per-question:

```typescript theme={null}
// Before sanitization
{
  "id": 1,
  "questionText": "Apa definisi kepemimpinan?",
  "options": [
    { "id": "a", "text": "Kemampuan mempengaruhi orang lain", "isCorrect": true },
    { "id": "b", "text": "Kemampuan mengatur keuangan", "isCorrect": false }
  ],
  "correctAnswer": "a",
  "points": 10
}

// After sanitization (what API key users receive)
{
  "id": 1,
  "questionText": "Apa definisi kepemimpinan?",
  "options": [
    { "id": "a", "text": "Kemampuan mempengaruhi orang lain" },
    { "id": "b", "text": "Kemampuan mengatur keuangan" }
  ],
  "points": 10
}
```

### Draft/unpublished course protection

A course that has not been published or does not have an IAI event code returns `404` to anyone who is not its owner or a `super_admin`. This applies to `GET /api/courses/{id}` — draft courses are never accidentally leaked via the detail endpoint.
