API Integration Patterns

Guide for integrating the frontend with the B12 SIS backend API.

API Base Configuration

Environment Setup

// environments/environment.ts
export const environment = {
  production: false,
  apiUrl: 'http://localhost:8080/api'
};

// environments/environment.prod.ts
export const environment = {
  production: true,
  apiUrl: 'https://api.school.edu/api'
};

ApiService

The core HTTP wrapper for all API calls:

import { Injectable } from '@angular/core';
import { HttpClient, HttpParams } from '@angular/common/http';
import { environment } from 'environments/environment';

@Injectable({ providedIn: 'root' })
export class ApiService {
  private baseUrl = environment.apiUrl;

  constructor(private http: HttpClient) {}

  get<T>(endpoint: string, params?: HttpParams) {
    return this.http.get<T>(`${this.baseUrl}/${endpoint}`, { params });
  }

  post<T>(endpoint: string, body?: any, queryParams?: Record<string, string>) {
    let url = `${this.baseUrl}/${endpoint}`;
    if (queryParams) {
      url += `?${new URLSearchParams(queryParams).toString()}`;
    }
    return this.http.post<T>(url, body);
  }

  put<T>(endpoint: string, body?: any) {
    return this.http.put<T>(`${this.baseUrl}/${endpoint}`, body);
  }

  delete<T>(endpoint: string) {
    return this.http.delete<T>(`${this.baseUrl}/${endpoint}`);
  }
}

Standard CRUD Operations

List with Pagination and Filters

// Request
POST /api/students/list

// Body
{
  "page": 1,
  "page_size": 25,
  "sort_by": "created_at",
  "sort_order": "desc",
  "filters": [
    {
      "field": "status",
      "operator": "eq",
      "value": "active"
    }
  ]
}

Frontend Implementation:

interface ListRequest {
  page: number;
  page_size: number;
  sort_by?: string;
  sort_order?: 'asc' | 'desc';
  filters?: Filter[];
}

interface Filter {
  field: string;
  operator: 'eq' | 'ne' | 'like' | 'gt' | 'gte' | 'lt' | 'lte' | 'in' | 'not_in';
  value: any;
}

// Service
listStudents(request: ListRequest) {
  return this.api.post<ListResponse<Student>>('students/list', request);
}

// Component
loadStudents() {
  this.studentService.listStudents({
    page: this.currentPage,
    page_size: 25,
    sort_by: 'last_name',
    sort_order: 'asc',
    filters: [
      { field: 'status', operator: 'eq', value: 'active' }
    ]
  }).subscribe(response => {
    this.students = response.data;
    this.totalRecords = response.metadata.total;
  });
}

Response Format

All API responses follow this structure:

interface ApiResponse<T> {
  data: T;
  message?: string;
  metadata?: {
    page: number;
    page_size: number;
    total: number;
    total_pages: number;
  };
}

Create

// Request
POST /api/students
{
  "first_name": "John",
  "last_name": "Doe",
  "email": "john.doe@school.edu",
  "date_of_birth": "2010-05-15"
}

// Response
{
  "data": {
    "id": "uuid-here",
    "first_name": "John",
    "last_name": "Doe",
    ...
  },
  "message": "Student created successfully"
}

Frontend Implementation:

createStudent(student: StudentInput) {
  return this.api.post<ApiResponse<Student>>('students', student);
}

// Component
onSubmit() {
  this.studentService.createStudent(this.form.value)
    .subscribe({
      next: (response) => {
        this.notification.showSuccess('Student created');
        this.router.navigate(['/module/students', response.data.id]);
      },
      error: (error) => {
        this.notification.showError(error.message);
      }
    });
}

Read (Get by ID)

// Request
GET /api/students/:id

// Response
{
  "data": {
    "id": "uuid-here",
    "first_name": "John",
    ...
  }
}

Frontend Implementation:

getStudent(id: string) {
  return this.api.get<ApiResponse<Student>>(`students/${id}`);
}

// Component
ngOnInit() {
  const id = this.route.snapshot.paramMap.get('id');
  this.studentService.getStudent(id).subscribe(response => {
    this.student = response.data;
  });
}

Update

// Request
PUT /api/students/:id
{
  "first_name": "Jane",
  "last_name": "Doe"
}

// Response
{
  "data": { ... },
  "message": "Student updated successfully"
}

Frontend Implementation:

updateStudent(id: string, data: Partial<StudentInput>) {
  return this.api.put<ApiResponse<Student>>(`students/${id}`, data);
}

// Component
onSave() {
  this.studentService.updateStudent(this.student.id, this.form.value)
    .subscribe({
      next: () => {
        this.notification.showSuccess('Student updated');
      }
    });
}

Delete

// Request
DELETE /api/students/:id

// Response
{
  "message": "Student deleted successfully"
}

Frontend Implementation:

deleteStudent(id: string) {
  return this.api.delete<ApiResponse<void>>(`students/${id}`);
}

// Component with confirmation
onDelete(student: Student) {
  if (confirm(`Delete ${student.first_name} ${student.last_name}?`)) {
    this.studentService.deleteStudent(student.id)
      .subscribe(() => {
        this.notification.showSuccess('Student deleted');
        this.refreshList();
      });
  }
}

Authentication

Login Flow

// AuthService
login(email: string, password: string) {
  return this.api.post<Auth>('auth/login', { email, password }).pipe(
    tap(user => {
      this.currentUserSubject.next(user);
    }),
    concatMap(() => this.api.get<{data: {permissions: Permission[], user: any}}>('auth/me')),
    tap(({data}) => {
      this.currentUserValue.permissions = data.permissions;
      this.currentUserValue.user = data.user;
      localStorage.setItem('currentUser', JSON.stringify(this.currentUserValue));
    })
  );
}

// Component
onLogin() {
  this.authService.login(this.email, this.password).subscribe({
    next: () => {
      this.router.navigate(['/module/dashboard']);
    },
    error: (error) => {
      this.errorMessage = 'Invalid email or password';
    }
  });
}

JWT Token Handling

The JwtInterceptor automatically adds the token:

@Injectable()
export class JwtInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService) {}

  intercept(request: HttpRequest<any>, next: HttpHandler) {
    const currentUser = this.authService.currentUserValue;
    if (currentUser?.token?.access_token) {
      request = request.clone({
        setHeaders: {
          Authorization: `Bearer ${currentUser.token.access_token}`
        }
      });
    }
    return next.handle(request);
  }
}

Token Refresh

// Backend: POST /api/auth/refresh
// Body: { "refresh_token": "..." }
// Response: { "access_token": "...", "refresh_token": "..." }

refreshToken() {
  const refreshToken = this.currentUserValue.token.refresh_token;
  return this.api.post<{access_token: string, refresh_token: string}>('auth/refresh', {
    refresh_token: refreshToken
  }).pipe(
    tap(tokens => {
      this.currentUserValue.token = tokens;
      this.currentUserSubject.next(this.currentUserValue);
      localStorage.setItem('currentUser', JSON.stringify(this.currentUserValue));
    })
  );
}

Multi-Tenancy

Team Header Injection

The TeamInterceptor adds the team context:

@Injectable()
export class TeamInterceptor implements HttpInterceptor {
  constructor(private authService: AuthService) {}

  intercept(request: HttpRequest<any>, next: HttpHandler) {
    const teamId = this.authService.currentUserValue.active_team_id;
    if (teamId) {
      request = request.clone({
        setHeaders: {
          'X-Team-ID': teamId
        }
      });
    }
    return next.handle(request);
  }
}

Switching Teams

// Service
setActiveTeam(teamId: string) {
  this.currentUserValue.active_team_id = teamId;
  this.currentUserSubject.next(this.currentUserValue);
  localStorage.setItem('currentUser', JSON.stringify(this.currentUserValue));
  return of(this.currentUserValue);
}

// Component (team selector)
onTeamChange(teamId: string) {
  this.authService.setActiveTeam(teamId).subscribe(() => {
    // Refresh current view to load team-specific data
    window.location.reload();
  });
}

Advanced Filtering

Complex Filter Queries

// AND filters (default)
{
  "filters": [
    { "field": "status", "operator": "eq", "value": "active" },
    { "field": "grade_level", "operator": "eq", "value": "10" }
  ]
}

// OR filters
{
  "filters": [
    { "field": "status", "operator": "in", "value": ["active", "pending"] }
  ]
}

// Date range
{
  "filters": [
    { "field": "created_at", "operator": "gte", "value": "2024-01-01" },
    { "field": "created_at", "operator": "lte", "value": "2024-12-31" }
  ]
}

// LIKE search
{
  "filters": [
    { "field": "first_name", "operator": "like", "value": "John" }
  ]
}

Relation Filters

Filter by related entity fields:

// Filter students by class name
{
  "filters": [
    {
      "field": "current_class_id",
      "operator": "relate",
      "relate_filters": [
        { "field": "name", "operator": "like", "value": "Grade 10" }
      ]
    }
  ]
}

File Upload

Upload to S3

// Get presigned URL
POST /api/file_uploads/presigned
{
  "file_name": "document.pdf",
  "content_type": "application/pdf",
  "module": "Student"
}

// Response
{
  "data": {
    "upload_url": "https://s3.../presigned-url",
    "file_url": "https://s3.../final-url"
  }
}

Frontend Implementation:

uploadFile(file: File, module: string) {
  // Step 1: Get presigned URL
  return this.api.post<{data: {upload_url: string, file_url: string}}>('file_uploads/presigned', {
    file_name: file.name,
    content_type: file.type,
    module
  }).pipe(
    // Step 2: Upload to S3
    concatMap(response => {
      return this.http.put(response.data.upload_url, file, {
        headers: { 'Content-Type': file.type }
      }).pipe(
        map(() => response.data.file_url)
      );
    })
  );
}

// Component
onFileSelected(event: Event) {
  const file = (event.target as HTMLInputElement).files[0];
  this.fileService.uploadFile(file, 'Student').subscribe(fileUrl => {
    this.form.patchValue({ document_url: fileUrl });
  });
}

Error Handling

ErrorInterceptor

@Injectable()
export class ErrorInterceptor implements HttpInterceptor {
  constructor(
    private authService: AuthService,
    private router: Router,
    private notification: NotificationService
  ) {}

  intercept(request: HttpRequest<any>, next: HttpHandler) {
    return next.handle(request).pipe(
      catchError((error: HttpErrorResponse) => {
        switch (error.status) {
          case 401:
            // Unauthorized - redirect to login
            this.authService.logout();
            this.router.navigate(['/authentication/signin']);
            break;
          case 403:
            // Forbidden
            this.notification.showError('You do not have permission');
            break;
          case 404:
            this.notification.showError('Resource not found');
            break;
          case 422:
            // Validation error
            const message = error.error?.message || 'Validation failed';
            this.notification.showError(message);
            break;
          case 500:
            this.notification.showError('Server error. Please try again.');
            break;
        }
        return throwError(() => error);
      })
    );
  }
}

Validation Errors

Backend returns validation errors:

{
  "message": "Validation failed",
  "errors": {
    "email": ["Email is required", "Email format is invalid"],
    "first_name": ["First name is required"]
  }
}

Frontend Handling:

onSubmit() {
  this.studentService.createStudent(this.form.value).subscribe({
    error: (error) => {
      if (error.error?.errors) {
        // Map backend errors to form controls
        Object.keys(error.error.errors).forEach(field => {
          const control = this.form.get(field);
          if (control) {
            control.setErrors({ serverError: error.error.errors[field][0] });
          }
        });
      }
    }
  });
}

Real-Time Updates

Polling Pattern

// Poll for updates every 30 seconds
pollForUpdates() {
  return interval(30000).pipe(
    startWith(0),
    switchMap(() => this.api.get('notifications/unread')),
    takeUntil(this.destroy$)
  );
}

WebSocket (if implemented)

// BroadcastService for cross-component communication
@Injectable({ providedIn: 'root' })
export class BroadcastService {
  private messageSource = new Subject<BroadcastMessage>();
  message$ = this.messageSource.asObservable();

  send(message: BroadcastMessage) {
    this.messageSource.next(message);
  }
}

Batch Operations

Bulk Delete

// Request
POST /api/students/bulk-delete
{
  "ids": ["uuid-1", "uuid-2", "uuid-3"]
}

// Response
{
  "message": "3 students deleted successfully"
}

Frontend Implementation:

bulkDelete(ids: string[]) {
  return this.api.post<ApiResponse<void>>('students/bulk-delete', { ids });
}

// Component
onBulkDelete() {
  const selectedIds = this.selectedStudents.map(s => s.id);
  this.studentService.bulkDelete(selectedIds).subscribe(() => {
    this.notification.showSuccess(`${selectedIds.length} students deleted`);
    this.clearSelection();
    this.refreshList();
  });
}

Export/Import

Export to Excel

// Request
POST /api/students/export
{
  "filters": [...],
  "columns": ["first_name", "last_name", "email", "status"]
}

// Response: File download

Frontend Implementation:

exportStudents(filters: Filter[], columns: string[]) {
  return this.http.post(`${this.baseUrl}/students/export`,
    { filters, columns },
    { responseType: 'blob' }
  ).pipe(
    tap(blob => {
      const url = window.URL.createObjectURL(blob);
      const a = document.createElement('a');
      a.href = url;
      a.download = 'students.xlsx';
      a.click();
      window.URL.revokeObjectURL(url);
    })
  );
}

Import from Excel

See Import System Guide for detailed import documentation.

Caching Strategies

Service-Level Caching

@Injectable({ providedIn: 'root' })
export class GradeLevelService {
  private cache$ = new ReplaySubject<GradeLevel[]>(1);
  private loaded = false;

  getGradeLevels() {
    if (!this.loaded) {
      this.api.get<ListResponse<GradeLevel>>('grade_levels/list').pipe(
        map(response => response.data)
      ).subscribe(data => {
        this.cache$.next(data);
        this.loaded = true;
      });
    }
    return this.cache$.asObservable();
  }

  invalidateCache() {
    this.loaded = false;
  }
}

HTTP Caching Headers

Backend returns appropriate cache headers:

  • Cache-Control: no-cache for dynamic data

  • Cache-Control: max-age=3600 for static data

Performance Tips

  1. Use pagination - Never load all records

  2. Lazy load modules - Reduce initial bundle

  3. Cancel pending requests - Use takeUntil or switchMap

  4. Debounce search - Prevent excessive API calls

  5. Cache reference data - Grade levels, subjects, etc.

// Debounced search
searchControl.valueChanges.pipe(
  debounceTime(300),
  distinctUntilChanged(),
  switchMap(term => this.search(term)),
  takeUntil(this.destroy$)
).subscribe(results => {
  this.searchResults = results;
});