diff --git a/app/Domains/Metadata/Concerns/HasCustomFields.php b/app/Domains/Metadata/Concerns/HasCustomFields.php new file mode 100644 index 00000000..a3b89bd2 --- /dev/null +++ b/app/Domains/Metadata/Concerns/HasCustomFields.php @@ -0,0 +1,72 @@ +morphMany( + CustomFieldValue::class, + 'custom_field_valuable', + ); + } + + /** + * Drop the answers when the record that owns them goes. + * + * The existence check spares the delete statement when there is nothing + * to remove, at the price of a query that asks first. + */ + protected static function booted() + { + static::deleting(function ($record) { + if ($record->fields()->exists()) { + $record->fields()->delete(); + } + }); + } + + /** + * The answer this record holds for the field with the given slug, with + * the definition already loaded alongside it. + * + * Null when this record never answered that field -- and equally when no + * field carries the slug at all. + */ + public function getCustomFieldBySlug($slug) + { + return $this->fields() + ->with('customField') + ->whereHas('customField', fn ($definition) => $definition->where('slug', $slug)) + ->first(); + } + + /** + * The answer itself, read from the column its type maps to. This is what + * a document placeholder naming the slug resolves to. + */ + public function getCustomFieldValueBySlug($slug) + { + return $this->getCustomFieldBySlug($slug)?->defaultAnswer; + } +} diff --git a/app/Domains/Metadata/Http/Controllers/CustomFieldsController.php b/app/Domains/Metadata/Http/Controllers/CustomFieldsController.php new file mode 100644 index 00000000..4b02ed28 --- /dev/null +++ b/app/Domains/Metadata/Http/Controllers/CustomFieldsController.php @@ -0,0 +1,119 @@ +authorize('viewAny', CustomField::class); + + $perPage = $request->has('limit') ? $request->limit : 5; + + $definitions = CustomField::applyFilters($request->all()) + ->whereCompany() + ->latest() + ->paginateData($perPage); + + return CustomFieldResource::collection($definitions); + } + + /** + * Define a new field. The company comes from the request header rather + * than the body, and the slug is minted once, here, from the model type + * and the name. + */ + public function store(CustomFieldRequest $request) + { + $this->authorize('create', CustomField::class); + + $companyId = (int) $request->header('company'); + + $customField = $this->customFieldService->create( + $request->validated(), + $request->input('default_answer'), + $companyId, + ); + + return new CustomFieldResource($customField); + } + + public function show(CustomField $customField) + { + $this->authorize('view', $customField); + + return new CustomFieldResource($customField); + } + + /** + * Rewrite a definition in place. The slug is not among the attributes the + * service touches, so a renamed field answers to the name it was born + * with — which is what keeps existing formatting placeholders working. + */ + public function update(CustomFieldRequest $request, CustomField $customField) + { + $this->authorize('update', $customField); + + $this->customFieldService->update( + $customField, + $request->validated(), + $request->input('default_answer'), + ); + + return new CustomFieldResource($customField); + } + + /** + * Delete a definition together with every answer ever recorded against it. + * + * The payload publishes `in_use`, but this endpoint never consults it: + * stored answers are swept first, the definition goes second, and there is + * no guard and nothing to confirm. Warning the operator is left to the + * interface. The probe in front of the sweep is redundant — an + * unconditional delete would remove the same rows — and is kept so the + * query trace stays what callers have always seen. + */ + public function destroy(CustomField $customField) + { + $this->authorize('delete', $customField); + + $answers = $customField->customFieldValues(); + + if ($answers->exists()) { + $answers->delete(); + } + + $customField->forceDelete(); + + return response()->json([ + 'success' => true, + ]); + } +} diff --git a/app/Domains/Metadata/Http/Controllers/NotesController.php b/app/Domains/Metadata/Http/Controllers/NotesController.php new file mode 100644 index 00000000..609c1897 --- /dev/null +++ b/app/Domains/Metadata/Http/Controllers/NotesController.php @@ -0,0 +1,114 @@ +authorize('view notes'); + + $perPage = $request->limit ?? 10; + + $notes = Note::latest() + ->whereCompany() + ->applyFilters($request->all()) + ->paginate($perPage); + + return NoteResource::collection($notes); + } + + /** + * Add a note to the library. The 201 comes from the resource itself, which + * notices it is wrapping a model that was only just created. + */ + public function store(NotesRequest $request) + { + $this->authorize('manage notes'); + + $note = Note::create($request->getNotesPayload()); + + $this->demoteRivalDefaults($note); + + return new NoteResource($note); + } + + public function show(Note $note) + { + $this->authorize('view notes', $note); + + return new NoteResource($note); + } + + /** + * Edit a note. The demotion sweep runs on the saved state, so switching a + * note's type and its default flag in one request promotes it inside the + * type it has just moved to. + */ + public function update(NotesRequest $request, Note $note) + { + $this->authorize('manage notes', $note); + + $note->update($request->getNotesPayload()); + + $this->demoteRivalDefaults($note); + + return new NoteResource($note); + } + + /** + * Drop a note from the library. Nothing looks for references first — + * whether a document still names this note is not this endpoint's concern. + */ + public function destroy(Note $note) + { + $this->authorize('manage notes', $note); + + $note->delete(); + + return response()->json([ + 'success' => true, + ]); + } + + /** + * A type can only have one default, so promoting one note clears the flag + * on the rest. + * + * KNOWN DEFECT, reproduced on purpose: "the rest" is narrowed by type and + * by "not this row" and by nothing else — no company narrowing — so saving + * a default note here also clears the default flag on other tenants' notes + * of the same type. The correction is scheduled to reach every install at + * once and is deliberately not made here. + */ + private function demoteRivalDefaults(Note $note): void + { + if (! $note->is_default) { + return; + } + + Note::where('id', '!=', $note->id) + ->where('type', $note->type) + ->where('is_default', true) + ->update(['is_default' => false]); + } +} diff --git a/app/Domains/Metadata/Http/Requests/CustomFieldRequest.php b/app/Domains/Metadata/Http/Requests/CustomFieldRequest.php new file mode 100644 index 00000000..4fce300e --- /dev/null +++ b/app/Domains/Metadata/Http/Requests/CustomFieldRequest.php @@ -0,0 +1,42 @@ +> + */ + public function rules(): array + { + return [ + 'name' => ['required'], + 'label' => ['required'], + 'model_type' => ['required'], + 'order' => ['required'], + 'type' => ['required'], + 'is_required' => ['required', 'boolean'], + 'options' => ['array'], + 'placeholder' => ['string', 'nullable'], + ]; + } +} diff --git a/app/Domains/Metadata/Http/Requests/NotesRequest.php b/app/Domains/Metadata/Http/Requests/NotesRequest.php new file mode 100644 index 00000000..10d2ac9f --- /dev/null +++ b/app/Domains/Metadata/Http/Requests/NotesRequest.php @@ -0,0 +1,62 @@ +> + */ + public function rules(): array + { + $company = $this->header('company'); + + $nameIsFree = $this->isMethod('PUT') + ? Rule::unique('notes') + ->ignore($this->route('note')->id) + ->where('type', $this->type) + ->where('company_id', $company) + : Rule::unique('notes') + ->where('company_id', $company) + ->where('type', $this->type); + + return [ + 'type' => ['required'], + 'name' => ['required', $nameIsFree], + 'notes' => ['required'], + 'is_default' => ['required'], + ]; + } + + /** + * The validated attributes, stamped with the company from the request + * header. A `company_id` sent by the client is not among them — it is not + * a validated key, so it never survives this far. + */ + public function getNotesPayload() + { + $attributes = $this->validated(); + $attributes['company_id'] = $this->header('company'); + + return $attributes; + } +} diff --git a/app/Domains/Metadata/Http/Resources/CustomFieldResource.php b/app/Domains/Metadata/Http/Resources/CustomFieldResource.php new file mode 100644 index 00000000..2eff2864 --- /dev/null +++ b/app/Domains/Metadata/Http/Resources/CustomFieldResource.php @@ -0,0 +1,72 @@ +resource; + + return [ + 'id' => $field->id, + 'name' => $field->name, + 'slug' => $field->slug, + 'label' => $field->label, + 'model_type' => $field->model_type, + 'type' => $field->type, + 'placeholder' => $field->placeholder, + 'options' => $field->options, + 'boolean_answer' => $field->boolean_answer, + 'date_answer' => $field->date_answer, + 'time_answer' => $field->time_answer, + 'string_answer' => $field->string_answer, + 'number_answer' => $field->number_answer, + 'date_time_answer' => $field->date_time_answer, + 'is_required' => $field->is_required, + 'in_use' => $field->in_use, + 'order' => $field->order, + 'company_id' => $field->company_id, + 'default_answer' => $field->default_answer, + 'company' => $this->when( + $field->company()->exists(), + fn () => new CompanyResource($field->company) + ), + ]; + } +} diff --git a/app/Domains/Metadata/Http/Resources/CustomFieldValueResource.php b/app/Domains/Metadata/Http/Resources/CustomFieldValueResource.php new file mode 100644 index 00000000..8e82ecdf --- /dev/null +++ b/app/Domains/Metadata/Http/Resources/CustomFieldValueResource.php @@ -0,0 +1,106 @@ +resource; + + return [ + 'id' => $value->id, + 'custom_field_valuable_type' => ModelIdentityMap::publicType($value->custom_field_valuable_type), + 'custom_field_valuable_id' => $value->custom_field_valuable_id, + 'type' => $value->type, + 'boolean_answer' => $value->boolean_answer, + 'date_answer' => $value->date_answer, + 'time_answer' => $value->time_answer, + 'string_answer' => $value->string_answer, + 'number_answer' => $value->number_answer, + 'date_time_answer' => $value->date_time_answer, + 'custom_field_id' => $value->custom_field_id, + 'company_id' => $value->company_id, + 'default_answer' => $value->defaultAnswer, + 'default_formatted_answer' => $this->dateTimeFormat(), + 'custom_field' => $this->when( + $value->customField()->exists(), + fn () => new CustomFieldResource($value->customField) + ), + 'company' => $this->when( + $value->company()->exists(), + fn () => new CompanyResource($value->company) + ), + ]; + } + + /** + * The stored answer rendered the way a reader should see it. + * + * The field's type decides the treatment. A moment in time is fixed to a + * minute in an ISO-looking layout that ignores the company's preferences + * entirely; a plain date goes through the owning company's configured date + * format; everything else -- text, numbers, switches, times, and any type + * the mapping does not recognise -- is handed back exactly as stored. + * + * An answer that reads as empty short-circuits to null before any of that, + * which sweeps up more than blanks: a switch turned off and a numeric zero + * are both falsy, so neither ever reaches this key. Two rough edges are + * preserved as they are. The column the type maps to is resolved before + * the emptiness check, so a row carrying no type at all fails here rather + * than returning null; and a company with no date format on file hands a + * null format down to the formatter, which rejects it -- a dated answer + * belonging to such a company cannot be serialised at all. + */ + public function dateTimeFormat() + { + $value = $this->resource; + + $column = getCustomFieldValueKey($value->type); + $answer = $value->default_answer; + + if (! $answer) { + return null; + } + + return match ($column) { + 'date_time_answer' => Carbon::parse($answer)->format('Y-m-d H:i'), + 'date_answer' => Carbon::parse($answer)->format( + CompanySetting::getSetting('carbon_date_format', $value->company_id) + ), + default => $answer, + }; + } +} diff --git a/app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldResource.php b/app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldResource.php new file mode 100644 index 00000000..4f3bfaa9 --- /dev/null +++ b/app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldResource.php @@ -0,0 +1,64 @@ +resource; + + return [ + 'id' => $field->id, + 'name' => $field->name, + 'slug' => $field->slug, + 'label' => $field->label, + 'model_type' => $field->model_type, + 'type' => $field->type, + 'placeholder' => $field->placeholder, + 'options' => $field->options, + 'boolean_answer' => $field->boolean_answer, + 'date_answer' => $field->date_answer, + 'time_answer' => $field->time_answer, + 'string_answer' => $field->string_answer, + 'number_answer' => $field->number_answer, + 'date_time_answer' => $field->date_time_answer, + 'is_required' => $field->is_required, + 'in_use' => $field->in_use, + 'order' => $field->order, + 'company_id' => $field->company_id, + 'default_answer' => $field->default_answer, + 'company' => $this->when( + $field->company()->exists(), + fn () => new CompanyResource($field->company) + ), + ]; + } +} diff --git a/app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldValueResource.php b/app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldValueResource.php new file mode 100644 index 00000000..af749777 --- /dev/null +++ b/app/Domains/Metadata/Http/Resources/CustomerPortal/CustomFieldValueResource.php @@ -0,0 +1,66 @@ +resource; + + return [ + 'id' => $value->id, + 'custom_field_valuable_type' => ModelIdentityMap::publicType($value->custom_field_valuable_type), + 'custom_field_valuable_id' => $value->custom_field_valuable_id, + 'type' => $value->type, + 'boolean_answer' => $value->boolean_answer, + 'date_answer' => $value->date_answer, + 'time_answer' => $value->time_answer, + 'string_answer' => $value->string_answer, + 'number_answer' => $value->number_answer, + 'date_time_answer' => $value->date_time_answer, + 'custom_field_id' => $value->custom_field_id, + 'company_id' => $value->company_id, + 'default_answer' => $value->defaultAnswer, + 'custom_field' => $this->when( + $value->customField()->exists(), + fn () => new CustomFieldResource($value->customField) + ), + 'company' => $this->when( + $value->company()->exists(), + fn () => new CompanyResource($value->company) + ), + ]; + } +} diff --git a/app/Domains/Metadata/Http/Resources/NoteResource.php b/app/Domains/Metadata/Http/Resources/NoteResource.php new file mode 100644 index 00000000..ab57efcf --- /dev/null +++ b/app/Domains/Metadata/Http/Resources/NoteResource.php @@ -0,0 +1,47 @@ +resource; + + return [ + 'id' => $note->id, + 'type' => $note->type, + 'name' => $note->name, + 'notes' => $note->notes, + 'is_default' => $note->is_default, + 'company' => $this->when( + $note->company()->exists(), + fn () => new CompanyResource($note->company) + ), + ]; + } +} diff --git a/app/Domains/Metadata/Models/CustomField.php b/app/Domains/Metadata/Models/CustomField.php new file mode 100644 index 00000000..f9c15e1d --- /dev/null +++ b/app/Domains/Metadata/Models/CustomField.php @@ -0,0 +1,199 @@ + + */ + protected function casts(): array + { + return [ + 'options' => 'array', + ]; + } + + /** + * Reduce a time-of-day fallback to H:i:s. + * + * An empty value never reaches the attribute bag -- not even as null -- + * so clearing the time on a definition that already has one silently + * leaves the old time in place. A value the parser cannot read becomes + * midnight rather than an error. + */ + public function setTimeAnswerAttribute(mixed $value): void + { + if ($value) { + $this->attributes['time_answer'] = date('H:i:s', strtotime($value)); + } + } + + /** + * Encode the option list on the way in. + * + * A set mutator wins over the array cast, so this runs in its place and + * encodes whatever arrives: null is stored as the four characters "null", + * and a string that is already JSON is encoded a second time and reads + * back as a string rather than as the structure it spells. + */ + public function setOptionsAttribute(mixed $value): void + { + $this->attributes['options'] = json_encode($value); + } + + /** + * The fallback answer, read from the column this field's input type maps + * to. A type outside the mapping reads the string column. + */ + public function getDefaultAnswerAttribute() + { + $answerColumn = getCustomFieldValueKey($this->type); + + return $this->{$answerColumn}; + } + + /** + * Whether any record has an answer on file for this field. + * + * Serialized with the definition so the interface can warn before a + * delete; nothing on the delete path itself consults it. + */ + public function getInUseAttribute() + { + return $this->customFieldValues()->exists(); + } + + /** + * The company the field was defined in. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * Every answer recorded against this field, whatever record type holds it. + */ + public function customFieldValues(): HasMany + { + return $this->hasMany(CustomFieldValue::class, 'custom_field_id'); + } + + /** + * Narrow to the company the current request is acting on. + * + * The company is read from the request header; the scope takes no + * argument and cannot be pointed at a different company. + */ + public function scopeWhereCompany($query) + { + $company = request()->header('company'); + + return $query->where('custom_fields.company_id', $company); + } + + /** + * Partial match on either name the field goes by, grouped so it stays one + * condition when it is combined with others. + */ + public function scopeWhereSearch($query, $search) + { + $needle = '%'.$search.'%'; + + $query->where(function ($grouped) use ($needle) { + $grouped->where('label', 'LIKE', $needle) + ->orWhere('name', 'LIKE', $needle); + }); + } + + /** + * Fields attached to one record type. + */ + public function scopeWhereType($query, $type) + { + $query->where('custom_fields.model_type', $type); + } + + /** + * Apply the listing filters that carry a value. An empty string, a zero + * or a null counts as a filter that was not sent. + */ + public function scopeApplyFilters($query, array $filters) + { + $wanted = collect($filters); + + if ($type = $wanted->get('type')) { + $query->whereType($type); + } + + if ($search = $wanted->get('search')) { + $query->whereSearch($search); + } + } + + /** + * A page of the requested size, or the whole set for the sentinel limit + * "all". + */ + public function scopePaginateData($query, $limit) + { + return $limit == 'all' ? $query->get() : $query->paginate($limit); + } +} diff --git a/app/Domains/Metadata/Models/CustomFieldValue.php b/app/Domains/Metadata/Models/CustomFieldValue.php new file mode 100644 index 00000000..34b49bb2 --- /dev/null +++ b/app/Domains/Metadata/Models/CustomFieldValue.php @@ -0,0 +1,109 @@ +attributes['time_answer'] = $value ? date('H:i:s', strtotime($value)) : null; + } + + /** + * The answer, read from the column this row's type maps to. A type + * outside the mapping reads the string column. + * + * The name is inherited from the definition the row was stamped from: + * there is nothing "default" about an answer a record actually gave. + */ + public function getDefaultAnswerAttribute() + { + $answerColumn = getCustomFieldValueKey($this->type); + + return $this->{$answerColumn}; + } + + /** + * The company the answer was stamped with. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * The definition this answers. + */ + public function customField(): BelongsTo + { + return $this->belongsTo(CustomField::class, 'custom_field_id'); + } + + /** + * The record the answer belongs to, whatever type it is. + */ + public function customFieldValuable(): MorphTo + { + return $this->morphTo(); + } +} diff --git a/app/Domains/Metadata/Models/Note.php b/app/Domains/Metadata/Models/Note.php new file mode 100644 index 00000000..860355d3 --- /dev/null +++ b/app/Domains/Metadata/Models/Note.php @@ -0,0 +1,72 @@ +belongsTo(Company::class); + } + + /** + * The two narrowings the notes screen offers. Each is taken for its truth + * value, so a filter of "0" cannot be told apart from one never sent. + * + * @param array $filters + */ + public function scopeApplyFilters(Builder $query, array $filters): void + { + $type = $filters['type'] ?? null; + + if ($type) { + $query->whereType($type); + } + + $search = $filters['search'] ?? null; + + if ($search) { + $query->whereSearch($search); + } + } + + public function scopeWhereSearch(Builder $query, string $search): void + { + $query->where('name', 'LIKE', "%{$search}%"); + } + + public function scopeWhereType(Builder $query, string $type): Builder + { + return $query->where('type', $type); + } + + /** + * Restrict to the company the request is acting for. The column is + * table-qualified so the scope survives being hung off a joined query. + */ + public function scopeWhereCompany(Builder $query): void + { + $company = request()->header('company'); + + $query->where('notes.company_id', $company); + } +} diff --git a/app/Domains/Metadata/Policies/CustomFieldPolicy.php b/app/Domains/Metadata/Policies/CustomFieldPolicy.php new file mode 100644 index 00000000..4213efec --- /dev/null +++ b/app/Domains/Metadata/Policies/CustomFieldPolicy.php @@ -0,0 +1,74 @@ +sameCompany($user, $customField); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-custom-field', CustomField::class); + } + + public function update(User $user, CustomField $customField): bool + { + return BouncerFacade::can('edit-custom-field', $customField) && $this->sameCompany($user, $customField); + } + + public function delete(User $user, CustomField $customField): bool + { + return $this->mayRemove($user, $customField); + } + + /** + * Restoring and erasing are governed by the delete ability as well; + * definitions are not soft-deleted, so neither is reachable in practice. + */ + public function restore(User $user, CustomField $customField): bool + { + return $this->mayRemove($user, $customField); + } + + public function forceDelete(User $user, CustomField $customField): bool + { + return $this->mayRemove($user, $customField); + } + + private function mayRemove(User $user, CustomField $customField): bool + { + return BouncerFacade::can('delete-custom-field', $customField) && $this->sameCompany($user, $customField); + } + + private function sameCompany(User $user, CustomField $customField): bool + { + return $user->hasCompany($customField->company_id); + } +} diff --git a/app/Domains/Metadata/Policies/NotePolicy.php b/app/Domains/Metadata/Policies/NotePolicy.php new file mode 100644 index 00000000..51d64b91 --- /dev/null +++ b/app/Domains/Metadata/Policies/NotePolicy.php @@ -0,0 +1,51 @@ +passes($user, $note, 'manage-all-notes'); + } + + public function viewNotes(User $user, ?Note $note = null) + { + return $this->passes($user, $note, 'view-all-notes'); + } + + /** + * Bouncer is asked about the note itself where there is one and about the + * class otherwise. Membership is only meaningful in the first case: a + * class-level question carries no company to test the user against. + * + * Note that the ability is resolved for whoever is logged in rather than + * for `$user`, which is what the facade does; only the membership half of + * the decision actually reads the argument. + */ + private function passes(User $user, ?Note $note, string $ability): bool + { + if (! BouncerFacade::can($ability, $note ?? Note::class)) { + return false; + } + + if ($note === null) { + return true; + } + + return $user->hasCompany($note->company_id); + } +}