395 lines
23 KiB
PHP
395 lines
23 KiB
PHP
<?php
|
|
|
|
namespace App\Services;
|
|
|
|
use App\Models\User;
|
|
use Illuminate\Database\Eloquent\Model;
|
|
use Illuminate\Database\Query\Builder;
|
|
use Illuminate\Support\Arr;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Schema;
|
|
use Illuminate\Validation\Rule;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
/** Subject mapping and publication effects for the existing common editorial engine. */
|
|
final class PortalEditorialSubjects
|
|
{
|
|
public const SUBJECTS = [
|
|
'projects' => ['table' => 'projects', 'fields' => ['code', 'name', 'status', 'description', 'budget_amount', 'budget_currency', 'start_date', 'end_date', 'primary_territory_id', 'territory_version']],
|
|
'clusters' => ['table' => 'economic_clusters', 'fields' => ['code', 'name', 'status', 'sector', 'primary_territory_id', 'territory_version']],
|
|
'corridors' => ['table' => 'development_corridors', 'fields' => ['code', 'name', 'status', 'description', 'primary_territory_id', 'territory_version']],
|
|
'cluster-project' => ['table' => 'cluster_projects', 'fields' => ['cluster_id', 'project_id', 'role', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
|
|
'corridor-project' => ['table' => 'corridor_projects', 'fields' => ['corridor_id', 'project_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
|
|
'corridor-cluster' => ['table' => 'corridor_clusters', 'fields' => ['corridor_id', 'cluster_id', 'association_type', 'source_reference', 'evidence_reference', 'valid_from', 'valid_until']],
|
|
'value-chains' => ['table' => 'value_chains', 'fields' => ['cluster_id', 'code', 'name', 'stage']],
|
|
'value-chain-links' => ['table' => 'value_chain_links', 'fields' => ['from_value_chain_id', 'to_value_chain_id', 'link_type']],
|
|
'investment-gaps' => ['table' => 'investment_gaps', 'fields' => ['cluster_id', 'value_chain_id', 'title', 'location', 'required_capacity', 'rationale', 'status', 'call_to_action']],
|
|
'zone-relations' => ['table' => 'investment_zone_relations', 'fields' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id', 'association_type', 'role', 'source_reference', 'justification', 'legal_source_id', 'valid_from', 'valid_until', 'priority', 'is_primary']],
|
|
];
|
|
|
|
private const PARENTS = ['cluster_id' => 'economic_clusters', 'project_id' => 'projects', 'corridor_id' => 'development_corridors', 'zone_id' => 'investment_zones',
|
|
'value_chain_id' => 'value_chains', 'from_value_chain_id' => 'value_chains', 'to_value_chain_id' => 'value_chains', 'primary_territory_id' => 'geo_locations'];
|
|
|
|
/** @return array{table:string,fields:list<string>} */
|
|
public function definition(string $kind): array
|
|
{
|
|
abort_unless(isset(self::SUBJECTS[$kind]), 404);
|
|
|
|
return self::SUBJECTS[$kind];
|
|
}
|
|
|
|
public function available(): bool
|
|
{
|
|
return (bool) config('editorial_workflow.structural_subjects') && app(PortalEditorialWorkflow::class)->available() && Schema::hasColumn('portal_editorial_editions', 'subject_kind');
|
|
}
|
|
|
|
public function writes(string $kind, string $key): bool
|
|
{
|
|
$this->definition($kind);
|
|
if (config('editorial_workflow.structural_subjects')) {
|
|
abort_unless($this->available(), 503, 'Estrutura editorial dos registos ainda não instalada.');
|
|
|
|
return true;
|
|
}
|
|
abort_if(Schema::hasColumn('portal_editorial_editions', 'subject_kind') && DB::table('portal_editorial_editions')->where('subject_kind', $kind)->where('subject_key', $key)->exists(), 503, 'Reactive o circuito para conservar as versões deste registo.');
|
|
|
|
return false;
|
|
}
|
|
|
|
public function query(string $kind, string $key): Builder
|
|
{
|
|
return DB::table($this->definition($kind)['table'])->where('id', $key);
|
|
}
|
|
|
|
public function editUrl(string $kind, \stdClass $record): string
|
|
{
|
|
return match ($kind) {
|
|
'projects', 'clusters', 'corridors' => '/management/'.$kind.'/'.$record->id.'/edit',
|
|
'value-chains', 'investment-gaps', 'cluster-project' => '/management/clusters/'.$record->cluster_id.'/edit',
|
|
'value-chain-links' => '/management/clusters/'.DB::table('value_chains')->where('id', $record->from_value_chain_id)->value('cluster_id').'/edit',
|
|
'corridor-project', 'corridor-cluster' => '/management/corridors/'.$record->corridor_id.'/edit',
|
|
'zone-relations' => '/management/investment/zones/'.$record->zone_id,
|
|
default => abort(404),
|
|
};
|
|
}
|
|
|
|
public function working(string $kind, \stdClass $record): \stdClass
|
|
{
|
|
if (! $this->available()) {
|
|
return $record;
|
|
}
|
|
$workflow = app(PortalEditorialWorkflow::class);
|
|
$edition = $workflow->latestSubject($kind, $record->id);
|
|
$copy = clone $record;
|
|
if ($edition && $edition->published_at === null && $edition->state !== 'archived') {
|
|
foreach ($workflow->payload($edition)['resource']['_subject']['fields'] as $field => $value) {
|
|
$copy->{$field} = $value;
|
|
}
|
|
}
|
|
$copy->editorial_version = (int) ($edition->revision_no ?? 1);
|
|
$copy->editorial_url = '/management/editorial/subjects/'.$kind.'/'.$record->id;
|
|
|
|
return $copy;
|
|
}
|
|
|
|
/** @param array<string,mixed> $fields */
|
|
public function stageModel(User $actor, string $kind, Model $record, array $fields, int $expected): \stdClass
|
|
{
|
|
return DB::transaction(function () use ($actor, $kind, $record, $fields, $expected) {
|
|
$new = ! $record->exists;
|
|
if ($new) {
|
|
$private = in_array($kind, ['value-chains', 'value-chain-links', 'investment-gaps'], true) ? ['deleted_at' => now()] : [];
|
|
PortalAudit::save($record, [...$fields, ...$private], $actor->id);
|
|
}
|
|
$allowed = $this->definition($kind)['fields'];
|
|
|
|
return app(PortalEditorialWorkflow::class)->saveSubject($actor, $kind, (string) $record->getKey(), $expected, Arr::only($fields, $allowed), $new);
|
|
});
|
|
}
|
|
|
|
/** @return array<string,string> */
|
|
public function labels(): array
|
|
{
|
|
return ['code' => 'Código', 'name' => 'Designação', 'status' => 'Estado', 'description' => 'Descrição', 'sector' => 'Sector', 'budget_amount' => 'Orçamento', 'budget_currency' => 'Moeda', 'start_date' => 'Data de início', 'end_date' => 'Data de conclusão',
|
|
'primary_territory_id' => 'Localização principal', 'cluster_id' => 'Cluster', 'project_id' => 'Projecto', 'corridor_id' => 'Corredor', 'zone_id' => 'Zona', 'value_chain_id' => 'Etapa da cadeia', 'from_value_chain_id' => 'Etapa de origem', 'to_value_chain_id' => 'Etapa de destino',
|
|
'role' => 'Papel', 'association_type' => 'Tipo de associação', 'source_reference' => 'Fonte', 'evidence_reference' => 'Evidência', 'valid_from' => 'Válido desde', 'valid_until' => 'Válido até', 'stage' => 'Fase da cadeia', 'link_type' => 'Tipo de ligação',
|
|
'title' => 'Título', 'location' => 'Localização', 'required_capacity' => 'Capacidade necessária', 'rationale' => 'Fundamento', 'call_to_action' => 'Acção proposta', 'justification' => 'Justificação', 'legal_source_id' => 'Fonte jurídica', 'priority' => 'Prioridade', 'is_primary' => 'Relação principal'];
|
|
}
|
|
|
|
/** @return array<string,list<string>> */
|
|
private function enums(string $kind): array
|
|
{
|
|
return ['status' => match ($kind) {
|
|
'projects' => ['draft', 'planned', 'active', 'suspended', 'completed', 'cancelled'], 'clusters' => ['planned', 'active', 'inactive'], 'corridors' => ['proposed', 'active', 'closed'], 'investment-gaps' => ['identified', 'in_review', 'resolved', 'withdrawn'], default => []
|
|
},
|
|
'stage' => ['input', 'production', 'processing', 'logistics', 'market'], 'link_type' => ['supplies', 'processes', 'distributes', 'supports'],
|
|
'association_type' => ['official', 'influence', 'proximity', 'potential', 'historical'], 'role' => $kind === 'zone-relations' ? ['primary', 'beneficiary', 'served', 'enabled', 'dependent', 'related', 'potential', 'historical'] : ['anchor', 'complementary', 'supporting']];
|
|
}
|
|
|
|
/** @return list<array<string,mixed>> */
|
|
public function form(string $kind): array
|
|
{
|
|
$labels = $this->labels();
|
|
$enums = $this->enums($kind);
|
|
$result = [];
|
|
foreach ($this->definition($kind)['fields'] as $field) {
|
|
if ($field === 'territory_version') {
|
|
continue;
|
|
}
|
|
$type = isset(self::PARENTS[$field]) || $field === 'legal_source_id' ? 'reference' : (isset($enums[$field]) ? 'enum' : (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true) ? 'date' : ($field === 'is_primary' ? 'boolean' : 'text')));
|
|
$result[] = ['name' => $field, 'label' => $labels[$field] ?? $field, 'type' => $type, 'options' => $enums[$field] ?? []];
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/** @param array<string,mixed> $input
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function validate(string $kind, array $input): array
|
|
{
|
|
$allowed = array_values(array_diff($this->definition($kind)['fields'], ['territory_version']));
|
|
abort_unless(array_diff(array_keys($input), $allowed) === [], 422, 'Campos fora do âmbito da proposta.');
|
|
$enums = $this->enums($kind);
|
|
$rules = [];
|
|
foreach ($allowed as $field) {
|
|
$rules[$field] = ['sometimes', 'nullable', 'string', 'max:10000'];
|
|
if (isset(self::PARENTS[$field]) || $field === 'legal_source_id') {
|
|
$table = self::PARENTS[$field] ?? 'investment_legal_sources';
|
|
$rules[$field] = ['sometimes', 'nullable', 'uuid', 'exists:'.$table.',id'];
|
|
} elseif (isset($enums[$field])) {
|
|
$rules[$field] = ['sometimes', 'required', Rule::in($enums[$field])];
|
|
} elseif (in_array($field, ['valid_from', 'valid_until', 'start_date', 'end_date'], true)) {
|
|
$rules[$field] = ['sometimes', 'nullable', 'date_format:Y-m-d'];
|
|
}
|
|
}
|
|
foreach (['name', 'code', 'title', 'sector'] as $field) {
|
|
if (in_array($field, $allowed, true)) {
|
|
$rules[$field] = ['sometimes', 'required', 'string', 'max:'.($field === 'code' ? '100' : '200')];
|
|
}
|
|
}
|
|
foreach (['source_reference' => 2000, 'evidence_reference' => 2000, 'justification' => 3000, 'location' => 1000, 'required_capacity' => 2000, 'rationale' => 5000, 'call_to_action' => 2000] as $field => $limit) {
|
|
if (in_array($field, $allowed, true)) {
|
|
$rules[$field] = ['sometimes', 'nullable', 'string', 'max:'.$limit];
|
|
}
|
|
}
|
|
$requiredReferences = match ($kind) {
|
|
'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'],
|
|
'value-chains' => ['cluster_id'], 'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id'], 'investment-gaps' => ['cluster_id', 'value_chain_id'], 'zone-relations' => ['zone_id'], default => [],
|
|
};
|
|
foreach ($requiredReferences as $field) {
|
|
$rules[$field] = ['sometimes', 'required', 'uuid', 'exists:'.self::PARENTS[$field].',id'];
|
|
}
|
|
if (! empty($input['valid_until']) && ! empty($input['valid_from'])) {
|
|
$rules['valid_until'][] = 'after_or_equal:valid_from';
|
|
}
|
|
if (! empty($input['end_date']) && ! empty($input['start_date'])) {
|
|
$rules['end_date'][] = 'after_or_equal:start_date';
|
|
}
|
|
if (in_array('budget_amount', $allowed, true)) {
|
|
$rules['budget_amount'] = ['sometimes', 'nullable', 'required_with:budget_currency', 'regex:/^\d{1,18}(\.\d{1,2})?$/'];
|
|
$rules['budget_currency'] = ['sometimes', 'nullable', 'required_with:budget_amount', 'regex:/^[A-Z]{3}$/'];
|
|
}
|
|
if (in_array('priority', $allowed, true)) {
|
|
$rules['priority'] = ['sometimes', 'integer', 'between:0,100'];
|
|
$rules['is_primary'] = ['sometimes', 'boolean'];
|
|
}
|
|
if ($kind === 'zone-relations' && count(array_filter(Arr::only($input, ['cluster_id', 'corridor_id', 'project_id']))) > 1) {
|
|
throw ValidationException::withMessages(['project_id' => 'Seleccione apenas um projecto, cluster ou corredor para a relação.']);
|
|
}
|
|
|
|
return validator($input, $rules)->validate();
|
|
}
|
|
|
|
/** @return list<array{id:string,name:string}> */
|
|
public function choices(string $kind, string $field, ?string $query = null): array
|
|
{
|
|
abort_unless(in_array($field, $this->definition($kind)['fields'], true), 404);
|
|
$table = self::PARENTS[$field] ?? ($field === 'legal_source_id' ? 'investment_legal_sources' : abort(404));
|
|
$label = $table === 'investment_legal_sources' ? 'title' : 'name';
|
|
$builder = DB::table($table);
|
|
if ($table === 'geo_locations') {
|
|
$builder->whereIn('location_type', ['province', 'district', 'admin_post', 'locality'])->whereIn('data_classification', ['public', 'internal']);
|
|
}
|
|
if (Schema::hasColumn($table, 'deleted_at')) {
|
|
$builder->whereNull('deleted_at');
|
|
}
|
|
if ($query) {
|
|
$builder->where($label, 'ilike', '%'.addcslashes($query, '%_\\').'%');
|
|
}
|
|
|
|
return array_values($builder->orderBy($label)->orderBy('id')->limit(100)->get(['id', $label.' as name'])->map(fn ($row) => ['id' => (string) $row->id, 'name' => (string) $row->name])->all());
|
|
}
|
|
|
|
public function authorize(User $actor, string $kind, string $key, bool $write = false): void
|
|
{
|
|
abort_unless($this->available(), 503);
|
|
abort_unless($actor->hasVerifiedEmail() && $actor->can('manage-portal') && ! app(PortalProfileAccess::class)->has($actor, 'auditor'), 403);
|
|
abort_unless($this->query($kind, $key)->exists(), 404);
|
|
if ($write) {
|
|
abort_unless(app(PortalProfileAccess::class)->mfa($actor), 403, 'Confirme o MFA na sessão existente.');
|
|
}
|
|
}
|
|
|
|
public function independent(string $kind): bool
|
|
{
|
|
return in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)
|
|
|| in_array($kind, config('editorial_workflow.independent_approval.kinds', []), true);
|
|
}
|
|
|
|
/** @param array<string,mixed> $fields
|
|
* @return array<string,mixed>
|
|
*/
|
|
public function snapshot(string $kind, string $key, array $fields): array
|
|
{
|
|
$definition = $this->definition($kind);
|
|
$record = $this->query($kind, $key)->first();
|
|
abort_unless($record !== null, 404);
|
|
abort_unless(array_diff(array_keys($fields), $definition['fields']) === [], 422, 'Campos fora do âmbito do registo.');
|
|
$context = ['record' => Arr::except(get_object_vars($record), ['created_at', 'updated_at']), 'parents' => []];
|
|
if (! empty($fields['primary_territory_id'])) {
|
|
$path = app(InvestmentGeography::class)->path($fields['primary_territory_id'], true);
|
|
abort_unless($path !== [] && count($path) <= 4 && $path[0]->location_type === 'province', 422, 'Seleccione uma localização na hierarquia existente.');
|
|
}
|
|
foreach (self::PARENTS as $field => $table) {
|
|
$id = $fields[$field] ?? $record->{$field} ?? null;
|
|
if (! $id) {
|
|
continue;
|
|
}
|
|
$parent = DB::table($table)->where('id', $id)->whereNull('deleted_at')->first();
|
|
abort_unless($parent !== null, 422, 'Contexto relacionado indisponível.');
|
|
$context['parents'][$field] = Arr::except(get_object_vars($parent), ['created_at', 'updated_at']);
|
|
}
|
|
if (in_array($kind, ['projects', 'clusters', 'corridors'], true)) {
|
|
$foreign = ['projects' => 'project_id', 'clusters' => 'cluster_id', 'corridors' => 'corridor_id'][$kind];
|
|
$context['portfolio'] = DB::table('investment_dossiers')->where($foreign, $key)->pluck('id')->all();
|
|
abort_unless($context['portfolio'] === [], 409, 'Este registo tem ficha associada. Continue na revisão da carteira.');
|
|
}
|
|
if ($source = $fields['legal_source_id'] ?? $record->legal_source_id ?? null) {
|
|
$sourceRecord = DB::table('investment_legal_sources')->where('id', $source)->first();
|
|
abort_unless($sourceRecord !== null, 422, 'Fonte jurídica indisponível.');
|
|
$context['legal_source'] = get_object_vars($sourceRecord);
|
|
}
|
|
|
|
return ['fields' => $fields, 'context' => $context];
|
|
}
|
|
|
|
/** @param array<string,mixed> $fields */
|
|
public function lock(string $kind, string $key, array $fields = []): \stdClass
|
|
{
|
|
$record = $this->query($kind, $key)->first();
|
|
abort_unless($record !== null, 404);
|
|
$parents = [];
|
|
foreach (self::PARENTS as $field => $table) {
|
|
$id = $fields[$field] ?? $record->{$field} ?? null;
|
|
if ($id) {
|
|
$parents[$table][] = $id;
|
|
}
|
|
}
|
|
ksort($parents);
|
|
foreach ($parents as $table => $ids) {
|
|
DB::table($table)->whereIn('id', array_unique($ids))->orderBy('id')->lockForUpdate()->get();
|
|
}
|
|
|
|
return $this->query($kind, $key)->lockForUpdate()->firstOrFail();
|
|
}
|
|
|
|
/** @param array<string,mixed> $payload */
|
|
public function publish(User $actor, \stdClass $record, \stdClass $edition, array $payload): void
|
|
{
|
|
$subject = $payload['resource']['_subject'];
|
|
$kind = $edition->subject_kind;
|
|
$fields = $subject['fields'];
|
|
$this->lock($kind, $edition->subject_key, $fields);
|
|
if ($subject['withdraw'] ?? false) {
|
|
$this->archive($actor, $record, $edition);
|
|
|
|
return;
|
|
}
|
|
$pair = match ($kind) {
|
|
'cluster-project' => ['cluster_id', 'project_id'], 'corridor-project' => ['corridor_id', 'project_id'], 'corridor-cluster' => ['corridor_id', 'cluster_id'],
|
|
'value-chain-links' => ['from_value_chain_id', 'to_value_chain_id', 'link_type'],
|
|
'zone-relations' => ['zone_id', 'cluster_id', 'corridor_id', 'project_id'], default => [],
|
|
};
|
|
if ($pair !== []) {
|
|
abort_if($kind === 'zone-relations' && count(array_filter(Arr::only($fields, ['cluster_id', 'corridor_id', 'project_id']))) !== 1, 422, 'A relação exige um projecto, cluster ou corredor.');
|
|
$duplicates = DB::table($this->definition($kind)['table'])->where('id', '<>', $record->id);
|
|
foreach ($pair as $field) {
|
|
$duplicates->where($field, $fields[$field] ?? null);
|
|
}
|
|
if (property_exists($record, 'deleted_at')) {
|
|
$duplicates->whereNull('deleted_at');
|
|
}
|
|
abort_if($duplicates->exists(), 409, 'Esta relação já existe. Reveja a proposta sem duplicar o registo.');
|
|
}
|
|
if (in_array($kind, ['projects', 'clusters', 'corridors', 'value-chains'], true) && isset($fields['code'])) {
|
|
$query = DB::table($this->definition($kind)['table'])->where('code', $fields['code'])->where('id', '<>', $record->id)->whereNull('deleted_at');
|
|
if ($kind === 'value-chains') {
|
|
$query->where('cluster_id', $fields['cluster_id'] ?? $record->cluster_id);
|
|
}
|
|
abort_if($query->exists(), 409, 'Código entretanto utilizado. Reveja a proposta.');
|
|
}
|
|
if (in_array($kind, ['cluster-project', 'corridor-project', 'corridor-cluster', 'zone-relations'], true)) {
|
|
foreach (['source_reference', 'valid_from'] as $required) {
|
|
abort_unless(! empty($fields[$required]), 422, 'Fonte e vigência são necessárias.');
|
|
}
|
|
abort_if($kind === 'zone-relations' && ($fields['association_type'] ?? '') === 'official' && empty($fields['legal_source_id']), 422, 'A associação oficial exige a fonte jurídica prevista.');
|
|
if (! empty($fields['legal_source_id'])) {
|
|
$source = DB::table('investment_legal_sources')->where('id', $fields['legal_source_id'])->lockForUpdate()->first();
|
|
abort_unless($source && $source->validation_status === 'validated' && $source->valid_from <= today()->toDateString()
|
|
&& (! $source->valid_until || $source->valid_until >= today()->toDateString()), 422, 'Fonte jurídica fora de vigência.');
|
|
}
|
|
$decision = json_decode($edition->approval, true, 512, JSON_THROW_ON_ERROR);
|
|
$fields += ['validation_status' => 'validated', 'validator_id' => $decision['actor_id'], 'editor_id' => $edition->author_id];
|
|
if (property_exists($record, 'data_classification')) {
|
|
$fields['data_classification'] = 'public';
|
|
}
|
|
}
|
|
if ($kind === 'value-chain-links') {
|
|
$chains = DB::table('value_chains')->whereIn('id', [$fields['from_value_chain_id'], $fields['to_value_chain_id']])->whereNull('deleted_at')->get(['id', 'cluster_id']);
|
|
abort_unless($chains->count() === 2 && $chains->pluck('cluster_id')->unique()->count() === 1, 422, 'As etapas devem ser distintas e pertencer ao mesmo cluster.');
|
|
}
|
|
if ($kind === 'investment-gaps') {
|
|
abort_unless(DB::table('value_chains')->where('id', $fields['value_chain_id'])->where('cluster_id', $fields['cluster_id'])->whereNull('deleted_at')->exists(), 422, 'Etapa fora do cluster da lacuna.');
|
|
}
|
|
if ($subject['new_record'] ?? false) {
|
|
$fields['deleted_at'] = null;
|
|
}
|
|
if (property_exists($record, 'data_classification')) {
|
|
$fields['data_classification'] = 'public';
|
|
}
|
|
if (property_exists($record, 'version_no')) {
|
|
$fields['version_no'] = (int) $record->version_no + 1;
|
|
}
|
|
$fields['updated_at'] = now();
|
|
$this->query($kind, $record->id)->update($fields);
|
|
$this->audit($actor, $edition, 'published', $record);
|
|
}
|
|
|
|
public function archive(User $actor, \stdClass $record, \stdClass $edition): void
|
|
{
|
|
$fields = property_exists($record, 'validation_status') ? ['validation_status' => 'revoked']
|
|
: (in_array($edition->subject_kind, ['projects', 'clusters', 'corridors'], true) ? ['data_classification' => 'internal'] : ['deleted_at' => now()]);
|
|
if (property_exists($record, 'version_no')) {
|
|
$fields['version_no'] = (int) $record->version_no + 1;
|
|
}
|
|
$this->query($edition->subject_kind, $record->id)->update([...$fields, 'updated_at' => now()]);
|
|
$this->audit($actor, $edition, 'archived', $record);
|
|
}
|
|
|
|
private function audit(User $actor, \stdClass $edition, string $action, \stdClass $before): void
|
|
{
|
|
$after = $this->query($edition->subject_kind, $edition->subject_key)->firstOrFail();
|
|
$changed = [];
|
|
foreach (get_object_vars($after) as $field => $value) {
|
|
if (($before->{$field} ?? null) !== $value) {
|
|
$changed[] = $field;
|
|
}
|
|
}
|
|
DB::table('audit_events')->insert(['source_system' => 'editorial-structural-subjects', 'entity_type' => $this->definition($edition->subject_kind)['table'],
|
|
'entity_id' => $edition->subject_key, 'actor_user_id' => $actor->id, 'action' => $action, 'data_classification' => 'restricted',
|
|
'before_data' => json_encode(Arr::only(get_object_vars($before), ['id', 'version_no', 'validation_status', 'deleted_at', 'data_classification']), JSON_THROW_ON_ERROR),
|
|
'after_data' => json_encode(['edition_id' => $edition->id, 'hash' => $edition->content_hash, 'record_id' => $edition->subject_key, 'changed_fields' => $changed], JSON_THROW_ON_ERROR)]);
|
|
}
|
|
}
|