Custom LMS Integration Guide

Build SCORM capabilities into your custom Learning Management System.

Table of Contents

Overview

This guide helps you integrate SCORM functionality into your custom LMS, enabling:

  • SCORM package management
  • Learner session tracking
  • Progress and completion reporting
  • Grade book integration

Architecture

Recommended Architecture

┌─────────────────┐
│   Custom LMS    │
│                 │
│  ┌───────────┐  │
│  │  Frontend │  │
│  │  (React/  │  │
│  │   Vue)    │  │
│  └─────┬─────┘  │
│        │        │
│  ┌─────▼─────┐  │
│  │  Backend  │  │
│  │  (Node/   │  │
│  │  Python)  │  │
│  └─────┬─────┘  │
└────────┼────────┘
         │
         │ API Calls
         │
┌────────▼────────┐
│  SCORM API      │
│  (AllureLMS)    │
└─────────────────┘

Data Flow

  1. Upload: LMS → SCORM API → Storage
  2. Launch: LMS → SCORM API → Launch URL
  3. Tracking: SCORM Player → SCORM API → LMS (via polling/webhooks)
  4. Reporting: LMS → SCORM API → Analytics

Implementation Steps

Step 1: Database Schema

Add SCORM-related tables to your LMS:

-- Packages table
CREATE TABLE scorm_packages (
  id UUID PRIMARY KEY,
  scorm_package_id VARCHAR(255) UNIQUE NOT NULL,
  title VARCHAR(255) NOT NULL,
  version VARCHAR(50),
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

-- Sessions table
CREATE TABLE scorm_sessions (
  id UUID PRIMARY KEY,
  scorm_session_id VARCHAR(255) UNIQUE NOT NULL,
  package_id UUID REFERENCES scorm_packages(id),
  user_id UUID REFERENCES users(id),
  completion_status VARCHAR(50),
  success_status VARCHAR(50),
  score DECIMAL(5,2),
  time_spent_seconds INTEGER,
  created_at TIMESTAMP DEFAULT NOW(),
  updated_at TIMESTAMP DEFAULT NOW()
);

Step 2: API Client

Create a SCORM API client:

// lib/scorm-client.ts
export class ScormClient {
  constructor(
    private apiKey: string,
    private baseUrl: string,
    private tenantId: string
  ) {}

  /** Mint presigned URL → PUT ZIP → process (run server-side when possible). */
  async uploadPackage(file: File, uploadedBy: string) {
    const mint = await fetch(`${this.baseUrl}/api/v1/packages/upload-url`, {
      method: 'POST',
      headers: {
        'X-API-Key': this.apiKey,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        tenant_id: this.tenantId,
        uploaded_by: uploadedBy,
        filename: file.name,
        file_size: file.size,
      }),
    });
    if (!mint.ok) throw new Error(`Mint failed: ${mint.statusText}`);
    const ticket = await mint.json();

    const put = await fetch(ticket.presigned_url, {
      method: 'PUT',
      headers: ticket.required_put_headers,
      body: file,
    });
    if (!put.ok) throw new Error(`Storage upload failed: ${put.status}`);

    const proc = await fetch(`${this.baseUrl}/api/v1/packages/process`, {
      method: 'POST',
      headers: {
        'X-API-Key': this.apiKey,
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        tenant_id: this.tenantId,
        uploaded_by: uploadedBy,
        storage_path: ticket.storage_path,
        original_filename: file.name,
      }),
    });
    if (!proc.ok) throw new Error(`Process failed: ${proc.statusText}`);
    return proc.json();
  }

  async launchSession(packageId: string, userId: string) {
    const response = await fetch(
      `${this.baseUrl}/api/v1/packages/${packageId}/launch`,
      {
        method: 'POST',
        headers: {
          'X-API-Key': this.apiKey,
          'Content-Type': 'application/json',
        },
        body: JSON.stringify({
          user_id: userId,
          session_id: crypto.randomUUID(),
        }),
      }
    );

    return response.json();
  }

  async getSession(sessionId: string) {
    const response = await fetch(
      `${this.baseUrl}/api/v1/sessions/${sessionId}`,
      {
        headers: { 'X-API-Key': this.apiKey },
      }
    );

    return response.json();
  }
}

Step 3: Backend Service

Create service layer:

// services/scorm-service.ts
import { ScormClient } from '@/lib/scorm-client';
import { db } from '@/lib/database';

export class ScormService {
  private client: ScormClient;

  constructor() {
    this.client = new ScormClient(
      process.env.CONNECT_API_KEY!,
      process.env.CONNECT_API_BASE_URL!,
      process.env.CONNECT_TENANT_ID!
    );
  }

  async uploadPackage(file: File, userId: string) {
    // Upload to SCORM API
    const pkg = await this.client.uploadPackage(file, userId);

    // Store in local database
    const localPackage = await db.scormPackages.create({
      data: {
        scorm_package_id: pkg.id,
        title: pkg.title,
        version: pkg.version,
      },
    });

    return localPackage;
  }

  async launchCourse(packageId: string, userId: string) {
    // Get SCORM package ID from local database
    const localPackage = await db.scormPackages.findUnique({
      where: { id: packageId },
    });

    if (!localPackage) {
      throw new Error('Package not found');
    }

    // Launch session
    const launch = await this.client.launchSession(
      localPackage.scorm_package_id,
      userId
    );

    // Store session in local database
    const session = await db.scormSessions.create({
      data: {
        scorm_session_id: launch.session_id,
        package_id: packageId,
        user_id: userId,
        completion_status: 'not_attempted',
      },
    });

    return {
      sessionId: session.id,
      playerUrl: launch.launch_url,
    };
  }

  async syncSessionProgress(sessionId: string) {
    // Get local session
    const localSession = await db.scormSessions.findUnique({
      where: { id: sessionId },
    });

    if (!localSession) {
      throw new Error('Session not found');
    }

    // Get latest from SCORM API
    const scormSession = await this.client.getSession(
      localSession.scorm_session_id
    );

    // Update local database
    const updated = await db.scormSessions.update({
      where: { id: sessionId },
      data: {
        completion_status: scormSession.completion_status,
        success_status: scormSession.success_status,
        score: scormSession.score?.scaled,
        time_spent_seconds: scormSession.time_spent_seconds,
      },
    });

    return updated;
  }
}

Code Examples

Upload Endpoint

// app/api/courses/upload/route.ts
import { ScormService } from '@/services/scorm-service';

export async function POST(request: Request) {
  const formData = await request.formData();
  const file = formData.get('file') as File;
  const userId = formData.get('userId') as string;

  const scormService = new ScormService();
  const package = await scormService.uploadPackage(file, userId);

  return Response.json({ package });
}

Launch Endpoint

// app/api/courses/[id]/launch/route.ts
import { ScormService } from '@/services/scorm-service';

export async function POST(
  request: Request,
  { params }: { params: { id: string } }
) {
  const { userId } = await request.json();
  const packageId = params.id;

  const scormService = new ScormService();
  const launch = await scormService.launchCourse(packageId, userId);

  return Response.json(launch);
}

Progress Sync Endpoint

// app/api/sessions/[id]/sync/route.ts
import { ScormService } from '@/services/scorm-service';

export async function POST(
  request: Request,
  { params }: { params: { id: string } }
) {
  const sessionId = params.id;

  const scormService = new ScormService();
  const session = await scormService.syncSessionProgress(sessionId);

  return Response.json(session);
}

UI Components

Package Upload Component

// components/ScormUpload.tsx
'use client';

import { useState } from 'react';

export function ScormUpload({ onUpload }: { onUpload: (pkg: any) => void }) {
  const [uploading, setUploading] = useState(false);
  const [progress, setProgress] = useState(0);

  async function handleUpload(event: React.ChangeEvent<HTMLInputElement>) {
    const file = event.target.files?.[0];
    if (!file) return;

    setUploading(true);
    const formData = new FormData();
    formData.append('file', file);
    formData.append('userId', getCurrentUserId());

    try {
      const response = await fetch('/api/courses/upload', {
        method: 'POST',
        body: formData,
      });

      const package = await response.json();
      onUpload(package);
    } catch (error) {
      console.error('Upload failed:', error);
    } finally {
      setUploading(false);
    }
  }

  return (
    <div>
      <input
        type="file"
        accept=".zip"
        onChange={handleUpload}
        disabled={uploading}
      />
      {uploading && <div>Uploading... {progress}%</div>}
    </div>
  );
}

Player Component

// components/ScormPlayer.tsx
'use client';

import { useEffect, useState } from 'react';

export function ScormPlayer({
  packageId,
  userId,
}: {
  packageId: string;
  userId: string;
}) {
  const [playerUrl, setPlayerUrl] = useState<string | null>(null);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    async function launch() {
      try {
        const response = await fetch(`/api/courses/${packageId}/launch`, {
          method: 'POST',
          headers: { 'Content-Type': 'application/json' },
          body: JSON.stringify({ userId }),
        });

        const { playerUrl } = await response.json();
        setPlayerUrl(playerUrl);
      } catch (error) {
        console.error('Launch failed:', error);
      } finally {
        setLoading(false);
      }
    }

    launch();
  }, [packageId, userId]);

  if (loading) return <div>Loading course...</div>;
  if (!playerUrl) return <div>Failed to load course</div>;

  return (
    <iframe
      src={playerUrl}
      width="100%"
      height="800px"
      frameBorder="0"
      allow="fullscreen"
      title="SCORM Course"
    />
  );
}

Data Synchronization

Polling Strategy

// hooks/useScormProgress.ts
import { useEffect, useState } from 'react';

export function useScormProgress(sessionId: string) {
  const [progress, setProgress] = useState<any>(null);

  useEffect(() => {
    const interval = setInterval(async () => {
      try {
        const response = await fetch(`/api/sessions/${sessionId}/sync`, {
          method: 'POST',
        });
        const session = await response.json();
        setProgress(session);

        if (session.completion_status === 'completed') {
          clearInterval(interval);
        }
      } catch (error) {
        console.error('Sync failed:', error);
      }
    }, 5000); // Poll every 5 seconds

    return () => clearInterval(interval);
  }, [sessionId]);

  return progress;
}

Webhook Integration

// app/api/webhooks/scorm/route.ts
export async function POST(request: Request) {
  // Verify webhook signature
  const signature = request.headers.get('X-Allure-Signature');
  const body = await request.text();
  
  // Verify signature (implement HMAC verification)
  if (!verifySignature(signature, body)) {
    return new Response('Invalid signature', { status: 401 });
  }

  const event = JSON.parse(body);

  switch (event.event) {
    case 'package.processing.completed':
      await handlePackageProcessed(event.data);
      break;
    case 'session.completed':
      await handleSessionCompleted(event.data);
      break;
  }

  return new Response('OK');
}

Best Practices

  1. Cache Package Metadata: Store package info locally to reduce API calls
  2. Async Processing: Handle package processing asynchronously
  3. Error Recovery: Implement retry logic for transient failures
  4. Progress Tracking: Use webhooks when possible, polling as fallback
  5. Grade Book Integration: Sync scores to your LMS grade book
  6. User Experience: Show loading states and progress indicators

Related Documentation


Last Updated: 2025-01-15