S2b Phase 1 per ARCH-FORM-BUILDER.md §20.2. Ten services + supporting
exceptions, jobs, and the organisations.default_locale column needed by
FormLocaleResolver. All services log via spatie/laravel-activitylog, write
operations are transactional, queued jobs are idempotent.
- FormSchemaService: CRUD, slug, version bump, duplicate, edit-lock,
public_token rotation (7-day grace window), typed-confirmation delete.
- FormFieldService: CRUD, reorder, insertFromLibrary, binding-change guard
(§6.5), conditional_logic + section cycle detection (§8, §4.8.1),
is_filterable toggle triggers BackfillFormValueIndexedJob (§7.2, §22.10).
- FormSubmissionService: createDraft with idempotency, saveDraft (auto-save),
submit with schema snapshot + signature hash computation (§9), review,
delegate/revoke, soft delete. Fires S1 domain events (§17.1).
- FormValueService: bulk upsert with FieldAccessService RBAC (§24.2),
Pattern A/C entity mirror writes (§6.1, §6.6) with cross-entity graceful
skip for person.user_id=null.
- FieldAccessService: canRead/canWrite/filterVisibleFields honouring
role_restrictions + subject-self (§18.3, §24.1).
- FormLocaleResolver: submitter → schema → org.default_locale → 'nl' (§16.2).
- FormTagSyncService: rebuildForPerson — replaces legacy TagSyncService
deleted in S2a (§31.10).
- FilterQueryBuilder: generic filter applier for entity_column / tags /
form_field sources (§7.4–§7.5).
- FormWebhookDispatcher + DeliverFormWebhookJob: HMAC-signed delivery with
SSRF protection, exponential backoff {1m,5m,30m,2h,8h}, max 5 attempts,
dead-letter on exhaustion (§17.5).
- FormSubmissionAnonymisationService: per-field anonymisation with separate
activity log entries (§13.3, §23.4).
MigrationRollbackTest: pin the S2a drop migration by filename so future
migrations don't shift the step offset.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
88 lines
3.0 KiB
PHP
88 lines
3.0 KiB
PHP
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Services\FormBuilder;
|
|
|
|
use App\Enums\FormBuilder\FormFieldType;
|
|
use App\Events\FormBuilder\FormSubmissionAnonymised;
|
|
use App\Models\FormBuilder\FormSubmission;
|
|
use App\Models\FormBuilder\FormValue;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Support\Facades\Storage;
|
|
|
|
/**
|
|
* Right-to-be-forgotten per ARCH §13.3 + §23.4. Blanks PII values + signature
|
|
* files, marks value_anonymised, writes per-field activity log entries.
|
|
*/
|
|
final class FormSubmissionAnonymisationService
|
|
{
|
|
public function anonymise(FormSubmission $submission, string $reason = 'retention_policy'): void
|
|
{
|
|
DB::transaction(function () use ($submission, $reason): void {
|
|
$values = FormValue::query()
|
|
->with('field')
|
|
->where('form_submission_id', $submission->id)
|
|
->get();
|
|
|
|
foreach ($values as $value) {
|
|
$field = $value->field;
|
|
if ($field === null || ! $field->is_pii) {
|
|
continue;
|
|
}
|
|
|
|
$this->anonymiseValue($value, $field->field_type);
|
|
|
|
activity()
|
|
->performedOn($value)
|
|
->withProperties([
|
|
'field_slug' => $field->slug,
|
|
'reason' => $reason,
|
|
'original_was_pii' => true,
|
|
])
|
|
->log('field.anonymised');
|
|
}
|
|
|
|
$submission->anonymised_at = now();
|
|
$submission->search_index = null;
|
|
$submission->save();
|
|
});
|
|
|
|
FormSubmissionAnonymised::dispatch($submission);
|
|
}
|
|
|
|
private function anonymiseValue(FormValue $value, string $fieldType): void
|
|
{
|
|
if ($fieldType === FormFieldType::SIGNATURE->value) {
|
|
$raw = $value->value;
|
|
if (is_array($raw) && isset($raw['file_path'], $raw['disk'])) {
|
|
try {
|
|
Storage::disk((string) $raw['disk'])->delete((string) $raw['file_path']);
|
|
} catch (\Throwable) {
|
|
// Swallow; best-effort.
|
|
}
|
|
}
|
|
$value->value = ['anonymised' => true];
|
|
} elseif (in_array($fieldType, [FormFieldType::FILE_UPLOAD->value, FormFieldType::IMAGE_UPLOAD->value], true)) {
|
|
$raw = $value->value;
|
|
if (is_array($raw) && isset($raw['file_path'])) {
|
|
try {
|
|
Storage::disk((string) ($raw['disk'] ?? config('filesystems.default')))->delete((string) $raw['file_path']);
|
|
} catch (\Throwable) {
|
|
// Best-effort.
|
|
}
|
|
}
|
|
$value->value = ['anonymised' => true, 'original_filename_redacted' => true];
|
|
} else {
|
|
$value->value = '[ANONYMISED]';
|
|
}
|
|
|
|
$value->value_indexed = null;
|
|
$value->value_number = null;
|
|
$value->value_date = null;
|
|
$value->value_bool = null;
|
|
$value->value_anonymised = true;
|
|
$value->save();
|
|
}
|
|
}
|