From e6eeacb6d4e7d6cefbe853464238c758a0ca2263 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Thu, 9 Apr 2026 00:29:36 +0200 Subject: [PATCH] feat(modules): company-context module surfaces and schema-driven settings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../Modules/CompanyModulesController.php | 52 ++++ .../Modules/ModuleSettingsController.php | 151 ++++++++++++ lang/en.json | 17 +- .../api/services/companyModules.service.ts | 13 + .../api/services/moduleSettings.service.ts | 36 +++ .../components/base/BaseSchemaForm.vue | 233 ++++++++++++++++++ .../modules/components/CompanyModuleCard.vue | 61 +++++ .../scripts/features/company/modules/index.ts | 15 +- .../features/company/modules/routes.ts | 26 +- .../scripts/features/company/modules/store.ts | 184 ++------------ .../modules/views/CompanyModulesIndexView.vue | 65 +++++ .../modules/views/ModuleSettingsView.vue | 100 ++++++++ routes/api.php | 10 + 13 files changed, 781 insertions(+), 182 deletions(-) create mode 100644 app/Http/Controllers/Company/Modules/CompanyModulesController.php create mode 100644 app/Http/Controllers/Company/Modules/ModuleSettingsController.php create mode 100644 resources/scripts/api/services/companyModules.service.ts create mode 100644 resources/scripts/api/services/moduleSettings.service.ts create mode 100644 resources/scripts/components/base/BaseSchemaForm.vue create mode 100644 resources/scripts/features/company/modules/components/CompanyModuleCard.vue create mode 100644 resources/scripts/features/company/modules/views/CompanyModulesIndexView.vue create mode 100644 resources/scripts/features/company/modules/views/ModuleSettingsView.vue diff --git a/app/Http/Controllers/Company/Modules/CompanyModulesController.php b/app/Http/Controllers/Company/Modules/CompanyModulesController.php new file mode 100644 index 00000000..48dedae1 --- /dev/null +++ b/app/Http/Controllers/Company/Modules/CompanyModulesController.php @@ -0,0 +1,52 @@ +authorize('manage modules'); + + $modules = Module::query() + ->where('enabled', true) + ->get() + ->map(function (Module $module) { + $slug = Str::kebab($module->name); + + return [ + 'slug' => $slug, + 'name' => $module->name, + 'version' => $module->version, + 'has_settings' => ModuleRegistry::settingsFor($slug) !== null, + 'menu' => ModuleRegistry::menuFor($slug), + ]; + }) + ->values(); + + return response()->json(['data' => $modules]); + } +} diff --git a/app/Http/Controllers/Company/Modules/ModuleSettingsController.php b/app/Http/Controllers/Company/Modules/ModuleSettingsController.php new file mode 100644 index 00000000..d3a6612b --- /dev/null +++ b/app/Http/Controllers/Company/Modules/ModuleSettingsController.php @@ -0,0 +1,151 @@ +authorize('manage modules'); + + $schema = ModuleRegistry::settingsFor($slug); + + if ($schema === null) { + abort(404, "Module '{$slug}' has not registered a settings schema."); + } + + $values = collect($schema->fields()) + ->mapWithKeys(fn (array $field) => [ + $field['key'] => CompanySetting::getSetting( + "module.{$slug}.{$field['key']}", + $request->header('company') + ) ?? $field['default'], + ]) + ->all(); + + return response()->json([ + 'schema' => $schema->toArray(), + 'values' => $values, + ]); + } + + public function update(Request $request, string $slug): JsonResponse + { + $this->authorize('manage modules'); + + $schema = ModuleRegistry::settingsFor($slug); + + if ($schema === null) { + abort(404, "Module '{$slug}' has not registered a settings schema."); + } + + $rules = $this->buildRules($schema); + $allowedKeys = array_keys($rules); + + $validated = $request->validate($rules); + + $companyId = $request->header('company'); + + // Only persist keys the schema knows about — silently drop unknown keys + // rather than letting modules write arbitrary settings. + $settingsToWrite = []; + foreach ($allowedKeys as $key) { + if (array_key_exists($key, $validated)) { + $settingsToWrite["module.{$slug}.{$key}"] = $this->normalizeForStorage($validated[$key]); + } + } + + if ($settingsToWrite !== []) { + CompanySetting::setSettings($settingsToWrite, $companyId); + } + + return response()->json(['success' => true]); + } + + /** + * Convert a Schema's field rule arrays into a flat Laravel validator rules array. + * + * Field rules are passed through verbatim — a field declared as + * `'rules' => ['required', 'string', 'max:255']` becomes + * `['my_field' => ['required', 'string', 'max:255']]`. The frontend's + * BaseSchemaForm.vue understands a subset of these for client-side validation; + * the backend validator is the source of truth. + * + * @return array> + */ + private function buildRules(Schema $schema): array + { + $rules = []; + + foreach ($schema->fields() as $field) { + $rules[$field['key']] = $this->withTypeRule($field); + } + + return $rules; + } + + /** + * Prepend a sensible per-type validation rule so booleans must be booleans, + * numbers must be numeric, etc., even if the module didn't declare it. + * + * @param array $field + * @return array + */ + private function withTypeRule(array $field): array + { + /** @var array $declared */ + $declared = $field['rules'] ?? []; + + $typeRule = match ($field['type']) { + 'switch' => 'boolean', + 'number' => 'numeric', + 'multiselect' => 'array', + default => 'nullable', + }; + + // Avoid duplicating the type rule if the module already declared it + if (in_array($typeRule, $declared, true)) { + return $declared; + } + + return array_merge([$typeRule], $declared); + } + + /** + * CompanySetting stores everything as strings. Cast booleans, ints, and + * arrays to a representation that round-trips through getSetting/setSetting + * without losing information. Reads happen in show() above and naturally + * return strings; the frontend handles re-coercion in BaseSchemaForm.vue. + */ + private function normalizeForStorage(mixed $value): string + { + if (is_bool($value)) { + return $value ? '1' : '0'; + } + + if (is_array($value)) { + return json_encode($value, JSON_UNESCAPED_SLASHES) ?: '[]'; + } + + return (string) ($value ?? ''); + } +} diff --git a/lang/en.json b/lang/en.json index d645c39d..cc37329d 100644 --- a/lang/en.json +++ b/lang/en.json @@ -773,7 +773,22 @@ "no_modules_installed": "No Modules Installed Yet!", "disable_warning": "All the settings for this particular will be reverted.", "what_you_get": "What you get", - "sign_up_and_get_token": "Sign up & Get Token" + "sign_up_and_get_token": "Sign up & Get Token", + "index": { + "description": "Modules activated by your administrator on this instance. Each company configures its own settings independently.", + "empty_title": "No active modules", + "empty_description": "Your administrator hasn't activated any modules on this instance yet. Once they do, modules will appear here with a settings shortcut." + }, + "settings": { + "title": "Module Settings", + "open": "Settings", + "saved": "Module settings saved successfully.", + "not_found": "This module has not registered a settings schema.", + "none": "No settings" + }, + "sidebar": { + "section_title": "Modules" + } }, "members": { "title": "Members", diff --git a/resources/scripts/api/services/companyModules.service.ts b/resources/scripts/api/services/companyModules.service.ts new file mode 100644 index 00000000..5cae7b94 --- /dev/null +++ b/resources/scripts/api/services/companyModules.service.ts @@ -0,0 +1,13 @@ +import { client } from '../client' +import type { CompanyModuleSummary } from '@/scripts/features/company/modules/store' + +export interface CompanyModulesListResponse { + data: CompanyModuleSummary[] +} + +export const companyModulesService = { + async list(): Promise { + const { data } = await client.get('/api/v1/company-modules') + return data + }, +} diff --git a/resources/scripts/api/services/moduleSettings.service.ts b/resources/scripts/api/services/moduleSettings.service.ts new file mode 100644 index 00000000..738b24ba --- /dev/null +++ b/resources/scripts/api/services/moduleSettings.service.ts @@ -0,0 +1,36 @@ +import { client } from '../client' + +export interface ModuleSettingsField { + key: string + type: 'text' | 'password' | 'textarea' | 'switch' | 'number' | 'select' | 'multiselect' + label: string + rules: string[] + default: unknown + options?: Record +} + +export interface ModuleSettingsSection { + title: string + fields: ModuleSettingsField[] +} + +export interface ModuleSettingsSchema { + sections: ModuleSettingsSection[] +} + +export interface ModuleSettingsResponse { + schema: ModuleSettingsSchema + values: Record +} + +export const moduleSettingsService = { + async fetch(slug: string): Promise { + const { data } = await client.get(`/api/v1/modules/${slug}/settings`) + return data + }, + + async update(slug: string, values: Record): Promise<{ success: boolean }> { + const { data } = await client.put(`/api/v1/modules/${slug}/settings`, values) + return data + }, +} diff --git a/resources/scripts/components/base/BaseSchemaForm.vue b/resources/scripts/components/base/BaseSchemaForm.vue new file mode 100644 index 00000000..e408f5a7 --- /dev/null +++ b/resources/scripts/components/base/BaseSchemaForm.vue @@ -0,0 +1,233 @@ + + + diff --git a/resources/scripts/features/company/modules/components/CompanyModuleCard.vue b/resources/scripts/features/company/modules/components/CompanyModuleCard.vue new file mode 100644 index 00000000..9364d6ad --- /dev/null +++ b/resources/scripts/features/company/modules/components/CompanyModuleCard.vue @@ -0,0 +1,61 @@ + + + diff --git a/resources/scripts/features/company/modules/index.ts b/resources/scripts/features/company/modules/index.ts index 374efe5a..fbeb6ab1 100644 --- a/resources/scripts/features/company/modules/index.ts +++ b/resources/scripts/features/company/modules/index.ts @@ -1,17 +1,14 @@ export { moduleRoutes } from './routes' -export { useModuleStore } from './store' +export { useCompanyModulesStore } from './store' export type { - ModuleState, - ModuleStore, - ModuleDetailResponse, - ModuleDetailMeta, - InstallationStep, + CompanyModuleSummary, + CompanyModulesState, } from './store' // Views -export { default as ModuleIndexView } from './views/ModuleIndexView.vue' -export { default as ModuleDetailView } from './views/ModuleDetailView.vue' +export { default as CompanyModulesIndexView } from './views/CompanyModulesIndexView.vue' +export { default as ModuleSettingsView } from './views/ModuleSettingsView.vue' // Components -export { default as ModuleCard } from './components/ModuleCard.vue' +export { default as CompanyModuleCard } from './components/CompanyModuleCard.vue' diff --git a/resources/scripts/features/company/modules/routes.ts b/resources/scripts/features/company/modules/routes.ts index 52df3e43..e7a6ae24 100644 --- a/resources/scripts/features/company/modules/routes.ts +++ b/resources/scripts/features/company/modules/routes.ts @@ -1,13 +1,25 @@ import type { RouteRecordRaw } from 'vue-router' -const ModuleIndexView = () => import('./views/ModuleIndexView.vue') -const ModuleDetailView = () => import('./views/ModuleDetailView.vue') +const CompanyModulesIndexView = () => import('./views/CompanyModulesIndexView.vue') +const ModuleSettingsView = () => import('./views/ModuleSettingsView.vue') +/** + * Company-context module routes. + * + * - `/admin/modules` — read-only Active Modules index, lists every module the + * super admin has activated on this instance with a "Settings" link. + * - `/admin/modules/:slug/settings` — schema-rendered settings form for a + * specific active module, backed by the InvoiceShelf\Modules\Registry::settingsFor() + * schema and CompanySetting persistence (per-company values). + * + * The marketplace browser (install/uninstall/activate) lives in the super-admin + * context at `/admin/administration/modules`, see features/admin/modules/routes.ts. + */ export const moduleRoutes: RouteRecordRaw[] = [ { path: 'modules', name: 'modules.index', - component: ModuleIndexView, + component: CompanyModulesIndexView, meta: { requiresAuth: true, ability: 'manage-module', @@ -15,13 +27,13 @@ export const moduleRoutes: RouteRecordRaw[] = [ }, }, { - path: 'modules/:slug', - name: 'modules.view', - component: ModuleDetailView, + path: 'modules/:slug/settings', + name: 'modules.settings', + component: ModuleSettingsView, meta: { requiresAuth: true, ability: 'manage-module', - title: 'modules.title', + title: 'modules.settings.title', }, }, ] diff --git a/resources/scripts/features/company/modules/store.ts b/resources/scripts/features/company/modules/store.ts index 1c8e152e..0cdbe730 100644 --- a/resources/scripts/features/company/modules/store.ts +++ b/resources/scripts/features/company/modules/store.ts @@ -1,180 +1,34 @@ import { defineStore } from 'pinia' -import { moduleService } from '../../../api/services/module.service' -import type { - Module, - ModuleReview, - ModuleFaq, - ModuleLink, - ModuleScreenshot, -} from '../../../types/domain/module' +import { companyModulesService } from '@/scripts/api/services/companyModules.service' -// ---------------------------------------------------------------- -// Types -// ---------------------------------------------------------------- - -export interface ModuleDetailMeta { - modules: Module[] +export interface CompanyModuleSummary { + slug: string + name: string + version: string + has_settings: boolean + menu: { title: string, link: string, icon: string } | null } -export interface ModuleDetailResponse { - data: Module - meta: ModuleDetailMeta +export interface CompanyModulesState { + modules: CompanyModuleSummary[] + isFetching: boolean } -export interface InstallationStep { - translationKey: string - stepUrl: string - time: string | null - started: boolean - completed: boolean -} - -// ---------------------------------------------------------------- -// Store -// ---------------------------------------------------------------- - -export interface ModuleState { - currentModule: ModuleDetailResponse | null - modules: Module[] - apiToken: string | null - currentUser: { - api_token: string | null - } - enableModules: string[] -} - -export const useModuleStore = defineStore('modules', { - state: (): ModuleState => ({ - currentModule: null, +export const useCompanyModulesStore = defineStore('company-modules', { + state: (): CompanyModulesState => ({ modules: [], - apiToken: null, - currentUser: { - api_token: null, - }, - enableModules: [], + isFetching: false, }), - getters: { - salesTaxUSEnabled: (state): boolean => - state.enableModules.includes('SalesTaxUS'), - - installedModules: (state): Module[] => - state.modules.filter((m) => m.installed), - }, - actions: { async fetchModules(): Promise { - const response = await moduleService.list() - this.modules = response.data - }, - - async fetchModule(slug: string): Promise { - const response = await moduleService.get(slug) - const data = response as unknown as ModuleDetailResponse - - if ((data as Record).error === 'invalid_token') { - this.currentModule = null - this.modules = [] - this.apiToken = null - this.currentUser.api_token = null - return data + this.isFetching = true + try { + const response = await companyModulesService.list() + this.modules = response.data + } finally { + this.isFetching = false } - - this.currentModule = data - return data - }, - - async checkApiToken(token: string): Promise<{ success: boolean; error?: string }> { - const response = await moduleService.checkToken(token) - return { - success: response.success ?? false, - error: response.error, - } - }, - - async disableModule(moduleName: string): Promise<{ success: boolean }> { - return moduleService.disable(moduleName) - }, - - async enableModule(moduleName: string): Promise<{ success: boolean }> { - return moduleService.enable(moduleName) - }, - - async installModule( - moduleName: string, - version: string, - onStepUpdate?: (step: InstallationStep) => void, - ): Promise { - const steps: InstallationStep[] = [ - { - translationKey: 'modules.download_zip_file', - stepUrl: '/api/v1/modules/download', - time: null, - started: false, - completed: false, - }, - { - translationKey: 'modules.unzipping_package', - stepUrl: '/api/v1/modules/unzip', - time: null, - started: false, - completed: false, - }, - { - translationKey: 'modules.copying_files', - stepUrl: '/api/v1/modules/copy', - time: null, - started: false, - completed: false, - }, - { - translationKey: 'modules.completing_installation', - stepUrl: '/api/v1/modules/complete', - time: null, - started: false, - completed: false, - }, - ] - - let path: string | null = null - - for (const step of steps) { - step.started = true - onStepUpdate?.(step) - - try { - const stepFns: Record Promise>> = { - '/api/v1/modules/download': () => - moduleService.download({ module: moduleName, version, path: path ?? undefined } as never) as Promise>, - '/api/v1/modules/unzip': () => - moduleService.unzip({ module: moduleName, version, path: path ?? undefined } as never) as Promise>, - '/api/v1/modules/copy': () => - moduleService.copy({ module: moduleName, version, path: path ?? undefined } as never) as Promise>, - '/api/v1/modules/complete': () => - moduleService.complete({ module: moduleName, version, path: path ?? undefined } as never) as Promise>, - } - - const result = await stepFns[step.stepUrl]() - step.completed = true - onStepUpdate?.(step) - - if ((result as Record).path) { - path = (result as Record).path as string - } - - if (!(result as Record).success) { - return false - } - } catch { - step.completed = true - onStepUpdate?.(step) - return false - } - } - - return true }, }, }) - -export type ModuleStore = ReturnType diff --git a/resources/scripts/features/company/modules/views/CompanyModulesIndexView.vue b/resources/scripts/features/company/modules/views/CompanyModulesIndexView.vue new file mode 100644 index 00000000..f593951f --- /dev/null +++ b/resources/scripts/features/company/modules/views/CompanyModulesIndexView.vue @@ -0,0 +1,65 @@ + + + diff --git a/resources/scripts/features/company/modules/views/ModuleSettingsView.vue b/resources/scripts/features/company/modules/views/ModuleSettingsView.vue new file mode 100644 index 00000000..c76d9196 --- /dev/null +++ b/resources/scripts/features/company/modules/views/ModuleSettingsView.vue @@ -0,0 +1,100 @@ + + + diff --git a/routes/api.php b/routes/api.php index 481f0100..3c4eaf9b 100644 --- a/routes/api.php +++ b/routes/api.php @@ -40,6 +40,8 @@ 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; @@ -482,7 +484,15 @@ Route::prefix('/v1')->group(function () { 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 () {