OfficeGest
Module System

Anatomy of a Module

The ModuleDefinition contract every OGMF module exports — its members, the satisfies authoring pattern, and worked examples from the workshop and crm modules.

Introduction

An OGMF module is a self-contained feature folder under src/modules/{domain}/. Its public face to the rest of the app is a single object: a ModuleDefinition. The app discovers these objects at build time and assembles them into a registry that drives routing, the sidebar, and cross-module extension points.

Discovery is glob-based. At app startup the root collects every module's definition through Vite's import.meta.glob:

const discovered = {
  ...import.meta.glob<{ default: ModuleDefinition }>('@/modules/**/module.ts'),
  ...import.meta.glob<{ default: ModuleDefinition }>('@/addons/**/module.ts'),
};

This is the constraint that the rest of this page hangs on: the file MUST be named module.ts, sit at the module root, and default-export a ModuleDefinition. A module that names the file something else, nests it deeper, or exports the definition under a named binding is invisible to the registry — it simply never loads.

The same glob pattern also picks up @/addons/**/module.ts. Addons author their definition exactly the way modules do; the only difference is the folder they live in. Everything on this page applies to both.


The ModuleDefinition type

ModuleDefinition is exported from @/core (re-exported there from @/core/types). It has four members — one required, three optional:

MemberTypeRequiredPurpose
idstringyesUnique identifier for this module.
routesRouteObject[]noRoute definitions. Omit if the module has no pages of its own.
sidebarSidebarItem[]noSidebar navigation items the module contributes.
slotsSlotContribution[]noContributions to other modules' extension points.

RouteObject is React Router's own route type (imported from react-router), so a module's routes are ordinary route definitions — typically authored in a sibling routes.tsx and imported into module.ts. SidebarItem and SlotContribution are OGMF types, also re-exported from @/core.

Each optional member maps to a sibling page:

MemberDocumented in
routesRouting
sidebarNavigation
slotsSlots

SidebarItem, briefly

A SidebarItem is a set of common fields intersected with one of two shapes — it is either a leaf (it navigates somewhere) or a branch (it groups children):

type SidebarItem = {
  label: string;
  icon?: ComponentType;
  privilege?: string;
  order?: number;
} & (
  | { to: To; link?: boolean }      // leaf — navigates
  | { children: SidebarItem[] }     // branch — groups
);

The discriminator is structural: an item carrying to is a navigable leaf; an item carrying children is a group. privilege? gates visibility and order? controls position. The full filtering and ordering behavior — and what link? does — lives in Navigation; here we only need to recognize the two shapes when reading the examples below.

SlotContribution, briefly

SlotContribution names a target slot and the contribution payload to inject there. @/core co-exports a typed helper, slotContribution, for building one. Neither example module on this page contributes slots; the contribution pattern is covered in Slots.


The authoring pattern: satisfies ModuleDefinition

Every module's module.ts follows the same skeleton — a default export typed with satisfies, never with a type annotation:

import type { ModuleDefinition } from "@/core";
import { routes } from './routes';
import { CarIcon } from "lucide-react";

export default {
  id: 'workshop',
  routes,
  sidebar: [ /* ... */ ],
} satisfies ModuleDefinition;

Why satisfies ModuleDefinition and not : ModuleDefinition?

  • A type annotation (const m: ModuleDefinition = {...}) widens the value to the declared type. The literal's precise inferred shape is thrown away — id becomes string, sidebar becomes SidebarItem[], and the specific labels and route arrays you wrote are no longer visible to the type system downstream.
  • satisfies checks the literal against ModuleDefinition — so a typo'd member, a missing id, or a malformed SidebarItem is still a compile error — while preserving the narrow inferred type of the literal. You get the shape guarantee without the widening.

For an anatomy object that other tooling may read, keeping the narrow type is the right default. Use satisfies for every module.ts.


Minimal example — the workshop module

The workshop module is the floor: an id, a routes array imported from its routes.tsx, and a single sidebar group with one leaf child.

import type { ModuleDefinition } from "@/core";
import { routes } from './routes';
import { CarIcon } from "lucide-react";

export default {
  id: 'workshop',
  routes,
  sidebar: [
    {
      label: 'workshop',
      icon: CarIcon,
      children: [
        { label: 'agenda', to: '/workshop/agenda' },
      ],
    },
  ],
} satisfies ModuleDefinition;

Reading it member by member:

  • id: 'workshop' — the required unique identifier.
  • routes — a RouteObject[] defined in workshop/routes.tsx and imported here. The shape of those route objects (lazy views, authorizationMiddleware, locale loaders) is covered in Routing.
  • sidebar — one branch item (label + icon + children) wrapping one leaf item (label + to). No privilege and no order here: this module does not gate the entry or fix its position.

This is a complete, valid module. Everything beyond it is additive.


Rich example — the crm module

The crm module exercises the full SidebarItem shape: a top-level branch with order and privilege, nested branches, leaves with per-item privilege, and a deliberately large order to sink the settings group to the bottom. The structure is otherwise the same satisfies ModuleDefinition skeleton.

import type { ModuleDefinition } from "@/core";
import { routes } from './routes';
import { ContactRound } from "lucide-react";

export default {
  id: 'crm',
  routes,
  sidebar: [
    {
      label: 'crm',
      icon: ContactRound,
      order: 20,
      privilege: 'crm',
      children: [
        { label: 'painelresumo', to: '/crm/dashboard', privilege: 'crm:painelresumo', order: 10 },
        { label: 'agenda', to: '/crm/agenda', privilege: 'crm:agenda', order: 20 },
        {
          label: 'pipeline',
          order: 30,
          children: [
            { label: 'leads', to: '/crm/leads', privilege: 'crm:leads' },
            { label: 'oportunidadesnegocios', to: '/crm/opportunities', privilege: 'crm:oportunidadesnegocios' },
            { label: 'ocupacao', to: '/crm/occupancy', privilege: 'crm:ocupacao' },
          ],
        },
        {
          label: 'atividade',
          order: 40,
          children: [
            { label: 'marcacoes', to: '/crm/appointments', privilege: 'crm:marcacoes' },
            { label: 'tarefas', to: '/crm/tasks', privilege: 'crm:tarefas' },
            { label: 'communications', to: '/crm/communications', privilege: 'crm:communications' },
            { label: 'messages', to: '/messages', privilege: 'crm:messages' },
          ],
        },
        { label: 'valesoferta', to: '/crm/gift-vouchers', privilege: 'crm:valesoferta', order: 50 },
        {
          label: 'apoio',
          privilege: 'crm:apoio',
          order: 999,
          children: [
            { label: 'campanhas', to: '/crm/settings/campaigns', privilege: 'crm:apoio:campanhas' },
            { label: 'equipas', to: '/crm/settings/teams', privilege: 'crm:apoio:equipas' },
            /* ...further settings leaves... */
          ],
        },
      ],
    },
  ],
} satisfies ModuleDefinition;

What this shows that the minimal example does not:

  • Top-level gating and ordering. The root crm branch carries privilege: 'crm' and order: 20.
  • Arbitrary nesting. pipeline, atividade, and apoio are branches inside a branch — children is SidebarItem[], so the tree is recursive to any depth.
  • Hierarchical privileges. Leaves use colon-scoped privilege strings (crm:leads, crm:apoio:campanhas) that mirror the menu nesting.
  • Cross-module links. The messages leaf points at /messages, a route owned elsewhere — a sidebar leaf's to is just a route target, not necessarily a route this module declares.
  • Deliberate placement. apoio sets order: 999 to sit last regardless of declaration order.

Both modules are the same object with the same satisfies discipline; crm just populates more of the SidebarItem surface. The semantics of how privilege filters and order sorts the assembled tree are detailed in Navigation.


Where to go next