value) and unresolved (list of tokens not resolved / not allowed) * Policy determines whether invalid tokens throw (fail) or are collected (blank|keep). * * @return array{values:array,unresolved:array,customTypes?:array} */ public function resolve( array $tokens, DocumentTemplate $template, Contract $contract, User $user, string $policy = 'fail', array $customOverrides = [], ?array $customDefaults = null, string $onMissingCustom = 'empty' ): array { $values = []; $unresolved = []; $customTypesOut = []; // Custom namespace: merge defaults from settings/template meta and overrides $settings = app(\App\Services\Documents\DocumentSettings::class)->get(); $defaults = $customDefaults ?? ($template->meta['custom_defaults'] ?? null) ?? ($settings->custom_defaults ?? []); if (! is_array($defaults)) { $defaults = []; } if (! is_array($customOverrides)) { $customOverrides = []; } $custom = array_replace($defaults, $customOverrides); // Collect custom types from template meta (optional) $customTypes = []; if (isset($template->meta['custom_default_types']) && is_array($template->meta['custom_default_types'])) { foreach ($template->meta['custom_default_types'] as $k => $t) { $t = in_array($t, ['string', 'number', 'date', 'text'], true) ? $t : 'string'; $customTypes[(string) $k] = $t; } } // Retrieve whitelist from DB settings (if present) and merge with config baseline (config acts as baseline; DB can add or override entity arrays) $settingsWhitelist = app(\App\Services\Documents\DocumentSettings::class)->get()->whitelist ?? []; $configWhitelist = config('documents.whitelist', []); // Merge preserving DB additions/overrides $globalWhitelist = array_replace($configWhitelist, $settingsWhitelist); // Always treat globally whitelisted entities as available, even if legacy template does not list them if ($template->entities && is_array($template->entities)) { $templateEntities = array_values(array_unique(array_merge($template->entities, array_keys($globalWhitelist)))); } else { $templateEntities = array_keys($globalWhitelist); } // Normalize template tokens list (used as an allow-list if columns / global whitelist are not exhaustive) $templateTokens = []; $rawTemplateTokens = $template->tokens ?? null; if (is_array($rawTemplateTokens)) { $templateTokens = array_values(array_filter(array_map('strval', $rawTemplateTokens))); } elseif (is_string($rawTemplateTokens)) { $decoded = json_decode($rawTemplateTokens, true); if (is_array($decoded)) { $templateTokens = array_values(array_filter(array_map('strval', $decoded))); } } foreach ($tokens as $token) { [$entity,$attr] = explode('.', $token, 2); if ($entity === 'generation') { $values[$token] = $this->generationAttribute($attr, $user); continue; } if ($entity === 'custom') { // Track type info if present if (isset($customTypes[$attr])) { $customTypesOut[$token] = $customTypes[$attr]; } if (array_key_exists($attr, $custom)) { $v = $custom[$attr]; if (is_scalar($v) || (is_object($v) && method_exists($v, '__toString'))) { $values[$token] = (string) $v; } else { $values[$token] = ''; } } else { // Missing custom – apply onMissingCustom policy locally (empty|leave|error) if ($onMissingCustom === 'error') { if ($policy === 'fail') { throw new \RuntimeException("Manjkajoč custom token: {$token}"); } $unresolved[] = $token; } elseif ($onMissingCustom === 'leave') { $unresolved[] = $token; } else { // empty $values[$token] = ''; } } continue; } if (! in_array($entity, $templateEntities, true)) { // If the token is explicitly listed on the template's tokens, allow it if (! $templateTokens || ! in_array($token, $templateTokens, true)) { if ($policy === 'fail') { throw new \RuntimeException("Nedovoljen entiteta token: $entity"); } $unresolved[] = $token; continue; } } // Allowed attributes: merge template-declared columns with global whitelist (config + DB settings) // Support nested dotted attributes (e.g. person.person_address.city). We allow if either the full // dotted path is listed or if the base prefix is listed (e.g. person.person_address) and the resolver // can handle it. // Safely read template-declared columns $columns = is_array($template->columns ?? null) ? $template->columns : []; $allowedFromTemplate = $columns[$entity] ?? []; $allowedFromGlobal = $globalWhitelist[$entity] ?? []; $allowed = array_values(array_unique(array_merge($allowedFromTemplate, $allowedFromGlobal))); $isAllowed = in_array($attr, $allowed, true); if (! $isAllowed && str_contains($attr, '.')) { // Check progressive prefixes: a.b.c -> a.b $parts = explode('.', $attr); while (count($parts) > 1 && ! $isAllowed) { array_pop($parts); $prefix = implode('.', $parts); if (in_array($prefix, $allowed, true)) { $isAllowed = true; break; } } } if (! $isAllowed) { if ($policy === 'fail') { throw new \RuntimeException("Nedovoljen stolpec token: $token"); } $unresolved[] = $token; continue; } $values[$token] = $this->entityAttribute($entity, $attr, $contract) ?? ''; } return [ 'values' => $values, 'unresolved' => array_values(array_unique($unresolved)), 'customTypes' => $customTypesOut, ]; } private function generationAttribute(string $attr, User $user): string { return match ($attr) { 'timestamp' => (string) now()->timestamp, 'date' => now()->toDateString(), // raw ISO; formatting applied later 'user_name' => $user->name ?? 'Uporabnik', default => '' }; } private function entityAttribute(string $entity, string $attr, Contract $contract): ?string { switch ($entity) { case 'contract': return (string) ($contract->{$attr} ?? ''); case 'client_case': return (string) optional($contract->clientCase)->{$attr}; case 'client': $client = optional($contract->clientCase)->client; if (! $client) { return ''; } if (str_contains($attr, '.')) { return $this->resolveNestedFromModel($client, $attr); } return (string) ($client->{$attr} ?? ''); case 'person': $person = optional($contract->clientCase)->person; if (! $person) { return ''; } if (str_contains($attr, '.')) { return $this->resolveNestedFromModel($person, $attr); } return (string) ($person->{$attr} ?? ''); case 'account': $account = optional($contract->account); return (string) $account->{$attr}; default: return ''; } } /** * Resolve nested dotted paths from a base model for supported relations/aliases. * Supports: * - Client: person.* * - Person: person_address.* (uses first active address) */ private function resolveNestedFromModel(object $model, string $path): string { $segments = explode('.', $path); $current = $model; foreach ($segments as $seg) { if (! $current) { return ''; } if ($current instanceof \App\Models\Client && $seg === 'person') { $current = $current->person; continue; } if ($current instanceof \App\Models\Person\Person && $seg === 'person_address') { $current = $current->addresses()->first(); continue; } // Default attribute access try { $current = is_array($current) ? ($current[$seg] ?? null) : ($current->{$seg} ?? null); } catch (\Throwable $e) { return ''; } } return $current !== null ? (string) $current : ''; } }