Added the support for generating docs from template doc

This commit is contained in:
Simon Pocrnjič
2025-10-06 21:46:28 +02:00
parent 0c8d1e0b5d
commit cec5796acf
69 changed files with 4570 additions and 374 deletions
+29
View File
@@ -0,0 +1,29 @@
<?php
namespace App\Events;
use App\Models\Document;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DocumentGenerated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public Document $document)
{
// Fire off preview generation immediately if enabled without waiting for listener chaining
$settings = app(\App\Services\Documents\DocumentSettings::class)->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(),
]);
}
}
}
}
+15
View File
@@ -0,0 +1,15 @@
<?php
namespace App\Events;
use App\Models\DocumentSetting;
use Illuminate\Broadcasting\InteractsWithSockets;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;
class DocumentSettingsUpdated
{
use Dispatchable, InteractsWithSockets, SerializesModels;
public function __construct(public DocumentSetting $settings) {}
}
@@ -0,0 +1,56 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Events\DocumentSettingsUpdated;
use App\Http\Controllers\Controller;
use App\Services\Documents\DocumentSettings as SettingsService;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
class DocumentSettingsController extends Controller
{
public function edit(SettingsService $svc)
{
$this->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);
}
}
}
@@ -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;
}
@@ -0,0 +1,36 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Http\Requests\StorePermissionRequest;
use App\Models\Permission;
use Illuminate\Http\RedirectResponse;
use Inertia\Inertia;
use Inertia\Response;
class PermissionController extends Controller
{
public function index(): Response
{
$permissions = Permission::query()
->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.');
}
}
@@ -0,0 +1,45 @@
<?php
namespace App\Http\Controllers\Admin;
use App\Http\Controllers\Controller;
use App\Models\Permission;
use App\Models\Role;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Gate;
use Inertia\Inertia;
use Inertia\Response;
class UserRoleController extends Controller
{
public function index(Request $request): Response
{
Gate::authorize('manage-settings');
$users = User::with('roles:id,slug,name')->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');
}
}
@@ -0,0 +1,117 @@
<?php
namespace App\Http\Controllers;
use App\Events\DocumentGenerated;
use App\Models\Activity;
use App\Models\Contract;
use App\Models\Document;
use App\Models\DocumentTemplate;
use App\Services\Documents\TokenValueResolver;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Gate;
use Illuminate\Support\Str;
use Symfony\Component\HttpFoundation\Response;
class ContractDocumentGenerationController extends Controller
{
public function __invoke(Request $request, Contract $contract): Response
{
if (Gate::denies('read')) { // baseline read permission required to generate
abort(403);
}
$request->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,
]);
}
}
@@ -0,0 +1,189 @@
<?php
namespace App\Http\Controllers;
use App\Models\Client;
use App\Models\ClientCase;
use App\Models\Document;
use App\Models\FieldJob;
use App\Models\Import; // assuming model name Import
use App\Models\Activity; // if this model exists
use App\Models\Contract;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\CarbonPeriod;
use Inertia\Inertia;
use Inertia\Response;
class DashboardController extends Controller
{
public function __invoke(): Response
{
$today = now()->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,
]);
}
}
+26
View File
@@ -0,0 +1,26 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsurePermission
{
public function handle(Request $request, Closure $next, ...$permissions): Response
{
$user = $request->user();
if (! $user) {
abort(403);
}
if ($user->hasRole('admin')) {
return $next($request);
}
if (! $user->hasPermission($permissions)) {
abort(403);
}
return $next($request);
}
}
+20
View File
@@ -0,0 +1,20 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Symfony\Component\HttpFoundation\Response;
class EnsureRole
{
public function handle(Request $request, Closure $next, ...$roles): Response
{
$user = $request->user();
if (! $user || ! $user->hasRole($roles)) {
abort(403);
}
return $next($request);
}
}
+17 -4
View File
@@ -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) {
@@ -0,0 +1,40 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StoreDocumentTemplateRequest extends FormRequest
{
public function authorize(): bool
{
$user = $this->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).',
];
}
}
@@ -0,0 +1,31 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class StorePermissionRequest extends FormRequest
{
public function authorize(): bool
{
return $this->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.',
];
}
}
@@ -0,0 +1,38 @@
<?php
namespace App\Http\Requests;
use Illuminate\Foundation\Http\FormRequest;
class UpdateDocumentTemplateRequest extends FormRequest
{
public function authorize(): bool
{
$u = $this->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'],
];
}
}
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace App\Listeners;
use App\Events\DocumentGenerated;
use Illuminate\Support\Facades\Log;
class LogDocumentGenerated
{
public function handle(DocumentGenerated $event): void
{
$doc = $event->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(),
]);
}
}
}
+2
View File
@@ -33,6 +33,8 @@ class Document extends Model
'preview_path',
'preview_mime',
'preview_generated_at',
'template_id',
'template_version',
];
protected $casts = [
+35
View File
@@ -0,0 +1,35 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Model;
class DocumentSetting extends Model
{
protected $fillable = [
'file_name_pattern',
'date_format',
'unresolved_policy',
'preview_enabled',
'whitelist',
'date_formats',
];
protected $casts = [
'preview_enabled' => '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' => [],
]);
}
}
+48
View File
@@ -0,0 +1,48 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
class DocumentTemplate extends Model
{
use HasFactory;
protected $fillable = [
'name', 'slug', 'custom_name', 'description', 'core_entity', 'entities', 'columns', 'tokens', 'version', 'engine', 'file_path', 'file_hash', 'file_size', 'mime_type', 'active', 'created_by', 'updated_by',
'output_filename_pattern', 'date_format', 'fail_on_unresolved', 'formatting_options', 'meta', 'action_id', 'decision_id', 'activity_note_template',
];
protected $casts = [
'entities' => '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');
}
}
+25
View File
@@ -0,0 +1,25 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Permission extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
];
public function roles(): BelongsToMany
{
return $this->belongsToMany(Role::class)
->withTimestamps()
->select('roles.id', 'roles.name', 'roles.slug');
}
}
+32
View File
@@ -0,0 +1,32 @@
<?php
namespace App\Models;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
class Role extends Model
{
use HasFactory;
protected $fillable = [
'name',
'slug',
'description',
];
public function permissions(): BelongsToMany
{
return $this->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');
}
}
+33
View File
@@ -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();
}
}
+7 -8
View File
@@ -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);
}
}
+46
View File
@@ -0,0 +1,46 @@
<?php
namespace App\Providers;
use App\Models\User;
use Illuminate\Foundation\Support\Providers\AuthServiceProvider as ServiceProvider;
use Illuminate\Support\Facades\Gate;
class AuthServiceProvider extends ServiceProvider
{
protected $policies = [
// 'App\Models\Model' => '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
});
}
}
+16
View File
@@ -0,0 +1,16 @@
<?php
namespace App\Providers;
use App\Events\DocumentGenerated;
use App\Listeners\LogDocumentGenerated;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;
class EventServiceProvider extends ServiceProvider
{
protected $listen = [
DocumentGenerated::class => [
LogDocumentGenerated::class,
],
];
}
@@ -0,0 +1,28 @@
<?php
namespace App\Services\Documents;
use App\Models\DocumentSetting;
use Illuminate\Support\Facades\Cache;
class DocumentSettings
{
private const CACHE_KEY = 'document_settings_singleton_v1';
public function get(): DocumentSetting
{
return Cache::remember(self::CACHE_KEY, 300, fn () => DocumentSetting::instance());
}
public function refresh(): DocumentSetting
{
Cache::forget(self::CACHE_KEY);
return $this->get();
}
public function fresh(): DocumentSetting
{
return $this->refresh();
}
}
@@ -0,0 +1,20 @@
<?php
namespace App\Services\Documents\Exceptions;
use RuntimeException;
class UnresolvedTokensException extends RuntimeException
{
/** @var array<int,string> */
public array $unresolved;
/**
* @param array<int,string> $unresolved
*/
public function __construct(array $unresolved, string $message = 'Unresolved tokens remain in template.')
{
parent::__construct($message);
$this->unresolved = $unresolved;
}
}
+21
View File
@@ -0,0 +1,21 @@
<?php
namespace App\Services\Documents;
class TokenScanner
{
private const REGEX = '/{{\s*([a-zA-Z0-9_]+\.[a-zA-Z0-9_]+)\s*}}/';
/**
* @return array<int,string>
*/
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]));
}
}