From cec5796acfb55845f88eff2f791a8c5a448c0f20 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Simon=20Pocrnji=C4=8D?= Date: Mon, 6 Oct 2025 21:46:28 +0200 Subject: [PATCH] Added the support for generating docs from template doc --- app/Events/DocumentGenerated.php | 29 + app/Events/DocumentSettingsUpdated.php | 15 + .../Admin/DocumentSettingsController.php | 56 ++ .../Admin/DocumentTemplateController.php | 64 ++- .../Admin/PermissionController.php | 36 ++ .../Controllers/Admin/UserRoleController.php | 45 ++ .../ContractDocumentGenerationController.php | 117 ++++ app/Http/Controllers/DashboardController.php | 189 +++++++ app/Http/Middleware/EnsurePermission.php | 26 + app/Http/Middleware/EnsureRole.php | 20 + app/Http/Middleware/HandleInertiaRequests.php | 21 +- .../Requests/StoreDocumentTemplateRequest.php | 40 ++ app/Http/Requests/StorePermissionRequest.php | 31 ++ .../UpdateDocumentTemplateRequest.php | 38 ++ app/Listeners/LogDocumentGenerated.php | 41 ++ app/Models/Document.php | 2 + app/Models/DocumentSetting.php | 35 ++ app/Models/DocumentTemplate.php | 48 ++ app/Models/Permission.php | 25 + app/Models/Role.php | 32 ++ app/Models/User.php | 33 ++ app/Policies/ArchiveSettingPolicy.php | 15 +- app/Providers/AuthServiceProvider.php | 46 ++ app/Providers/EventServiceProvider.php | 16 + app/Services/Documents/DocumentSettings.php | 28 + .../Exceptions/UnresolvedTokensException.php | 20 + app/Services/Documents/TokenScanner.php | 21 + bootstrap/app.php | 5 +- bootstrap/providers.php | 1 + config/documents.php | 26 + database/factories/ActionFactory.php | 3 +- ...00_create_roles_and_permissions_tables.php | 51 ++ ...5_010000_update_pivot_tables_remove_id.php | 151 ++++++ ...120000_create_document_templates_table.php | 55 ++ ...add_tokens_to_document_templates_table.php | 22 + ...gs_columns_to_document_templates_table.php | 24 + ..._create_document_generation_logs_table.php | 26 + ...ng_options_to_document_templates_table.php | 22 + ..._160000_create_document_settings_table.php | 26 + ...ate_formats_to_document_settings_table.php | 22 + ...ity_fields_to_document_templates_table.php | 48 ++ database/seeders/RolePermissionSeeder.php | 56 ++ .../examples/contract_summary_template.docx | Bin 0 -> 12386 bytes resources/examples/create_test_template.php | 81 +++ resources/js/Layouts/AdminLayout.vue | 271 ++++++++++ resources/js/Layouts/AppLayout.vue | 38 +- .../js/Pages/Admin/DocumentSettings/Edit.vue | 161 ++++++ .../js/Pages/Admin/DocumentSettings/Index.vue | 32 ++ .../js/Pages/Admin/DocumentTemplates/Edit.vue | 333 ++++++++++++ .../Pages/Admin/DocumentTemplates/Index.vue | 442 ++++++++-------- .../js/Pages/Admin/DocumentTemplates/Show.vue | 237 +++++++++ resources/js/Pages/Admin/Index.vue | 76 +++ .../js/Pages/Admin/Permissions/Create.vue | 65 +++ .../js/Pages/Admin/Permissions/Index.vue | 77 +++ resources/js/Pages/Admin/Users/Index.vue | 269 ++++++++++ .../js/Pages/Cases/Partials/ContractTable.vue | 75 +++ resources/js/Pages/Dashboard.vue | 498 ++++++++++++++---- routes/api.php | 13 + routes/web.php | 62 +-- tests/Feature/Admin/CreatePermissionTest.php | 50 ++ tests/Feature/Admin/DocumentTemplatesTest.php | 60 +++ tests/Feature/AdminUserRoleTest.php | 32 ++ tests/Feature/DashboardTest.php | 43 ++ tests/Feature/DocumentGenerationTest.php | 83 +++ .../Feature/DocumentSettingsOverrideTest.php | 57 ++ .../Feature/DocumentTemplateActivityTest.php | 78 +++ .../DocumentTemplateEnhancementsTest.php | 118 +++++ tests/Feature/PivotIntegrityTest.php | 65 +++ tests/TestCase.php | 1 + 69 files changed, 4570 insertions(+), 374 deletions(-) create mode 100644 app/Events/DocumentGenerated.php create mode 100644 app/Events/DocumentSettingsUpdated.php create mode 100644 app/Http/Controllers/Admin/DocumentSettingsController.php create mode 100644 app/Http/Controllers/Admin/PermissionController.php create mode 100644 app/Http/Controllers/Admin/UserRoleController.php create mode 100644 app/Http/Controllers/ContractDocumentGenerationController.php create mode 100644 app/Http/Controllers/DashboardController.php create mode 100644 app/Http/Middleware/EnsurePermission.php create mode 100644 app/Http/Middleware/EnsureRole.php create mode 100644 app/Http/Requests/StoreDocumentTemplateRequest.php create mode 100644 app/Http/Requests/StorePermissionRequest.php create mode 100644 app/Http/Requests/UpdateDocumentTemplateRequest.php create mode 100644 app/Listeners/LogDocumentGenerated.php create mode 100644 app/Models/DocumentSetting.php create mode 100644 app/Models/DocumentTemplate.php create mode 100644 app/Models/Permission.php create mode 100644 app/Models/Role.php create mode 100644 app/Providers/AuthServiceProvider.php create mode 100644 app/Providers/EventServiceProvider.php create mode 100644 app/Services/Documents/DocumentSettings.php create mode 100644 app/Services/Documents/Exceptions/UnresolvedTokensException.php create mode 100644 app/Services/Documents/TokenScanner.php create mode 100644 config/documents.php create mode 100644 database/migrations/2025_10_05_000000_create_roles_and_permissions_tables.php create mode 100644 database/migrations/2025_10_05_010000_update_pivot_tables_remove_id.php create mode 100644 database/migrations/2025_10_06_120000_create_document_templates_table.php create mode 100644 database/migrations/2025_10_06_130500_add_tokens_to_document_templates_table.php create mode 100644 database/migrations/2025_10_06_140000_add_settings_columns_to_document_templates_table.php create mode 100644 database/migrations/2025_10_06_141000_create_document_generation_logs_table.php create mode 100644 database/migrations/2025_10_06_150000_add_formatting_options_to_document_templates_table.php create mode 100644 database/migrations/2025_10_06_160000_create_document_settings_table.php create mode 100644 database/migrations/2025_10_06_170000_add_date_formats_to_document_settings_table.php create mode 100644 database/migrations/2025_10_06_190000_add_meta_and_activity_fields_to_document_templates_table.php create mode 100644 database/seeders/RolePermissionSeeder.php create mode 100644 resources/examples/contract_summary_template.docx create mode 100644 resources/examples/create_test_template.php create mode 100644 resources/js/Layouts/AdminLayout.vue create mode 100644 resources/js/Pages/Admin/DocumentSettings/Edit.vue create mode 100644 resources/js/Pages/Admin/DocumentSettings/Index.vue create mode 100644 resources/js/Pages/Admin/DocumentTemplates/Edit.vue create mode 100644 resources/js/Pages/Admin/DocumentTemplates/Show.vue create mode 100644 resources/js/Pages/Admin/Index.vue create mode 100644 resources/js/Pages/Admin/Permissions/Create.vue create mode 100644 resources/js/Pages/Admin/Permissions/Index.vue create mode 100644 resources/js/Pages/Admin/Users/Index.vue create mode 100644 tests/Feature/Admin/CreatePermissionTest.php create mode 100644 tests/Feature/Admin/DocumentTemplatesTest.php create mode 100644 tests/Feature/AdminUserRoleTest.php create mode 100644 tests/Feature/DashboardTest.php create mode 100644 tests/Feature/DocumentGenerationTest.php create mode 100644 tests/Feature/DocumentSettingsOverrideTest.php create mode 100644 tests/Feature/DocumentTemplateActivityTest.php create mode 100644 tests/Feature/DocumentTemplateEnhancementsTest.php create mode 100644 tests/Feature/PivotIntegrityTest.php diff --git a/app/Events/DocumentGenerated.php b/app/Events/DocumentGenerated.php new file mode 100644 index 0000000..9bfba62 --- /dev/null +++ b/app/Events/DocumentGenerated.php @@ -0,0 +1,29 @@ +get(); + if ($settings->preview_enabled) { + try { + dispatch(new \App\Jobs\GenerateDocumentPreview($document->id)); + } catch (\Throwable $e) { + \Log::warning('Failed to dispatch preview job on event', [ + 'document_id' => $document->id, + 'error' => $e->getMessage(), + ]); + } + } + } +} diff --git a/app/Events/DocumentSettingsUpdated.php b/app/Events/DocumentSettingsUpdated.php new file mode 100644 index 0000000..10d4bc4 --- /dev/null +++ b/app/Events/DocumentSettingsUpdated.php @@ -0,0 +1,15 @@ +authorizeAccess(); + $settings = $svc->get(); + + return Inertia::render('Admin/DocumentSettings/Edit', [ + 'settings' => $settings, + 'defaults' => [ + 'file_name_pattern' => config('documents.file_name_pattern'), + 'date_format' => config('documents.date_format'), + 'unresolved_policy' => config('documents.unresolved_policy'), + ], + ]); + } + + public function update(Request $request, SettingsService $svc) + { + $this->authorizeAccess(); + $data = $request->validate([ + 'file_name_pattern' => ['required', 'string', 'max:255'], + 'date_format' => ['required', 'string', 'max:40'], + 'unresolved_policy' => ['required', 'in:fail,blank,keep'], + 'preview_enabled' => ['required', 'boolean'], + 'whitelist' => ['nullable', 'array'], + 'whitelist.*' => ['array'], + 'date_formats' => ['nullable', 'array'], + 'date_formats.*' => ['string'], + ]); + $settings = $svc->get(); + $settings->fill($data)->save(); + $svc->refresh(); + event(new DocumentSettingsUpdated($settings)); + + return redirect()->back()->with('success', 'Nastavitve shranjene.'); + } + + private function authorizeAccess(): void + { + if (Gate::denies('manage-settings') && Gate::denies('manage-document-templates')) { + abort(403); + } + } +} diff --git a/app/Http/Controllers/Admin/DocumentTemplateController.php b/app/Http/Controllers/Admin/DocumentTemplateController.php index b9dca16..f1adfb7 100644 --- a/app/Http/Controllers/Admin/DocumentTemplateController.php +++ b/app/Http/Controllers/Admin/DocumentTemplateController.php @@ -5,6 +5,7 @@ use App\Http\Controllers\Controller; use App\Http\Requests\StoreDocumentTemplateRequest; use App\Http\Requests\UpdateDocumentTemplateRequest; +use App\Models\Action; use App\Models\DocumentTemplate; use App\Services\Documents\TokenScanner; use Illuminate\Support\Facades\Auth; @@ -19,9 +20,16 @@ public function index() { $this->ensurePermission(); $templates = DocumentTemplate::query()->orderByDesc('updated_at')->get(); + $actions = Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']); + $actionsMapped = $actions->map(fn ($a) => [ + 'id' => $a->id, + 'name' => $a->name, + 'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(), + ]); return Inertia::render('Admin/DocumentTemplates/Index', [ 'templates' => $templates, + 'actions' => $actionsMapped, ]); } @@ -35,10 +43,51 @@ public function toggleActive(DocumentTemplate $template) return redirect()->back()->with('success', 'Status predloge posodobljen.'); } + public function show(DocumentTemplate $template) + { + $this->ensurePermission(); + return Inertia::render('Admin/DocumentTemplates/Show', [ + 'template' => $template, + ]); + } + + public function edit(DocumentTemplate $template) + { + $this->ensurePermission(); + $actions = Action::with(['decisions:id,name'])->orderBy('name')->get(['id', 'name']); + $actionsMapped = $actions->map(fn ($a) => [ + 'id' => $a->id, + 'name' => $a->name, + 'decisions' => $a->decisions->map(fn ($d) => ['id' => $d->id, 'name' => $d->name])->values(), + ]); + + return Inertia::render('Admin/DocumentTemplates/Edit', [ + 'template' => $template, + 'actions' => $actionsMapped, + ]); + } + public function updateSettings(UpdateDocumentTemplateRequest $request, DocumentTemplate $template) { $this->ensurePermission(); - $template->fill($request->only(['output_filename_pattern', 'date_format'])); + $template->fill($request->only([ + 'output_filename_pattern', 'date_format', 'action_id', 'decision_id', 'activity_note_template', + ])); + // If both action & decision provided, ensure decision belongs to action (parity with import templates) + if ($request->filled('action_id') && $request->filled('decision_id')) { + $belongs = \DB::table('action_decision') + ->where('action_id', $request->integer('action_id')) + ->where('decision_id', $request->integer('decision_id')) + ->exists(); + if (! $belongs) { + return redirect()->back()->withErrors(['decision_id' => 'Izbrana odločitev ne pripada izbrani akciji.']); + } + } elseif ($request->filled('action_id') && ! $request->filled('decision_id')) { + // Allow clearing decision when action changes + if ($template->isDirty('action_id')) { + $template->decision_id = null; + } + } if ($request->has('fail_on_unresolved')) { $template->fail_on_unresolved = (bool) $request->boolean('fail_on_unresolved'); } @@ -153,6 +202,19 @@ public function store(StoreDocumentTemplateRequest $request) 'currency_space' => true, ], ]; + // Optional meta + activity linkage fields (parity with import templates style) + if ($request->filled('meta') && is_array($request->input('meta'))) { + $payload['meta'] = array_filter($request->input('meta'), fn ($v) => $v !== null && $v !== ''); + } + if ($request->filled('action_id')) { + $payload['action_id'] = $request->integer('action_id'); + } + if ($request->filled('decision_id')) { + $payload['decision_id'] = $request->integer('decision_id'); + } + if ($request->filled('activity_note_template')) { + $payload['activity_note_template'] = $request->input('activity_note_template'); + } if (Schema::hasColumn('document_templates', 'tokens')) { $payload['tokens'] = $tokens; } diff --git a/app/Http/Controllers/Admin/PermissionController.php b/app/Http/Controllers/Admin/PermissionController.php new file mode 100644 index 0000000..2daf5de --- /dev/null +++ b/app/Http/Controllers/Admin/PermissionController.php @@ -0,0 +1,36 @@ +select('id','name','slug','description','created_at') + ->orderBy('name') + ->get(); + + return Inertia::render('Admin/Permissions/Index', [ + 'permissions' => $permissions, + ]); + } + public function create(): Response + { + return Inertia::render('Admin/Permissions/Create'); + } + + public function store(StorePermissionRequest $request): RedirectResponse + { + Permission::create($request->validated()); + + return redirect()->route('admin.index')->with('success', 'Dovoljenje ustvarjeno.'); + } +} diff --git a/app/Http/Controllers/Admin/UserRoleController.php b/app/Http/Controllers/Admin/UserRoleController.php new file mode 100644 index 0000000..48da74f --- /dev/null +++ b/app/Http/Controllers/Admin/UserRoleController.php @@ -0,0 +1,45 @@ +orderBy('name')->get(['id', 'name', 'email']); + $roles = Role::with('permissions:id,slug,name')->orderBy('name')->get(['id', 'name', 'slug']); + $permissions = Permission::orderBy('slug')->get(['id', 'name', 'slug']); + + return Inertia::render('Admin/Users/Index', [ + 'users' => $users, + 'roles' => $roles, + 'permissions' => $permissions, + ]); + } + + public function update(Request $request, User $user): RedirectResponse + { + Gate::authorize('manage-settings'); + + $validated = $request->validate([ + 'roles' => ['array'], + 'roles.*' => ['integer', 'exists:roles,id'], + ]); + + $user->roles()->sync($validated['roles'] ?? []); + + return back()->with('success', 'Roles updated'); + } +} diff --git a/app/Http/Controllers/ContractDocumentGenerationController.php b/app/Http/Controllers/ContractDocumentGenerationController.php new file mode 100644 index 0000000..e3b8090 --- /dev/null +++ b/app/Http/Controllers/ContractDocumentGenerationController.php @@ -0,0 +1,117 @@ +validate([ + 'template_slug' => ['required', 'string', 'exists:document_templates,slug'], + ]); + + $template = DocumentTemplate::where('slug', $request->template_slug) + ->where('core_entity', 'contract') + ->where('active', true) + ->orderByDesc('version') + ->firstOrFail(); + + // Load related data minimally + $contract->load(['clientCase.client.person', 'clientCase.person', 'clientCase.client']); + + $renderer = app(\App\Services\Documents\DocxTemplateRenderer::class); + try { + $result = $renderer->render($template, $contract, Auth::user()); + } catch (\App\Services\Documents\Exceptions\UnresolvedTokensException $e) { + return response()->json([ + 'status' => 'error', + 'message' => 'Unresolved tokens detected.', + 'tokens' => $e->unresolved ?? [], + ], 422); + } catch (\Throwable $e) { + return response()->json([ + 'status' => 'error', + 'message' => 'Generation failed.', + ], 500); + } + + $doc = new Document; + $doc->fill([ + 'uuid' => (string) Str::uuid(), + 'name' => $result['fileName'], + 'description' => 'Generated from template '.$template->slug.' v'.$template->version, + 'user_id' => Auth::id(), + 'disk' => 'public', + 'path' => $result['relativePath'], + 'file_name' => $result['fileName'], + 'original_name' => $result['fileName'], + 'extension' => 'docx', + 'mime_type' => 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + 'size' => $result['size'], + 'checksum' => $result['checksum'], + 'is_public' => true, + 'template_id' => $template->id, + 'template_version' => $template->version, + ]); + $contract->documents()->save($doc); + + // Dispatch domain event + event(new DocumentGenerated($doc)); + + // Optional: create an activity if template links to action/decision + if (($template->action_id || $template->decision_id || $template->activity_note_template) && $contract->client_case_id) { + try { + $note = null; + if ($template->activity_note_template) { + // Interpolate tokens in note using existing resolver logic (non-failing policy: keep) + /** @var TokenValueResolver $resolver */ + $resolver = app(TokenValueResolver::class); + $rawNote = $template->activity_note_template; + $tokens = []; + if (preg_match_all('/\{([a-zA-Z0-9_\.]+)\}/', $rawNote, $m)) { + $tokens = array_unique($m[1]); + } + $values = []; + if ($tokens) { + $resolved = $resolver->resolve($tokens, $template, $contract, Auth::user(), 'keep'); + foreach ($resolved['values'] as $k => $v) { + $values['{'.$k.'}'] = $v; + } + } + $note = strtr($rawNote, $values); + } + + Activity::create(array_filter([ + 'note' => $note, + 'action_id' => $template->action_id, + 'decision_id' => $template->decision_id, + 'contract_id' => $contract->id, + 'client_case_id' => $contract->client_case_id, + ], fn ($v) => ! is_null($v) && $v !== '')); + } catch (\Throwable $e) { + // swallow activity creation errors to not block document generation + } + } + + return response()->json([ + 'status' => 'ok', + 'document_uuid' => $doc->uuid, + 'path' => $doc->path, + ]); + } +} diff --git a/app/Http/Controllers/DashboardController.php b/app/Http/Controllers/DashboardController.php new file mode 100644 index 0000000..61ee31e --- /dev/null +++ b/app/Http/Controllers/DashboardController.php @@ -0,0 +1,189 @@ +startOfDay(); + $yesterday = now()->subDay()->startOfDay(); + $staleThreshold = now()->subDays(7); // assumption: stale if no activity in last 7 days + + $clientsTotal = Client::count(); + $clientsNew7d = Client::where('created_at', '>=', now()->subDays(7))->count(); + // FieldJob table does not have a scheduled_at column (schema shows: assigned_at, completed_at, cancelled_at) + // Temporary logic: if scheduled_at ever added we'll use it; otherwise fall back to assigned_at then created_at. + if (Schema::hasColumn('field_jobs', 'scheduled_at')) { + $fieldJobsToday = FieldJob::whereDate('scheduled_at', $today)->count(); + } else { + // Prefer assigned_at when present, otherwise created_at + $fieldJobsToday = FieldJob::whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today)->count(); + } + $documentsToday = Document::whereDate('created_at', $today)->count(); + $activeImports = Import::whereIn('status', ['queued', 'processing'])->count(); + $activeContracts = Contract::where('active', 1)->count(); + + // Basic activities deferred list (limit 10) + $activities = Activity::query() + ->with(['clientCase:id,uuid']) + ->latest() + ->limit(10) + ->get(['id','note','created_at','client_case_id','contract_id','action_id','decision_id']) + ->map(fn($a) => [ + 'id' => $a->id, + 'note' => $a->note, + 'created_at' => $a->created_at, + 'client_case_id' => $a->client_case_id, + 'client_case_uuid' => $a->clientCase?->uuid, + 'contract_id' => $a->contract_id, + 'action_id' => $a->action_id, + 'decision_id' => $a->decision_id, + ]); + + // 7-day trends (including today) + $start = now()->subDays(6)->startOfDay(); + $end = now()->endOfDay(); + + $dateKeys = collect(range(0,6)) + ->map(fn($i) => now()->subDays(6 - $i)->format('Y-m-d')); + + $clientTrendRaw = Client::whereBetween('created_at', [$start,$end]) + ->selectRaw('DATE(created_at) as d, COUNT(*) as c') + ->groupBy('d') + ->pluck('c','d'); + $documentTrendRaw = Document::whereBetween('created_at', [$start,$end]) + ->selectRaw('DATE(created_at) as d, COUNT(*) as c') + ->groupBy('d') + ->pluck('c','d'); + $fieldJobTrendRaw = FieldJob::whereBetween(DB::raw('COALESCE(assigned_at, created_at)'), [$start,$end]) + ->selectRaw('DATE(COALESCE(assigned_at, created_at)) as d, COUNT(*) as c') + ->groupBy('d') + ->pluck('c','d'); + $importTrendRaw = Import::whereBetween('created_at', [$start,$end]) + ->selectRaw('DATE(created_at) as d, COUNT(*) as c') + ->groupBy('d') + ->pluck('c','d'); + + // Completed field jobs last 7 days + $fieldJobCompletedRaw = FieldJob::whereNotNull('completed_at') + ->whereBetween('completed_at', [$start, $end]) + ->selectRaw('DATE(completed_at) as d, COUNT(*) as c') + ->groupBy('d') + ->pluck('c','d'); + + $trends = [ + 'clients_new' => $dateKeys->map(fn($d) => (int) ($clientTrendRaw[$d] ?? 0))->values(), + 'documents_new' => $dateKeys->map(fn($d) => (int) ($documentTrendRaw[$d] ?? 0))->values(), + 'field_jobs' => $dateKeys->map(fn($d) => (int) ($fieldJobTrendRaw[$d] ?? 0))->values(), + 'imports_new' => $dateKeys->map(fn($d) => (int) ($importTrendRaw[$d] ?? 0))->values(), + 'field_jobs_completed' => $dateKeys->map(fn($d) => (int) ($fieldJobCompletedRaw[$d] ?? 0))->values(), + 'labels' => $dateKeys, + ]; + + // Stale client cases (no activity in last 7 days) + $staleCases = \App\Models\ClientCase::query() + ->leftJoin('activities', function($join) { + $join->on('activities.client_case_id', '=', 'client_cases.id') + ->whereNull('activities.deleted_at'); + }) + ->selectRaw('client_cases.id, client_cases.uuid, client_cases.client_ref, MAX(activities.created_at) as last_activity_at, client_cases.created_at') + ->groupBy('client_cases.id','client_cases.uuid','client_cases.client_ref','client_cases.created_at') + ->havingRaw('(MAX(activities.created_at) IS NULL OR MAX(activities.created_at) < ?) AND client_cases.created_at < ?', [$staleThreshold, $staleThreshold]) + ->orderByRaw('last_activity_at NULLS FIRST, client_cases.created_at ASC') + ->limit(10) + ->get() + ->map(fn($c) => [ + 'id' => $c->id, + 'uuid' => $c->uuid, + 'client_ref' => $c->client_ref, + 'last_activity_at' => $c->last_activity_at, + 'created_at' => $c->created_at, + 'days_stale' => $c->last_activity_at ? now()->diffInDays($c->last_activity_at) : now()->diffInDays($c->created_at), + ]); + + // Field jobs assigned today + $fieldJobsAssignedToday = FieldJob::query() + ->whereDate(DB::raw('COALESCE(assigned_at, created_at)'), $today) + ->select(['id','assigned_user_id','priority','assigned_at','created_at','contract_id']) + ->latest(DB::raw('COALESCE(assigned_at, created_at)')) + ->limit(15) + ->get(); + + // Imports in progress (queued / processing) + $importsInProgress = Import::query() + ->whereIn('status', ['queued','processing']) + ->latest('created_at') + ->limit(10) + ->get(['id','uuid','file_name','status','total_rows','imported_rows','valid_rows','invalid_rows','started_at']) + ->map(fn($i) => [ + 'id' => $i->id, + 'uuid' => $i->uuid, + 'file_name' => $i->file_name, + 'status' => $i->status, + 'total_rows' => $i->total_rows, + 'imported_rows' => $i->imported_rows, + 'valid_rows' => $i->valid_rows, + 'invalid_rows' => $i->invalid_rows, + 'progress_pct' => $i->total_rows ? round(($i->imported_rows / max(1,$i->total_rows))*100,1) : null, + 'started_at' => $i->started_at, + ]); + + // Active document templates summary (active versions) + $activeTemplates = \App\Models\DocumentTemplate::query() + ->where('active', true) + ->latest('updated_at') + ->limit(10) + ->get(['id','name','slug','version','updated_at']); + + // System health (deferred) + $queueBacklog = Schema::hasTable('jobs') ? DB::table('jobs')->count() : null; + $failedJobs = Schema::hasTable('failed_jobs') ? DB::table('failed_jobs')->count() : null; + $recentActivity = Activity::query()->latest('created_at')->value('created_at'); + $lastActivityMinutes = null; + if ($recentActivity) { + // diffInMinutes is absolute (non-negative) but guard anyway & cast to int + $lastActivityMinutes = (int) max(0, now()->diffInMinutes($recentActivity)); + } + $systemHealth = [ + 'queue_backlog' => $queueBacklog, + 'failed_jobs' => $failedJobs, + 'last_activity_minutes' => $lastActivityMinutes, + 'last_activity_iso' => $recentActivity?->toIso8601String(), + 'generated_at' => now()->toIso8601String(), + ]; + + return Inertia::render('Dashboard', [ + 'kpis' => [ + 'clients_total' => $clientsTotal, + 'clients_new_7d' => $clientsNew7d, + 'field_jobs_today' => $fieldJobsToday, + 'documents_today' => $documentsToday, + 'active_imports' => $activeImports, + 'active_contracts' => $activeContracts, + ], + 'trends' => $trends, + ])->with([ // deferred props (Inertia v2 style) + 'activities' => fn () => $activities, + 'systemHealth' => fn () => $systemHealth, + 'staleCases' => fn () => $staleCases, + 'fieldJobsAssignedToday' => fn () => $fieldJobsAssignedToday, + 'importsInProgress' => fn () => $importsInProgress, + 'activeTemplates' => fn () => $activeTemplates, + ]); + } +} diff --git a/app/Http/Middleware/EnsurePermission.php b/app/Http/Middleware/EnsurePermission.php new file mode 100644 index 0000000..6850e00 --- /dev/null +++ b/app/Http/Middleware/EnsurePermission.php @@ -0,0 +1,26 @@ +user(); + if (! $user) { + abort(403); + } + if ($user->hasRole('admin')) { + return $next($request); + } + if (! $user->hasPermission($permissions)) { + abort(403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/EnsureRole.php b/app/Http/Middleware/EnsureRole.php new file mode 100644 index 0000000..a702d1c --- /dev/null +++ b/app/Http/Middleware/EnsureRole.php @@ -0,0 +1,20 @@ +user(); + if (! $user || ! $user->hasRole($roles)) { + abort(403); + } + + return $next($request); + } +} diff --git a/app/Http/Middleware/HandleInertiaRequests.php b/app/Http/Middleware/HandleInertiaRequests.php index 999bccc..e9f46db 100644 --- a/app/Http/Middleware/HandleInertiaRequests.php +++ b/app/Http/Middleware/HandleInertiaRequests.php @@ -36,6 +36,22 @@ public function version(Request $request): ?string public function share(Request $request): array { return array_merge(parent::share($request), [ + 'auth' => [ + 'user' => function () use ($request) { + $user = $request->user(); + if (! $user) { + return null; + } + + return [ + 'id' => $user->id, + 'name' => $user->name, + 'email' => $user->email, + 'roles' => $user->roles()->select('id', 'name', 'slug')->get(), + 'permissions' => $user->permissions()->pluck('slug')->values(), + ]; + }, + ], 'flash' => [ 'success' => fn () => $request->session()->get('success'), 'error' => fn () => $request->session()->get('error'), @@ -65,14 +81,11 @@ public function share(Request $request): array ->limit(20) ->get(); - - - return [ 'dueToday' => [ 'count' => $activities->count(), 'items' => $activities, - 'date' => $today, + 'date' => $today, ], ]; } catch (\Throwable $e) { diff --git a/app/Http/Requests/StoreDocumentTemplateRequest.php b/app/Http/Requests/StoreDocumentTemplateRequest.php new file mode 100644 index 0000000..fe64682 --- /dev/null +++ b/app/Http/Requests/StoreDocumentTemplateRequest.php @@ -0,0 +1,40 @@ +user(); + + return $user && ($user->hasPermission('manage-document-templates') || $user->hasPermission('manage-settings') || $user->hasRole('admin')); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + // Slug uniqueness enforced only for first version; controller will increment version if slug exists + 'slug' => ['required', 'string', 'max:255'], + 'custom_name' => ['nullable', 'string', 'max:255'], + 'description' => ['nullable', 'string'], + 'file' => ['required', 'file', 'mimetypes:application/vnd.openxmlformats-officedocument.wordprocessingml.document', 'max:4096'], + 'meta' => ['sometimes', 'array'], + 'meta.*' => ['nullable'], + 'action_id' => ['nullable', 'integer', 'exists:actions,id'], // New optional field + 'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], // New optional field + 'activity_note_template' => ['nullable', 'string'], // New optional field + ]; + } + + public function messages(): array + { + return [ + 'file.mimetypes' => 'Datoteka mora biti DOCX.', + 'file.max' => 'Datoteka je prevelika (max 4MB).', + ]; + } +} diff --git a/app/Http/Requests/StorePermissionRequest.php b/app/Http/Requests/StorePermissionRequest.php new file mode 100644 index 0000000..f5cd387 --- /dev/null +++ b/app/Http/Requests/StorePermissionRequest.php @@ -0,0 +1,31 @@ +user()?->hasPermission('manage-settings') || $this->user()?->hasRole('admin'); + } + + public function rules(): array + { + return [ + 'name' => ['required', 'string', 'max:255'], + 'slug' => ['required', 'string', 'max:255', 'alpha_dash', 'unique:permissions,slug'], + 'description' => ['nullable', 'string', 'max:500'], + ]; + } + + public function messages(): array + { + return [ + 'name.required' => 'Ime je obvezno.', + 'slug.required' => 'Slug je obvezen.', + 'slug.unique' => 'Slug že obstaja.', + ]; + } +} diff --git a/app/Http/Requests/UpdateDocumentTemplateRequest.php b/app/Http/Requests/UpdateDocumentTemplateRequest.php new file mode 100644 index 0000000..b55ba9c --- /dev/null +++ b/app/Http/Requests/UpdateDocumentTemplateRequest.php @@ -0,0 +1,38 @@ +user(); + + return $u && ($u->hasPermission('manage-document-templates') || $u->hasPermission('manage-settings') || $u->hasRole('admin')); + } + + public function rules(): array + { + return [ + 'output_filename_pattern' => ['nullable', 'string', 'max:255'], + 'date_format' => ['nullable', 'string', 'max:40'], + 'fail_on_unresolved' => ['sometimes', 'boolean'], + 'number_decimals' => ['nullable', 'integer', 'min:0', 'max:6'], + 'decimal_separator' => ['nullable', 'string', 'max:2'], + 'thousands_separator' => ['nullable', 'string', 'max:2'], + 'currency_symbol' => ['nullable', 'string', 'max:8'], + 'currency_position' => ['nullable', 'in:before,after'], + 'currency_space' => ['nullable', 'boolean'], + 'default_date_format' => ['nullable', 'string', 'max:40'], + 'date_formats' => ['nullable', 'array'], + 'date_formats.*' => ['nullable', 'string', 'max:40'], + 'meta' => ['sometimes', 'array'], + 'meta.*' => ['nullable'], + 'action_id' => ['nullable', 'integer', 'exists:actions,id'], + 'decision_id' => ['nullable', 'integer', 'exists:decisions,id'], + 'activity_note_template' => ['nullable', 'string'], + ]; + } +} diff --git a/app/Listeners/LogDocumentGenerated.php b/app/Listeners/LogDocumentGenerated.php new file mode 100644 index 0000000..afed410 --- /dev/null +++ b/app/Listeners/LogDocumentGenerated.php @@ -0,0 +1,41 @@ +document; + Log::info('Document generated', [ + 'uuid' => $doc->uuid, + 'template_id' => $doc->template_id, + 'template_version' => $doc->template_version, + 'user_id' => $doc->user_id, + 'path' => $doc->path, + ]); + + try { + \DB::table('document_generation_logs')->insert([ + 'document_id' => $doc->id, + 'user_id' => $doc->user_id, + 'ip' => request()?->ip(), + 'user_agent' => substr((string) request()?->userAgent(), 0, 255), + 'context' => json_encode([ + 'template_id' => $doc->template_id, + 'template_version' => $doc->template_version, + ]), + 'created_at' => now(), + 'updated_at' => now(), + ]); + } catch (\Throwable $e) { + Log::warning('Failed to persist document generation log', [ + 'document_id' => $doc->id, + 'error' => $e->getMessage(), + ]); + } + } +} diff --git a/app/Models/Document.php b/app/Models/Document.php index 1c50d28..73202fc 100644 --- a/app/Models/Document.php +++ b/app/Models/Document.php @@ -33,6 +33,8 @@ class Document extends Model 'preview_path', 'preview_mime', 'preview_generated_at', + 'template_id', + 'template_version', ]; protected $casts = [ diff --git a/app/Models/DocumentSetting.php b/app/Models/DocumentSetting.php new file mode 100644 index 0000000..727e8cb --- /dev/null +++ b/app/Models/DocumentSetting.php @@ -0,0 +1,35 @@ + 'boolean', + 'whitelist' => 'array', + 'date_formats' => 'array', + ]; + + public static function instance(): self + { + return static::query()->first() ?? static::create([ + 'file_name_pattern' => config('documents.file_name_pattern'), + 'date_format' => config('documents.date_format'), + 'unresolved_policy' => config('documents.unresolved_policy'), + 'preview_enabled' => config('documents.preview.enabled', true), + 'whitelist' => config('documents.whitelist'), + 'date_formats' => [], + ]); + } +} diff --git a/app/Models/DocumentTemplate.php b/app/Models/DocumentTemplate.php new file mode 100644 index 0000000..04bb879 --- /dev/null +++ b/app/Models/DocumentTemplate.php @@ -0,0 +1,48 @@ + 'array', + 'columns' => 'array', + 'tokens' => 'array', + 'active' => 'boolean', + 'version' => 'integer', + 'fail_on_unresolved' => 'boolean', + 'formatting_options' => 'array', + 'meta' => 'array', + ]; + + public function action(): BelongsTo + { + return $this->belongsTo(Action::class); + } + + public function decision(): BelongsTo + { + return $this->belongsTo(Decision::class); + } + + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'created_by'); + } + + public function updater(): BelongsTo + { + return $this->belongsTo(User::class, 'updated_by'); + } +} diff --git a/app/Models/Permission.php b/app/Models/Permission.php new file mode 100644 index 0000000..f91c935 --- /dev/null +++ b/app/Models/Permission.php @@ -0,0 +1,25 @@ +belongsToMany(Role::class) + ->withTimestamps() + ->select('roles.id', 'roles.name', 'roles.slug'); + } +} diff --git a/app/Models/Role.php b/app/Models/Role.php new file mode 100644 index 0000000..b50cdd8 --- /dev/null +++ b/app/Models/Role.php @@ -0,0 +1,32 @@ +belongsToMany(Permission::class) + ->withTimestamps() + ->select('permissions.id', 'permissions.name', 'permissions.slug'); + } + + public function users(): BelongsToMany + { + return $this->belongsToMany(User::class) + ->withTimestamps() + ->select('users.id', 'users.name', 'users.email'); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 3dd467c..b5c9247 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -16,6 +16,7 @@ class User extends Authenticatable /** @use HasFactory<\Database\Factories\UserFactory> */ use HasFactory; + use HasProfilePhoto; use Notifiable; use TwoFactorAuthenticatable; @@ -64,4 +65,36 @@ protected function casts(): array 'password' => 'hashed', ]; } + + /** + * Roles relationship. + */ + public function roles() + { + return $this->belongsToMany(Role::class) + ->withTimestamps() + ->select('roles.id', 'roles.name', 'roles.slug'); + } + + /** + * Retrieve a flattened collection of permissions via roles. + */ + public function permissions() + { + return $this->roles()->with('permissions')->get()->pluck('permissions')->flatten()->unique('id'); + } + + public function hasRole(string|array $roles): bool + { + $roles = (array) $roles; + + return $this->roles->pluck('slug')->intersect($roles)->isNotEmpty(); + } + + public function hasPermission(string|array $permissions): bool + { + $permissions = (array) $permissions; + + return $this->permissions()->pluck('slug')->intersect($permissions)->isNotEmpty(); + } } diff --git a/app/Policies/ArchiveSettingPolicy.php b/app/Policies/ArchiveSettingPolicy.php index a8bcad8..30313ee 100644 --- a/app/Policies/ArchiveSettingPolicy.php +++ b/app/Policies/ArchiveSettingPolicy.php @@ -7,34 +7,33 @@ class ArchiveSettingPolicy { - protected function isAdmin(User $user): bool + protected function canManage(User $user): bool { - // Placeholder: adjust to real permission system / role flag - return (bool) ($user->is_admin ?? false); + return $user->hasPermission('manage-settings') || $user->hasRole(['admin']); } public function viewAny(User $user): bool { - return $this->isAdmin($user); + return $this->canManage($user); } public function view(User $user, ArchiveSetting $setting): bool { - return $this->isAdmin($user); + return $this->canManage($user); } public function create(User $user): bool { - return $this->isAdmin($user); + return $this->canManage($user); } public function update(User $user, ArchiveSetting $setting): bool { - return $this->isAdmin($user); + return $this->canManage($user); } public function delete(User $user, ArchiveSetting $setting): bool { - return $this->isAdmin($user); + return $this->canManage($user); } } diff --git a/app/Providers/AuthServiceProvider.php b/app/Providers/AuthServiceProvider.php new file mode 100644 index 0000000..3347509 --- /dev/null +++ b/app/Providers/AuthServiceProvider.php @@ -0,0 +1,46 @@ + 'App\Policies\ModelPolicy', + ]; + + public function boot(): void + { + $this->registerPolicies(); + + // Fallback: map generic CRUD abilities to permission slugs directly + foreach (['create', 'read', 'update', 'delete'] as $ability) { + Gate::define($ability, function (User $user) use ($ability): bool { + return $user->hasPermission($ability) || $user->hasRole('admin'); + }); + } + + // More specific examples + Gate::define('manage-settings', function (User $user): bool { + return $user->hasPermission('manage-settings') || $user->hasRole('admin'); + }); + Gate::define('manage-imports', function (User $user): bool { + return $user->hasPermission('manage-imports') || $user->hasRole('admin'); + }); + Gate::define('manage-document-templates', function (User $user): bool { + return $user->hasPermission('manage-document-templates') || $user->hasRole('admin'); + }); + + // Global override for admin role + Gate::before(function (User $user, string $ability) { + if ($user->hasRole('admin')) { + return true; + } + + return null; // fall through + }); + } +} diff --git a/app/Providers/EventServiceProvider.php b/app/Providers/EventServiceProvider.php new file mode 100644 index 0000000..815f4c2 --- /dev/null +++ b/app/Providers/EventServiceProvider.php @@ -0,0 +1,16 @@ + [ + LogDocumentGenerated::class, + ], + ]; +} diff --git a/app/Services/Documents/DocumentSettings.php b/app/Services/Documents/DocumentSettings.php new file mode 100644 index 0000000..2251ef0 --- /dev/null +++ b/app/Services/Documents/DocumentSettings.php @@ -0,0 +1,28 @@ + DocumentSetting::instance()); + } + + public function refresh(): DocumentSetting + { + Cache::forget(self::CACHE_KEY); + + return $this->get(); + } + + public function fresh(): DocumentSetting + { + return $this->refresh(); + } +} diff --git a/app/Services/Documents/Exceptions/UnresolvedTokensException.php b/app/Services/Documents/Exceptions/UnresolvedTokensException.php new file mode 100644 index 0000000..2d7ac5a --- /dev/null +++ b/app/Services/Documents/Exceptions/UnresolvedTokensException.php @@ -0,0 +1,20 @@ + */ + public array $unresolved; + + /** + * @param array $unresolved + */ + public function __construct(array $unresolved, string $message = 'Unresolved tokens remain in template.') + { + parent::__construct($message); + $this->unresolved = $unresolved; + } +} diff --git a/app/Services/Documents/TokenScanner.php b/app/Services/Documents/TokenScanner.php new file mode 100644 index 0000000..2581caf --- /dev/null +++ b/app/Services/Documents/TokenScanner.php @@ -0,0 +1,21 @@ + + */ + public function scan(string $content): array + { + preg_match_all(self::REGEX, $content, $m); + if (empty($m[1])) { + return []; + } + + return array_values(array_unique($m[1])); + } +} diff --git a/bootstrap/app.php b/bootstrap/app.php index 461aafd..9e3b6ad 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -17,7 +17,10 @@ \Illuminate\Http\Middleware\AddLinkHeadersForPreloadedAssets::class, ]); - // + $middleware->alias([ + 'role' => \App\Http\Middleware\EnsureRole::class, + 'permission' => \App\Http\Middleware\EnsurePermission::class, + ]); }) ->withExceptions(function (Exceptions $exceptions) { // diff --git a/bootstrap/providers.php b/bootstrap/providers.php index 55e8e15..67d2d5f 100644 --- a/bootstrap/providers.php +++ b/bootstrap/providers.php @@ -2,6 +2,7 @@ return [ App\Providers\AppServiceProvider::class, + App\Providers\AuthServiceProvider::class, App\Providers\FortifyServiceProvider::class, App\Providers\JetstreamServiceProvider::class, ]; diff --git a/config/documents.php b/config/documents.php new file mode 100644 index 0000000..ff37d20 --- /dev/null +++ b/config/documents.php @@ -0,0 +1,26 @@ + '{slug}_{generation.date}_{generation.timestamp}.docx', + + // Default date format (php date format) for generation.date + 'date_format' => 'Y-m-d', + + // Global policy: fail | blank | keep + 'unresolved_policy' => 'fail', + + // Preview generation + 'preview' => [ + 'enabled' => true, + ], + + // Whitelist of entities & attributes permitted for token usage + 'whitelist' => [ + 'contract' => ['reference', 'start_date', 'end_date', 'description'], + 'client_case' => ['client_ref'], + 'client' => [], + 'person' => ['full_name', 'first_name', 'last_name', 'nu'], + ], +]; diff --git a/database/factories/ActionFactory.php b/database/factories/ActionFactory.php index 8e72255..2af92b8 100644 --- a/database/factories/ActionFactory.php +++ b/database/factories/ActionFactory.php @@ -17,7 +17,8 @@ class ActionFactory extends Factory public function definition(): array { return [ - // + 'name' => $this->faker->unique()->words(2, true), + 'color_tag' => $this->faker->optional()->safeColorName(), ]; } } diff --git a/database/migrations/2025_10_05_000000_create_roles_and_permissions_tables.php b/database/migrations/2025_10_05_000000_create_roles_and_permissions_tables.php new file mode 100644 index 0000000..8278a77 --- /dev/null +++ b/database/migrations/2025_10_05_000000_create_roles_and_permissions_tables.php @@ -0,0 +1,51 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('permissions', function (Blueprint $table) { + $table->id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('description')->nullable(); + $table->timestamps(); + }); + + Schema::create('permission_role', function (Blueprint $table) { + $table->id(); + $table->foreignId('permission_id')->constrained()->cascadeOnDelete(); + $table->foreignId('role_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['permission_id', 'role_id']); + }); + + Schema::create('role_user', function (Blueprint $table) { + $table->id(); + $table->foreignId('role_id')->constrained()->cascadeOnDelete(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->timestamps(); + $table->unique(['role_id', 'user_id']); + }); + } + + public function down(): void + { + Schema::dropIfExists('role_user'); + Schema::dropIfExists('permission_role'); + Schema::dropIfExists('permissions'); + Schema::dropIfExists('roles'); + } +}; diff --git a/database/migrations/2025_10_05_010000_update_pivot_tables_remove_id.php b/database/migrations/2025_10_05_010000_update_pivot_tables_remove_id.php new file mode 100644 index 0000000..b9bf818 --- /dev/null +++ b/database/migrations/2025_10_05_010000_update_pivot_tables_remove_id.php @@ -0,0 +1,151 @@ +getDriverName() === 'sqlite') { + // Rebuild role_user without surrogate id + if (Schema::hasTable('role_user')) { + Schema::create('role_user_temp', function (Blueprint $table) { + $table->foreignId('role_id'); + $table->foreignId('user_id'); + $table->timestamps(); + }); + // Copy distinct rows + try { + \DB::statement('INSERT INTO role_user_temp (role_id, user_id, created_at, updated_at) SELECT role_id, user_id, created_at, updated_at FROM role_user GROUP BY role_id, user_id'); + } catch (Throwable $e) { /* ignore */ + } + Schema::drop('role_user'); + Schema::rename('role_user_temp', 'role_user'); + // Add composite primary key via raw SQL + try { + \DB::statement('CREATE UNIQUE INDEX role_user_role_user_unique ON role_user(role_id, user_id)'); + } catch (Throwable $e) { /* ignore */ + } + } + if (Schema::hasTable('permission_role')) { + Schema::create('permission_role_temp', function (Blueprint $table) { + $table->foreignId('permission_id'); + $table->foreignId('role_id'); + $table->timestamps(); + }); + try { + \DB::statement('INSERT INTO permission_role_temp (permission_id, role_id, created_at, updated_at) SELECT permission_id, role_id, created_at, updated_at FROM permission_role GROUP BY permission_id, role_id'); + } catch (Throwable $e) { /* ignore */ + } + Schema::drop('permission_role'); + Schema::rename('permission_role_temp', 'permission_role'); + try { + \DB::statement('CREATE UNIQUE INDEX permission_role_permission_role_unique ON permission_role(permission_id, role_id)'); + } catch (Throwable $e) { /* ignore */ + } + } + + return; // sqlite path done + } + // role_user + if (Schema::hasColumn('role_user', 'id')) { + // Drop id column; Postgres requires dropping pk constraint implicitly named maybe 'role_user_pkey' + // Attempt raw drop primary if exists + try { + \DB::statement('ALTER TABLE role_user DROP CONSTRAINT role_user_pkey'); + } catch (Throwable $e) { /* ignore */ + } + Schema::table('role_user', function (Blueprint $table) { + $table->dropColumn('id'); + }); + } + // Add composite primary key if not present + try { + \DB::statement('ALTER TABLE role_user ADD PRIMARY KEY (role_id, user_id)'); + } catch (Throwable $e) { /* ignore */ + } + + // permission_role + if (Schema::hasColumn('permission_role', 'id')) { + try { + \DB::statement('ALTER TABLE permission_role DROP CONSTRAINT permission_role_pkey'); + } catch (Throwable $e) { /* ignore */ + } + Schema::table('permission_role', function (Blueprint $table) { + $table->dropColumn('id'); + }); + } + try { + \DB::statement('ALTER TABLE permission_role ADD PRIMARY KEY (permission_id, role_id)'); + } catch (Throwable $e) { /* ignore */ + } + } + + public function down(): void + { + if (Schema::getConnection()->getDriverName() === 'sqlite') { + // Recreate tables with id column again (best-effort) + if (Schema::hasTable('role_user')) { + Schema::create('role_user_orig', function (Blueprint $table) { + $table->id(); + $table->foreignId('role_id'); + $table->foreignId('user_id'); + $table->timestamps(); + }); + try { + \DB::statement('INSERT INTO role_user_orig (role_id, user_id, created_at, updated_at) SELECT role_id, user_id, created_at, updated_at FROM role_user'); + } catch (Throwable $e) { /* ignore */ + } + Schema::drop('role_user'); + Schema::rename('role_user_orig', 'role_user'); + try { + \DB::statement('CREATE UNIQUE INDEX role_user_role_user_unique ON role_user(role_id, user_id)'); + } catch (Throwable $e) { /* ignore */ + } + } + if (Schema::hasTable('permission_role')) { + Schema::create('permission_role_orig', function (Blueprint $table) { + $table->id(); + $table->foreignId('permission_id'); + $table->foreignId('role_id'); + $table->timestamps(); + }); + try { + \DB::statement('INSERT INTO permission_role_orig (permission_id, role_id, created_at, updated_at) SELECT permission_id, role_id, created_at, updated_at FROM permission_role'); + } catch (Throwable $e) { /* ignore */ + } + Schema::drop('permission_role'); + Schema::rename('permission_role_orig', 'permission_role'); + try { + \DB::statement('CREATE UNIQUE INDEX permission_role_permission_role_unique ON permission_role(permission_id, role_id)'); + } catch (Throwable $e) { /* ignore */ + } + } + + return; + } + // Re-add id columns (simple auto increment) and drop composite PKs + Schema::table('role_user', function (Blueprint $table) { + try { + $table->dropPrimary(); + } catch (Throwable $e) { /* ignore */ + } + if (! Schema::hasColumn('role_user', 'id')) { + $table->id()->first(); + } + $table->unique(['role_id', 'user_id']); + }); + Schema::table('permission_role', function (Blueprint $table) { + try { + $table->dropPrimary(); + } catch (Throwable $e) { /* ignore */ + } + if (! Schema::hasColumn('permission_role', 'id')) { + $table->id()->first(); + } + $table->unique(['permission_id', 'role_id']); + }); + } +}; diff --git a/database/migrations/2025_10_06_120000_create_document_templates_table.php b/database/migrations/2025_10_06_120000_create_document_templates_table.php new file mode 100644 index 0000000..bd10068 --- /dev/null +++ b/database/migrations/2025_10_06_120000_create_document_templates_table.php @@ -0,0 +1,55 @@ +id(); + $table->string('name'); + $table->string('slug')->unique(); + $table->string('custom_name')->nullable(); + $table->text('description')->nullable(); + $table->string('core_entity'); // e.g. 'contract' + $table->json('entities'); // list of related entities allowed + $table->json('columns'); // map entity => allowed columns + $table->unsignedInteger('version')->default(1); + $table->string('engine')->default('tokens'); + $table->string('file_path'); + $table->string('file_hash', 64); + $table->unsignedBigInteger('file_size'); + $table->string('mime_type', 120); + $table->boolean('active')->default(true); + $table->foreignId('created_by')->constrained('users'); + $table->foreignId('updated_by')->nullable()->constrained('users'); + $table->timestamps(); + $table->index(['core_entity', 'active']); + }); + + Schema::table('documents', function (Blueprint $table) { + if (! Schema::hasColumn('documents', 'template_id')) { + $table->foreignId('template_id')->nullable()->after('uuid')->constrained('document_templates'); + } + if (! Schema::hasColumn('documents', 'template_version')) { + $table->unsignedInteger('template_version')->nullable()->after('template_id'); + } + }); + } + + public function down(): void + { + Schema::table('documents', function (Blueprint $table) { + if (Schema::hasColumn('documents', 'template_version')) { + $table->dropColumn('template_version'); + } + if (Schema::hasColumn('documents', 'template_id')) { + $table->dropConstrainedForeignId('template_id'); + } + }); + Schema::dropIfExists('document_templates'); + } +}; diff --git a/database/migrations/2025_10_06_130500_add_tokens_to_document_templates_table.php b/database/migrations/2025_10_06_130500_add_tokens_to_document_templates_table.php new file mode 100644 index 0000000..3713694 --- /dev/null +++ b/database/migrations/2025_10_06_130500_add_tokens_to_document_templates_table.php @@ -0,0 +1,22 @@ +json('tokens')->nullable()->after('columns'); + }); + } + + public function down(): void + { + Schema::table('document_templates', function (Blueprint $table) { + $table->dropColumn('tokens'); + }); + } +}; diff --git a/database/migrations/2025_10_06_140000_add_settings_columns_to_document_templates_table.php b/database/migrations/2025_10_06_140000_add_settings_columns_to_document_templates_table.php new file mode 100644 index 0000000..aa9b2c6 --- /dev/null +++ b/database/migrations/2025_10_06_140000_add_settings_columns_to_document_templates_table.php @@ -0,0 +1,24 @@ +string('output_filename_pattern')->nullable()->after('file_size'); + $table->string('date_format')->nullable()->after('output_filename_pattern'); + $table->boolean('fail_on_unresolved')->default(true)->after('date_format'); + }); + } + + public function down(): void + { + Schema::table('document_templates', function (Blueprint $table) { + $table->dropColumn(['output_filename_pattern', 'date_format', 'fail_on_unresolved']); + }); + } +}; diff --git a/database/migrations/2025_10_06_141000_create_document_generation_logs_table.php b/database/migrations/2025_10_06_141000_create_document_generation_logs_table.php new file mode 100644 index 0000000..8d5b3d9 --- /dev/null +++ b/database/migrations/2025_10_06_141000_create_document_generation_logs_table.php @@ -0,0 +1,26 @@ +id(); + $table->foreignId('document_id')->constrained('documents')->cascadeOnDelete(); + $table->foreignId('user_id')->nullable()->constrained('users')->nullOnDelete(); + $table->string('ip')->nullable(); + $table->string('user_agent')->nullable(); + $table->json('context')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('document_generation_logs'); + } +}; diff --git a/database/migrations/2025_10_06_150000_add_formatting_options_to_document_templates_table.php b/database/migrations/2025_10_06_150000_add_formatting_options_to_document_templates_table.php new file mode 100644 index 0000000..51d17e8 --- /dev/null +++ b/database/migrations/2025_10_06_150000_add_formatting_options_to_document_templates_table.php @@ -0,0 +1,22 @@ +json('formatting_options')->nullable()->after('fail_on_unresolved'); + }); + } + + public function down(): void + { + Schema::table('document_templates', function (Blueprint $table) { + $table->dropColumn('formatting_options'); + }); + } +}; diff --git a/database/migrations/2025_10_06_160000_create_document_settings_table.php b/database/migrations/2025_10_06_160000_create_document_settings_table.php new file mode 100644 index 0000000..ad2bee9 --- /dev/null +++ b/database/migrations/2025_10_06_160000_create_document_settings_table.php @@ -0,0 +1,26 @@ +id(); + $table->string('file_name_pattern')->nullable(); + $table->string('date_format')->nullable(); + $table->string('unresolved_policy')->nullable(); + $table->boolean('preview_enabled')->default(true); + $table->json('whitelist')->nullable(); + $table->timestamps(); + }); + } + + public function down(): void + { + Schema::dropIfExists('document_settings'); + } +}; diff --git a/database/migrations/2025_10_06_170000_add_date_formats_to_document_settings_table.php b/database/migrations/2025_10_06_170000_add_date_formats_to_document_settings_table.php new file mode 100644 index 0000000..59ba74e --- /dev/null +++ b/database/migrations/2025_10_06_170000_add_date_formats_to_document_settings_table.php @@ -0,0 +1,22 @@ +json('date_formats')->nullable()->after('whitelist'); + }); + } + + public function down(): void + { + Schema::table('document_settings', function (Blueprint $table) { + $table->dropColumn('date_formats'); + }); + } +}; diff --git a/database/migrations/2025_10_06_190000_add_meta_and_activity_fields_to_document_templates_table.php b/database/migrations/2025_10_06_190000_add_meta_and_activity_fields_to_document_templates_table.php new file mode 100644 index 0000000..49d87dc --- /dev/null +++ b/database/migrations/2025_10_06_190000_add_meta_and_activity_fields_to_document_templates_table.php @@ -0,0 +1,48 @@ +json('meta')->nullable()->after('formatting_options'); + } + if (! Schema::hasColumn('document_templates', 'action_id')) { + $table->foreignId('action_id')->nullable()->after('meta')->constrained()->nullOnDelete(); + } + if (! Schema::hasColumn('document_templates', 'decision_id')) { + $table->foreignId('decision_id')->nullable()->after('action_id')->constrained()->nullOnDelete(); + } + if (! Schema::hasColumn('document_templates', 'activity_note_template')) { + $table->text('activity_note_template')->nullable()->after('decision_id'); + } + $table->index(['action_id', 'decision_id'], 'document_templates_action_decision_idx'); + }); + } + + public function down(): void + { + Schema::table('document_templates', function (Blueprint $table) { + if (Schema::hasColumn('document_templates', 'activity_note_template')) { + $table->dropColumn('activity_note_template'); + } + if (Schema::hasColumn('document_templates', 'decision_id')) { + $table->dropConstrainedForeignId('decision_id'); + } + if (Schema::hasColumn('document_templates', 'action_id')) { + $table->dropConstrainedForeignId('action_id'); + } + if (Schema::hasColumn('document_templates', 'meta')) { + $table->dropColumn('meta'); + } + if (Schema::hasColumn('document_templates', 'action_id') || Schema::hasColumn('document_templates', 'decision_id')) { + $table->dropIndex('document_templates_action_decision_idx'); + } + }); + } +}; diff --git a/database/seeders/RolePermissionSeeder.php b/database/seeders/RolePermissionSeeder.php new file mode 100644 index 0000000..5c6d86a --- /dev/null +++ b/database/seeders/RolePermissionSeeder.php @@ -0,0 +1,56 @@ + 'create', 'name' => 'Create'], + ['slug' => 'read', 'name' => 'Read'], + ['slug' => 'update', 'name' => 'Update'], + ['slug' => 'delete', 'name' => 'Delete'], + ['slug' => 'manage-settings', 'name' => 'Manage Settings'], + ['slug' => 'manage-imports', 'name' => 'Manage Imports'], + ['slug' => 'manage-document-templates', 'name' => 'Manage Document Templates'], + ]); + + $permissions->each(function ($perm) { + Permission::firstOrCreate(['slug' => $perm['slug']], [ + 'name' => $perm['name'], + 'description' => $perm['name'].' permission', + ]); + }); + + $admin = Role::firstOrCreate(['slug' => 'admin'], [ + 'name' => 'Administrator', + 'description' => 'Full access to all features', + ]); + $staff = Role::firstOrCreate(['slug' => 'staff'], [ + 'name' => 'Staff', + 'description' => 'Standard internal user', + ]); + $viewer = Role::firstOrCreate(['slug' => 'viewer'], [ + 'name' => 'Viewer', + 'description' => 'Read-only access', + ]); + + // Attach permissions + $admin->permissions()->sync(Permission::pluck('id')); + $staff->permissions()->sync(Permission::whereIn('slug', ['create', 'read', 'update'])->pluck('id')); + $viewer->permissions()->sync(Permission::where('slug', 'read')->pluck('id')); + + // Optionally ensure first user is admin + $firstUser = User::query()->orderBy('id')->first(); + if ($firstUser && ! $firstUser->roles()->where('roles.id', $admin->id)->exists()) { + $firstUser->roles()->attach($admin->id); + } + } +} diff --git a/resources/examples/contract_summary_template.docx b/resources/examples/contract_summary_template.docx new file mode 100644 index 0000000000000000000000000000000000000000..3427e07c722d10c8d222a4c5bcbb1a8497375f8c GIT binary patch literal 12386 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 literal 0 HcmV?d00001 diff --git a/resources/examples/create_test_template.php b/resources/examples/create_test_template.php new file mode 100644 index 0000000..4eeda72 --- /dev/null +++ b/resources/examples/create_test_template.php @@ -0,0 +1,81 @@ + + + + + + +XML; + +$rels = <<<'XML' + + + + +XML; + +$document = <<<'XML' + + + + Contract Summary Report + Reference: {{contract.reference}} + Client Case Ref: {{client_case.client_ref}} + Start Date: {{contract.start_date}} + End Date: {{contract.end_date}} + Description: {{contract.description}} + Generated By: {{generation.user_name}} on {{generation.date}} + + +XML; + +$zip = new ZipArchive; +if ($zip->open($file, ZipArchive::CREATE) !== true) { + fwrite(STDERR, "Cannot create docx file\n"); + exit(1); +} +// Core parts +$zip->addFromString('[Content_Types].xml', $contentTypes); +$zip->addEmptyDir('_rels'); +$zip->addFromString('_rels/.rels', $rels); +$zip->addEmptyDir('word'); +$zip->addFromString('word/document.xml', $document); +$zip->close(); + +$hash = hash_file('sha256', $file); +$size = filesize($file); +echo json_encode([ + 'file' => $file, + 'size_bytes' => $size, + 'sha256' => $hash, + 'tokens_included' => [ + 'contract.reference', 'client_case.client_ref', 'contract.start_date', 'contract.end_date', 'contract.description', 'generation.user_name', 'generation.date', + ], +], JSON_PRETTY_PRINT).PHP_EOL; diff --git a/resources/js/Layouts/AdminLayout.vue b/resources/js/Layouts/AdminLayout.vue new file mode 100644 index 0000000..2ead6f8 --- /dev/null +++ b/resources/js/Layouts/AdminLayout.vue @@ -0,0 +1,271 @@ + + + diff --git a/resources/js/Layouts/AppLayout.vue b/resources/js/Layouts/AppLayout.vue index 7cb37a8..d3b40ee 100644 --- a/resources/js/Layouts/AppLayout.vue +++ b/resources/js/Layouts/AppLayout.vue @@ -203,17 +203,42 @@ const rawMenuGroups = [ routeName: "settings", active: ["settings", "settings.*"], }, + // Admin panel (roles & permissions management) + // Only shown if current user has admin role or manage-settings permission. + // We'll filter it out below if not authorized. + { + key: "admin-panel", + title: "Administrator", + routeName: "admin.index", + active: ["admin.index", "admin.users.index", "admin.permissions.create"], + requires: { role: "admin", permission: "manage-settings" }, + }, ], }, ]; const menuGroups = computed(() => { - return rawMenuGroups.map((g) => ({ - label: g.label, - items: [...g.items].sort((a, b) => - a.title.localeCompare(b.title, "sl", { sensitivity: "base" }) - ), - })); + const user = page.props.auth?.user || {}; + const roles = (user.roles || []).map((r) => r.slug); + const permissions = user.permissions || []; + + // Helper to determine inclusion based on optional requires meta + function allowed(item) { + if (!item.requires) return true; + const needRole = item.requires.role; + const needPerm = item.requires.permission; + return ( + (needRole && roles.includes(needRole)) || + (needPerm && permissions.includes(needPerm)) + ); + } + + return rawMenuGroups.map((g) => { + const items = g.items + .filter(allowed) + .sort((a, b) => a.title.localeCompare(b.title, "sl", { sensitivity: "base" })); + return { label: g.label, items }; + }); }); // Icon map for menu keys -> FontAwesome icon definitions @@ -227,6 +252,7 @@ const menuIconMap = { "import-templates-new": faFileCirclePlus, fieldjobs: faMap, settings: faGear, + "admin-panel": faUserGroup, }; function isActive(patterns) { diff --git a/resources/js/Pages/Admin/DocumentSettings/Edit.vue b/resources/js/Pages/Admin/DocumentSettings/Edit.vue new file mode 100644 index 0000000..6fc6eda --- /dev/null +++ b/resources/js/Pages/Admin/DocumentSettings/Edit.vue @@ -0,0 +1,161 @@ + + + diff --git a/resources/js/Pages/Admin/DocumentSettings/Index.vue b/resources/js/Pages/Admin/DocumentSettings/Index.vue new file mode 100644 index 0000000..e598ab1 --- /dev/null +++ b/resources/js/Pages/Admin/DocumentSettings/Index.vue @@ -0,0 +1,32 @@ + + + diff --git a/resources/js/Pages/Admin/DocumentTemplates/Edit.vue b/resources/js/Pages/Admin/DocumentTemplates/Edit.vue new file mode 100644 index 0000000..47d9574 --- /dev/null +++ b/resources/js/Pages/Admin/DocumentTemplates/Edit.vue @@ -0,0 +1,333 @@ +