Skip to main content
Papillon supports multiple school management systems through a plugin-based service architecture. This guide explains how to add new service integrations and maintain existing ones.

Service Architecture Overview

Each service integration in Papillon follows a standardized plugin pattern that allows the app to work with different school management systems without coupling the UI to specific implementations.

Supported Services

Papillon currently supports:
  • PRONOTE - French school management system
  • Skolengo - Modern school platform
  • EcoleDirecte - French education portal
  • Multi (ESUP) - University platform
  • TurboSelf - Canteen management
  • ARD - Canteen payment system
  • Izly - Student payment card
  • Alise - Canteen balance system
  • Appscho - School management
  • Lannion - Local school system

Service Plugin Interface

All services must implement the SchoolServicePlugin interface defined in services/shared/types.ts.

Required Properties

export interface SchoolServicePlugin {
  // Display name shown to users
  displayName: string;
  
  // Service identifier from Services enum
  service: Services;
  
  // List of supported capabilities
  capabilities: Capabilities[];
  
  // Authentication data
  authData: Auth;
  
  // Service-specific session object
  session: any;
  
  // Required: Refresh/initialize the service
  refreshAccount: (credentials: Auth) => Promise<ServicePlugin>;
  
  // Optional: Feature-specific methods
  getHomeworks?: (weekNumber: number) => Promise<Homework[]>;
  getNews?: () => Promise<News[]>;
  getGradesForPeriod?: (period: Period) => Promise<PeriodGrades>;
  getWeeklyTimetable?: (weekNumber: number, date: Date) => Promise<CourseDay[]>;
  // ... more optional methods
}

Capabilities System

Capabilities define what features a service supports:
export enum Capabilities {
  REFRESH,                  // Can refresh session
  HOMEWORK,                 // Supports homework
  NEWS,                     // Supports news/announcements
  GRADES,                   // Supports grades
  ATTENDANCE,               // Supports attendance
  ATTENDANCE_PERIODS,       // Supports attendance periods
  CANTEEN_MENU,            // Supports canteen menus
  CHAT_READ,               // Can read messages
  CHAT_CREATE,             // Can create new chats
  CHAT_REPLY,              // Can reply to messages
  TIMETABLE,               // Supports timetable
  HAVE_KIDS,               // Multi-student support (parent account)
  CANTEEN_BALANCE,         // Shows canteen balance
  CANTEEN_HISTORY,         // Shows transaction history
  CANTEEN_BOOKINGS,        // Supports meal booking
  CANTEEN_QRCODE,          // Supports QR code generation
}

Creating a New Service Integration

1

Create service directory

Create a new directory in services/ with your service name:
mkdir services/my-service
2

Add to Services enum

In stores/account/types.ts, add your service to the Services enum:
export enum Services {
  PRONOTE = "pronote",
  SKOLENGO = "skolengo",
  MY_SERVICE = "my-service", // Add this
  // ...
}
3

Install API client (if needed)

If there’s an existing API client library:
npm install my-service-api
Or create your own API client in the service directory.
4

Create the main service class

Create services/my-service/index.ts:
import { SchoolServicePlugin, Capabilities } from "@/services/shared/types";
import { Auth, Services } from "@/stores/account/types";

export class MyService implements SchoolServicePlugin {
  displayName = "My Service";
  service = Services.MY_SERVICE;
  capabilities: Capabilities[] = [Capabilities.REFRESH];
  session: any = undefined;
  authData: Auth = {};
  
  constructor(public accountId: string) {}
  
  async refreshAccount(credentials: Auth): Promise<MyService> {
    // 1. Authenticate with the service
    // 2. Store the session
    // 3. Determine capabilities based on user permissions
    // 4. Return this
    return this;
  }
}
5

Implement feature methods

Add methods for each supported feature. See Implementing Features below.
6

Register the service

Add your service to the service registry in services/shared/index.ts:
import { MyService } from "@/services/my-service";

export function getServiceInstance(
  service: Services,
  accountId: string
): SchoolServicePlugin {
  switch (service) {
    case Services.PRONOTE:
      return new Pronote(accountId);
    case Services.MY_SERVICE:
      return new MyService(accountId);
    // ...
  }
}

Implementing Features

Each feature corresponds to a capability and one or more methods.

Homework

import { Homework } from "@/services/shared/homework";

export class MyService implements SchoolServicePlugin {
  // ... other properties
  
  async refreshAccount(credentials: Auth): Promise<MyService> {
    // After authentication, add capability
    this.capabilities.push(Capabilities.HOMEWORK);
    return this;
  }
  
  async getHomeworks(weekNumber: number): Promise<Homework[]> {
    // 1. Check session validity
    if (!this.session) {
      error("Session is not valid", "MyService.getHomeworks");
    }
    
    // 2. Fetch homework from API
    const rawHomework = await this.session.getHomework(weekNumber);
    
    // 3. Map to Papillon's Homework interface
    return rawHomework.map(hw => ({
      id: hw.id,
      createdByAccount: this.accountId,
      subject: hw.subject,
      description: hw.description,
      dueDate: new Date(hw.dueDate),
      done: hw.completed,
      // ... more fields
    }));
  }
}

Grades

import { Period, PeriodGrades } from "@/services/shared/grade";

export class MyService implements SchoolServicePlugin {
  async getGradesPeriods(): Promise<Period[]> {
    const periods = await this.session.getPeriods();
    
    return periods.map(p => ({
      id: p.id,
      name: p.name,
      startDate: new Date(p.start),
      endDate: new Date(p.end),
    }));
  }
  
  async getGradesForPeriod(period: Period): Promise<PeriodGrades> {
    const grades = await this.session.getGrades(period.id);
    
    return {
      grades: grades.map(g => ({
        id: g.id,
        createdByAccount: this.accountId,
        subject: g.subject,
        description: g.description,
        grade: g.value,
        outOf: g.outOf,
        coefficient: g.coefficient,
        average: g.classAverage,
        date: new Date(g.date),
      })),
      averages: grades.subjects.map(s => ({
        subject: s.name,
        average: s.average,
        classAverage: s.classAverage,
      })),
    };
  }
}

Timetable

import { CourseDay } from "@/services/shared/timetable";

export class MyService implements SchoolServicePlugin {
  async getWeeklyTimetable(
    weekNumber: number, 
    date: Date
  ): Promise<CourseDay[]> {
    const timetable = await this.session.getTimetable(date);
    
    return timetable.map(day => ({
      date: new Date(day.date),
      courses: day.lessons.map(lesson => ({
        id: lesson.id,
        createdByAccount: this.accountId,
        subject: lesson.subject,
        teacher: lesson.teacher,
        room: lesson.room,
        startTime: new Date(lesson.start),
        endTime: new Date(lesson.end),
        status: lesson.cancelled ? "cancelled" : "confirmed",
        // ... more fields
      })),
    }));
  }
}

Chat/Messaging

import { Chat, Message, Recipient } from "@/services/shared/chat";

export class MyService implements SchoolServicePlugin {
  async getChats(): Promise<Chat[]> {
    const conversations = await this.session.getConversations();
    
    return conversations.map(conv => ({
      id: conv.id,
      createdByAccount: this.accountId,
      subject: conv.subject,
      creator: conv.author,
      recipients: conv.recipients,
      lastMessage: conv.lastMessage,
      unread: conv.unreadCount > 0,
    }));
  }
  
  async getChatMessages(chat: Chat): Promise<Message[]> {
    const messages = await this.session.getMessages(chat.id);
    
    return messages.map(msg => ({
      id: msg.id,
      content: msg.body,
      author: msg.author,
      date: new Date(msg.date),
      read: msg.read,
    }));
  }
  
  async sendMessageInChat(chat: Chat, content: string): Promise<void> {
    await this.session.sendMessage(chat.id, content);
  }
}

Data Mapping

Each service has unique data structures. Use mapper functions to convert to Papillon’s standard format.

Create Mappers

Create services/my-service/mappers.ts:
import { Homework } from "@/services/shared/homework";

export function mapHomework(
  raw: any,
  accountId: string
): Homework {
  return {
    id: raw.id,
    createdByAccount: accountId,
    subject: raw.subjectName,
    description: parseHtml(raw.description),
    dueDate: new Date(raw.deadline),
    done: raw.status === "completed",
    // ... more fields
  };
}

function parseHtml(html: string): string {
  // Remove HTML tags, convert to plain text
  return html.replace(/<[^>]*>/g, "");
}

Shared Types

Use the shared type definitions from services/shared/:
  • homework.ts - Homework interface
  • grade.ts - Grades and periods
  • timetable.ts - Courses and schedules
  • news.ts - Announcements
  • attendance.ts - Attendance records
  • chat.ts - Messages and conversations
  • canteen.ts - Canteen menus and bookings

Authentication & Session Management

Token Refresh

Implement automatic token refresh:
export class MyService implements SchoolServicePlugin {
  tokenExpiration = new Date().getTime() + (5 * 60 * 1000); // 5 min
  
  private async checkTokenValidity(): Promise<boolean> {
    const now = new Date().getTime();
    
    if (now > this.tokenExpiration) {
      // Refresh token
      await this.refreshAccount(this.authData);
      this.tokenExpiration = now + (5 * 60 * 1000);
      return new Date().getTime() <= this.tokenExpiration;
    }
    
    return true;
  }
  
  async getHomeworks(weekNumber: number): Promise<Homework[]> {
    // Check token before each API call
    await this.checkTokenValidity();
    
    if (!this.session) {
      error("Session is not valid", "MyService.getHomeworks");
    }
    
    // Fetch data...
  }
}

Secure Storage

Store credentials securely:
import * as SecureStore from "expo-secure-store";

export async function saveCredentials(
  accountId: string,
  credentials: Auth
) {
  await SecureStore.setItemAsync(
    `account_${accountId}_auth`,
    JSON.stringify(credentials)
  );
}

export async function loadCredentials(
  accountId: string
): Promise<Auth | null> {
  const json = await SecureStore.getItemAsync(`account_${accountId}_auth`);
  return json ? JSON.parse(json) : null;
}

Error Handling

Use the Logger

Never use console.log, use the logger utility:
import { error, warn, info } from "@/utils/logger/logger";

export class MyService implements SchoolServicePlugin {
  async getHomeworks(weekNumber: number): Promise<Homework[]> {
    try {
      info(`Fetching homework for week ${weekNumber}`, "MyService");
      const homework = await this.session.getHomework(weekNumber);
      return homework.map(mapHomework);
    } catch (err) {
      error(
        `Failed to fetch homework: ${err.message}`,
        "MyService.getHomeworks"
      );
      throw err;
    }
  }
}

Custom Error Types

Define service-specific errors in services/errors/:
export class MyServiceError extends Error {
  constructor(
    message: string,
    public code: string,
    public statusCode?: number
  ) {
    super(message);
    this.name = "MyServiceError";
  }
}

export class AuthenticationError extends MyServiceError {
  constructor(message: string) {
    super(message, "AUTH_ERROR", 401);
  }
}

Testing Your Service

Manual Testing

  1. Add test credentials in dev mode
  2. Try all features your service supports
  3. Test error cases (invalid credentials, network errors)
  4. Test on both iOS and Android
  5. Check data persistence and caching

Unit Tests

Create tests in services/my-service/__tests__/:
import { MyService } from "../index";

describe("MyService", () => {
  let service: MyService;
  
  beforeEach(() => {
    service = new MyService("test-account");
  });
  
  it("should initialize with correct properties", () => {
    expect(service.displayName).toBe("My Service");
    expect(service.service).toBe(Services.MY_SERVICE);
  });
  
  it("should fetch homework", async () => {
    // Mock the API
    const mockHomework = [...];
    service.session = { getHomework: jest.fn().mockResolvedValue(mockHomework) };
    
    const homework = await service.getHomeworks(1);
    expect(homework).toHaveLength(mockHomework.length);
  });
});

Example: Complete Service Implementation

Here’s a complete minimal service implementation:
import { SchoolServicePlugin, Capabilities } from "@/services/shared/types";
import { Homework } from "@/services/shared/homework";
import { Auth, Services } from "@/stores/account/types";
import { error, info } from "@/utils/logger/logger";
import { MyServiceClient } from "my-service-api";

export class MyService implements SchoolServicePlugin {
  displayName = "My Service";
  service = Services.MY_SERVICE;
  capabilities: Capabilities[] = [Capabilities.REFRESH];
  session: MyServiceClient | undefined;
  authData: Auth = {};
  tokenExpiration = 0;
  
  constructor(public accountId: string) {}
  
  async refreshAccount(credentials: Auth): Promise<MyService> {
    info("Refreshing account", "MyService");
    
    try {
      // Authenticate
      this.session = new MyServiceClient();
      await this.session.login(credentials.username, credentials.password);
      
      // Store auth data
      this.authData = credentials;
      this.tokenExpiration = Date.now() + (30 * 60 * 1000); // 30 min
      
      // Detect capabilities
      if (this.session.features.homework) {
        this.capabilities.push(Capabilities.HOMEWORK);
      }
      if (this.session.features.grades) {
        this.capabilities.push(Capabilities.GRADES);
      }
      
      return this;
    } catch (err) {
      error(`Failed to refresh account: ${err.message}`, "MyService");
      throw err;
    }
  }
  
  private async checkToken(): Promise<void> {
    if (Date.now() > this.tokenExpiration) {
      await this.refreshAccount(this.authData);
    }
  }
  
  async getHomeworks(weekNumber: number): Promise<Homework[]> {
    await this.checkToken();
    
    if (!this.session) {
      error("Session is not valid", "MyService.getHomeworks");
    }
    
    const homework = await this.session.getHomework(weekNumber);
    
    return homework.map(hw => ({
      id: hw.id,
      createdByAccount: this.accountId,
      subject: hw.subject,
      description: hw.description,
      dueDate: new Date(hw.dueDate),
      done: hw.completed,
    }));
  }
}

Best Practices

  • Don’t import from other services
  • Use shared types from services/shared/
  • Each service should be self-contained
  • Map all data to Papillon’s standard format
  • Handle missing fields with defaults
  • Document API quirks in comments
  • Always validate API responses
  • Check session validity before calls
  • Handle network errors gracefully
  • Provide meaningful error messages
  • Cache data when possible
  • Implement pagination for large datasets
  • Use batch requests when available
  • Avoid unnecessary API calls
  • Never log credentials or tokens
  • Use HTTPS for all requests
  • Validate SSL certificates
  • Store sensitive data in SecureStore

Next Steps

When adding a new service, open an issue first to discuss the integration approach with maintainers.

Build docs developers (and LLMs) love