Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
79 changes: 71 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,15 @@

The MIT-licensed SDK for official [InvoiceShelf](https://invoiceshelf.com) v3 modules. It extends [`nwidart/laravel-modules`](https://github.com/nWidart/laravel-modules) with the contracts used by InvoiceShelf's module host and signed marketplace packages.

SDK **3.3** supports Module API **1.2**. First-party module packages live in their own repositories and are licensed `AGPL-3.0-only`; this SDK remains MIT.
SDK **3.4** supports Module API **1.3**. First-party module packages live in their own repositories and are licensed `AGPL-3.0-only`; this SDK remains MIT.

## What a module can add

- Sidebar entries and schema-driven, per-company settings.
- Local, compiled JavaScript and CSS registered with the host.
- Typed frontend contributions through [`frontend/index.d.ts`](frontend/index.d.ts): routes, menus, HTTP access, translations, notifications, and lifecycle events. The host supplies the Vue, router, Axios, and i18n instances—modules do not bundle another framework runtime.
- Narrow host contracts under [`src/Contracts/Host`](src/Contracts/Host) for settings, authorization, and the AI assistant's read-only company-data queries. Module code must not depend on InvoiceShelf Eloquent models.
- Typed frontend contributions through [`frontend/index.d.ts`](frontend/index.d.ts): full-page routes, settings pages, menus, HTTP access, translations, notifications, and lifecycle events. The host supplies the Vue, router, Axios, and i18n instances—modules do not bundle another framework runtime.
- Company abilities contributed to the host's authorization catalogue, so a module can ship its own permissions instead of reusing host ones.
- Narrow host contracts under [`src/Contracts/Host`](src/Contracts/Host) for settings, authorization, and read-only company-data queries. `CompanyDataReader` covers the AI assistant's built-in queries plus `companyMembers()` and `existingInvoiceIds()` for modules that assign work to members or stamp their entries against invoices. Module code must not depend on InvoiceShelf Eloquent models.
- AI drivers through [`src/Ai`](src/Ai): extend `AiDriver`, return `AiChatResponse`, and throw `AiException` for safe, localizable provider failures.

The host controls discovery, installation, activation, migrations, and provider registration. Official packages are not a general-purpose runtime Composer installer for arbitrary third-party code.
Expand All @@ -20,8 +21,8 @@ Declare compatibility in `module.json`, then test it against the host versions y

| Concern | Current contract |
| --- | --- |
| SDK | `invoiceshelf/modules` `^3.3` |
| Module API | `^1.2.0` |
| SDK | `invoiceshelf/modules` `^3.4` |
| Module API | `^1.3.0` |
| Host application | Your explicit InvoiceShelf 3.x range |
| PHP | The range your module actually supports |

Expand Down Expand Up @@ -50,7 +51,7 @@ New official modules use schema version 2. Their identity is permanent after the
],
"compatibility": {
"invoiceshelf": ">=3.0.0-alpha.2 <4.0.0",
"module_api": "^1.2.0",
"module_api": "^1.3.0",
"php": "^8.4.0",
"extensions": ["ext-json"]
},
Expand All @@ -59,7 +60,7 @@ New official modules use schema version 2. Their identity is permanent after the
"uninstall": {
"data_cleanup": "Modules\\SalesTaxUs\\Lifecycle\\DataCleanup"
},
"assets": ["dist/module.js", "dist/module.css"]
"assets": ["dist/init.js", "dist/style.css"]
}
```

Expand Down Expand Up @@ -90,7 +91,7 @@ An intentionally empty method is valid for a module with nothing beyond reversib
Install the SDK in the module project, then use Laravel Modules as usual:

```bash
composer require invoiceshelf/modules:^3.3
composer require invoiceshelf/modules:^3.4
php artisan module:make SalesTaxUs
```

Expand All @@ -106,6 +107,68 @@ Registry::registerMenu('sales-tax-us', [
]);
```

### Sidebar placement

`registerMenu` and `registerUserMenu` accept two placement keys besides `title`, `link` and `icon`:

- `group`: the sidebar group the entry joins. The default is `modules`, rendered last with the
"Modules" label. A module may join a core group instead: `main` (Dashboard 10, Customers 20,
Items 30), `documents` (Estimates 10, Invoices 20, Payments 30, Expenses 40) or `admin`
(Members 20, Reports 30, Settings 40).
- `priority`: an integer; lower sorts first inside the group, default `100`. Entries with equal
priority keep registration order, so official modules set explicit values (`10`, `20`, ...).
Groups keep the order the host sends them in: the core groups first, then module groups in
registration order, so a low priority never lifts a module group above the core ones.

```php
Registry::registerMenu('tasks-projects', [
'title' => 'tasksprojects::menu.title',
'link' => '/admin/modules/tasks-projects',
'icon' => 'ClipboardDocumentListIcon',
'priority' => 10,
]);
```

### Abilities

`Registry::registerAbility()` adds a module's own permissions to the host's ability catalogue. Every
ability is stored namespaced as `{slug}:{ability}`, so a module can never collide with a host ability
or with another module:

```php
Registry::registerAbility('sales-tax-us', [
'ability' => 'view-filing',
'name' => 'View Tax Filings',
'depends_on' => ['view-invoice', Registry::abilityId('sales-tax-us', 'view-rate')],
'owner_only' => false,
]);
```

`ability` is plain kebab-case and `name` is the label shown in the role editor. `depends_on` lists
abilities implied by this one: host abilities in plain form (`view-invoice`), the module's own in
namespaced form via `Registry::abilityId()`. Module abilities are never model-scoped.

The host grants a module's abilities to owner roles when the module is enabled and removes them when
it is uninstalled; other roles get them through the role editor. Frontend pages must gate on the same
namespaced id through `meta.ability` (see below), and re-registering an identical entry is a no-op
while a conflicting redefinition throws.

### Full-page routes

`extensions.registerPage()` mounts a module page at `/admin/modules/{slug}/{path}`, where `{slug}` is
the `module.json` slug. Declare `meta.ability` with the namespaced ability id so the host route guard
can check it, and add `children` for sub-routes relative to the page. The `settings` path is reserved
by the host for the schema-rendered settings page.

Point the `Registry::registerMenu` link at `/admin/modules/{slug}` for a module with its own pages,
and at `/admin/modules/{slug}/settings` for a settings-only module.

`registerPage`, `Registry::registerAbility`, and the `CompanyDataReader::companyMembers()` and
`existingInvoiceIds()` queries are Module API **1.3.0** additions. Declare
`"module_api": "^1.3.0"` in `module.json` before using them.

### Validating a package

Validate both the manifest and the distributable package before every release:

```bash
Expand Down
23 changes: 23 additions & 0 deletions frontend/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,28 @@ export interface SettingsPageContribution
meta?: Record<string, unknown>
}

export interface PageRouteMeta {
/** Namespaced ability id(s) checked by the host route guard, e.g. 'tasks-projects:view-project'. */
ability?: string | string[]
/** i18n key for the page title. */
title?: string
[key: string]: unknown
}

export interface PageChildContribution {
id: string
/** Relative to the parent page, without a leading slash. '' is the index child. */
path: string
component: Component
meta?: PageRouteMeta
}

export interface PageContribution extends PageChildContribution {
/** The module.json slug. The page mounts at /admin/modules/{module}/{path}; 'settings' is reserved by the host. */
module: string
children?: PageChildContribution[]
}

export interface BootstrapCompletedEvent {
adminMode: boolean
companyId: number | null
Expand All @@ -64,6 +86,7 @@ export interface InvoiceShelfExtensionApi {
registerAdminSettingsNavigation(contribution: SettingsNavigationContribution): () => void
registerCompanySettingsPage(contribution: SettingsPageContribution): () => void
registerAdminSettingsPage(contribution: SettingsPageContribution): () => void
registerPage(contribution: PageContribution): () => void
addMessages(messages: Record<string, Record<string, unknown>>): void
notify(type: 'success' | 'error' | 'warning' | 'info', message: string): void
on<EventName extends keyof InvoiceShelfExtensionEvents>(
Expand Down
28 changes: 25 additions & 3 deletions src/Contracts/Host/CompanyDataReader.php
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@
namespace InvoiceShelf\Modules\Contracts\Host;

/**
* Read-only company data required by the AI assistant's twelve built-in queries.
* Read-only, company-scoped queries the host exposes to official modules.
*
* Introduced for the AI assistant's built-in queries, the reader now also
* serves other official modules that need to read company data without
* touching host Eloquent models.
*
* Returned values are arrays of scalar data only. Hosts must not expose ORM
* models, collections, or framework-specific value objects across this boundary.
Expand All @@ -15,10 +19,19 @@ interface CompanyDataReader
/** @return array<string, mixed> */
public function companyStats(int $companyId, string $startDate, string $endDate): array;

/** @return array<string, mixed>|null */
/**
* The row carries the customer's `currency_id` and a `currency` sub-array
* shaped {id, code, symbol, precision}, or null when none is set.
*
* @return array<string, mixed>|null
*/
public function findCustomer(int $companyId, int $customerId): ?array;

/** @return array<string, mixed> */
/**
* Rows carry the customer's `currency_id`.
*
* @return array<string, mixed>
*/
public function searchCustomers(int $companyId, ?string $query, int $limit): array;

/** @return array<string, mixed> */
Expand Down Expand Up @@ -53,4 +66,13 @@ public function searchItems(int $companyId, ?string $query, int $limit): array;

/** @return array<string, mixed> */
public function rankItems(int $companyId, string $metric, ?string $startDate, ?string $endDate, int $limit): array;

/** @return list<array{id: int, name: string, email: string, avatar: string|null}> members of the company, ordered by name then id */
public function companyMembers(int $companyId): array;

/**
* @param list<int> $invoiceIds
* @return list<int> the subset of $invoiceIds that exist in the company
*/
public function existingInvoiceIds(int $companyId, array $invoiceIds): array;
}
Loading