feat(form-builder): add core services (schema, field, submission, value, field-access, locale, tag-sync, filter, webhook, anonymisation)
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>
This commit is contained in:
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\FormBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class BindingChangeBlockedException extends RuntimeException
|
||||
{
|
||||
public static function forField(string $fieldId, int $submissionCount): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Binding change blocked for field %s: %d submitted submission(s) exist. Use forceBindingChange=true to override.',
|
||||
$fieldId,
|
||||
$submissionCount,
|
||||
));
|
||||
}
|
||||
}
|
||||
20
api/app/Exceptions/FormBuilder/CyclicDependencyException.php
Normal file
20
api/app/Exceptions/FormBuilder/CyclicDependencyException.php
Normal file
@@ -0,0 +1,20 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\FormBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class CyclicDependencyException extends RuntimeException
|
||||
{
|
||||
public static function forField(string $slug): self
|
||||
{
|
||||
return new self(sprintf('Cyclic conditional_logic detected on field %s.', $slug));
|
||||
}
|
||||
|
||||
public static function forSection(string $sectionId): self
|
||||
{
|
||||
return new self(sprintf('Cyclic section dependency detected on section %s.', $sectionId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\FormBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class DestructiveConfirmationRequiredException extends RuntimeException
|
||||
{
|
||||
public static function forName(string $expectedName): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Destructive action requires ?confirmed_name=%s',
|
||||
$expectedName,
|
||||
));
|
||||
}
|
||||
}
|
||||
22
api/app/Exceptions/FormBuilder/EditLockConflictException.php
Normal file
22
api/app/Exceptions/FormBuilder/EditLockConflictException.php
Normal file
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\FormBuilder;
|
||||
|
||||
use App\Models\User;
|
||||
use RuntimeException;
|
||||
|
||||
final class EditLockConflictException extends RuntimeException
|
||||
{
|
||||
public function __construct(
|
||||
public readonly ?User $holder,
|
||||
public readonly ?\DateTimeInterface $expiresAt,
|
||||
) {
|
||||
parent::__construct(sprintf(
|
||||
'Schema is currently locked by user %s until %s.',
|
||||
$holder?->id ?? 'unknown',
|
||||
$expiresAt?->format('Y-m-d H:i:s') ?? 'unknown',
|
||||
));
|
||||
}
|
||||
}
|
||||
18
api/app/Exceptions/FormBuilder/FrozenSchemaException.php
Normal file
18
api/app/Exceptions/FormBuilder/FrozenSchemaException.php
Normal file
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Exceptions\FormBuilder;
|
||||
|
||||
use RuntimeException;
|
||||
|
||||
final class FrozenSchemaException extends RuntimeException
|
||||
{
|
||||
public static function forSchema(string $schemaId): self
|
||||
{
|
||||
return new self(sprintf(
|
||||
'Schema %s is frozen (freeze_on_submit=true and has submitted submissions).',
|
||||
$schemaId,
|
||||
));
|
||||
}
|
||||
}
|
||||
44
api/app/Jobs/FormBuilder/BackfillFormValueIndexedJob.php
Normal file
44
api/app/Jobs/FormBuilder/BackfillFormValueIndexedJob.php
Normal file
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\FormBuilder;
|
||||
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* Re-saves every FormValue for a field so the observer re-computes typed
|
||||
* columns (ARCH §7.2, §22.10). Idempotent — handles toggle in either direction.
|
||||
*/
|
||||
final class BackfillFormValueIndexedJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
public function __construct(public readonly string $fieldId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
$field = FormField::query()->find($this->fieldId);
|
||||
if ($field === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
FormValue::query()
|
||||
->where('form_field_id', $this->fieldId)
|
||||
->chunkById(500, function ($values) use ($field): void {
|
||||
foreach ($values as $value) {
|
||||
$value->setRelation('field', $field);
|
||||
$value->save();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
187
api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php
Normal file
187
api/app/Jobs/FormBuilder/DeliverFormWebhookJob.php
Normal file
@@ -0,0 +1,187 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Jobs\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormWebhookDeliveryStatus;
|
||||
use App\Models\FormBuilder\FormWebhookDelivery;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Foundation\Bus\Dispatchable;
|
||||
use Illuminate\Queue\InteractsWithQueue;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* HTTP delivery with HMAC signing, SSRF protection, exponential backoff
|
||||
* retries, and dead-letter on exhaustion (ARCH §17.5.3–§17.5.4, §22.10).
|
||||
*/
|
||||
final class DeliverFormWebhookJob implements ShouldQueue
|
||||
{
|
||||
use Dispatchable;
|
||||
use InteractsWithQueue;
|
||||
use Queueable;
|
||||
use SerializesModels;
|
||||
|
||||
/** @var array<int, int> Retry delays in seconds per attempt. */
|
||||
public array $backoff = [60, 300, 1800, 7200, 28800];
|
||||
|
||||
public int $timeout = 30;
|
||||
|
||||
public int $tries = 5;
|
||||
|
||||
public function __construct(public readonly string $deliveryId) {}
|
||||
|
||||
public function handle(): void
|
||||
{
|
||||
/** @var FormWebhookDelivery|null $delivery */
|
||||
$delivery = FormWebhookDelivery::query()->find($this->deliveryId);
|
||||
if ($delivery === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webhook = $delivery->webhook;
|
||||
if ($webhook === null || ! $webhook->is_active) {
|
||||
$delivery->status = FormWebhookDeliveryStatus::FAILED->value;
|
||||
$delivery->failed_permanently_at = now();
|
||||
$delivery->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$url = (string) $webhook->url;
|
||||
if (! $this->urlIsSafe($url)) {
|
||||
$delivery->status = FormWebhookDeliveryStatus::FAILED->value;
|
||||
$delivery->failed_permanently_at = now();
|
||||
$delivery->response_body_excerpt = 'SSRF protection: URL rejected.';
|
||||
$delivery->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = (array) ($delivery->payload_snapshot ?? []);
|
||||
$body = json_encode($payload, JSON_THROW_ON_ERROR);
|
||||
|
||||
$headers = ['Content-Type' => 'application/json'];
|
||||
if (! empty($webhook->secret)) {
|
||||
$headers['X-Crewli-Signature'] = 'sha256='.hash_hmac('sha256', $body, (string) $webhook->secret);
|
||||
}
|
||||
|
||||
$delivery->attempts = (int) $delivery->attempts + 1;
|
||||
$delivery->last_attempt_at = now();
|
||||
|
||||
try {
|
||||
$response = Http::withHeaders($headers)
|
||||
->timeout((int) config('form_builder.webhooks.timeout_seconds', 10))
|
||||
->withBody($body, 'application/json')
|
||||
->post($url);
|
||||
|
||||
$delivery->response_status = $response->status();
|
||||
$delivery->response_body_excerpt = mb_substr((string) $response->body(), 0, 1000);
|
||||
|
||||
if ($response->successful()) {
|
||||
$delivery->status = FormWebhookDeliveryStatus::DELIVERED->value;
|
||||
$delivery->delivered_at = now();
|
||||
$delivery->save();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($this->isRetriable($response->status()) && $delivery->attempts < $this->tries) {
|
||||
$delivery->status = FormWebhookDeliveryStatus::PENDING->value;
|
||||
$delivery->next_retry_at = now()->addSeconds($this->backoff[$delivery->attempts - 1] ?? 28800);
|
||||
$delivery->save();
|
||||
$this->release($this->backoff[$delivery->attempts - 1] ?? 28800);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$delivery->status = $delivery->attempts >= $this->tries
|
||||
? FormWebhookDeliveryStatus::DEAD_LETTER->value
|
||||
: FormWebhookDeliveryStatus::FAILED->value;
|
||||
$delivery->failed_permanently_at = now();
|
||||
$delivery->save();
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('form-webhook.delivery.exception', [
|
||||
'delivery_id' => $delivery->id,
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
if ($delivery->attempts < $this->tries) {
|
||||
$delivery->status = FormWebhookDeliveryStatus::PENDING->value;
|
||||
$delivery->next_retry_at = now()->addSeconds($this->backoff[$delivery->attempts - 1] ?? 28800);
|
||||
$delivery->save();
|
||||
$this->release($this->backoff[$delivery->attempts - 1] ?? 28800);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$delivery->status = FormWebhookDeliveryStatus::DEAD_LETTER->value;
|
||||
$delivery->failed_permanently_at = now();
|
||||
$delivery->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function urlIsSafe(string $url): bool
|
||||
{
|
||||
$parts = parse_url($url);
|
||||
if ($parts === false || ! isset($parts['host'], $parts['scheme'])) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (! in_array(strtolower($parts['scheme']), ['http', 'https'], true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$host = (string) $parts['host'];
|
||||
$allowlist = (array) config('form_builder.webhooks.allowlist_domains', []);
|
||||
if ($allowlist !== [] && ! in_array(strtolower($host), array_map('strtolower', $allowlist), true)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$ips = @gethostbynamel($host) ?: [$host];
|
||||
$blocklist = (array) config('form_builder.webhooks.blocklist_ips', []);
|
||||
foreach ($ips as $ip) {
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP)) {
|
||||
continue;
|
||||
}
|
||||
foreach ($blocklist as $cidr) {
|
||||
if ($this->ipInCidr($ip, (string) $cidr)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (! filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function ipInCidr(string $ip, string $cidr): bool
|
||||
{
|
||||
if (! str_contains($cidr, '/')) {
|
||||
return $ip === $cidr;
|
||||
}
|
||||
[$subnet, $mask] = explode('/', $cidr, 2);
|
||||
$ipLong = ip2long($ip);
|
||||
$subnetLong = ip2long($subnet);
|
||||
if ($ipLong === false || $subnetLong === false) {
|
||||
return false;
|
||||
}
|
||||
$maskLong = -1 << (32 - (int) $mask);
|
||||
|
||||
return (($ipLong & $maskLong) === ($subnetLong & $maskLong));
|
||||
}
|
||||
|
||||
private function isRetriable(int $status): bool
|
||||
{
|
||||
if ($status >= 500) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return in_array($status, [408, 425, 429], true);
|
||||
}
|
||||
}
|
||||
163
api/app/Services/FormBuilder/FieldAccessService.php
Normal file
163
api/app/Services/FormBuilder/FieldAccessService.php
Normal file
@@ -0,0 +1,163 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\User;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
/**
|
||||
* Per-field RBAC for form rendering, value writes, and filter access.
|
||||
* Implements ARCH §24 (role_restrictions matrix) + §18.3 (subject-self
|
||||
* always has access).
|
||||
*/
|
||||
final class FieldAccessService
|
||||
{
|
||||
public function canRead(?User $user, FormField $field, ?FormSubmission $submission = null): bool
|
||||
{
|
||||
if ($this->isSubjectSelf($user, $submission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$restrictions = $this->effectiveRestrictions($field);
|
||||
$readRule = $restrictions['read'] ?? true;
|
||||
|
||||
return $this->matches($user, $readRule);
|
||||
}
|
||||
|
||||
public function canWrite(?User $user, FormField $field, ?FormSubmission $submission = null): bool
|
||||
{
|
||||
if ($this->isSubjectSelf($user, $submission)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$restrictions = $this->effectiveRestrictions($field);
|
||||
$writeRule = $restrictions['write'] ?? ['any_of_roles' => ['org_admin', 'event_manager']];
|
||||
|
||||
return $this->matches($user, $writeRule);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Collection<int, FormField> $fields
|
||||
* @return Collection<int, FormField>
|
||||
*/
|
||||
public function filterVisibleFields(?User $user, Collection $fields, ?FormSubmission $submission = null): Collection
|
||||
{
|
||||
return $fields->filter(fn (FormField $f) => $this->canRead($user, $f, $submission))->values();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function effectiveRestrictions(FormField $field): array
|
||||
{
|
||||
$r = $field->role_restrictions ?? null;
|
||||
if (is_array($r) && $r !== []) {
|
||||
return $r;
|
||||
}
|
||||
|
||||
if ($field->is_admin_only) {
|
||||
return [
|
||||
'read' => ['any_of_roles' => ['org_admin']],
|
||||
'write' => ['any_of_roles' => ['org_admin']],
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
'read' => true,
|
||||
'write' => ['any_of_roles' => ['org_admin', 'event_manager']],
|
||||
];
|
||||
}
|
||||
|
||||
private function isSubjectSelf(?User $user, ?FormSubmission $submission): bool
|
||||
{
|
||||
if ($user === null || $submission === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($submission->submitted_by_user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($submission->subject_type === 'user' && $submission->subject_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if ($submission->subject_type === 'user_profile' && $submission->subject_id !== null) {
|
||||
$profile = \App\Models\UserProfile::query()->find($submission->subject_id);
|
||||
if ($profile !== null && $profile->user_id === $user->id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if ($submission->subject_type === 'person' && $submission->subject_id !== null) {
|
||||
$personUserId = \App\Models\Person::withoutGlobalScopes()
|
||||
->whereKey($submission->subject_id)
|
||||
->value('user_id');
|
||||
if ($personUserId !== null && $personUserId === $user->id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $rule
|
||||
*/
|
||||
private function matches(?User $user, $rule): bool
|
||||
{
|
||||
if ($rule === true) {
|
||||
return true;
|
||||
}
|
||||
if ($rule === false) {
|
||||
return false;
|
||||
}
|
||||
if (! is_array($rule)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($user === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($rule['subject_self']) && $rule['subject_self'] === true) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($rule['any_of_roles']) && is_array($rule['any_of_roles'])) {
|
||||
foreach ($rule['any_of_roles'] as $role) {
|
||||
if ($user->hasRole((string) $role)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isset($rule['all_of_roles']) && is_array($rule['all_of_roles'])) {
|
||||
foreach ($rule['all_of_roles'] as $role) {
|
||||
if (! $user->hasRole((string) $role)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
if (isset($rule['not_roles']) && is_array($rule['not_roles'])) {
|
||||
foreach ($rule['not_roles'] as $role) {
|
||||
if ($user->hasRole((string) $role)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
113
api/app/Services/FormBuilder/FilterQueryBuilder.php
Normal file
113
api/app/Services/FormBuilder/FilterQueryBuilder.php
Normal file
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Database\Eloquent\Builder;
|
||||
|
||||
/**
|
||||
* Generic list-endpoint filter applier per ARCH §7.4–§7.5. Scaffolded here
|
||||
* so Phase-5 Personen integration can wire up; TAG_PICKER and SECTION_PRIORITY
|
||||
* joins match the §7.1 storage matrix.
|
||||
*/
|
||||
final class FilterQueryBuilder
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FieldAccessService $fieldAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $params
|
||||
* @param array<string, array{source:string, label?:string, field_type?:string, options?:mixed, options_enum?:string}> $registry
|
||||
*/
|
||||
public function apply(Builder $query, array $params, array $registry, ?User $actor, string $context = 'persons'): Builder
|
||||
{
|
||||
foreach ($params as $key => $value) {
|
||||
$def = $registry[$key] ?? null;
|
||||
if ($def === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$source = (string) $def['source'];
|
||||
match ($source) {
|
||||
'entity_column' => $this->applyEntityColumn($query, $key, $value),
|
||||
'tags' => $this->applyTagFilter($query, $value, $context),
|
||||
'form_field' => $this->applyFormFieldFilter($query, (string) ($def['form_field_id'] ?? ''), $value, $actor),
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
return $query;
|
||||
}
|
||||
|
||||
private function applyEntityColumn(Builder $query, string $column, mixed $value): void
|
||||
{
|
||||
if (is_array($value)) {
|
||||
$query->whereIn($column, $value);
|
||||
|
||||
return;
|
||||
}
|
||||
if (is_bool($value)) {
|
||||
$query->where($column, $value);
|
||||
|
||||
return;
|
||||
}
|
||||
if ($value === null || $value === '') {
|
||||
return;
|
||||
}
|
||||
$query->where($column, $value);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string>|string $tagIds
|
||||
*/
|
||||
private function applyTagFilter(Builder $query, mixed $tagIds, string $context): void
|
||||
{
|
||||
$ids = is_array($tagIds) ? array_values($tagIds) : [$tagIds];
|
||||
$ids = array_filter($ids, 'is_string');
|
||||
if ($ids === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ($context === 'persons') {
|
||||
$query->whereHas('user.organisationTags', fn (Builder $q) => $q->whereIn('person_tag_id', $ids));
|
||||
}
|
||||
}
|
||||
|
||||
private function applyFormFieldFilter(Builder $query, string $fieldId, mixed $value, ?User $actor): void
|
||||
{
|
||||
$field = FormField::query()->find($fieldId);
|
||||
if ($field === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (! $this->fieldAccess->canRead($actor, $field)) {
|
||||
throw new AuthorizationException(sprintf('Not allowed to filter on field %s.', $fieldId));
|
||||
}
|
||||
|
||||
$query->whereIn('persons.id', function ($sub) use ($fieldId, $value): void {
|
||||
$sub->from('form_submissions')
|
||||
->join('form_values', 'form_values.form_submission_id', '=', 'form_submissions.id')
|
||||
->where('form_values.form_field_id', $fieldId)
|
||||
->where('form_submissions.subject_type', 'person')
|
||||
->where('form_submissions.status', 'submitted')
|
||||
->whereColumn('form_submissions.subject_id', 'persons.id')
|
||||
->select('form_submissions.subject_id');
|
||||
|
||||
if (is_array($value)) {
|
||||
$sub->join('form_value_options', 'form_value_options.form_value_id', '=', 'form_values.id')
|
||||
->whereIn('form_value_options.option_value', $value);
|
||||
} elseif (is_bool($value)) {
|
||||
$sub->where('form_values.value_bool', $value);
|
||||
} elseif (is_numeric($value)) {
|
||||
$sub->where('form_values.value_number', $value);
|
||||
} else {
|
||||
$sub->where('form_values.value_indexed', $value);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
334
api/app/Services/FormBuilder/FormFieldService.php
Normal file
334
api/app/Services/FormBuilder/FormFieldService.php
Normal file
@@ -0,0 +1,334 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Exceptions\FormBuilder\BindingChangeBlockedException;
|
||||
use App\Exceptions\FormBuilder\CyclicDependencyException;
|
||||
use App\Exceptions\FormBuilder\DestructiveConfirmationRequiredException;
|
||||
use App\Exceptions\FormBuilder\FrozenSchemaException;
|
||||
use App\Jobs\FormBuilder\BackfillFormValueIndexedJob;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormFieldLibrary;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSchemaSection;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* FormField CRUD + reorder + library insertion + binding safety
|
||||
* (ARCH §4.2, §6.5, §8, §4.8.1, §7.2).
|
||||
*/
|
||||
final class FormFieldService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FormSchemaService $schemaService,
|
||||
) {}
|
||||
|
||||
public function create(FormSchema $schema, array $data): FormField
|
||||
{
|
||||
$this->assertNotFrozen($schema);
|
||||
|
||||
$data['form_schema_id'] = $schema->id;
|
||||
$data['sort_order'] ??= $this->nextSortOrder($schema);
|
||||
|
||||
$this->assertNoConditionalCycle($schema, null, $data['conditional_logic'] ?? null, $data['slug'] ?? null);
|
||||
|
||||
/** @var FormField $field */
|
||||
$field = FormField::create($data);
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
$field->logFieldChange('field.created');
|
||||
|
||||
if ($field->is_filterable) {
|
||||
BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default');
|
||||
}
|
||||
|
||||
return $field->refresh();
|
||||
}
|
||||
|
||||
public function update(FormField $field, array $data, bool $forceBindingChange = false): FormField
|
||||
{
|
||||
$schema = $field->schema;
|
||||
$this->assertNotFrozenForStructural($schema, $data);
|
||||
|
||||
if (array_key_exists('binding', $data) && $data['binding'] !== $field->binding) {
|
||||
$this->assertBindingChangeAllowed($field, $forceBindingChange);
|
||||
}
|
||||
|
||||
if (array_key_exists('conditional_logic', $data)) {
|
||||
$this->assertNoConditionalCycle($schema, $field, $data['conditional_logic'], $data['slug'] ?? $field->slug);
|
||||
}
|
||||
|
||||
$before = [
|
||||
'binding' => $field->binding,
|
||||
'is_filterable' => $field->is_filterable,
|
||||
'is_pii' => $field->is_pii,
|
||||
'field_type' => $field->field_type,
|
||||
];
|
||||
|
||||
$field->fill($data);
|
||||
$field->save();
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
|
||||
$field->logFieldChange('field.updated', [
|
||||
'old' => $before,
|
||||
'new' => [
|
||||
'binding' => $field->binding,
|
||||
'is_filterable' => $field->is_filterable,
|
||||
'is_pii' => $field->is_pii,
|
||||
'field_type' => $field->field_type,
|
||||
],
|
||||
]);
|
||||
|
||||
if ($before['is_filterable'] !== $field->is_filterable) {
|
||||
BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default');
|
||||
}
|
||||
|
||||
return $field->refresh();
|
||||
}
|
||||
|
||||
public function delete(FormField $field, ?string $confirmedName = null): void
|
||||
{
|
||||
$schema = $field->schema;
|
||||
$this->assertNotFrozen($schema);
|
||||
|
||||
$hasValues = FormValue::query()->where('form_field_id', $field->id)->exists();
|
||||
if ($hasValues && $confirmedName !== $field->label) {
|
||||
throw DestructiveConfirmationRequiredException::forName($field->label);
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($field, $schema): void {
|
||||
$field->logFieldChange('field.deleted');
|
||||
$field->delete();
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $orderedFieldIds
|
||||
*/
|
||||
public function reorder(FormSchema $schema, array $orderedFieldIds): void
|
||||
{
|
||||
DB::transaction(function () use ($schema, $orderedFieldIds): void {
|
||||
foreach ($orderedFieldIds as $index => $fieldId) {
|
||||
FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->whereKey($fieldId)
|
||||
->update(['sort_order' => $index]);
|
||||
}
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
});
|
||||
}
|
||||
|
||||
public function insertFromLibrary(FormSchema $schema, FormFieldLibrary $library, array $overrides = []): FormField
|
||||
{
|
||||
$this->assertNotFrozen($schema);
|
||||
|
||||
$data = array_merge([
|
||||
'form_schema_id' => $schema->id,
|
||||
'library_field_id' => $library->id,
|
||||
'field_type' => $library->field_type,
|
||||
'slug' => $this->ensureUniqueSlug($schema, $library->slug),
|
||||
'label' => $library->label,
|
||||
'help_text' => $library->help_text,
|
||||
'options' => $library->options,
|
||||
'validation_rules' => $library->validation_rules,
|
||||
'is_required' => (bool) $library->default_is_required,
|
||||
'is_filterable' => (bool) $library->default_is_filterable,
|
||||
'binding' => $library->default_binding,
|
||||
'translations' => $library->translations,
|
||||
'sort_order' => $this->nextSortOrder($schema),
|
||||
], $overrides);
|
||||
|
||||
if (! isset($data['slug']) || $data['slug'] === '') {
|
||||
$data['slug'] = $this->ensureUniqueSlug($schema, $library->slug);
|
||||
} else {
|
||||
$data['slug'] = $this->ensureUniqueSlug($schema, $data['slug']);
|
||||
}
|
||||
|
||||
/** @var FormField $field */
|
||||
$field = FormField::create($data);
|
||||
|
||||
FormFieldLibrary::query()->whereKey($library->id)->increment('usage_count');
|
||||
|
||||
$this->schemaService->bumpVersion($schema);
|
||||
$field->logFieldChange('field.inserted_from_library', ['library_field_id' => $library->id]);
|
||||
|
||||
if ($field->is_filterable) {
|
||||
BackfillFormValueIndexedJob::dispatch($field->id)->onQueue('default');
|
||||
}
|
||||
|
||||
return $field->refresh();
|
||||
}
|
||||
|
||||
private function assertBindingChangeAllowed(FormField $field, bool $forceBindingChange): void
|
||||
{
|
||||
$submittedCount = FormSubmission::query()
|
||||
->where('form_schema_id', $field->form_schema_id)
|
||||
->where('status', 'submitted')
|
||||
->count();
|
||||
|
||||
if ($submittedCount > 0 && ! $forceBindingChange) {
|
||||
throw BindingChangeBlockedException::forField($field->id, $submittedCount);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotFrozen(FormSchema $schema): void
|
||||
{
|
||||
if ($schema->freeze_on_submit && $this->schemaService->hasSubmittedSubmissions($schema)) {
|
||||
throw FrozenSchemaException::forSchema($schema->id);
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNotFrozenForStructural(FormSchema $schema, array $data): void
|
||||
{
|
||||
$structuralKeys = ['field_type', 'binding', 'options', 'validation_rules', 'is_required', 'slug'];
|
||||
foreach ($structuralKeys as $key) {
|
||||
if (array_key_exists($key, $data)) {
|
||||
$this->assertNotFrozen($schema);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function assertNoConditionalCycle(FormSchema $schema, ?FormField $subject, mixed $conditionalLogic, ?string $subjectSlug): void
|
||||
{
|
||||
if ($conditionalLogic === null || $subjectSlug === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$dependsOn = $this->extractConditionSlugs($conditionalLogic);
|
||||
if ($dependsOn === []) {
|
||||
return;
|
||||
}
|
||||
|
||||
$adjacency = $this->buildConditionalAdjacency($schema, $subject, $subjectSlug, $dependsOn);
|
||||
|
||||
$visiting = [];
|
||||
$visited = [];
|
||||
$walk = function (string $node) use (&$walk, &$adjacency, &$visiting, &$visited, $subjectSlug): void {
|
||||
if (isset($visited[$node])) {
|
||||
return;
|
||||
}
|
||||
if (isset($visiting[$node])) {
|
||||
throw CyclicDependencyException::forField($subjectSlug);
|
||||
}
|
||||
$visiting[$node] = true;
|
||||
foreach ($adjacency[$node] ?? [] as $next) {
|
||||
$walk($next);
|
||||
}
|
||||
unset($visiting[$node]);
|
||||
$visited[$node] = true;
|
||||
};
|
||||
|
||||
$walk($subjectSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function extractConditionSlugs(mixed $logic): array
|
||||
{
|
||||
if (! is_array($logic)) {
|
||||
return [];
|
||||
}
|
||||
$slugs = [];
|
||||
$walk = function ($node) use (&$walk, &$slugs): void {
|
||||
if (! is_array($node)) {
|
||||
return;
|
||||
}
|
||||
if (isset($node['field_slug'])) {
|
||||
$slugs[] = (string) $node['field_slug'];
|
||||
}
|
||||
foreach ($node as $child) {
|
||||
if (is_array($child)) {
|
||||
$walk($child);
|
||||
}
|
||||
}
|
||||
};
|
||||
$walk($logic);
|
||||
|
||||
return array_values(array_unique($slugs));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $seedDeps
|
||||
* @return array<string, array<int, string>>
|
||||
*/
|
||||
private function buildConditionalAdjacency(FormSchema $schema, ?FormField $subject, string $subjectSlug, array $seedDeps): array
|
||||
{
|
||||
$fields = FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->get(['id', 'slug', 'conditional_logic']);
|
||||
|
||||
$adjacency = [];
|
||||
foreach ($fields as $f) {
|
||||
if ($subject !== null && $f->id === $subject->id) {
|
||||
continue;
|
||||
}
|
||||
$deps = $this->extractConditionSlugs($f->conditional_logic);
|
||||
if ($deps !== []) {
|
||||
$adjacency[$f->slug] = $deps;
|
||||
}
|
||||
}
|
||||
$adjacency[$subjectSlug] = $seedDeps;
|
||||
|
||||
return $adjacency;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<int, string>
|
||||
*/
|
||||
public function detectSectionCycle(FormSchema $schema, FormSchemaSection $section, ?string $dependsOnId): void
|
||||
{
|
||||
if ($dependsOnId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$chain = [];
|
||||
$current = $dependsOnId;
|
||||
$safety = 100;
|
||||
while ($current !== null && $safety-- > 0) {
|
||||
if ($current === $section->id) {
|
||||
throw CyclicDependencyException::forSection($section->id);
|
||||
}
|
||||
$chain[] = $current;
|
||||
$parent = FormSchemaSection::query()
|
||||
->whereKey($current)
|
||||
->value('depends_on_section_id');
|
||||
$current = $parent !== null ? (string) $parent : null;
|
||||
}
|
||||
}
|
||||
|
||||
private function nextSortOrder(FormSchema $schema): int
|
||||
{
|
||||
$max = (int) FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->max('sort_order');
|
||||
|
||||
return $max + 1;
|
||||
}
|
||||
|
||||
private function ensureUniqueSlug(FormSchema $schema, string $slug): string
|
||||
{
|
||||
$base = \Illuminate\Support\Str::slug($slug) ?: 'veld';
|
||||
$candidate = $base;
|
||||
$i = 2;
|
||||
while (FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->where('slug', $candidate)
|
||||
->exists()
|
||||
) {
|
||||
$candidate = $base.'-'.$i;
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
}
|
||||
34
api/app/Services/FormBuilder/FormLocaleResolver.php
Normal file
34
api/app/Services/FormBuilder/FormLocaleResolver.php
Normal file
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
|
||||
/**
|
||||
* Resolves the effective locale for a form submission per ARCH §16.2.
|
||||
* Chain: submitter.locale → schema.locale → organisation.default_locale → 'nl'.
|
||||
*/
|
||||
final class FormLocaleResolver
|
||||
{
|
||||
public function resolve(FormSchema $schema, ?User $submitter = null): string
|
||||
{
|
||||
if ($submitter !== null && ! empty($submitter->locale)) {
|
||||
return (string) $submitter->locale;
|
||||
}
|
||||
|
||||
if (! empty($schema->locale)) {
|
||||
return (string) $schema->locale;
|
||||
}
|
||||
|
||||
$organisation = $schema->organisation ?? Organisation::withoutGlobalScopes()->find($schema->organisation_id);
|
||||
if ($organisation !== null && ! empty($organisation->default_locale)) {
|
||||
return (string) $organisation->default_locale;
|
||||
}
|
||||
|
||||
return 'nl';
|
||||
}
|
||||
}
|
||||
260
api/app/Services/FormBuilder/FormSchemaService.php
Normal file
260
api/app/Services/FormBuilder/FormSchemaService.php
Normal file
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Enums\FormBuilder\FormSubmissionStatus;
|
||||
use App\Exceptions\FormBuilder\DestructiveConfirmationRequiredException;
|
||||
use App\Exceptions\FormBuilder\EditLockConflictException;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSchemaSection;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\Organisation;
|
||||
use App\Models\User;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Schema CRUD + lifecycle per ARCH §4.1, §14, §18.1.
|
||||
*/
|
||||
final class FormSchemaService
|
||||
{
|
||||
public function create(Organisation $organisation, array $data, User $actor): FormSchema
|
||||
{
|
||||
return DB::transaction(function () use ($organisation, $data, $actor): FormSchema {
|
||||
$purpose = $this->resolvePurpose($data['purpose'] ?? null);
|
||||
$data['organisation_id'] = $organisation->id;
|
||||
$data['slug'] = $this->ensureUniqueSlug($organisation, $data['slug'] ?? Str::slug($data['name'] ?? 'formulier'));
|
||||
$data['purpose'] = $purpose->value;
|
||||
$data['submission_mode'] ??= $purpose->defaultSubmissionMode()->value;
|
||||
$data['version'] = 1;
|
||||
$data['created_by_user_id'] = $actor->id;
|
||||
$data['last_updated_by_user_id'] = $actor->id;
|
||||
|
||||
/** @var FormSchema $schema */
|
||||
$schema = FormSchema::create($data);
|
||||
|
||||
$schema->logSchemaChange('schema.created', ['purpose' => $purpose->value]);
|
||||
|
||||
return $schema->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function update(FormSchema $schema, array $data, User $actor): FormSchema
|
||||
{
|
||||
return DB::transaction(function () use ($schema, $data, $actor): FormSchema {
|
||||
if (isset($data['slug']) && $data['slug'] !== $schema->slug) {
|
||||
$data['slug'] = $this->ensureUniqueSlug($schema->organisation, $data['slug'], $schema->id);
|
||||
}
|
||||
|
||||
$before = $schema->toArray();
|
||||
$schema->fill($data);
|
||||
|
||||
if ($this->isStructuralChange($schema)) {
|
||||
$schema->version = (int) $schema->version + 1;
|
||||
}
|
||||
|
||||
$schema->last_updated_by_user_id = $actor->id;
|
||||
$schema->save();
|
||||
|
||||
$schema->logSchemaChange('schema.updated', [
|
||||
'old_version' => $before['version'] ?? null,
|
||||
'new_version' => $schema->version,
|
||||
]);
|
||||
|
||||
return $schema->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function duplicate(FormSchema $source, User $actor, ?string $nameOverride = null): FormSchema
|
||||
{
|
||||
return DB::transaction(function () use ($source, $actor, $nameOverride): FormSchema {
|
||||
$name = $nameOverride ?? ($source->name.' (kopie)');
|
||||
$copy = $source->replicate([
|
||||
'public_token', 'public_token_previous', 'public_token_rotated_at',
|
||||
'edit_lock_user_id', 'edit_lock_expires_at',
|
||||
]);
|
||||
$copy->name = $name;
|
||||
$copy->slug = $this->ensureUniqueSlug($source->organisation, Str::slug($name));
|
||||
$copy->version = 1;
|
||||
$copy->is_published = false;
|
||||
$copy->created_by_user_id = $actor->id;
|
||||
$copy->last_updated_by_user_id = $actor->id;
|
||||
$copy->save();
|
||||
|
||||
// Copy sections (id mapping) then fields (pointing at new section ids).
|
||||
$sectionIdMap = [];
|
||||
foreach ($source->sections as $section) {
|
||||
$newSection = $section->replicate();
|
||||
$newSection->form_schema_id = $copy->id;
|
||||
$newSection->save();
|
||||
$sectionIdMap[$section->id] = $newSection->id;
|
||||
}
|
||||
|
||||
foreach ($source->fields as $field) {
|
||||
$newField = $field->replicate();
|
||||
$newField->form_schema_id = $copy->id;
|
||||
if ($field->form_schema_section_id && isset($sectionIdMap[$field->form_schema_section_id])) {
|
||||
$newField->form_schema_section_id = $sectionIdMap[$field->form_schema_section_id];
|
||||
}
|
||||
$newField->save();
|
||||
}
|
||||
|
||||
$copy->logSchemaChange('schema.duplicated', ['source_schema_id' => $source->id]);
|
||||
|
||||
return $copy->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function publish(FormSchema $schema, User $actor): FormSchema
|
||||
{
|
||||
$schema->is_published = true;
|
||||
$schema->last_updated_by_user_id = $actor->id;
|
||||
$schema->save();
|
||||
$schema->logSchemaChange('schema.published');
|
||||
|
||||
return $schema->refresh();
|
||||
}
|
||||
|
||||
public function unpublish(FormSchema $schema, User $actor): FormSchema
|
||||
{
|
||||
$schema->is_published = false;
|
||||
$schema->last_updated_by_user_id = $actor->id;
|
||||
$schema->save();
|
||||
$schema->logSchemaChange('schema.unpublished');
|
||||
|
||||
return $schema->refresh();
|
||||
}
|
||||
|
||||
public function rotatePublicToken(FormSchema $schema, User $actor, int $graceDays = 7): FormSchema
|
||||
{
|
||||
return DB::transaction(function () use ($schema, $actor, $graceDays): FormSchema {
|
||||
$previous = $schema->public_token;
|
||||
$schema->public_token = (string) Str::ulid();
|
||||
$schema->public_token_previous = $previous;
|
||||
$schema->public_token_rotated_at = now();
|
||||
$schema->last_updated_by_user_id = $actor->id;
|
||||
$schema->save();
|
||||
|
||||
$schema->logSchemaChange('schema.public_token_rotated', [
|
||||
'grace_days' => $graceDays,
|
||||
'previous_token_present' => $previous !== null,
|
||||
]);
|
||||
|
||||
return $schema->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function acquireEditLock(FormSchema $schema, User $user, int $ttlMinutes = 10): FormSchema
|
||||
{
|
||||
return DB::transaction(function () use ($schema, $user, $ttlMinutes): FormSchema {
|
||||
$now = now();
|
||||
$holderId = $schema->edit_lock_user_id;
|
||||
$expiresAt = $schema->edit_lock_expires_at;
|
||||
|
||||
if ($holderId !== null && $holderId !== $user->id && $expiresAt !== null && $expiresAt->greaterThan($now)) {
|
||||
throw new EditLockConflictException(
|
||||
User::query()->find($holderId),
|
||||
$expiresAt,
|
||||
);
|
||||
}
|
||||
|
||||
$schema->edit_lock_user_id = $user->id;
|
||||
$schema->edit_lock_expires_at = $now->copy()->addMinutes($ttlMinutes);
|
||||
$schema->save();
|
||||
|
||||
return $schema->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function releaseEditLock(FormSchema $schema, User $user): FormSchema
|
||||
{
|
||||
if ($schema->edit_lock_user_id !== null && $schema->edit_lock_user_id !== $user->id && ! $user->hasRole('org_admin')) {
|
||||
throw new EditLockConflictException(
|
||||
User::query()->find($schema->edit_lock_user_id),
|
||||
$schema->edit_lock_expires_at,
|
||||
);
|
||||
}
|
||||
|
||||
$schema->edit_lock_user_id = null;
|
||||
$schema->edit_lock_expires_at = null;
|
||||
$schema->save();
|
||||
|
||||
return $schema->refresh();
|
||||
}
|
||||
|
||||
public function delete(FormSchema $schema, User $actor, ?string $confirmedName = null): void
|
||||
{
|
||||
$hasSubmissions = FormSubmission::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->exists();
|
||||
|
||||
if ($hasSubmissions) {
|
||||
if ($confirmedName !== $schema->name) {
|
||||
throw DestructiveConfirmationRequiredException::forName($schema->name);
|
||||
}
|
||||
}
|
||||
|
||||
DB::transaction(function () use ($schema, $actor): void {
|
||||
$schema->last_updated_by_user_id = $actor->id;
|
||||
$schema->save();
|
||||
$schema->logSchemaChange('schema.deleted');
|
||||
$schema->delete();
|
||||
});
|
||||
}
|
||||
|
||||
public function bumpVersion(FormSchema $schema): void
|
||||
{
|
||||
$schema->version = (int) $schema->version + 1;
|
||||
$schema->save();
|
||||
}
|
||||
|
||||
public function hasSubmittedSubmissions(FormSchema $schema): bool
|
||||
{
|
||||
return FormSubmission::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->where('status', FormSubmissionStatus::SUBMITTED->value)
|
||||
->exists();
|
||||
}
|
||||
|
||||
private function resolvePurpose(mixed $purpose): FormPurpose
|
||||
{
|
||||
if ($purpose instanceof FormPurpose) {
|
||||
return $purpose;
|
||||
}
|
||||
|
||||
return FormPurpose::from((string) $purpose);
|
||||
}
|
||||
|
||||
private function ensureUniqueSlug(?Organisation $organisation, string $slug, ?string $excludeId = null): string
|
||||
{
|
||||
$orgId = $organisation?->id;
|
||||
if ($orgId === null) {
|
||||
return $slug;
|
||||
}
|
||||
|
||||
$base = Str::slug($slug);
|
||||
$candidate = $base;
|
||||
$i = 2;
|
||||
while (FormSchema::withoutGlobalScopes()
|
||||
->where('organisation_id', $orgId)
|
||||
->where('slug', $candidate)
|
||||
->when($excludeId !== null, fn ($q) => $q->where('id', '!=', $excludeId))
|
||||
->exists()
|
||||
) {
|
||||
$candidate = $base.'-'.$i;
|
||||
$i++;
|
||||
}
|
||||
|
||||
return $candidate;
|
||||
}
|
||||
|
||||
private function isStructuralChange(FormSchema $schema): bool
|
||||
{
|
||||
return $schema->isDirty(['purpose', 'submission_mode', 'locale', 'freeze_on_submit', 'snapshot_mode', 'section_level_submit', 'consent_version']);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
<?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();
|
||||
}
|
||||
}
|
||||
298
api/app/Services/FormBuilder/FormSubmissionService.php
Normal file
298
api/app/Services/FormBuilder/FormSubmissionService.php
Normal file
@@ -0,0 +1,298 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormSchemaSnapshotMode;
|
||||
use App\Enums\FormBuilder\FormSubmissionReviewStatus;
|
||||
use App\Enums\FormBuilder\FormSubmissionStatus;
|
||||
use App\Events\FormBuilder\FormSubmissionCreated;
|
||||
use App\Events\FormBuilder\FormSubmissionDeleted;
|
||||
use App\Events\FormBuilder\FormSubmissionDraftUpdated;
|
||||
use App\Events\FormBuilder\FormSubmissionReviewed;
|
||||
use App\Events\FormBuilder\FormSubmissionSubmitted;
|
||||
use App\Exceptions\FormBuilder\FrozenSchemaException;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormSubmissionDelegation;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use App\Models\User;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
/**
|
||||
* Submission lifecycle: draft → submitted → reviewed per ARCH §4.3, §15.
|
||||
* Fires domain events so webhooks / listeners / activity attach without
|
||||
* tight coupling (ARCH §18.5).
|
||||
*/
|
||||
final class FormSubmissionService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FormLocaleResolver $localeResolver,
|
||||
private readonly FormValueService $valueService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $context opened_at / public_submitter_* / is_test / idempotency_key
|
||||
*/
|
||||
public function createDraft(FormSchema $schema, ?Model $subject, ?User $submitter, array $context = []): FormSubmission
|
||||
{
|
||||
if (isset($context['idempotency_key'])) {
|
||||
$existing = FormSubmission::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->where('idempotency_key', $context['idempotency_key'])
|
||||
->first();
|
||||
if ($existing !== null) {
|
||||
return $existing;
|
||||
}
|
||||
}
|
||||
|
||||
return DB::transaction(function () use ($schema, $subject, $submitter, $context): FormSubmission {
|
||||
$submission = new FormSubmission;
|
||||
$submission->form_schema_id = $schema->id;
|
||||
if ($subject !== null) {
|
||||
$submission->subject_type = $this->morphKeyFor($subject);
|
||||
$submission->subject_id = (string) $subject->getKey();
|
||||
}
|
||||
$submission->submitted_by_user_id = $submitter?->id;
|
||||
$submission->status = FormSubmissionStatus::DRAFT->value;
|
||||
$submission->is_test = (bool) ($context['is_test'] ?? false);
|
||||
$submission->opened_at = $context['opened_at'] ?? now();
|
||||
$submission->idempotency_key = $context['idempotency_key'] ?? null;
|
||||
$submission->public_submitter_name = $context['public_submitter_name'] ?? null;
|
||||
$submission->public_submitter_email = $context['public_submitter_email'] ?? null;
|
||||
$submission->public_submitter_ip = $context['public_submitter_ip'] ?? null;
|
||||
$submission->submitted_in_locale = $this->localeResolver->resolve($schema, $submitter);
|
||||
$submission->save();
|
||||
|
||||
FormSubmissionCreated::dispatch($submission);
|
||||
|
||||
return $submission->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $values slug → value
|
||||
*/
|
||||
public function saveDraft(FormSubmission $submission, array $values, ?User $actor): FormSubmission
|
||||
{
|
||||
$this->assertWritable($submission);
|
||||
|
||||
DB::transaction(function () use ($submission, $values, $actor): void {
|
||||
$this->valueService->upsertMany($submission, $values, $actor);
|
||||
$submission->first_interacted_at ??= now();
|
||||
$submission->auto_save_count = (int) $submission->auto_save_count + 1;
|
||||
$submission->save();
|
||||
});
|
||||
|
||||
FormSubmissionDraftUpdated::dispatch($submission, array_keys($values));
|
||||
|
||||
return $submission->refresh();
|
||||
}
|
||||
|
||||
public function submit(FormSubmission $submission, ?User $actor): FormSubmission
|
||||
{
|
||||
$this->assertWritable($submission);
|
||||
|
||||
return DB::transaction(function () use ($submission, $actor): FormSubmission {
|
||||
$schema = $submission->schema;
|
||||
|
||||
$submission->status = FormSubmissionStatus::SUBMITTED->value;
|
||||
$submission->submitted_at = now();
|
||||
$submission->schema_version_at_submit = $schema->version;
|
||||
|
||||
if ($schema->snapshot_mode !== FormSchemaSnapshotMode::NEVER) {
|
||||
$submission->schema_snapshot = $this->buildSnapshot($schema);
|
||||
}
|
||||
|
||||
if ($submission->opened_at !== null) {
|
||||
$submission->submission_duration_seconds = now()->diffInSeconds($submission->opened_at);
|
||||
}
|
||||
|
||||
$submission->save();
|
||||
|
||||
// Compute SIGNATURE hashes on submit (ARCH §9). One query, scalar-safe.
|
||||
$this->finaliseSignatureValues($submission);
|
||||
|
||||
FormSubmissionSubmitted::dispatch($submission);
|
||||
|
||||
return $submission->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function review(FormSubmission $submission, FormSubmissionReviewStatus $status, ?string $notes, User $reviewer): FormSubmission
|
||||
{
|
||||
return DB::transaction(function () use ($submission, $status, $notes, $reviewer): FormSubmission {
|
||||
$submission->review_status = $status->value;
|
||||
$submission->review_notes = $notes;
|
||||
$submission->reviewed_by_user_id = $reviewer->id;
|
||||
$submission->reviewed_at = now();
|
||||
$submission->save();
|
||||
|
||||
FormSubmissionReviewed::dispatch($submission, $status->value);
|
||||
|
||||
return $submission->refresh();
|
||||
});
|
||||
}
|
||||
|
||||
public function delegate(FormSubmission $submission, User $delegatee, User $delegator, ?string $message = null): FormSubmissionDelegation
|
||||
{
|
||||
/** @var FormSubmissionDelegation $delegation */
|
||||
$delegation = FormSubmissionDelegation::create([
|
||||
'form_submission_id' => $submission->id,
|
||||
'delegated_to_user_id' => $delegatee->id,
|
||||
'delegated_by_user_id' => $delegator->id,
|
||||
'granted_at' => now(),
|
||||
'message' => $message,
|
||||
]);
|
||||
|
||||
activity()
|
||||
->performedOn($submission)
|
||||
->causedBy($delegator)
|
||||
->withProperties(['delegated_to_user_id' => $delegatee->id])
|
||||
->log('submission.delegated');
|
||||
|
||||
return $delegation;
|
||||
}
|
||||
|
||||
public function revokeDelegation(FormSubmissionDelegation $delegation, User $actor): FormSubmissionDelegation
|
||||
{
|
||||
$delegation->revoked_at = now();
|
||||
$delegation->save();
|
||||
|
||||
activity()
|
||||
->performedOn($delegation->submission ?? $delegation)
|
||||
->causedBy($actor)
|
||||
->log('submission.delegation_revoked');
|
||||
|
||||
return $delegation->refresh();
|
||||
}
|
||||
|
||||
public function delete(FormSubmission $submission, User $actor): void
|
||||
{
|
||||
DB::transaction(function () use ($submission, $actor): void {
|
||||
$submission->delete();
|
||||
FormSubmissionDeleted::dispatch($submission);
|
||||
activity()->performedOn($submission)->causedBy($actor)->log('submission.deleted');
|
||||
});
|
||||
}
|
||||
|
||||
private function assertWritable(FormSubmission $submission): void
|
||||
{
|
||||
$schema = $submission->schema;
|
||||
if (
|
||||
$submission->status === FormSubmissionStatus::SUBMITTED
|
||||
&& $schema->freeze_on_submit
|
||||
) {
|
||||
throw FrozenSchemaException::forSchema($schema->id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildSnapshot(FormSchema $schema): array
|
||||
{
|
||||
$schema->loadMissing(['fields', 'sections']);
|
||||
|
||||
return [
|
||||
'schema_version' => $schema->version,
|
||||
'snapshot_created_at' => now()->toIso8601String(),
|
||||
'schema' => [
|
||||
'name' => $schema->name,
|
||||
'slug' => $schema->slug,
|
||||
'purpose' => $schema->purpose instanceof \BackedEnum ? $schema->purpose->value : $schema->purpose,
|
||||
'description' => $schema->description,
|
||||
'locale' => $schema->locale,
|
||||
'freeze_on_submit' => (bool) $schema->freeze_on_submit,
|
||||
'section_level_submit' => (bool) $schema->section_level_submit,
|
||||
'consent_version' => $schema->consent_version,
|
||||
'settings' => $schema->settings,
|
||||
],
|
||||
'sections' => $schema->sections->map(fn ($s) => [
|
||||
'id' => $s->id,
|
||||
'slug' => $s->slug,
|
||||
'name' => $s->name,
|
||||
'sort_order' => $s->sort_order,
|
||||
'depends_on_section_slug' => $this->sectionSlug($schema, $s->depends_on_section_id),
|
||||
'required_for_schema_submit' => (bool) $s->required_for_schema_submit,
|
||||
])->toArray(),
|
||||
'fields' => $schema->fields->map(fn ($f) => [
|
||||
'id' => $f->id,
|
||||
'slug' => $f->slug,
|
||||
'field_type' => $f->field_type,
|
||||
'label' => $f->label,
|
||||
'help_text' => $f->help_text,
|
||||
'section_slug' => $this->sectionSlug($schema, $f->form_schema_section_id),
|
||||
'options' => $f->options,
|
||||
'validation_rules' => $f->validation_rules,
|
||||
'is_required' => (bool) $f->is_required,
|
||||
'is_filterable' => (bool) $f->is_filterable,
|
||||
'is_pii' => (bool) $f->is_pii,
|
||||
'binding' => $f->binding,
|
||||
'conditional_logic' => $f->conditional_logic,
|
||||
'translations' => $f->translations,
|
||||
'value_storage_hint' => $f->value_storage_hint instanceof \BackedEnum ? $f->value_storage_hint->value : $f->value_storage_hint,
|
||||
'sort_order' => $f->sort_order,
|
||||
])->toArray(),
|
||||
];
|
||||
}
|
||||
|
||||
private function sectionSlug(FormSchema $schema, ?string $sectionId): ?string
|
||||
{
|
||||
if ($sectionId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $schema->sections->firstWhere('id', $sectionId)?->slug;
|
||||
}
|
||||
|
||||
private function finaliseSignatureValues(FormSubmission $submission): void
|
||||
{
|
||||
$signatureValues = FormValue::query()
|
||||
->with('field')
|
||||
->where('form_submission_id', $submission->id)
|
||||
->get()
|
||||
->filter(fn ($v) => $v->field?->field_type === FormFieldType::SIGNATURE->value);
|
||||
|
||||
foreach ($signatureValues as $value) {
|
||||
$raw = $value->value ?? [];
|
||||
if (! is_array($raw) || ! isset($raw['file_path'])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$payload = array_merge([
|
||||
'signed_at' => now()->toIso8601String(),
|
||||
'signer_name' => $submission->public_submitter_name ?? optional($submission->submittedBy)->name,
|
||||
'signer_ip' => $submission->public_submitter_ip ?? request()?->ip(),
|
||||
], $raw);
|
||||
|
||||
$disk = $raw['disk'] ?? config('filesystems.default');
|
||||
$bytes = '';
|
||||
try {
|
||||
if (\Illuminate\Support\Facades\Storage::disk($disk)->exists($raw['file_path'])) {
|
||||
$bytes = (string) \Illuminate\Support\Facades\Storage::disk($disk)->get($raw['file_path']);
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
$bytes = '';
|
||||
}
|
||||
|
||||
$hashInput = $bytes.($payload['signed_at'] ?? '').($payload['signer_name'] ?? '').($payload['signer_ip'] ?? '');
|
||||
$payload['hash'] = 'sha256:'.hash('sha256', $hashInput);
|
||||
$payload['disk'] = $disk;
|
||||
|
||||
$value->value = $payload;
|
||||
$value->save();
|
||||
}
|
||||
}
|
||||
|
||||
private function morphKeyFor(Model $model): string
|
||||
{
|
||||
$alias = $model->getMorphClass();
|
||||
|
||||
return (string) $alias;
|
||||
}
|
||||
}
|
||||
101
api/app/Services/FormBuilder/FormTagSyncService.php
Normal file
101
api/app/Services/FormBuilder/FormTagSyncService.php
Normal file
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Enums\FormBuilder\FormPurpose;
|
||||
use App\Enums\FormBuilder\FormSubmissionStatus;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use App\Models\Person;
|
||||
use App\Models\UserOrganisationTag;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
|
||||
/**
|
||||
* Rebuilds self_reported user_organisation_tags from all TAG_PICKER values
|
||||
* across a person's submitted event_registration submissions (ARCH §31.10,
|
||||
* FORM-02). Replaces the legacy TagSyncService purged in S2a.
|
||||
*
|
||||
* Contract: no-op when person.user_id is null; never mutates organiser_assigned
|
||||
* rows; idempotent.
|
||||
*/
|
||||
final class FormTagSyncService
|
||||
{
|
||||
public function rebuildForPerson(Person $person): void
|
||||
{
|
||||
if ($person->user_id === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$organisationId = $person->event?->organisation_id;
|
||||
if ($organisationId === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$rows = FormValue::query()
|
||||
->join('form_submissions', 'form_submissions.id', '=', 'form_values.form_submission_id')
|
||||
->join('form_fields', 'form_fields.id', '=', 'form_values.form_field_id')
|
||||
->join('form_schemas', 'form_schemas.id', '=', 'form_submissions.form_schema_id')
|
||||
->where('form_submissions.subject_type', 'person')
|
||||
->where('form_submissions.subject_id', $person->id)
|
||||
->where('form_submissions.status', FormSubmissionStatus::SUBMITTED->value)
|
||||
->where('form_submissions.is_test', false)
|
||||
->where('form_schemas.purpose', FormPurpose::EVENT_REGISTRATION->value)
|
||||
->where('form_schemas.organisation_id', $organisationId)
|
||||
->where('form_fields.field_type', FormFieldType::TAG_PICKER->value)
|
||||
->select(['form_values.value'])
|
||||
->get();
|
||||
|
||||
$tagIds = [];
|
||||
foreach ($rows as $row) {
|
||||
$decoded = is_string($row->value) ? json_decode($row->value, true) : $row->value;
|
||||
if (is_array($decoded) && array_is_list($decoded)) {
|
||||
foreach ($decoded as $id) {
|
||||
if (is_string($id) && $id !== '') {
|
||||
$tagIds[$id] = true;
|
||||
}
|
||||
}
|
||||
} elseif (is_array($decoded) && isset($decoded['value']) && is_array($decoded['value'])) {
|
||||
foreach ($decoded['value'] as $id) {
|
||||
if (is_string($id) && $id !== '') {
|
||||
$tagIds[$id] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$tagIds = array_keys($tagIds);
|
||||
|
||||
DB::transaction(function () use ($person, $organisationId, $tagIds): void {
|
||||
UserOrganisationTag::query()
|
||||
->where('user_id', $person->user_id)
|
||||
->where('organisation_id', $organisationId)
|
||||
->where('source', 'self_reported')
|
||||
->delete();
|
||||
|
||||
$now = now();
|
||||
foreach ($tagIds as $tagId) {
|
||||
UserOrganisationTag::query()->updateOrCreate(
|
||||
[
|
||||
'user_id' => $person->user_id,
|
||||
'organisation_id' => $organisationId,
|
||||
'person_tag_id' => $tagId,
|
||||
],
|
||||
[
|
||||
'source' => 'self_reported',
|
||||
'assigned_at' => $now,
|
||||
],
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
activity()
|
||||
->performedOn($person)
|
||||
->withProperties([
|
||||
'organisation_id' => $organisationId,
|
||||
'tag_count' => count($tagIds),
|
||||
])
|
||||
->log('person.tags.self_reported_rebuilt');
|
||||
}
|
||||
}
|
||||
173
api/app/Services/FormBuilder/FormValueService.php
Normal file
173
api/app/Services/FormBuilder/FormValueService.php
Normal file
@@ -0,0 +1,173 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormFieldType;
|
||||
use App\Models\FormBuilder\FormField;
|
||||
use App\Models\FormBuilder\FormSchema;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormValue;
|
||||
use App\Models\User;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
/**
|
||||
* Writes form values with per-field RBAC (FieldAccessService) and pattern
|
||||
* A/C entity mirror writes (ARCH §6.1, §6.6). The FormValueObserver handles
|
||||
* typed-column + pivot rebuilding — the service only persists the JSON value.
|
||||
*/
|
||||
final class FormValueService
|
||||
{
|
||||
public function __construct(
|
||||
private readonly FieldAccessService $fieldAccess,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @param array<string, mixed> $slugToValue
|
||||
*/
|
||||
public function upsertMany(FormSubmission $submission, array $slugToValue, ?User $actor): void
|
||||
{
|
||||
$schema = $submission->schema;
|
||||
$fields = FormField::query()
|
||||
->where('form_schema_id', $schema->id)
|
||||
->whereIn('slug', array_keys($slugToValue))
|
||||
->get()
|
||||
->keyBy('slug');
|
||||
|
||||
DB::transaction(function () use ($slugToValue, $fields, $submission, $actor): void {
|
||||
foreach ($slugToValue as $slug => $raw) {
|
||||
$field = $fields->get($slug);
|
||||
if ($field === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (! $this->fieldAccess->canWrite($actor, $field, $submission)) {
|
||||
throw new AuthorizationException(sprintf('Not allowed to write field "%s".', $slug));
|
||||
}
|
||||
|
||||
$this->writeValue($submission, $field, $raw);
|
||||
$this->writeEntityMirror($submission, $field, $raw);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private function writeValue(FormSubmission $submission, FormField $field, mixed $raw): void
|
||||
{
|
||||
$payload = $this->normalisePayload($field, $raw);
|
||||
|
||||
/** @var FormValue|null $value */
|
||||
$value = FormValue::query()
|
||||
->where('form_submission_id', $submission->id)
|
||||
->where('form_field_id', $field->id)
|
||||
->first();
|
||||
|
||||
if ($value === null) {
|
||||
$value = new FormValue;
|
||||
$value->form_submission_id = $submission->id;
|
||||
$value->form_field_id = $field->id;
|
||||
}
|
||||
|
||||
$value->setRelation('field', $field);
|
||||
$value->value = $payload;
|
||||
$value->value_anonymised = false;
|
||||
$value->save();
|
||||
}
|
||||
|
||||
private function normalisePayload(FormField $field, mixed $raw): mixed
|
||||
{
|
||||
$multi = in_array($field->field_type, [
|
||||
FormFieldType::MULTISELECT->value,
|
||||
FormFieldType::CHECKBOX_LIST->value,
|
||||
FormFieldType::TAG_PICKER->value,
|
||||
FormFieldType::AVAILABILITY_PICKER->value,
|
||||
FormFieldType::SECTION_PRIORITY->value,
|
||||
FormFieldType::TABLE_ROWS->value,
|
||||
], true);
|
||||
|
||||
if ($multi) {
|
||||
return is_array($raw) ? array_values($raw) : [];
|
||||
}
|
||||
|
||||
return $raw;
|
||||
}
|
||||
|
||||
private function writeEntityMirror(FormSubmission $submission, FormField $field, mixed $raw): void
|
||||
{
|
||||
$binding = $field->binding;
|
||||
if (! is_array($binding) || ($binding['mode'] ?? null) === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
$mode = (string) $binding['mode'];
|
||||
if (! in_array($mode, ['entity_owned', 'mirrored'], true)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$entity = (string) ($binding['entity'] ?? '');
|
||||
$column = (string) ($binding['column'] ?? '');
|
||||
if ($entity === '' || $column === '') {
|
||||
return;
|
||||
}
|
||||
|
||||
$registry = config('form_binding.'.$entity);
|
||||
if (! is_array($registry) || ! isset($registry[$column]) || ! ($registry[$column]['writable'] ?? false)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$target = $this->resolveEntityTarget($submission, $entity);
|
||||
if ($target === null) {
|
||||
// Cross-entity Pattern C (person → user_profile) may have null user_id.
|
||||
Log::info('form-builder.mirror.skipped', [
|
||||
'submission_id' => $submission->id,
|
||||
'field_id' => $field->id,
|
||||
'entity' => $entity,
|
||||
'column' => $column,
|
||||
'reason' => 'target_not_resolvable',
|
||||
]);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scalar = is_scalar($raw) ? $raw : null;
|
||||
$target->{$column} = $scalar;
|
||||
$target->save();
|
||||
}
|
||||
|
||||
private function resolveEntityTarget(FormSubmission $submission, string $entity): ?\Illuminate\Database\Eloquent\Model
|
||||
{
|
||||
$subjectType = $submission->subject_type;
|
||||
$subjectId = $submission->subject_id;
|
||||
if ($subjectId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if ($subjectType === $entity) {
|
||||
$map = config('form_subjects');
|
||||
$model = $map[$entity]['model'] ?? null;
|
||||
if ($model === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return $model::withoutGlobalScopes()->find($subjectId);
|
||||
}
|
||||
|
||||
// Cross-entity: person → user_profile via person.user_id
|
||||
if ($entity === 'user_profile' && $subjectType === 'person') {
|
||||
$person = \App\Models\Person::withoutGlobalScopes()->find($subjectId);
|
||||
if ($person === null || $person->user_id === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \App\Models\UserProfile::firstOrCreate(['user_id' => $person->user_id]);
|
||||
}
|
||||
|
||||
if ($entity === 'user_profile' && $subjectType === 'user') {
|
||||
return \App\Models\UserProfile::firstOrCreate(['user_id' => $subjectId]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
86
api/app/Services/FormBuilder/FormWebhookDispatcher.php
Normal file
86
api/app/Services/FormBuilder/FormWebhookDispatcher.php
Normal file
@@ -0,0 +1,86 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace App\Services\FormBuilder;
|
||||
|
||||
use App\Enums\FormBuilder\FormWebhookDeliveryStatus;
|
||||
use App\Jobs\FormBuilder\DeliverFormWebhookJob;
|
||||
use App\Models\FormBuilder\FormSchemaWebhook;
|
||||
use App\Models\FormBuilder\FormSubmission;
|
||||
use App\Models\FormBuilder\FormWebhookDelivery;
|
||||
|
||||
/**
|
||||
* Finds active webhooks for a submission's schema + trigger and queues a
|
||||
* DeliverFormWebhookJob per delivery row (ARCH §17.5.2).
|
||||
*/
|
||||
final class FormWebhookDispatcher
|
||||
{
|
||||
public function dispatchForSubmission(FormSubmission $submission, string $triggerEvent): void
|
||||
{
|
||||
if ($submission->is_test) {
|
||||
return;
|
||||
}
|
||||
|
||||
$webhooks = FormSchemaWebhook::query()
|
||||
->where('form_schema_id', $submission->form_schema_id)
|
||||
->where('trigger_event', $triggerEvent)
|
||||
->where('is_active', true)
|
||||
->get();
|
||||
|
||||
foreach ($webhooks as $webhook) {
|
||||
/** @var FormWebhookDelivery $delivery */
|
||||
$delivery = FormWebhookDelivery::create([
|
||||
'form_schema_webhook_id' => $webhook->id,
|
||||
'form_submission_id' => $submission->id,
|
||||
'trigger_event' => $triggerEvent,
|
||||
'status' => FormWebhookDeliveryStatus::PENDING->value,
|
||||
'attempts' => 0,
|
||||
'payload_snapshot' => $this->buildPayload($submission, $triggerEvent),
|
||||
]);
|
||||
|
||||
DeliverFormWebhookJob::dispatch($delivery->id)->onConnection('webhooks')->onQueue('webhooks');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function buildPayload(FormSubmission $submission, string $triggerEvent): array
|
||||
{
|
||||
$submission->loadMissing(['schema', 'schema.organisation', 'values.field']);
|
||||
|
||||
$values = [];
|
||||
foreach ($submission->values as $value) {
|
||||
if ($value->field?->slug) {
|
||||
$values[$value->field->slug] = $value->value;
|
||||
}
|
||||
}
|
||||
|
||||
return [
|
||||
'event' => 'form_submission.'.$triggerEvent,
|
||||
'triggered_at' => now()->toIso8601String(),
|
||||
'organisation' => [
|
||||
'id' => $submission->schema?->organisation?->id,
|
||||
'name' => $submission->schema?->organisation?->name,
|
||||
'slug' => $submission->schema?->organisation?->slug,
|
||||
],
|
||||
'schema' => [
|
||||
'id' => $submission->schema?->id,
|
||||
'purpose' => $submission->schema?->purpose instanceof \BackedEnum
|
||||
? $submission->schema->purpose->value
|
||||
: $submission->schema?->purpose,
|
||||
'slug' => $submission->schema?->slug,
|
||||
'version' => $submission->schema?->version,
|
||||
],
|
||||
'submission' => [
|
||||
'id' => $submission->id,
|
||||
'subject_type' => $submission->subject_type,
|
||||
'subject_id' => $submission->subject_id,
|
||||
'submitted_at' => optional($submission->submitted_at)->toIso8601String(),
|
||||
'submitted_by_user_id' => $submission->submitted_by_user_id,
|
||||
'values' => $values,
|
||||
],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
Schema::table('organisations', function (Blueprint $table): void {
|
||||
$table->string('default_locale', 10)->default('nl')->after('billing_status');
|
||||
});
|
||||
}
|
||||
|
||||
public function down(): void
|
||||
{
|
||||
Schema::table('organisations', function (Blueprint $table): void {
|
||||
$table->dropColumn('default_locale');
|
||||
});
|
||||
}
|
||||
};
|
||||
@@ -67,11 +67,13 @@ final class MigrationRollbackTest extends TestCase
|
||||
{
|
||||
Artisan::call('migrate:fresh');
|
||||
|
||||
// step=1 targets the most recent migration — the S2a drop —
|
||||
// whose down() is a hard failure.
|
||||
// Target the S2a drop migration by name so subsequent unrelated
|
||||
// migrations don't shift the step offset.
|
||||
$this->expectException(\RuntimeException::class);
|
||||
$this->expectExceptionMessage('Legacy registration tables cannot be restored');
|
||||
|
||||
Artisan::call('migrate:rollback', ['--step' => 1]);
|
||||
$path = 'database/migrations/2026_04_20_100000_drop_remaining_legacy_registration_tables.php';
|
||||
$migration = require base_path($path);
|
||||
$migration->down();
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user