Retour à la documentation

SDK Reference complète

Référence exhaustive de toutes les APIs du Module SDK BizzOptima — types, capabilities et compatibilité.

Référence de @bizzoptima/module-sdk v1. Chaque namespace liste ses méthodes, la capability requise et les notes de compatibilité avec le desktop v0.1.0.

Niveaux de confiance : unsigned_local → signed_local → signed_registry → trusted_bundled / dev_bypass.
Les capabilities SDK 2.0 nécessitent au minimum signed_local. Les modules non signés n'ont accès qu'à db:read et events:subscribe.

Namespaces Core — toujours disponibles

api.db — Base de données SQLitedb:read / db:write

SQLite locale scopée au module. Chaque module gère ses propres tables. Les tables des autres modules ne sont pas accessibles.

db.querydb:read
<T>(sql: string, params?: unknown[]) => Promise<T[]>

Requête SELECT. Paramètres positionnels avec ?.

db.rundb:write
(sql: string, params?: unknown[]) => Promise<{ changes: number; lastInsertRowid: number | bigint }>

Cas réel — Module de gestion de contacts

tsx
// ─── Types ────────────────────────────────────────────────────────
interface Contact {
  id: string;
  name: string;
  phone: string;
  email: string;
  note: string;
  created_at: string;
}

// ─── Dans init() — migration DB ───────────────────────────────────
async function migrate(api: BizzOptimaModuleAPI) {
  await api.db.run(`
    CREATE TABLE IF NOT EXISTS contacts (
      id         TEXT PRIMARY KEY,
      name       TEXT NOT NULL,
      phone      TEXT,
      email      TEXT,
      note       TEXT DEFAULT '',
      created_at TEXT NOT NULL
    )
  `);
  // Index pour la recherche rapide
  await api.db.run(
    'CREATE INDEX IF NOT EXISTS idx_contacts_name ON contacts(name)',
  );
}

// ─── Composant React ──────────────────────────────────────────────
function ContactList() {
  const api = useModuleAPI();
  const toast = useToast();
  const [search, setSearch] = React.useState('');

  // useDB se re-déclenche quand les params changent
  const { data: contacts, loading, refetch } = useDB<Contact>(
    'SELECT * FROM contacts WHERE name LIKE ? ORDER BY name ASC',
    [`%${search}%`],
  );

  async function addContact(name: string, phone: string) {
    await api.db.run(
      'INSERT INTO contacts (id, name, phone, email, note, created_at) VALUES (?,?,?,?,?,?)',
      [crypto.randomUUID(), name, phone, '', '', new Date().toISOString()],
    );
    toast('Contact ajouté', { type: 'success' });
    refetch();
  }

  async function deleteContact(id: string) {
    const ok = await api.ui.confirm('Supprimer ce contact ?');
    if (!ok) return;
    const result = await api.db.run('DELETE FROM contacts WHERE id = ?', [id]);
    if (result.changes > 0) {
      toast('Contact supprimé', { type: 'info' });
      refetch();
    }
  }

  if (loading) return <div>Chargement…</div>;

  return (
    <div>
      <input value={search} onChange={e => setSearch(e.target.value)} placeholder="Rechercher…" />
      {contacts.map(c => (
        <div key={c.id}>
          <strong>{c.name}</strong> — {c.phone}
          <button onClick={() => deleteContact(c.id)}>Supprimer</button>
        </div>
      ))}
      <button onClick={() => addContact('Nouveau Contact', '+237 6XX XXX XXX')}>
        Ajouter
      </button>
    </div>
  );
}
✅ v0.1.0
api.events — Bus inter-modulesevents:emit / events:subscribe

Bus d'événements partagé entre tous les modules actifs. Permet à un module de réagir aux actions d'un autre sans couplage direct.

events.emitevents:emit
(name: string, payload: unknown, options?) => Promise<void>

options: { entityType?, entityId? } pour le log d'audit.

events.subscribeevents:subscribe
(name: string, callback) => () => void

La fonction retournée désinscrit l'écouteur.

Cas réel — Module de fidélité réagit aux ventes

tsx
// ─── Module "loyalty" — réagit aux ventes du module "sales" ────────

interface SaleCompletedPayload {
  saleId: string;
  customerId: string | null;
  total: number;
  currency: string;
  items: Array<{ productId: string; qty: number; price: number }>;
}

// Dans init() du module loyalty :
function initLoyalty(api: BizzOptimaModuleAPI) {
  // Créer la table de points
  api.db.run(`
    CREATE TABLE IF NOT EXISTS loyalty_points (
      customer_id TEXT PRIMARY KEY,
      points      INTEGER NOT NULL DEFAULT 0,
      updated_at  TEXT NOT NULL
    )
  `);

  // S'abonner aux ventes terminées (émises par le module "sales")
  const unsubscribe = api.events.subscribe(
    'sale.completed',
    async (raw: unknown) => {
      const payload = raw as SaleCompletedPayload;
      if (!payload.customerId) return; // vente anonyme → pas de points

      // 1 point par tranche de 1000 XAF
      const pointsEarned = Math.floor(payload.total / 1000);
      if (pointsEarned === 0) return;

      await api.db.run(`
        INSERT INTO loyalty_points (customer_id, points, updated_at)
        VALUES (?, ?, ?)
        ON CONFLICT(customer_id) DO UPDATE
          SET points = points + excluded.points,
              updated_at = excluded.updated_at
      `, [payload.customerId, pointsEarned, new Date().toISOString()]);

      const pts = pointsEarned === 1 ? '1 point' : pointsEarned + ' points';
      api.ui.toast(
        '+' + pts + ' de fidélité ajouté(s) !',
        { type: 'success', duration: 3000 },
      );
    },
  );

  return unsubscribe; // appelé quand le module est désactivé
}

// ─── Module "sales" — émet l'événement à chaque vente ──────────────

async function completeSale(api: BizzOptimaModuleAPI, sale: Sale) {
  // ... logique de vente ...

  // Émettre pour tous les modules abonnés
  await api.events.emit('sale.completed', {
    saleId: sale.id,
    customerId: sale.customerId,
    total: sale.total,
    currency: api.config.getCurrency(),
    items: sale.items,
  }, {
    entityType: 'sale',
    entityId: sale.id,
  });
}

// ─── Hook pour composant React ─────────────────────────────────────
function LoyaltyBadge({ customerId }: { customerId: string }) {
  const [points, setPoints] = React.useState(0);
  const api = useModuleAPI();

  React.useEffect(() => {
    api.db.query<{ points: number }>(
      'SELECT points FROM loyalty_points WHERE customer_id = ?',
      [customerId],
    ).then(rows => setPoints(rows[0]?.points ?? 0));
  }, [customerId]);

  // Mettre à jour en temps réel quand une vente arrive
  useModuleEvents('sale.completed', () => {
    api.db.query<{ points: number }>(
      'SELECT points FROM loyalty_points WHERE customer_id = ?',
      [customerId],
    ).then(rows => setPoints(rows[0]?.points ?? 0));
  });

  return <span>{points} pts</span>;
}
⚠️ Partiel v0.1.0
api.config — Configuration businessToujours disponible

Snapshot de la configuration business. Valeurs lues depuis le profil configuré au premier lancement.

config.getBusiness
() => BusinessConfigSnapshot

{ businessId, businessName, currency, locale, activeModuleIds… }

config.getCurrency
() => string

Code ISO 4217 : 'XAF', 'USD', 'EUR', 'GHS'…

config.getLocale
() => 'fr' | 'en'
config.getInstalledModules
() => Promise<InstalledModuleInfo[]>

Includes trustLevel for each installed module.

Cas réel — Affichage multi-devise et locale

tsx
// Composant qui s'adapte automatiquement à la locale et devise du business

function PriceDisplay({ amount }: { amount: number }) {
  const locale = useLocale();
  const currency = useCurrency();

  // Formatage natif selon la locale du business
  const formatted = new Intl.NumberFormat(
    locale === 'fr' ? 'fr-CM' : 'en-US',
    { style: 'currency', currency, minimumFractionDigits: 0 },
  ).format(amount);

  return <span className="font-mono font-semibold">{formatted}</span>;
}

// Composant qui liste les modules actifs pour l'inter-communication
function ModuleStatus() {
  const api = useModuleAPI();
  const business = useBusinessConfig();
  const [modules, setModules] = React.useState<InstalledModuleInfo[]>([]);

  React.useEffect(() => {
    api.config.getInstalledModules().then(setModules);
  }, []);

  const hasInventory = business.activeModuleIds.includes('inventory');
  const hasSales = business.activeModuleIds.includes('sales');

  return (
    <div>
      <h3>{business.businessName}</h3>
      <p>Devise : {business.currency} | Locale : {business.locale}</p>
      {!hasInventory && (
        <div className="warning">
          Le module Inventaire n&apos;est pas actif — certaines fonctionnalités sont limitées.
        </div>
      )}
      <ul>
        {modules.map(m => (
          <li key={m.id}>
            {m.name} v{m.version}
            <Badge color={m.isActive ? 'emerald' : 'slate'}>
              {m.isActive ? 'actif' : 'inactif'}
            </Badge>
          </li>
        ))}
      </ul>
    </div>
  );
}
⚠️ Partiel v0.1.0
api.ui — UI helpersToujours disponible

Toasts, dialogues de confirmation et alertes — rendu dans l'UI principale de BizzOptima, pas dans l'iframe du module.

ui.toast
(message: string, options?: ToastOptions) => void

options: { type: 'success'|'error'|'warning'|'info', duration: number }

ui.confirm
(message: string, title?: string) => Promise<boolean>

Retourne true si l'utilisateur confirme.

ui.alert
(message: string, title?: string) => void

Cas réel — Flux de suppression avec confirmation

tsx
// Flux complet : action critique avec confirmation + feedback
async function deleteInvoice(api: BizzOptimaModuleAPI, invoiceId: string, invoiceNumber: string) {
  // 1. Confirmation explicite
  const confirmed = await api.ui.confirm(
    `Supprimer la facture n°${invoiceNumber} ?

Cette action est irréversible.`,
    'Confirmer la suppression',
  );
  if (!confirmed) return;

  try {
    // 2. Vérifier les dépendances (paiements associés)
    const payments = await api.db.query(
      'SELECT id FROM payments WHERE invoice_id = ? AND status = ?',
      [invoiceId, 'completed'],
    );
    if (payments.length > 0) {
      await api.ui.alert(
        `Impossible de supprimer : ${payments.length} paiement(s) complété(s) sont liés à cette facture.`,
        'Suppression bloquée',
      );
      return;
    }

    // 3. Suppression en cascade
    await api.db.run('DELETE FROM invoice_lines WHERE invoice_id = ?', [invoiceId]);
    await api.db.run('DELETE FROM invoices WHERE id = ?', [invoiceId]);

    // 4. Notifier les autres modules
    await api.events.emit('invoice.deleted', { invoiceId, invoiceNumber });

    // 5. Feedback utilisateur
    api.ui.toast(`Facture n°${invoiceNumber} supprimée`, { type: 'success' });

  } catch (err) {
    api.ui.toast('Erreur lors de la suppression', { type: 'error', duration: 5000 });
    console.error('[invoicing] deleteInvoice error:', err);
  }
}

// Hook useToast pour les composants React
function QuickActions({ invoiceId }: { invoiceId: string }) {
  const toast = useToast();
  const api = useModuleAPI();

  const handleCopy = async () => {
    const rows = await api.db.query<{ number: string }>(
      'SELECT number FROM invoices WHERE id = ?', [invoiceId]
    );
    await api.clipboard.writeText(rows[0]?.number ?? '');
    toast('Numéro copié dans le presse-papier', { type: 'info' });
  };

  return (
    <div>
      <button onClick={handleCopy}>Copier numéro</button>
      <button onClick={() => deleteInvoice(api, invoiceId, '2024-0042')}>
        Supprimer
      </button>
    </div>
  );
}
⚠️ Partiel v0.1.0
api.interModule — Communication sécuriséeToujours disponible

RPC typé entre modules actifs. Le module exposant peut restreindre qui peut l'appeler. Le module appelant ne connaît pas l'implémentation interne.

interModule.expose
(method, handler, options?) => () => void

options.allowedCallers: moduleId[] — si absent, tout module peut appeler.

interModule.request
<T>(targetId, method, params?) => Promise<T>

Throw si la cible n'a pas exposé la méthode ou si le caller n'est pas autorisé.

Cas réel — Module Inventaire expose son stock au module Caisse

tsx
// ─── Module "inventory" — expose des méthodes ─────────────────────

interface StockCheckResult {
  productId: string;
  available: number;
  reserved: number;
  canSell: boolean;
}

// Dans init() du module inventory :
function initInventory(api: BizzOptimaModuleAPI) {
  // Méthode accessible à tous les modules
  api.interModule.expose(
    'checkStock',
    async (params: unknown): Promise<StockCheckResult> => {
      const { productId, qty = 1 } = params as { productId: string; qty?: number };
      const rows = await api.db.query<{ available: number; reserved: number }>(
        'SELECT available, reserved FROM stock WHERE product_id = ?',
        [productId],
      );
      const stock = rows[0] ?? { available: 0, reserved: 0 };
      return {
        productId,
        available: stock.available,
        reserved: stock.reserved,
        canSell: stock.available - stock.reserved >= qty,
      };
    },
  );

  // Méthode sensible — réservation de stock
  // Seuls les modules autorisés peuvent réserver
  api.interModule.expose(
    'reserveStock',
    createSecureHandler(
      async (params: unknown, caller) => {
        const { productId, qty, orderId } = params as {
          productId: string; qty: number; orderId: string
        };
        await api.db.run(
          'UPDATE stock SET reserved = reserved + ? WHERE product_id = ?',
          [qty, productId],
        );
        await api.db.run(
          'INSERT INTO reservations (product_id, qty, order_id, reserved_by, created_at) VALUES (?,?,?,?,?)',
          [productId, qty, orderId, caller.moduleId, new Date().toISOString()],
        );
        return { ok: true, reserved: qty };
      },
      { moduleId: 'inventory', allowedCallers: ['quick_cashier', 'sales', 'orders'] },
    ),
  );
}

// ─── Module "quick_cashier" — utilise l'inventaire ─────────────────

async function addToCart(api: BizzOptimaModuleAPI, productId: string, qty: number) {
  // 1. Vérifier le stock avant d'ajouter au panier
  const stock = await api.interModule.request<StockCheckResult>(
    'inventory',     // moduleId cible
    'checkStock',    // méthode exposée
    { productId, qty },
  );

  if (!stock.canSell) {
    api.ui.toast(
      `Stock insuffisant : ${stock.available} disponible(s), ${stock.reserved} réservé(s)`,
      { type: 'warning' },
    );
    return;
  }

  // 2. Réserver le stock
  await api.interModule.request('inventory', 'reserveStock', {
    productId,
    qty,
    orderId: `cart_${Date.now()}`,
  });

  // 3. Ajouter au panier local
  await api.db.run(
    'INSERT INTO cart_items (product_id, qty) VALUES (?,?)',
    [productId, qty],
  );
}

// ─── Hook React pour vérification en temps réel ────────────────────
function StockIndicator({ productId }: { productId: string }) {
  const api = useModuleAPI();
  const [stock, setStock] = React.useState<StockCheckResult | null>(null);

  React.useEffect(() => {
    api.interModule.request<StockCheckResult>('inventory', 'checkStock', { productId })
      .then(setStock)
      .catch(() => null); // inventory module peut ne pas être actif
  }, [productId]);

  if (!stock) return null;
  return (
    <span style={{ color: stock.canSell ? 'green' : 'red' }}>
      {stock.available} en stock
    </span>
  );
}
⚠️ Partiel v0.1.0
api.security — Contexte de sécuritéToujours disponible

Lecture seule. Retourne le niveau de confiance et les capabilities du module, disponibles dès l'initialisation.

security.getContext
() => SecurityContext

{ moduleId, trustLevel, capabilities, sessionToken }

security.getTrustLevel
() => ModuleTrustLevel
security.hasCapability
(cap: ModuleCapability) => boolean
security.requireCapability
(cap: ModuleCapability) => void

Throw CapabilityError si absent — fail fast avant toute opération sensible.

Cas réel — Feature gating par niveau de signature

tsx
import {
  usePermission, usePermissions, useTrustLevel,
  PermissionGuard, getMissingCapabilities, CapabilityError,
} from '@bizzoptima/module-sdk';

// ─── Composant avec feature gating ────────────────────────────────
function SyncPanel() {
  const api = useModuleAPI();
  const trustLevel = useTrustLevel();
  const canHttp = usePermission('http:request');
  const canFs = usePermission('fs:write');
  const canAll = usePermissions(['http:request', 'fs:write', 'notifications:send']);

  // Affichage d'un banner si le module n'est pas signé
  if (trustLevel === 'unsigned_local') {
    return (
      <div className="upgrade-banner">
        <p>Ce module n&apos;est pas signé.</p>
        <p>La synchronisation cloud nécessite un module signé (signed_local ou supérieur).</p>
        <a href="https://bizzoptima.com/docs/module-sdk#signing">Signer mon module →</a>
      </div>
    );
  }

  return (
    <div>
      {/* Accès HTTP — signé local suffit */}
      <PermissionGuard
        api={api}
        requires="http:request"
        fallback={<p className="text-gray-500">Sync cloud non disponible (module non signé)</p>}
      >
        <SyncButton />
      </PermissionGuard>

      {/* Export CSV — nécessite fs:write en plus */}
      <PermissionGuard
        api={api}
        requires={['http:request', 'fs:write']}
        fallback={
          <div>
            <p>Export nécessite :</p>
            <ul>
              {getMissingCapabilities(api, ['http:request', 'fs:write']).map(cap => (
                <li key={cap}>{cap}</li>
              ))}
            </ul>
          </div>
        }
      >
        <ExportButton />
      </PermissionGuard>
    </div>
  );
}

// ─── Handler sécurisé avec fail-fast ──────────────────────────────
async function exportAndSync(api: BizzOptimaModuleAPI) {
  // Vérification explicite avant toute opération coûteuse
  try {
    api.security.requireCapability('fs:write');
    api.security.requireCapability('http:request');
  } catch (err) {
    if (err instanceof CapabilityError) {
      api.ui.alert(
        `Capability manquante : ${err.capability}.
` +
        `Niveau actuel : ${err.trustLevel}.
` +
        `Signez le module pour débloquer cette fonctionnalité.`
      );
      return;
    }
    throw err;
  }

  // Ici on est sûr d'avoir les deux capabilities
  const data = await api.db.query('SELECT * FROM sales WHERE synced = 0');
  const json = JSON.stringify(data);
  await api.fs.writeText('export/unsynced.json', json);
  await api.http.post('https://erp.example.com/api/sync', data);
}
⚠️ Partiel v0.1.0
api.settings — Paramètres persistéssettings:read / settings:write

Stockage clé/valeur persisté, scopé au module. Indépendant de la DB SQLite.

settings.getsettings:read
(key: string) => Promise<string | null>
settings.setsettings:write
(key: string, value: string) => Promise<void>
settings.deletesettings:write
(key: string) => Promise<void>
settings.getAllsettings:read
() => Promise<Record<string, string>>
tsx
// Lire un paramètre (hook)
const [erpUrl, setErpUrl, loading] = useSetting('erp_url', '');

// Impératif dans init()
const lastSync = await api.settings.get('last_erp_sync');
await api.settings.set('last_erp_sync', new Date().toISOString());

// Réinitialiser
await api.settings.delete('cache_key');
❌ v0.2.0Pas encore dans le bridge preload
api.navigatenavigate
navigatenavigate
(route: string) => void

Route relative à l'app BizzOptima.

tsx
// Dans un composant React
const navigate = useNavigate();

// Naviguer vers un autre module
navigate('/modules/inventory');

// Naviguer vers les paramètres de l'app
navigate('/settings');
⚠️ Via renderer callback

Namespaces SDK 2.0 — nécessitent un module signé

api.http — HTTP sans CORShttp:request

Toutes les requêtes passent par le main process Electron — pas de restriction CORS, suit le proxy système, supporte HTTPS auto-signé pour les APIs locales.

http.get
<T>(url: string, headers?: Record<string,string>) => Promise<T>

Parse automatiquement le JSON.

http.post
<T>(url: string, body: unknown, headers?: Record<string,string>) => Promise<T>
http.request
(url: string, init?: HttpRequestInit) => Promise<HttpResponse>

Accès bas niveau : status, headers, body brut.

Cas réel — Synchronisation avec une API externe (ERP, comptabilité)

tsx
// ─── Service de sync avec une API externe ─────────────────────────

interface ErpProduct {
  sku: string;
  name: string;
  price: number;
  stock: number;
}

interface SyncResult {
  imported: number;
  errors: string[];
  lastSyncAt: string;
}

async function syncFromErp(api: BizzOptimaModuleAPI, erpUrl: string, apiKey: string): Promise<SyncResult> {
  api.security.requireCapability('http:request');

  const errors: string[] = [];
  let imported = 0;

  // 1. Récupérer le catalogue depuis l'ERP
  let products: ErpProduct[];
  try {
    products = await api.http.get<ErpProduct[]>(
      `${erpUrl}/api/products?format=bizzoptima`,
      { 'X-API-Key': apiKey, 'Accept': 'application/json' },
    );
  } catch (err) {
    throw new Error(`Connexion ERP échouée : ${(err as Error).message}`);
  }

  // 2. Upsert dans la DB locale
  for (const product of products) {
    try {
      await api.db.run(`
        INSERT INTO products (sku, name, price, stock, synced_at)
        VALUES (?, ?, ?, ?, ?)
        ON CONFLICT(sku) DO UPDATE SET
          name = excluded.name,
          price = excluded.price,
          stock = excluded.stock,
          synced_at = excluded.synced_at
      `, [product.sku, product.name, product.price, product.stock, new Date().toISOString()]);
      imported++;
    } catch (err) {
      errors.push(`[SKU ${product.sku}] ${(err as Error).message}`);
    }
  }

  const lastSyncAt = new Date().toISOString();
  await api.settings.set('last_erp_sync', lastSyncAt);

  return { imported, errors, lastSyncAt };
}

// ─── Composant React avec état de sync ────────────────────────────
function ErpSyncPanel() {
  const api = useModuleAPI();
  const { online } = useNetwork();
  const [syncing, setSyncing] = React.useState(false);
  const [result, setResult] = React.useState<SyncResult | null>(null);

  // Lire la config de l'ERP depuis les settings
  const [erpUrl] = useSetting('erp_url', '');
  const [apiKey] = useSetting('erp_api_key', '');

  async function handleSync() {
    if (!online) {
      api.ui.toast('Pas de connexion internet', { type: 'warning' });
      return;
    }
    if (!erpUrl) {
      api.ui.alert('Configurez l'URL de votre ERP dans les paramètres.', 'Configuration requise');
      return;
    }
    setSyncing(true);
    try {
      const res = await syncFromErp(api, erpUrl, apiKey);
      setResult(res);
      api.ui.toast(`${res.imported} produit(s) importé(s)`, { type: 'success' });
    } catch (err) {
      api.ui.toast((err as Error).message, { type: 'error' });
    } finally {
      setSyncing(false);
    }
  }

  return (
    <div>
      <button onClick={handleSync} disabled={syncing || !online}>
        {syncing ? 'Synchronisation…' : 'Synchroniser depuis l'ERP'}
      </button>
      {!online && <span className="text-red-500">● Hors ligne</span>}
      {result && (
        <div>
          <p>{result.imported} produits importés — {result.lastSyncAt}</p>
          {result.errors.map((e, i) => <p key={i} className="text-red-500">{e}</p>)}
        </div>
      )}
    </div>
  );
}

// ─── Hook useHttp pour fetch simple ────────────────────────────────
function ExchangeRates() {
  const { data: rates, loading, error } = useHttp<{ XAF: number; USD: number }>(
    'https://api.exchangerate.host/latest?base=EUR&symbols=XAF,USD',
  );
  if (loading) return <p>Chargement des taux…</p>;
  if (error || !rates) return <p>Taux indisponibles</p>;
  return (
    <div>
      <p>1 EUR = {rates.XAF} XAF</p>
      <p>1 EUR = {rates.USD} USD</p>
    </div>
  );
}
✅ v0.1.0
api.fs — Système de fichiers sandboxéfs:read / fs:write / fs:dialog

Accès restreint au dossier userData/module-data/{moduleId}/. Toute écriture est isolée — un module ne peut pas lire les fichiers d'un autre.

fs.readText / writeText
(path: string, content?: string) => Promise<string|null|void>
fs.readBase64 / writeBase64
(path: string, data?: string) => Promise<string|null|void>

Pour les images, PDF et binaires.

fs.list
(directory?: string) => Promise<FsFileInfo[]>

FsFileInfo: { name, path, size, modifiedAt, isDirectory }

fs.exists / delete
(path: string) => Promise<boolean|void>
fs.pickFilefs:dialog
(filters?) => Promise<string|null>

Ouvre le sélecteur de fichiers OS natif.

Cas réel — Export des ventes en CSV

tsx
// ─── Export CSV complet ────────────────────────────────────────────

interface SaleRow {
  id: string;
  date: string;
  customer_name: string;
  total: number;
  payment_method: string;
}

function escCsv(val: string | number): string {
  const s = String(val);
  if (s.includes(',') || s.includes('"') || s.includes('\n')) {
    return '"' + s.replace(/"/g, '""') + '"';
  }
  return s;
}

async function exportSalesToCsv(
  api: BizzOptimaModuleAPI,
  fromDate: string,
  toDate: string,
): Promise<void> {
  api.security.requireCapability('fs:write');

  // 1. Requête des données
  const sales = await api.db.query<SaleRow>(`
    SELECT s.id, s.created_at as date, c.name as customer_name,
           s.total, s.payment_method
    FROM sales s
    LEFT JOIN customers c ON s.customer_id = c.id
    WHERE s.created_at BETWEEN ? AND ?
    ORDER BY s.created_at DESC
  `, [fromDate, toDate]);

  if (sales.length === 0) {
    api.ui.alert('Aucune vente sur la période sélectionnée.');
    return;
  }

  // 2. Construire le CSV
  const headers = ['ID', 'Date', 'Client', 'Total', 'Paiement'];
  const rows = sales.map(s => [
    escCsv(s.id),
    escCsv(s.date.slice(0, 10)),
    escCsv(s.customer_name ?? 'Anonyme'),
    escCsv(s.total),
    escCsv(s.payment_method),
  ].join(','));

  const csv = [headers.join(','), ...rows].join('\n');

  // 3. Écrire dans le sandbox du module
  const fileName = `exports/sales_${fromDate.slice(0,10)}_to_${toDate.slice(0,10)}.csv`;
  await api.fs.writeText(fileName, csv);

  // 4. Notification + ouverture du dossier
  api.ui.toast(`Export créé : ${sales.length} lignes`, { type: 'success' });

  // Ouvrir le fichier dans l'explorateur (shell:open requis)
  // api.shell.openPath(fileName);
}

// ─── Import depuis fichier utilisateur ────────────────────────────
async function importFromCsv(api: BizzOptimaModuleAPI) {
  // Ouvre le sélecteur de fichier natif
  const filePath = await api.fs.pickFile([
    { name: 'CSV Files', extensions: ['csv'] },
    { name: 'Excel Files', extensions: ['xlsx', 'xls'] },
  ]);

  if (!filePath) return; // annulé par l'utilisateur

  // Note: le filePath ici est externe au sandbox du module.
  // Pour lire un fichier externe, il faut passer par api.http ou
  // utiliser le chemin retourné par le dialog directement.
  api.ui.toast(`Fichier sélectionné : ${filePath}`, { type: 'info' });
}

// ─── Gestion d'un répertoire d'exports ────────────────────────────
async function listExports(api: BizzOptimaModuleAPI) {
  const files = await api.fs.list('exports');
  // files: FsFileInfo[]
  return files
    .filter(f => !f.isDirectory && f.name.endsWith('.csv'))
    .sort((a, b) => b.modifiedAt.localeCompare(a.modifiedAt));
}

// ─── Composant de gestion des exports ─────────────────────────────
function ExportManager() {
  const api = useModuleAPI();
  const canExport = usePermissions(['fs:read', 'fs:write']);
  const [exports, setExports] = React.useState<FsFileInfo[]>([]);

  React.useEffect(() => {
    if (canExport) {
      listExports(api).then(setExports);
    }
  }, [canExport]);

  if (!canExport) {
    return <p>Export non disponible (module non signé)</p>;
  }

  return (
    <div>
      <button onClick={() => exportSalesToCsv(api, '2024-01-01', '2024-12-31')}>
        Exporter les ventes 2024
      </button>
      <h3>Exports existants</h3>
      {exports.map(f => (
        <div key={f.path}>
          {f.name} — {(f.size / 1024).toFixed(1)} ko
          <button onClick={() => api.fs.delete(f.path)}>Supprimer</button>
        </div>
      ))}
    </div>
  );
}
✅ v0.1.0
api.scheduler — Planificateur de tâchesscheduler:manage

Jobs persistés en SQLite (survivent aux redémarrages). Une tâche se déclenche même si le module était inactif quand l'heure est venue.

scheduler.once
(id: string, delayMs: number, callback: () => void) => ScheduledJob
scheduler.interval
(id: string, intervalMs: number, callback: () => void) => ScheduledJob
scheduler.daily
(id: string, time: string, callback: () => void) => ScheduledJob

time format: 'HH:MM'

scheduler.cancel
(id: string) => void
scheduler.onFired
(callback: (jobId: string) => void) => () => void

Cas réel — Rapport quotidien + alerte stock

tsx
// Dans init() — configurer les tâches planifiées

function initScheduler(api: BizzOptimaModuleAPI) {
  // 1. Rapport quotidien à 23h00
  api.scheduler.daily('daily-report', '23:00', async () => {
    const today = new Date().toISOString().slice(0, 10);
    const sales = await api.db.query<{ total: number; count: number }>(
      'SELECT SUM(total) as total, COUNT(*) as count FROM sales WHERE DATE(created_at) = ?',
      [today],
    );
    const { total = 0, count = 0 } = sales[0] ?? {};

    // Écrire le rapport en JSON
    const report = { date: today, total, count, generatedAt: new Date().toISOString() };
    await api.fs.writeText(
      `reports/daily/${today}.json`,
      JSON.stringify(report, null, 2),
    );

    // Notifier l'utilisateur
    api.notifications.send(
      'Rapport journalier généré',
      `${count} vente(s) · Total : ${total.toLocaleString()} XAF`,
    );
  });

  // 2. Vérification du stock toutes les 2h
  api.scheduler.interval('stock-check', 2 * 60 * 60 * 1000, async () => {
    const lowStock = await api.db.query<{ name: string; available: number; min_stock: number }>(
      'SELECT name, available, min_stock FROM products WHERE available <= min_stock AND min_stock > 0',
    );

    if (lowStock.length > 0) {
      api.notifications.send(
        `⚠️ ${lowStock.length} produit(s) en rupture imminente`,
        lowStock.slice(0, 3).map(p => `${p.name}: ${p.available} restants`).join('\n'),
      );
    }
  });

  // 3. Backup auto 5 minutes après ouverture de l'app
  api.scheduler.once('startup-backup', 5 * 60 * 1000, async () => {
    const allData = await api.db.query('SELECT * FROM sales ORDER BY created_at DESC LIMIT 1000');
    await api.fs.writeText('backups/latest.json', JSON.stringify(allData));
  });

  // 4. Écouter les déclenchements (pour logs)
  api.scheduler.onFired((jobId) => {
    console.log(`[scheduler] Job déclenché : ${jobId} à ${new Date().toISOString()}`);
  });
}

// ─── Composant de gestion des tâches planifiées ─────────────────
function SchedulerPanel() {
  const api = useModuleAPI();
  const [jobs, setJobs] = React.useState<ScheduledJob[]>([]);

  React.useEffect(() => {
    setJobs(api.scheduler.list());
  }, []);

  return (
    <div>
      <h3>Tâches planifiées</h3>
      {jobs.map(job => (
        <div key={job.id}>
          <strong>{job.id}</strong> — {job.type}
          {job.nextRunAt && <span> · Prochain : {new Date(job.nextRunAt).toLocaleString()}</span>}
          <button onClick={() => { api.scheduler.cancel(job.id); setJobs(api.scheduler.list()); }}>
            Annuler
          </button>
        </div>
      ))}
    </div>
  );
}
⚠️ Partiel v0.1.0
api.notifications — Notifications OSnotifications:send

Notifications natives du système d'exploitation (Windows toast, macOS banner). Apparaissent même quand BizzOptima est en arrière-plan.

notifications.send
(title: string, body: string, options?: { icon?: string; silent?: boolean }) => void

Cas réel — Alerte multi-niveaux pour le stock

tsx
// ─── Système d'alertes avec niveaux de priorité ─────────────────

interface StockAlert {
  productId: string;
  name: string;
  available: number;
  minStock: number;
  level: 'critical' | 'warning' | 'info';
}

async function checkAndNotifyStock(api: BizzOptimaModuleAPI) {
  api.security.requireCapability('notifications:send');

  const lowStock = await api.db.query<Omit<StockAlert, 'level'>>(`
    SELECT product_id as productId, name, available, min_stock as minStock
    FROM products
    WHERE available <= min_stock
    ORDER BY (available / CAST(min_stock AS REAL)) ASC
    LIMIT 20
  `);

  if (lowStock.length === 0) return;

  // Classer par niveau de criticité
  const critical = lowStock.filter(p => p.available === 0);
  const warning = lowStock.filter(p => p.available > 0 && p.available <= p.minStock / 2);
  const info = lowStock.filter(p => p.available > p.minStock / 2);

  // Notification groupée par niveau
  if (critical.length > 0) {
    api.notifications.send(
      `🚨 ${critical.length} produit(s) en rupture totale`,
      critical.slice(0, 3).map(p => `❌ ${p.name} : 0 unité`).join('\n'),
      { silent: false }, // son activé pour les alertes critiques
    );
  }

  if (warning.length > 0) {
    api.notifications.send(
      `⚠️ ${warning.length} produit(s) en stock critique`,
      warning.slice(0, 3).map(p => `${p.name} : ${p.available}/${p.minStock} min`).join('\n'),
      { silent: true },
    );
  }

  if (info.length > 0 && critical.length === 0) {
    // Ne pas spammer si des alertes critiques sont déjà envoyées
    api.notifications.send(
      `ℹ️ ${info.length} produit(s) sous le seuil`,
      `Vérifiez votre stock.`,
      { silent: true },
    );
  }
}
✅ v0.1.0
api.print — Impressionprint:document

Impression via le moteur Chromium d'Electron. Supporte le CSS complet, les media queries @print et les imprimantes thermiques.

print.htmlprint:document
(html: string) => Promise<void>

Génère un document HTML complet et l'imprime silencieusement.

print.receiptprint:document
(data: ReceiptData) => Promise<void>

v0.2.0 — template reçu thermique 58mm/80mm.

Cas réel — Génération et impression d'une facture

tsx
// ─── Génération de facture HTML + impression ─────────────────────

interface InvoiceLine {
  description: string;
  qty: number;
  unitPrice: number;
  total: number;
}

interface Invoice {
  number: string;
  date: string;
  customerName: string;
  customerEmail: string;
  lines: InvoiceLine[];
  subtotal: number;
  tax: number;
  total: number;
  currency: string;
}

function buildInvoiceHtml(invoice: Invoice, businessName: string): string {
  const fmt = (n: number) => n.toLocaleString('fr-FR') + ' ' + invoice.currency;

  return `<!DOCTYPE html>
<html lang="fr">
<head>
  <meta charset="utf-8">
  <title>Facture ${invoice.number}</title>
  <style>
    @page { margin: 15mm; size: A4; }
    body { font-family: Arial, sans-serif; font-size: 12px; color: #1a1a1a; }
    .header { display: flex; justify-content: space-between; margin-bottom: 30px; }
    .business { font-size: 20px; font-weight: bold; color: #059669; }
    .invoice-number { font-size: 24px; font-weight: bold; }
    table { width: 100%; border-collapse: collapse; margin-top: 20px; }
    th { background: #059669; color: white; padding: 8px 12px; text-align: left; }
    td { padding: 8px 12px; border-bottom: 1px solid #e5e7eb; }
    .totals { margin-top: 20px; text-align: right; }
    .total-final { font-size: 18px; font-weight: bold; color: #059669; }
    @media print { button { display: none; } }
  </style>
</head>
<body>
  <div class="header">
    <div>
      <div class="business">${businessName}</div>
      <div>Facture n°${invoice.number}</div>
      <div>Date : ${new Date(invoice.date).toLocaleDateString('fr-FR')}</div>
    </div>
    <div>
      <div><strong>Client</strong></div>
      <div>${invoice.customerName}</div>
      <div>${invoice.customerEmail}</div>
    </div>
  </div>
  <table>
    <thead>
      <tr><th>Description</th><th>Qté</th><th>Prix unitaire</th><th>Total</th></tr>
    </thead>
    <tbody>
      ${invoice.lines.map(l => `
        <tr>
          <td>${l.description}</td>
          <td>${l.qty}</td>
          <td>${fmt(l.unitPrice)}</td>
          <td>${fmt(l.total)}</td>
        </tr>
      `).join('')}
    </tbody>
  </table>
  <div class="totals">
    <p>Sous-total : ${fmt(invoice.subtotal)}</p>
    <p>TVA (19.25%) : ${fmt(invoice.tax)}</p>
    <p class="total-final">TOTAL : ${fmt(invoice.total)}</p>
  </div>
</body>
</html>`;
}

async function printInvoice(api: BizzOptimaModuleAPI, invoiceId: string) {
  api.security.requireCapability('print:document');

  // 1. Charger la facture
  const rows = await api.db.query<InvoiceLine & { invoice_number: string; customer_name: string }>(
    `SELECT i.number as invoice_number, c.name as customer_name,
             il.description, il.qty, il.unit_price as unitPrice, il.total
      FROM invoices i
      JOIN customers c ON i.customer_id = c.id
      JOIN invoice_lines il ON il.invoice_id = i.id
      WHERE i.id = ?`,
    [invoiceId],
  );

  if (rows.length === 0) {
    api.ui.toast('Facture introuvable', { type: 'error' });
    return;
  }

  const subtotal = rows.reduce((s, r) => s + r.total, 0);
  const tax = subtotal * 0.1925;
  const invoice: Invoice = {
    number: rows[0].invoice_number,
    date: new Date().toISOString(),
    customerName: rows[0].customer_name,
    customerEmail: '',
    lines: rows.map(r => ({ description: r.description, qty: r.qty, unitPrice: r.unitPrice, total: r.total })),
    subtotal,
    tax,
    total: subtotal + tax,
    currency: api.config.getCurrency(),
  };

  const html = buildInvoiceHtml(invoice, api.config.getBusiness().businessName);
  await api.print.html(html);
  api.ui.toast('Facture envoyée à l'imprimante', { type: 'success' });
}
✅ v0.1.0
api.network · api.clipboard · api.shellDivers

network — Détection hors-ligne avec bannière

tsx
// Composant offline-aware — affiche une bannière quand hors ligne

function OfflineAwareApp() {
  const { online } = useNetwork();

  return (
    <div>
      {!online && (
        <div className="offline-banner" style={{ background: '#fef2f2', padding: '8px 16px', borderBottom: '1px solid #fca5a5' }}>
          ⚠️ Vous êtes hors ligne. Les données cloud ne seront pas synchronisées.
        </div>
      )}
      <MainContent />
    </div>
  );
}

// Utilisation impérative
function SyncButton() {
  const api = useModuleAPI();
  const { online } = useNetwork();

  const handleSync = async () => {
    if (!api.network.isOnline()) {
      api.ui.toast('Hors ligne — sync impossible', { type: 'warning' });
      return;
    }
    // ... sync logic
  };

  return <button disabled={!online} onClick={handleSync}>Synchroniser</button>;
}

clipboard — Copie d'informations critiques

tsx
// Copier un code de transaction ou numéro de facture

async function copyTransactionId(api: BizzOptimaModuleAPI, txId: string) {
  api.security.requireCapability('clipboard:write');
  await api.clipboard.writeText(txId);
  api.ui.toast('Référence copiée : ' + txId, { type: 'info', duration: 2000 });
}

// Lire le presse-papier pour coller un code EAN
async function pasteBarcode(api: BizzOptimaModuleAPI) {
  api.security.requireCapability('clipboard:read');
  const barcode = await api.clipboard.readText();
  if (!barcode || !/^[0-9]{8,13}$/.test(barcode)) {
    api.ui.toast('Contenu du presse-papier non reconnu comme code-barres', { type: 'warning' });
    return null;
  }
  return barcode;
}

shell — Ouvrir un PDF généré

tsx
// Après avoir écrit un PDF ou CSV, l'ouvrir dans l'app par défaut

async function generateAndOpen(api: BizzOptimaModuleAPI) {
  api.security.requireCapability('shell:open');

  // Générer le rapport
  const csv = '...' // contenu généré
  const filePath = 'exports/rapport-2024.csv';
  await api.fs.writeText(filePath, csv);

  // Ouvrir avec l'app par défaut (Excel, LibreOffice…)
  api.shell.openPath(filePath);

  // Ou ouvrir un lien externe dans le navigateur
  api.shell.openUrl('https://docs.bizzoptima.com/modules');
}
✅ v0.1.0
api.agent — Agent IA Xpressagent:run

Agent IA avec tool calling. Les outils permettent à l'IA d'interroger la DB, appeler des APIs et effectuer des actions. Disponible uniquement si Xpress AI est configuré.

agent.isAvailable
() => boolean

Vérifie si Xpress AI est configuré sur ce poste.

agent.runagent:run
(options: AgentRunOptions) => Promise<AgentRunResult>

Cas réel — Analyse des ventes par commande naturelle

tsx
// ─── Agent avec outils DB + HTTP ─────────────────────────────────

async function analyzeSalesWithAI(api: BizzOptimaModuleAPI, question: string): Promise<string> {
  api.security.requireCapability('agent:run');

  if (!api.agent.isAvailable()) {
    return 'Xpress AI n'est pas configuré sur ce poste.';
  }

  const result = await api.agent.run({
    systemPrompt: `Tu es un assistant d'analyse commerciale pour ${api.config.getBusiness().businessName}.
Tu as accès aux données de ventes. Réponds en français, de manière concise.
Utilise toujours les outils pour avoir des données à jour avant de répondre.`,

    prompt: question,
    maxSteps: 8,

    tools: [
      {
        name: 'get_sales_summary',
        description: 'Résumé des ventes pour une période donnée (total, nombre, panier moyen)',
        parameters: {
          from_date: { type: 'string', description: 'Date de début YYYY-MM-DD' },
          to_date: { type: 'string', description: 'Date de fin YYYY-MM-DD' },
        },
        handler: async ({ from_date, to_date }) => {
          return api.db.query(`
            SELECT
              COUNT(*) as count,
              SUM(total) as total_revenue,
              AVG(total) as avg_basket,
              MAX(total) as max_sale,
              MIN(total) as min_sale
            FROM sales
            WHERE DATE(created_at) BETWEEN ? AND ?
          `, [from_date, to_date]);
        },
      },
      {
        name: 'get_top_products',
        description: 'Produits les plus vendus sur une période',
        parameters: {
          from_date: { type: 'string' },
          to_date: { type: 'string' },
          limit: { type: 'number', description: 'Nombre de produits à retourner (défaut: 5)' },
        },
        handler: async ({ from_date, to_date, limit = 5 }) => {
          return api.db.query(`
            SELECT p.name, SUM(si.qty) as total_qty, SUM(si.total) as revenue
            FROM sale_items si
            JOIN products p ON si.product_id = p.id
            JOIN sales s ON si.sale_id = s.id
            WHERE DATE(s.created_at) BETWEEN ? AND ?
            GROUP BY p.id
            ORDER BY revenue DESC
            LIMIT ?
          `, [from_date, to_date, limit]);
        },
      },
      {
        name: 'get_low_stock_products',
        description: 'Produits dont le stock est bas ou épuisé',
        parameters: {},
        handler: async () => {
          return api.db.query(`
            SELECT name, available, min_stock
            FROM products
            WHERE available <= min_stock
            ORDER BY available ASC
            LIMIT 10
          `);
        },
      },
    ],
  });

  return result.output;
}

// ─── Composant React avec interface de chat ────────────────────────
function AISalesAssistant() {
  const api = useModuleAPI();
  const canUseAI = usePermission('agent:run');
  const [question, setQuestion] = React.useState('');
  const [answer, setAnswer] = React.useState('');
  const [loading, setLoading] = React.useState(false);

  if (!canUseAI || !api.agent.isAvailable()) {
    return <p>Xpress AI non disponible sur ce poste.</p>;
  }

  const handleAsk = async () => {
    if (!question.trim()) return;
    setLoading(true);
    setAnswer('');
    try {
      const res = await analyzeSalesWithAI(api, question);
      setAnswer(res);
    } catch (err) {
      setAnswer('Erreur : ' + (err as Error).message);
    } finally {
      setLoading(false);
    }
  };

  const suggestions = [
    'Quelles sont les ventes d'aujourd'hui ?',
    'Quels sont les 5 produits les plus vendus ce mois-ci ?',
    'Y a-t-il des produits en rupture de stock ?',
  ];

  return (
    <div>
      <div>
        {suggestions.map(s => (
          <button key={s} onClick={() => setQuestion(s)}>{s}</button>
        ))}
      </div>
      <textarea
        value={question}
        onChange={e => setQuestion(e.target.value)}
        placeholder="Posez une question sur vos ventes…"
        rows={3}
      />
      <button onClick={handleAsk} disabled={loading}>
        {loading ? 'Analyse en cours…' : 'Analyser'}
      </button>
      {answer && <div className="ai-answer">{answer}</div>}
    </div>
  );
}
⚠️ Partiel v0.1.0

Hooks React

Hooks disponibles
useModuleAPI
() => BizzOptimaModuleAPI

Accède à l'instance API complète.

useBusinessConfig
() => BusinessConfigSnapshot
useLocale
() => 'fr' | 'en'
useCurrency
() => string
useDB
<T>(sql, params?) => { data, loading, error, refetch }
useModuleEvents
(eventName, callback) => void

Auto-unsubscribe on unmount.

useToast
() => (message, options?) => void
useNavigate
() => (route) => void
useSecurity
() => SecurityContext
useTrustLevel
() => ModuleTrustLevel
useCapabilities
() => ModuleCapability[]
usePermission
(cap: ModuleCapability) => boolean
usePermissions
(caps: ModuleCapability[]) => boolean

Retourne true si TOUTES les caps sont disponibles.

useNetwork
() => { online: boolean }
useHttp
<T>(url, init?) => { data, loading, error, refetch }

Requiert http:request.

useSetting
(key, defaultValue?) => [value, setter, loading]

Requiert settings:read/write.

Utilitaires de sécurité

Guards et helpers
PermissionGuard
({ api, requires, fallback?, children }) => JSX

HOC qui conditionne le rendu à une capability.

createSecureHandler
(handler, options) => SecureHandler

Wraps un handler inter-module avec vérification du caller.

createPaymentGuard
(api, { allowedCallers? }) => (caller?) => void

Guard pour opérations de paiement sensibles.

assertCapabilities
(api, capabilities[]) => void

Throw CapabilityError sur la première capability manquante.

getMissingCapabilities
(api, required[]) => ModuleCapability[]

Retourne les capabilities manquantes.

validateManifest
(manifest) => { valid, errors[], warnings[] }

Valide le manifest avant build.

tsx
// HOC conditionnel
<PermissionGuard api={api} requires="http:request" fallback={<p>Module non signé</p>}>
  <SyncButton />
</PermissionGuard>

// Erreurs typées
import { CapabilityError, InterModuleAuthError } from '@bizzoptima/module-sdk';

try {
  api.security.requireCapability('fs:write');
} catch (e) {
  if (e instanceof CapabilityError) {
    // e.capability, e.moduleId, e.trustLevel
  }
}

Capabilities par niveau de confiance

Capabilityunsignedsigned_localsigned_registrytrusted_bundled
db:read✅✅✅✅
db:write—✅✅✅
events:subscribe✅✅✅✅
events:emit—✅✅✅
navigate—✅✅✅
http:request—✅✅✅
fs:read / fs:write—✅✅✅
fs:dialog——✅✅
scheduler:manage—✅✅✅
notifications:send—✅✅✅
print:document——✅✅
clipboard:read——✅✅
clipboard:write—✅✅✅
shell:open—✅✅✅
settings:read——✅✅
settings:write———✅