diff --git a/api/app/Http/Resources/FormBuilder/FormFieldLibraryResource.php b/api/app/Http/Resources/FormBuilder/FormFieldLibraryResource.php new file mode 100644 index 00000000..a8723a54 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormFieldLibraryResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'field_type' => $this->field_type, + 'label' => $this->label, + 'help_text' => $this->help_text, + 'options' => $this->options, + 'validation_rules' => $this->validation_rules, + 'default_is_required' => (bool) $this->default_is_required, + 'default_is_filterable' => (bool) $this->default_is_filterable, + 'default_binding' => $this->default_binding, + 'translations' => $this->translations, + 'description' => $this->description, + 'usage_count' => (int) ($this->usage_count ?? 0), + 'is_system' => (bool) $this->is_system, + 'is_active' => (bool) $this->is_active, + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormFieldResource.php b/api/app/Http/Resources/FormBuilder/FormFieldResource.php new file mode 100644 index 00000000..c65c0a23 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormFieldResource.php @@ -0,0 +1,127 @@ + + */ + public function toArray(Request $request): array + { + $locale = app(FormLocaleResolver::class)->resolve( + $this->resource->schema, + $request->user(), + ); + + return [ + 'id' => $this->id, + 'form_schema_id' => $this->form_schema_id, + 'form_schema_section_id' => $this->form_schema_section_id, + 'library_field_id' => $this->library_field_id, + 'field_type' => $this->field_type, + 'slug' => $this->slug, + 'label' => $this->resolvedLabel($locale), + 'help_text' => $this->resolvedHelpText($locale), + 'section' => $this->section, + 'options' => $this->normalizedOptions($locale), + 'available_tags' => $this->when( + $this->field_type === FormFieldType::TAG_PICKER->value, + fn () => $this->availableTags(), + ), + 'validation_rules' => $this->validation_rules, + 'is_required' => (bool) $this->is_required, + 'is_filterable' => (bool) $this->is_filterable, + 'is_portal_visible' => (bool) $this->is_portal_visible, + 'is_admin_only' => (bool) $this->is_admin_only, + 'is_unique' => (bool) $this->is_unique, + 'is_pii' => (bool) $this->is_pii, + 'display_width' => $this->display_width instanceof \BackedEnum ? $this->display_width->value : $this->display_width, + 'binding' => $this->binding, + 'conditional_logic' => $this->conditional_logic, + 'role_restrictions' => $this->role_restrictions, + 'translations' => $this->translations, + 'value_storage_hint' => $this->value_storage_hint instanceof \BackedEnum ? $this->value_storage_hint->value : $this->value_storage_hint, + 'review_required' => (bool) $this->review_required, + 'sort_order' => (int) $this->sort_order, + ]; + } + + private function resolvedLabel(string $locale): string + { + $translations = $this->translations ?? []; + if (isset($translations[$locale]['label']) && $translations[$locale]['label'] !== '') { + return (string) $translations[$locale]['label']; + } + + return (string) $this->label; + } + + private function resolvedHelpText(string $locale): ?string + { + $translations = $this->translations ?? []; + if (isset($translations[$locale]['help_text'])) { + return (string) $translations[$locale]['help_text']; + } + + return $this->help_text; + } + + /** + * @return array|null + */ + private function normalizedOptions(string $locale): ?array + { + $options = $this->options; + if (! is_array($options)) { + return null; + } + $translations = $this->translations ?? []; + if (isset($translations[$locale]['options']) && is_array($translations[$locale]['options'])) { + return array_values($translations[$locale]['options']); + } + + return array_values($options); + } + + /** + * @return array> + */ + private function availableTags(): array + { + $organisationId = $this->resource->schema?->organisation_id; + if ($organisationId === null) { + return []; + } + + $categoryFilter = (array) (($this->validation_rules['tag_categories'] ?? null) ?: []); + + $query = PersonTag::withoutGlobalScopes() + ->where('organisation_id', $organisationId) + ->where('is_active', true); + + if ($categoryFilter !== []) { + $query->whereIn('category', $categoryFilter); + } + + return $query->get(['id', 'name', 'category']) + ->map(fn ($t) => [ + 'id' => (string) $t->id, + 'name' => (string) $t->name, + 'category' => (string) $t->category, + ]) + ->all(); + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormSchemaResource.php b/api/app/Http/Resources/FormBuilder/FormSchemaResource.php new file mode 100644 index 00000000..03aa41b1 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormSchemaResource.php @@ -0,0 +1,98 @@ + + */ + public function toArray(Request $request): array + { + $fieldsCollection = $this->relationLoaded('fields') + ? $this->fields + : $this->fields()->get(); + + $visible = app(FieldAccessService::class) + ->filterVisibleFields($request->user(), $fieldsCollection); + + $submissionsCount = $this->whenCounted( + 'submissions', + default: FormSubmission::query()->where('form_schema_id', $this->id)->count(), + ); + + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'owner_type' => $this->owner_type, + 'owner_id' => $this->owner_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'purpose' => $this->purpose instanceof \BackedEnum ? $this->purpose->value : $this->purpose, + 'custom_purpose_slug' => $this->custom_purpose_slug, + 'description' => $this->description, + 'is_published' => (bool) $this->is_published, + 'submission_mode' => $this->submission_mode instanceof \BackedEnum ? $this->submission_mode->value : $this->submission_mode, + 'locale' => $this->locale, + 'settings' => $this->settings, + 'snapshot_mode' => $this->snapshot_mode instanceof \BackedEnum ? $this->snapshot_mode->value : $this->snapshot_mode, + 'freeze_on_submit' => (bool) $this->freeze_on_submit, + 'retention_days' => $this->retention_days, + 'consent_version' => $this->consent_version, + 'section_level_submit' => (bool) $this->section_level_submit, + 'auto_save_enabled' => (bool) $this->auto_save_enabled, + 'max_submissions' => $this->max_submissions, + 'version' => (int) $this->version, + 'public_token' => $this->public_token, + 'public_token_previous' => $this->public_token_previous, + 'public_token_rotated_at' => optional($this->public_token_rotated_at)->toIso8601String(), + 'submission_deadline' => optional($this->submission_deadline)->toIso8601String(), + 'created_by_user_id' => $this->created_by_user_id, + 'last_updated_by_user_id' => $this->last_updated_by_user_id, + 'edit_lock_user_id' => $this->edit_lock_user_id, + 'edit_lock_expires_at' => optional($this->edit_lock_expires_at)->toIso8601String(), + 'is_locked' => $this->isLocked(), + 'public_form_url' => $this->publicFormUrl(), + 'fields_count' => $fieldsCollection->count(), + 'submissions_count' => $submissionsCount, + 'has_submissions' => is_int($submissionsCount) ? $submissionsCount > 0 : null, + 'fields' => FormFieldResource::collection($visible), + 'sections' => FormSchemaSectionResource::collection( + $this->relationLoaded('sections') ? $this->sections : $this->sections()->get(), + ), + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + ]; + } + + private function isLocked(): bool + { + if ($this->edit_lock_user_id === null || $this->edit_lock_expires_at === null) { + return false; + } + + return $this->edit_lock_expires_at->isFuture(); + } + + private function publicFormUrl(): ?string + { + if (empty($this->public_token)) { + return null; + } + + $base = rtrim((string) config('crewli.portal_url', config('app.url')), '/'); + + return $base.'/f/'.$this->public_token; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormSchemaSectionResource.php b/api/app/Http/Resources/FormBuilder/FormSchemaSectionResource.php new file mode 100644 index 00000000..b347f9e5 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormSchemaSectionResource.php @@ -0,0 +1,33 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'form_schema_id' => $this->form_schema_id, + 'slug' => $this->slug, + 'name' => $this->name, + 'description' => $this->description, + 'sort_order' => (int) $this->sort_order, + 'submit_independent' => (bool) $this->submit_independent, + 'depends_on_section_id' => $this->depends_on_section_id, + 'required_for_schema_submit' => (bool) $this->required_for_schema_submit, + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormSchemaSummaryResource.php b/api/app/Http/Resources/FormBuilder/FormSchemaSummaryResource.php new file mode 100644 index 00000000..a8cecd33 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormSchemaSummaryResource.php @@ -0,0 +1,35 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'purpose' => $this->purpose instanceof \BackedEnum ? $this->purpose->value : $this->purpose, + 'submission_mode' => $this->submission_mode instanceof \BackedEnum ? $this->submission_mode->value : $this->submission_mode, + 'is_published' => (bool) $this->is_published, + 'version' => (int) $this->version, + 'updated_at' => optional($this->updated_at)->toIso8601String(), + 'submissions_count' => $this->whenCounted('submissions'), + 'fields_count' => $this->whenCounted('fields'), + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormSchemaWebhookResource.php b/api/app/Http/Resources/FormBuilder/FormSchemaWebhookResource.php new file mode 100644 index 00000000..e34a5b46 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormSchemaWebhookResource.php @@ -0,0 +1,41 @@ + + */ + public function toArray(Request $request): array + { + $urlHost = null; + if (! empty($this->url)) { + $parts = parse_url((string) $this->url); + $urlHost = $parts['host'] ?? null; + } + + return [ + 'id' => $this->id, + 'form_schema_id' => $this->form_schema_id, + 'name' => $this->name, + 'trigger_event' => $this->trigger_event, + 'url_host' => $urlHost, + 'has_secret' => ! empty($this->secret), + 'is_active' => (bool) $this->is_active, + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormSubmissionResource.php b/api/app/Http/Resources/FormBuilder/FormSubmissionResource.php new file mode 100644 index 00000000..011dc8f2 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormSubmissionResource.php @@ -0,0 +1,87 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing(['values.field', 'sectionStatuses', 'delegations']); + + $fieldAccess = app(FieldAccessService::class); + $fields = $this->values->map(fn ($v) => $v->field)->filter(); + $visibleFieldIds = $fieldAccess + ->filterVisibleFields($request->user(), $fields, $this->resource) + ->pluck('id') + ->all(); + + $values = []; + foreach ($this->values as $value) { + if ($value->field === null) { + continue; + } + if (! in_array($value->field->id, $visibleFieldIds, true)) { + continue; + } + $values[$value->field->slug] = [ + 'value' => $value->value, + 'value_anonymised' => (bool) $value->value_anonymised, + ]; + } + + return [ + 'id' => $this->id, + 'form_schema_id' => $this->form_schema_id, + 'subject_type' => $this->subject_type, + 'subject_id' => $this->subject_id, + 'submitted_by_user_id' => $this->submitted_by_user_id, + 'public_submitter_name' => $this->public_submitter_name, + 'public_submitter_email' => $this->public_submitter_email, + 'status' => $this->status instanceof \BackedEnum ? $this->status->value : $this->status, + 'review_status' => $this->review_status instanceof \BackedEnum ? $this->review_status->value : $this->review_status, + 'review_info' => $this->when($this->reviewed_at !== null, fn () => [ + 'reviewed_by_user_id' => $this->reviewed_by_user_id, + 'reviewed_at' => optional($this->reviewed_at)->toIso8601String(), + 'notes' => $this->review_notes, + ]), + 'submitted_at' => optional($this->submitted_at)->toIso8601String(), + 'schema_version_at_submit' => $this->schema_version_at_submit, + 'submitted_in_locale' => $this->submitted_in_locale, + 'opened_at' => optional($this->opened_at)->toIso8601String(), + 'first_interacted_at' => optional($this->first_interacted_at)->toIso8601String(), + 'submission_duration_seconds' => $this->submission_duration_seconds, + 'is_test' => (bool) $this->is_test, + 'values' => $values, + 'section_statuses' => $this->sectionStatuses->map(fn ($s) => [ + 'form_schema_section_id' => $s->form_schema_section_id, + 'status' => $s->status, + 'submitted_at' => optional($s->submitted_at)->toIso8601String(), + 'reviewed_at' => optional($s->reviewed_at)->toIso8601String(), + ])->all(), + 'delegations' => $this->delegations + ->filter(fn ($d) => $d->revoked_at === null) + ->map(fn ($d) => [ + 'id' => $d->id, + 'delegated_to_user_id' => $d->delegated_to_user_id, + 'delegated_by_user_id' => $d->delegated_by_user_id, + 'granted_at' => optional($d->granted_at)->toIso8601String(), + 'message' => $d->message, + ])->values()->all(), + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormSubmissionSummaryResource.php b/api/app/Http/Resources/FormBuilder/FormSubmissionSummaryResource.php new file mode 100644 index 00000000..7062b170 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormSubmissionSummaryResource.php @@ -0,0 +1,34 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'form_schema_id' => $this->form_schema_id, + 'subject_type' => $this->subject_type, + 'subject_id' => $this->subject_id, + 'submitted_by_user_id' => $this->submitted_by_user_id, + 'status' => $this->status instanceof \BackedEnum ? $this->status->value : $this->status, + 'review_status' => $this->review_status instanceof \BackedEnum ? $this->review_status->value : $this->review_status, + 'submitted_at' => optional($this->submitted_at)->toIso8601String(), + 'is_test' => (bool) $this->is_test, + 'created_at' => optional($this->created_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/FormTemplateResource.php b/api/app/Http/Resources/FormBuilder/FormTemplateResource.php new file mode 100644 index 00000000..ddbc92ff --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/FormTemplateResource.php @@ -0,0 +1,35 @@ + + */ + public function toArray(Request $request): array + { + return [ + 'id' => $this->id, + 'organisation_id' => $this->organisation_id, + 'name' => $this->name, + 'slug' => $this->slug, + 'purpose' => $this->purpose instanceof \BackedEnum ? $this->purpose->value : $this->purpose, + 'description' => $this->description, + 'schema_snapshot' => $this->schema_snapshot, + 'is_system' => (bool) $this->is_system, + 'is_active' => (bool) $this->is_active, + 'created_at' => optional($this->created_at)->toIso8601String(), + 'updated_at' => optional($this->updated_at)->toIso8601String(), + ]; + } +} diff --git a/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php new file mode 100644 index 00000000..2daa0ad7 --- /dev/null +++ b/api/app/Http/Resources/FormBuilder/PublicFormSchemaResource.php @@ -0,0 +1,65 @@ + + */ + public function toArray(Request $request): array + { + $this->resource->loadMissing(['fields', 'sections']); + + $visibleFields = $this->fields + ->filter(fn ($f) => (bool) $f->is_portal_visible && ! (bool) $f->is_admin_only) + ->values(); + + return [ + 'id' => $this->id, + 'name' => $this->name, + 'slug' => $this->slug, + 'purpose' => $this->purpose instanceof \BackedEnum ? $this->purpose->value : $this->purpose, + 'description' => $this->description, + 'locale' => $this->locale, + 'consent_version' => $this->consent_version, + 'submission_deadline' => optional($this->submission_deadline)->toIso8601String(), + 'section_level_submit' => (bool) $this->section_level_submit, + 'sections' => $this->sections->map(fn ($s) => [ + 'id' => $s->id, + 'slug' => $s->slug, + 'name' => $s->name, + 'description' => $s->description, + 'sort_order' => (int) $s->sort_order, + ])->values()->all(), + 'fields' => $visibleFields->map(fn ($f) => [ + 'id' => $f->id, + 'slug' => $f->slug, + 'field_type' => $f->field_type, + 'label' => $f->label, + 'help_text' => $f->help_text, + 'options' => is_array($f->options) ? array_values($f->options) : null, + 'validation_rules' => $f->validation_rules, + 'is_required' => (bool) $f->is_required, + 'display_width' => $f->display_width instanceof \BackedEnum ? $f->display_width->value : $f->display_width, + 'conditional_logic' => $f->conditional_logic, + 'sort_order' => (int) $f->sort_order, + 'form_schema_section_id' => $f->form_schema_section_id, + ])->values()->all(), + ]; + } +}