Teren-app/app/Services/Documents/TokenValueResolver.php
2025-10-12 19:07:41 +02:00

238 lines
9.7 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Services\Documents;
use App\Models\Contract;
use App\Models\DocumentTemplate;
use App\Models\User;
class TokenValueResolver
{
/**
* Resolve tokens to values.
* Returns array with keys: values (resolved token=>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<string,string>,unresolved:array<int,string>,customTypes?:array<string,string>}
*/
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 : '';
}
}