Files
InvoiceShelf/routes/api.php
Darko Gjorgjijoski e6eeacb6d4 feat(modules): company-context module surfaces and schema-driven settings
Adds the read-only company "Active Modules" index page (lists every
instance-activated module with a Settings shortcut) and the schema-driven
settings framework (generic BaseSchemaForm.vue renderer + per-company
persistence in CompanySetting). Bundled because they share the same
routes/api.php edit and the index page's Settings button targets the
settings page.

Backend:

- CompanyModulesController::index() returns every Module::enabled = true row
  with a kebab-case slug (via Str::kebab()) and a has_settings flag computed
  from \InvoiceShelf\Modules\Registry::settingsFor(). nwidart stores module
  names in PascalCase ("HelloWorld") but URLs and registry keys use kebab
  ("hello-world") — the controller normalizes so module authors can call
  Registry::registerSettings('hello-world') naturally without thinking
  about the storage format.

- ModuleSettingsController::show(\$slug) returns the registered Schema +
  per-company values from CompanySetting (defaults flow through when nothing
  has been saved yet). update(\$slug) builds Laravel validator rules from
  the Schema's per-field rules arrays — with type-rule fallbacks for
  switch -> boolean, number -> numeric, multiselect -> array — silently
  drops unknown keys, and persists via CompanySetting::setSettings() under
  the module.{slug}.{key} prefix. Activation is instance-global, but
  settings are per-company: two companies on the same instance can
  configure the same activated module differently.

- routes/api.php mounts GET /api/v1/company-modules at the root of the
  company API group and GET/PUT /api/v1/modules/{slug}/settings inside the
  existing modules prefix.

Frontend:

- BaseSchemaForm.vue is the central new component — a generic schema-driven
  form renderer that maps schema fields to BaseInput / BaseTextarea /
  BaseSwitch / BaseMultiselect by type, and builds Vuelidate rules
  dynamically from each field's rules array (supports required, email, url,
  numeric, min:N, max:N). New fields are added by extending the type ->
  component map.

- CompanyModulesIndexView.vue fetches /company-modules and renders a card
  grid (with empty/loading states); CompanyModuleCard.vue is the per-row
  component with the Settings button. ModuleSettingsView.vue fetches
  /modules/{slug}/settings, hands {schema, values} to BaseSchemaForm, and
  posts back on submit.

- Company-context routes.ts is rebuilt after the previous commit relocated
  the marketplace browser away. It now declares modules.index +
  modules.settings, both gated by manage-module ability.

- New api/services/{companyModules,moduleSettings}.service.ts thin clients.

- lang/en.json adds modules.index.{description,empty_title,empty_description},
  modules.settings.{title,open,saved,not_found,none}, and
  modules.sidebar.section_title. The sidebar key is added here even though
  the dynamic sidebar rendering lands in the next commit — keeping all i18n
  additions in one file edit avoids hunk-splitting lang/en.json.
2026-04-09 00:29:36 +02:00

550 lines
24 KiB
PHP

<?php
use App\Http\Controllers\Admin\AdminDashboardController;
use App\Http\Controllers\Admin\BackupsController;
use App\Http\Controllers\Admin\CompaniesController;
use App\Http\Controllers\Admin\CountriesController;
use App\Http\Controllers\Admin\CurrenciesController;
use App\Http\Controllers\Admin\FontController;
use App\Http\Controllers\Admin\Modules\ModuleInstallationController;
use App\Http\Controllers\Admin\Modules\ModulesController;
use App\Http\Controllers\Admin\Settings\DiskController;
use App\Http\Controllers\Admin\Settings\MailConfigurationController;
use App\Http\Controllers\Admin\Settings\PDFConfigurationController;
use App\Http\Controllers\Admin\Settings\SettingsController;
use App\Http\Controllers\Admin\UpdateController;
use App\Http\Controllers\Admin\UsersController;
use App\Http\Controllers\AppVersionController;
use App\Http\Controllers\Company\Auth\AuthController;
use App\Http\Controllers\Company\Auth\ForgotPasswordController;
use App\Http\Controllers\Company\Auth\InvitationRegistrationController;
use App\Http\Controllers\Company\Auth\ResetPasswordController;
use App\Http\Controllers\Company\Customer\CustomersController;
use App\Http\Controllers\Company\Customer\CustomerStatsController;
use App\Http\Controllers\Company\CustomField\CustomFieldsController;
use App\Http\Controllers\Company\Dashboard\DashboardController;
use App\Http\Controllers\Company\Estimate\EstimatesController;
use App\Http\Controllers\Company\Estimate\EstimateTemplatesController;
use App\Http\Controllers\Company\ExchangeRate\ExchangeRateProviderController;
use App\Http\Controllers\Company\Expense\ExpenseCategoriesController;
use App\Http\Controllers\Company\Expense\ExpensesController;
use App\Http\Controllers\Company\General\BootstrapController;
use App\Http\Controllers\Company\General\ConfigController;
use App\Http\Controllers\Company\General\FormatsController;
use App\Http\Controllers\Company\General\InvitationResponseController;
use App\Http\Controllers\Company\General\NotesController;
use App\Http\Controllers\Company\General\SearchController;
use App\Http\Controllers\Company\General\SerialNumberController;
use App\Http\Controllers\Company\Invoice\InvoicesController;
use App\Http\Controllers\Company\Invoice\InvoiceTemplatesController;
use App\Http\Controllers\Company\Item\ItemsController;
use App\Http\Controllers\Company\Item\UnitsController;
use App\Http\Controllers\Company\Members\MembersController;
use App\Http\Controllers\Company\Modules\CompanyModulesController;
use App\Http\Controllers\Company\Modules\ModuleSettingsController;
use App\Http\Controllers\Company\Payment\PaymentMethodsController;
use App\Http\Controllers\Company\Payment\PaymentsController;
use App\Http\Controllers\Company\RecurringInvoice\RecurringInvoiceController;
use App\Http\Controllers\Company\RecurringInvoice\RecurringInvoiceFrequencyController;
use App\Http\Controllers\Company\Role\AbilitiesController;
use App\Http\Controllers\Company\Role\RolesController;
use App\Http\Controllers\Company\Settings\CompanyController;
use App\Http\Controllers\Company\Settings\CompanyMailConfigurationController;
use App\Http\Controllers\Company\Settings\CompanySettingsController;
use App\Http\Controllers\Company\Settings\InvitationController;
use App\Http\Controllers\Company\Settings\TaxTypesController;
use App\Http\Controllers\Company\Settings\UserProfileController;
use App\Http\Controllers\CustomerPortal\Auth\ForgotPasswordController as AuthForgotPasswordController;
use App\Http\Controllers\CustomerPortal\Auth\ResetPasswordController as AuthResetPasswordController;
use App\Http\Controllers\CustomerPortal\Estimate\AcceptEstimateController as CustomerAcceptEstimateController;
use App\Http\Controllers\CustomerPortal\Estimate\EstimatesController as CustomerEstimatesController;
use App\Http\Controllers\CustomerPortal\Expense\ExpensesController as CustomerExpensesController;
use App\Http\Controllers\CustomerPortal\General\BootstrapController as CustomerBootstrapController;
use App\Http\Controllers\CustomerPortal\General\DashboardController as CustomerDashboardController;
use App\Http\Controllers\CustomerPortal\General\ProfileController as CustomerProfileController;
use App\Http\Controllers\CustomerPortal\Invoice\InvoicesController as CustomerInvoicesController;
use App\Http\Controllers\CustomerPortal\Payment\PaymentMethodController;
use App\Http\Controllers\CustomerPortal\Payment\PaymentsController as CustomerPaymentsController;
use App\Http\Controllers\Setup\AppDomainController;
use App\Http\Controllers\Setup\DatabaseConfigurationController;
use App\Http\Controllers\Setup\FilePermissionsController;
use App\Http\Controllers\Setup\FinishController;
use App\Http\Controllers\Setup\LanguagesController;
use App\Http\Controllers\Setup\LoginController;
use App\Http\Controllers\Setup\OnboardingWizardController;
use App\Http\Controllers\Setup\RequirementsController;
use App\Http\Controllers\Webhook\CronJobController;
use Illuminate\Support\Facades\Route;
/*
|--------------------------------------------------------------------------
| API Routes
|--------------------------------------------------------------------------
|
| Here is where you can register API routes for your application. These
| routes are loaded by the RouteServiceProvider within a group which
| is assigned the "api" middleware group. Enjoy building your API!
|
*/
// ping
// ----------------------------------
Route::get('ping', function () {
return response()->json([
'success' => 'invoiceshelf-self-hosted',
]);
})->name('ping');
// Version 1 endpoints
// --------------------------------------
Route::prefix('/v1')->group(function () {
// App version
// ----------------------------------
Route::get('/app/version', AppVersionController::class);
// Authentication & Password Reset
// ----------------------------------
Route::prefix('auth')->group(function () {
Route::post('login', [AuthController::class, 'login']);
Route::post('logout', [AuthController::class, 'logout'])->middleware('auth:sanctum');
// Send reset password mail
Route::post('password/email', [ForgotPasswordController::class, 'sendResetLinkEmail'])->middleware('throttle:10,2');
// handle reset password form process
Route::post('reset/password', [ResetPasswordController::class, 'reset']);
});
// Invitation Registration (public)
// ----------------------------------
Route::get('/invitations/{token}/details', [InvitationRegistrationController::class, 'details']);
Route::post('/auth/register-with-invitation', [InvitationRegistrationController::class, 'register']);
// Countries
// ----------------------------------
Route::get('/countries', CountriesController::class);
// Onboarding
// ----------------------------------
Route::middleware(['redirect-if-installed'])->prefix('installation')->group(function () {
Route::get('/wizard-step', [OnboardingWizardController::class, 'getStep']);
Route::post('/wizard-step', [OnboardingWizardController::class, 'updateStep']);
Route::post('/wizard-language', [OnboardingWizardController::class, 'saveLanguage']);
Route::get('/languages', [LanguagesController::class, 'languages']);
Route::get('/requirements', [RequirementsController::class, 'requirements']);
Route::get('/permissions', [FilePermissionsController::class, 'permissions']);
Route::post('/database/config', [DatabaseConfigurationController::class, 'saveDatabaseEnvironment']);
Route::get('/database/config', [DatabaseConfigurationController::class, 'getDatabaseEnvironment']);
Route::put('/set-domain', AppDomainController::class);
Route::post('/login', LoginController::class);
Route::post('/finish', FinishController::class);
});
// Super Admin
// ----------------------------------
Route::middleware(['auth:sanctum', 'super-admin'])->prefix('super-admin')->group(function () {
Route::get('dashboard', [AdminDashboardController::class, 'index']);
Route::get('companies', [CompaniesController::class, 'index']);
Route::get('companies/{company}', [CompaniesController::class, 'show']);
Route::put('companies/{company}', [CompaniesController::class, 'update']);
Route::get('users', [UsersController::class, 'index']);
Route::get('users/{user}', [UsersController::class, 'show']);
Route::put('users/{user}', [UsersController::class, 'update']);
Route::post('users/{user}/impersonate', [UsersController::class, 'impersonate']);
});
// Stop impersonation - uses auth:sanctum only (the impersonated user's token, not super-admin)
Route::middleware(['auth:sanctum'])->prefix('super-admin')->group(function () {
Route::post('stop-impersonating', [UsersController::class, 'stopImpersonating']);
});
Route::middleware(['auth:sanctum', 'company'])->group(function () {
Route::middleware(['bouncer'])->group(function () {
// Bootstrap
// ----------------------------------
Route::get('/bootstrap', BootstrapController::class);
// Invitations (user-scoped — respond to invitations)
// ----------------------------------
Route::get('/invitations/pending', [InvitationResponseController::class, 'pending']);
Route::post('/invitations/{invitation:token}/accept', [InvitationResponseController::class, 'accept']);
Route::post('/invitations/{invitation:token}/decline', [InvitationResponseController::class, 'decline']);
// Currencies
// ----------------------------------
Route::prefix('/currencies')->group(function () {
Route::get('/used', [ExchangeRateProviderController::class, 'usedCurrenciesWithoutRate']);
Route::post('/bulk-update-exchange-rate', [ExchangeRateProviderController::class, 'bulkUpdate']);
});
// Dashboard
// ----------------------------------
Route::get('/dashboard', DashboardController::class);
// Auth check
// ----------------------------------
Route::get('/auth/check', [AuthController::class, 'check']);
// Search users
// ----------------------------------
Route::get('/search', SearchController::class);
Route::get('/search/user', [SearchController::class, 'users']);
// MISC
// ----------------------------------
Route::get('/config', ConfigController::class);
Route::get('/currencies', CurrenciesController::class);
Route::get('/timezones', [FormatsController::class, 'timezones']);
Route::get('/date/formats', [FormatsController::class, 'dateFormats']);
Route::get('/time/formats', [FormatsController::class, 'timeFormats']);
Route::get('/next-number', [SerialNumberController::class, 'nextNumber']);
Route::get('/number-placeholders', [SerialNumberController::class, 'placeholders']);
Route::get('/current-company', [BootstrapController::class, 'currentCompany']);
// Company Invitations (company-scoped — send invitations)
// ----------------------------------
Route::apiResource('company-invitations', InvitationController::class)->only(['index', 'store', 'destroy']);
// Customers
// ----------------------------------
Route::post('/customers/delete', [CustomersController::class, 'delete']);
Route::get('customers/{customer}/stats', CustomerStatsController::class);
Route::resource('customers', CustomersController::class);
// Items
// ----------------------------------
Route::post('/items/delete', [ItemsController::class, 'delete']);
Route::resource('items', ItemsController::class);
Route::resource('units', UnitsController::class);
// Invoices
// -------------------------------------------------
Route::get('/invoices/{invoice}/send/preview', [InvoicesController::class, 'sendPreview']);
Route::post('/invoices/{invoice}/send', [InvoicesController::class, 'send']);
Route::post('/invoices/{invoice}/clone', [InvoicesController::class, 'clone']);
Route::post('/invoices/{invoice}/convert-to-estimate', [InvoicesController::class, 'convertToEstimate']);
Route::post('/invoices/{invoice}/status', [InvoicesController::class, 'changeStatus']);
Route::post('/invoices/delete', [InvoicesController::class, 'delete']);
Route::get('/invoices/templates', InvoiceTemplatesController::class);
Route::apiResource('invoices', InvoicesController::class);
// Recurring Invoice
// -------------------------------------------------
Route::get('/recurring-invoice-frequency', RecurringInvoiceFrequencyController::class);
Route::post('/recurring-invoices/delete', [RecurringInvoiceController::class, 'delete']);
Route::apiResource('recurring-invoices', RecurringInvoiceController::class);
// Estimates
// -------------------------------------------------
Route::get('/estimates/{estimate}/send/preview', [EstimatesController::class, 'sendPreview']);
Route::post('/estimates/{estimate}/send', [EstimatesController::class, 'send']);
Route::post('/estimates/{estimate}/clone', [EstimatesController::class, 'clone']);
Route::post('/estimates/{estimate}/status', [EstimatesController::class, 'changeStatus']);
Route::post('/estimates/{estimate}/convert-to-invoice', [EstimatesController::class, 'convertToInvoice']);
Route::get('/estimates/templates', EstimateTemplatesController::class);
Route::post('/estimates/delete', [EstimatesController::class, 'delete']);
Route::apiResource('estimates', EstimatesController::class);
// Expenses
// ----------------------------------
Route::get('/expenses/{expense}/show/receipt', [ExpensesController::class, 'showReceipt']);
Route::post('/expenses/{expense}/upload/receipts', [ExpensesController::class, 'uploadReceipt']);
Route::post('/expenses/delete', [ExpensesController::class, 'delete']);
Route::apiResource('expenses', ExpensesController::class);
Route::apiResource('categories', ExpenseCategoriesController::class);
// Payments
// ----------------------------------
Route::get('/payments/{payment}/send/preview', [PaymentsController::class, 'sendPreview']);
Route::post('/payments/{payment}/send', [PaymentsController::class, 'send']);
Route::post('/payments/delete', [PaymentsController::class, 'delete']);
Route::apiResource('payments', PaymentsController::class);
Route::apiResource('payment-methods', PaymentMethodsController::class);
// Custom fields
// ----------------------------------
Route::resource('custom-fields', CustomFieldsController::class);
// Backup & Disk
// ----------------------------------
Route::apiResource('backups', BackupsController::class);
Route::apiResource('/disks', DiskController::class);
Route::get('download-backup', [BackupsController::class, 'download']);
Route::get('/disk/drivers', [DiskController::class, 'getDiskDrivers']);
Route::get('/disk/purposes', [DiskController::class, 'getDiskPurposes']);
Route::put('/disk/purposes', [DiskController::class, 'updateDiskPurposes']);
// Fonts
// ----------------------------------
Route::get('/fonts/status', [FontController::class, 'status']);
Route::post('/fonts/{package}/install', [FontController::class, 'install']);
// Exchange Rate
// ----------------------------------
Route::get('/currencies/{currency}/exchange-rate', [ExchangeRateProviderController::class, 'getRate']);
Route::get('/currencies/{currency}/active-provider', [ExchangeRateProviderController::class, 'activeProvider']);
Route::get('/used-currencies', [ExchangeRateProviderController::class, 'usedCurrencies']);
Route::get('/supported-currencies', [ExchangeRateProviderController::class, 'supportedCurrencies']);
Route::apiResource('exchange-rate-providers', ExchangeRateProviderController::class);
// Settings
// ----------------------------------
Route::get('/me', [UserProfileController::class, 'show']);
Route::put('/me', [UserProfileController::class, 'update']);
Route::get('/me/settings', [UserProfileController::class, 'showSettings']);
Route::put('/me/settings', [UserProfileController::class, 'updateSettings']);
Route::post('/me/upload-avatar', [UserProfileController::class, 'uploadAvatar']);
Route::put('/company', [CompanyController::class, 'updateCompany']);
Route::post('/company/upload-logo', [CompanyController::class, 'uploadCompanyLogo']);
Route::get('/company/settings', [CompanySettingsController::class, 'show']);
Route::post('/company/settings', [CompanySettingsController::class, 'update']);
Route::get('/settings', [SettingsController::class, 'show']);
Route::post('/settings', [SettingsController::class, 'update']);
Route::get('/company/has-transactions', [CompanySettingsController::class, 'checkTransactions']);
// Mails
// ----------------------------------
Route::get('/mail/drivers', [MailConfigurationController::class, 'getMailDrivers']);
Route::get('/mail/config', [MailConfigurationController::class, 'getMailEnvironment']);
Route::post('/mail/config', [MailConfigurationController::class, 'saveMailEnvironment']);
Route::post('/mail/test', [MailConfigurationController::class, 'testEmailConfig']);
Route::get('/company/mail/config', [CompanyMailConfigurationController::class, 'getDefaultConfig']);
Route::get('/company/mail/company-config', [CompanyMailConfigurationController::class, 'getMailConfig']);
Route::post('/company/mail/company-config', [CompanyMailConfigurationController::class, 'saveMailConfig']);
Route::post('/company/mail/company-test', [CompanyMailConfigurationController::class, 'testMailConfig']);
// PDF Generation
// ----------------------------------
Route::get('/pdf/drivers', [PDFConfigurationController::class, 'getDrivers']);
Route::get('/pdf/config', [PDFConfigurationController::class, 'getEnvironment']);
Route::post('/pdf/config', [PDFConfigurationController::class, 'saveEnvironment']);
Route::apiResource('notes', NotesController::class);
// Tax Types
// ----------------------------------
Route::apiResource('tax-types', TaxTypesController::class);
// Roles
// ----------------------------------
Route::get('abilities', AbilitiesController::class);
Route::apiResource('roles', RolesController::class);
});
// Self Update
// ----------------------------------
Route::get('/check/update', [UpdateController::class, 'checkVersion']);
Route::post('/update/download', [UpdateController::class, 'download']);
Route::post('/update/unzip', [UpdateController::class, 'unzip']);
Route::post('/update/copy', [UpdateController::class, 'copy']);
Route::post('/update/delete', [UpdateController::class, 'delete']);
Route::post('/update/clean', [UpdateController::class, 'clean']);
Route::post('/update/migrate', [UpdateController::class, 'migrate']);
Route::post('/update/finish', [UpdateController::class, 'finish']);
// Companies
// -------------------------------------------------
Route::post('companies', [CompaniesController::class, 'store']);
Route::post('/transfer/ownership/{user}', [CompanySettingsController::class, 'transferOwnership']);
Route::post('companies/delete', [CompaniesController::class, 'destroy']);
Route::get('companies', [CompaniesController::class, 'userCompanies']);
// Users
// ----------------------------------
Route::post('/members/delete', [MembersController::class, 'delete']);
Route::apiResource('/members', MembersController::class);
// Modules
// ----------------------------------
Route::prefix('/modules')->group(function () {
Route::get('/', [ModulesController::class, 'index']);
Route::get('/check', [ModulesController::class, 'checkToken']);
Route::get('/{module}', [ModulesController::class, 'show']);
Route::post('/{module}/enable', [ModulesController::class, 'enable']);
Route::post('/{module}/disable', [ModulesController::class, 'disable']);
Route::post('/download', [ModuleInstallationController::class, 'download']);
Route::post('/upload', [ModuleInstallationController::class, 'upload']);
Route::post('/unzip', [ModuleInstallationController::class, 'unzip']);
Route::post('/copy', [ModuleInstallationController::class, 'copy']);
Route::post('/complete', [ModuleInstallationController::class, 'complete']);
// Per-slug settings (schema-driven, per-company storage)
Route::get('/{slug}/settings', [ModuleSettingsController::class, 'show']);
Route::put('/{slug}/settings', [ModuleSettingsController::class, 'update']);
});
// Company-context Active Modules index (read-only, lists every
// instance-activated module with a has_settings flag)
Route::get('/company-modules', [CompanyModulesController::class, 'index']);
});
Route::prefix('/{company:slug}/customer')->group(function () {
// Authentication & Password Reset
// ----------------------------------
Route::prefix('auth')->group(function () {
// Send reset password mail
Route::post('password/email', [AuthForgotPasswordController::class, 'sendResetLinkEmail']);
// handle reset password form process
Route::post('reset/password', [AuthResetPasswordController::class, 'reset'])->name('customer.password.reset');
});
// Invoices, Estimates, Payments and Expenses endpoints
// -------------------------------------------------------
Route::middleware(['auth:customer', 'customer-portal'])->group(function () {
Route::get('/bootstrap', CustomerBootstrapController::class);
Route::get('/dashboard', CustomerDashboardController::class);
Route::get('invoices', [CustomerInvoicesController::class, 'index']);
Route::get('invoices/{id}', [CustomerInvoicesController::class, 'show']);
Route::post('/estimate/{estimate}/status', CustomerAcceptEstimateController::class);
Route::get('estimates', [CustomerEstimatesController::class, 'index']);
Route::get('estimates/{id}', [CustomerEstimatesController::class, 'show']);
Route::get('payments', [CustomerPaymentsController::class, 'index']);
Route::get('payments/{id}', [CustomerPaymentsController::class, 'show']);
Route::get('/payment-method', PaymentMethodController::class);
Route::get('expenses', [CustomerExpensesController::class, 'index']);
Route::get('expenses/{id}', [CustomerExpensesController::class, 'show']);
Route::post('/profile', [CustomerProfileController::class, 'updateProfile']);
Route::get('/me', [CustomerProfileController::class, 'getUser']);
Route::get('/countries', CountriesController::class);
});
});
});
Route::get('/cron', CronJobController::class)->middleware('cron-job');