From a93d13a188aae7704eac025484aa485e2c033462 Mon Sep 17 00:00:00 2001 From: Darko Gjorgjijoski Date: Thu, 20 Aug 2026 21:50:34 +0200 Subject: [PATCH] feat(catalog,contacts): fresh catalog and contacts implementation --- .../Http/Controllers/ItemsController.php | 156 ++++++++ .../Http/Controllers/UnitsController.php | 115 ++++++ .../Http/Requests/DeleteItemsRequest.php | 69 ++++ .../Catalog/Http/Requests/ItemsRequest.php | 42 ++ .../Catalog/Http/Requests/UnitRequest.php | 57 +++ .../Catalog/Http/Resources/ItemResource.php | 60 +++ .../Catalog/Http/Resources/UnitResource.php | 36 ++ app/Domains/Catalog/Models/Item.php | 233 +++++++++++ app/Domains/Catalog/Models/Unit.php | 112 ++++++ app/Domains/Catalog/Policies/ItemPolicy.php | 83 ++++ app/Domains/Catalog/Policies/UnitPolicy.php | 76 ++++ .../Company/CustomerStatsController.php | 48 +++ .../Company/CustomersController.php | 153 +++++++ .../Http/Controllers/CountriesController.php | 23 ++ .../Auth/ForgotPasswordController.php | 62 +++ .../CustomerPortal/Auth/LoginController.php | 77 ++++ .../Auth/ResetPasswordController.php | 92 +++++ .../CustomerPortal/BootstrapController.php | 64 +++ .../Middleware/CustomerPortalMiddleware.php | 37 ++ .../CustomerPortal/CustomerLoginRequest.php | 34 ++ .../CustomerPortal/CustomerProfileRequest.php | 140 +++++++ .../Http/Requests/CustomerRequest.php | 196 +++++++++ .../Http/Requests/DeleteCustomersRequest.php | 39 ++ .../Http/Resources/AddressResource.php | 45 +++ .../Http/Resources/CountryResource.php | 25 ++ .../CustomerPortal/AddressResource.php | 45 +++ .../CustomerPortal/CountryResource.php | 25 ++ .../CustomerPortal/CustomerResource.php | 65 +++ .../Http/Resources/CustomerResource.php | 77 ++++ app/Domains/Contacts/Models/Address.php | 88 ++++ app/Domains/Contacts/Models/Country.php | 29 ++ app/Domains/Contacts/Models/Customer.php | 376 ++++++++++++++++++ .../CustomerMailResetPasswordNotification.php | 77 ++++ .../Contacts/Policies/CustomerPolicy.php | 85 ++++ 34 files changed, 2941 insertions(+) create mode 100644 app/Domains/Catalog/Http/Controllers/ItemsController.php create mode 100644 app/Domains/Catalog/Http/Controllers/UnitsController.php create mode 100644 app/Domains/Catalog/Http/Requests/DeleteItemsRequest.php create mode 100644 app/Domains/Catalog/Http/Requests/ItemsRequest.php create mode 100644 app/Domains/Catalog/Http/Requests/UnitRequest.php create mode 100644 app/Domains/Catalog/Http/Resources/ItemResource.php create mode 100644 app/Domains/Catalog/Http/Resources/UnitResource.php create mode 100644 app/Domains/Catalog/Models/Item.php create mode 100644 app/Domains/Catalog/Models/Unit.php create mode 100644 app/Domains/Catalog/Policies/ItemPolicy.php create mode 100644 app/Domains/Catalog/Policies/UnitPolicy.php create mode 100644 app/Domains/Contacts/Http/Controllers/Company/CustomerStatsController.php create mode 100644 app/Domains/Contacts/Http/Controllers/Company/CustomersController.php create mode 100644 app/Domains/Contacts/Http/Controllers/CountriesController.php create mode 100644 app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/ForgotPasswordController.php create mode 100644 app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/LoginController.php create mode 100644 app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php create mode 100644 app/Domains/Contacts/Http/Controllers/CustomerPortal/BootstrapController.php create mode 100644 app/Domains/Contacts/Http/Middleware/CustomerPortalMiddleware.php create mode 100644 app/Domains/Contacts/Http/Requests/CustomerPortal/CustomerLoginRequest.php create mode 100644 app/Domains/Contacts/Http/Requests/CustomerPortal/CustomerProfileRequest.php create mode 100644 app/Domains/Contacts/Http/Requests/CustomerRequest.php create mode 100644 app/Domains/Contacts/Http/Requests/DeleteCustomersRequest.php create mode 100644 app/Domains/Contacts/Http/Resources/AddressResource.php create mode 100644 app/Domains/Contacts/Http/Resources/CountryResource.php create mode 100644 app/Domains/Contacts/Http/Resources/CustomerPortal/AddressResource.php create mode 100644 app/Domains/Contacts/Http/Resources/CustomerPortal/CountryResource.php create mode 100644 app/Domains/Contacts/Http/Resources/CustomerPortal/CustomerResource.php create mode 100644 app/Domains/Contacts/Http/Resources/CustomerResource.php create mode 100644 app/Domains/Contacts/Models/Address.php create mode 100644 app/Domains/Contacts/Models/Country.php create mode 100644 app/Domains/Contacts/Models/Customer.php create mode 100644 app/Domains/Contacts/Notifications/CustomerMailResetPasswordNotification.php create mode 100644 app/Domains/Contacts/Policies/CustomerPolicy.php diff --git a/app/Domains/Catalog/Http/Controllers/ItemsController.php b/app/Domains/Catalog/Http/Controllers/ItemsController.php new file mode 100644 index 00000000..16a6ae41 --- /dev/null +++ b/app/Domains/Catalog/Http/Controllers/ItemsController.php @@ -0,0 +1,156 @@ +authorize('viewAny', Item::class); + + $filters = $request->all(); + + $items = Item::query() + ->whereCompany() + ->leftJoin('units', 'items.unit_id', '=', 'units.id') + ->applyFilters($filters) + ->select(['items.*', 'units.name as unit_name']) + ->latest() + ->paginateData($request->input('limit', self::DEFAULT_LIMIT)); + + return ItemResource::collection($items)->additional([ + 'meta' => $this->listingMeta(), + ]); + } + + /** + * A single catalog entry. + */ + public function show(Item $item) + { + $this->authorize('view', $item); + + return new ItemResource($item); + } + + /** + * Put a new entry in the catalog, optionally with default taxes of its own. + * + * Company, creator and currency are decided from the request context, not + * from the payload. The answer is 200 rather than 201: the service hands + * back a freshly read row, so the resource does not see a just-created + * model. Kept as is. + */ + public function store(ItemsRequest $request) + { + $this->authorize('create', Item::class); + + $companyId = (int) $request->header('company'); + $creatorId = (int) $request->user()->getAuthIdentifier(); + + $created = $this->itemService->create( + $request->validated(), + $request->input('taxes', []), + $companyId, + $creatorId, + ); + + return new ItemResource($created); + } + + /** + * Amend an existing entry. + * + * The submitted tax list replaces the stored one wholesale; sending an + * empty list clears the taxes. + */ + public function update(ItemsRequest $request, Item $item) + { + $this->authorize('update', $item); + + $companyId = (int) $request->header('company'); + + $updated = $this->itemService->update( + $item, + $request->validated(), + $request->input('taxes', []), + $companyId, + ); + + return new ItemResource($updated); + } + + /** + * Drop a batch of entries at once. + * + * What may be removed at all is settled by the request object. The ids it + * cleared are then narrowed to the active company, so an id belonging + * elsewhere is quietly skipped rather than refused. + */ + public function delete(DeleteItemsRequest $request) + { + $this->authorize('delete multiple items'); + + $ownIds = Item::query() + ->whereCompany() + ->whereIn('id', $request->input('ids')) + ->pluck('id'); + + Item::destroy($ownIds); + + return response()->json(['success' => true]); + } + + /** + * Extras shipped beside the listing rows: the tax types that may be + * charged on a sale, newest first, and the size of the catalog. The count + * covers the whole company and ignores the filters just applied. + * + * @return array + */ + private function listingMeta(): array + { + return [ + 'tax_types' => TaxType::query() + ->whereCompany() + ->where('transaction_type', TaxType::TRANSACTION_TYPE_SALES) + ->latest() + ->get(), + 'item_total_count' => Item::query()->whereCompany()->count(), + ]; + } +} diff --git a/app/Domains/Catalog/Http/Controllers/UnitsController.php b/app/Domains/Catalog/Http/Controllers/UnitsController.php new file mode 100644 index 00000000..c772046d --- /dev/null +++ b/app/Domains/Catalog/Http/Controllers/UnitsController.php @@ -0,0 +1,115 @@ +authorize('viewAny', Unit::class); + + $filters = $request->all(); + + $results = Unit::query() + ->applyFilters($filters) + ->whereCompany() + ->latest() + ->paginateData($request->input('limit', self::DEFAULT_LIMIT)); + + return UnitResource::collection($results); + } + + /** + * A single unit. + */ + public function show(Unit $unit) + { + $this->authorize('view', $unit); + + return new UnitResource($unit); + } + + /** + * Register a unit under the acting company. + * + * The resource answers 201 by itself, because the wrapped model was just + * created and the verb is POST. + */ + public function store(UnitRequest $request) + { + $this->authorize('create', Unit::class); + + $payload = $request->getUnitPayload(); + + return new UnitResource(Unit::create($payload)); + } + + /** + * Rename an existing unit. + */ + public function update(UnitRequest $request, Unit $unit) + { + $this->authorize('update', $unit); + + $payload = $request->getUnitPayload(); + + $unit->update($payload); + + return new UnitResource($unit); + } + + /** + * Retire a unit, provided no catalog item still measures by it. + * + * A unit in use is refused with the validation-shaped rejection above. + * A successful removal reports its outcome as a sentence under `success` + * rather than as a flag -- kept as is, clients read the text. + */ + public function destroy(Unit $unit) + { + $this->authorize('delete', $unit); + + if ($unit->items()->exists()) { + return respondJson(self::IN_USE_ERROR, self::IN_USE_MESSAGE); + } + + $unit->delete(); + + return response()->json(['success' => 'Unit deleted successfully']); + } +} diff --git a/app/Domains/Catalog/Http/Requests/DeleteItemsRequest.php b/app/Domains/Catalog/Http/Requests/DeleteItemsRequest.php new file mode 100644 index 00000000..fda0e7cf --- /dev/null +++ b/app/Domains/Catalog/Http/Requests/DeleteItemsRequest.php @@ -0,0 +1,69 @@ + + */ + private const BLOCKING_RELATIONS = [ + 'invoiceItems', + 'estimateItems', + 'taxes', + ]; + + /** + * Access is settled by the standalone bulk-delete ability in the + * controller. + */ + public function authorize(): bool + { + return true; + } + + /** + * Every submitted id has to name a real item and be free of the relations + * above. + * + * Quirk kept as is: existence is checked against the whole table, not + * within the acting company, so an id owned by another company passes + * validation here and is then dropped by the company-scoped deletion. + * + * @return array + */ + public function rules(): array + { + $unreferenced = array_map( + fn (string $relation) => new RelationNotExist(Item::class, $relation), + self::BLOCKING_RELATIONS, + ); + + return [ + 'ids' => ['required'], + 'ids.*' => array_merge( + ['required', Rule::exists('items', 'id')], + $unreferenced, + ), + ]; + } +} diff --git a/app/Domains/Catalog/Http/Requests/ItemsRequest.php b/app/Domains/Catalog/Http/Requests/ItemsRequest.php new file mode 100644 index 00000000..6d6c06ae --- /dev/null +++ b/app/Domains/Catalog/Http/Requests/ItemsRequest.php @@ -0,0 +1,42 @@ + + */ + public function rules(): array + { + return [ + 'name' => ['required'], + 'price' => ['required'], + 'unit_id' => ['nullable'], + 'description' => ['nullable'], + ]; + } +} diff --git a/app/Domains/Catalog/Http/Requests/UnitRequest.php b/app/Domains/Catalog/Http/Requests/UnitRequest.php new file mode 100644 index 00000000..9eda2777 --- /dev/null +++ b/app/Domains/Catalog/Http/Requests/UnitRequest.php @@ -0,0 +1,57 @@ + + */ + public function rules(): array + { + $free = Rule::unique('units')->where('company_id', $this->header('company')); + + // Quirk kept as is: only PUT excuses the edited row from the name + // check. A PATCH would collide with its own stored name. + if ($this->isMethod('PUT')) { + $free->ignore($this->route('unit'), 'id'); + } + + return [ + 'name' => ['required', $free], + ]; + } + + /** + * The validated attributes with the acting company folded in, ready to be + * written to a unit. + * + * @return array + */ + public function getUnitPayload() + { + return array_merge($this->validated(), [ + 'company_id' => $this->header('company'), + ]); + } +} diff --git a/app/Domains/Catalog/Http/Resources/ItemResource.php b/app/Domains/Catalog/Http/Resources/ItemResource.php new file mode 100644 index 00000000..ca02b03e --- /dev/null +++ b/app/Domains/Catalog/Http/Resources/ItemResource.php @@ -0,0 +1,60 @@ + $this->id, + 'name' => $this->name, + 'description' => $this->description, + 'price' => $this->price, + 'unit_id' => $this->unit_id, + 'company_id' => $this->company_id, + 'creator_id' => $this->creator_id, + 'currency_id' => $this->currency_id, + 'created_at' => $this->created_at, + 'updated_at' => $this->updated_at, + 'tax_per_item' => $this->tax_per_item, + 'formatted_created_at' => $this->formattedCreatedAt, + 'unit' => $this->when( + $this->unit()->exists(), + fn () => new UnitResource($this->unit) + ), + 'company' => $this->when( + $this->company()->exists(), + fn () => new CompanyResource($this->company) + ), + 'taxes' => $this->when( + $this->taxes()->exists(), + fn () => TaxResource::collection($this->taxes) + ), + 'currency' => $this->when( + $this->currency()->exists(), + fn () => new CurrencyResource($this->currency) + ), + ]; + } +} diff --git a/app/Domains/Catalog/Http/Resources/UnitResource.php b/app/Domains/Catalog/Http/Resources/UnitResource.php new file mode 100644 index 00000000..d073a509 --- /dev/null +++ b/app/Domains/Catalog/Http/Resources/UnitResource.php @@ -0,0 +1,36 @@ + $this->id, + 'name' => $this->name, + 'company_id' => $this->company_id, + 'company' => $this->when( + $this->company()->exists(), + fn () => new CompanyResource($this->company) + ), + ]; + } +} diff --git a/app/Domains/Catalog/Models/Item.php b/app/Domains/Catalog/Models/Item.php new file mode 100644 index 00000000..57fdfb7a --- /dev/null +++ b/app/Domains/Catalog/Models/Item.php @@ -0,0 +1,233 @@ + 'integer', + ]; + } + + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id', 'id'); + } + + /** + * Whoever added the item. Attribution only -- it confers no access. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(User::class, 'creator_id', 'id'); + } + + /** + * The currency the price is expressed in, taken from the company setting + * at creation time and left alone afterwards. + */ + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class, 'currency_id', 'id'); + } + + /** + * The optional measure the item is sold by. Unitless items are ordinary: + * the column is nullable and the listing simply reports no unit for them. + */ + public function unit(): BelongsTo + { + return $this->belongsTo(Unit::class, 'unit_id', 'id'); + } + + /** + * The item's own default taxes. + * + * Item taxes and document-line taxes share one table, and what marks a + * row as the item's own is the absence of a document parent. Both parent + * columns have to be excluded: a row pointing at an invoice line is that + * line's tax, not a default of the item it was created from. + */ + public function taxes(): HasMany + { + return $this->hasMany(Tax::class, 'item_id', 'id') + ->whereNull('invoice_item_id') + ->whereNull('estimate_item_id'); + } + + /** + * Invoice lines drawn from this item; their existence pins it against + * deletion. + */ + public function invoiceItems(): HasMany + { + return $this->hasMany(InvoiceItem::class, 'item_id', 'id'); + } + + /** + * Estimate lines drawn from this item; likewise a bar to deletion. + */ + public function estimateItems(): HasMany + { + return $this->hasMany(EstimateItem::class, 'item_id', 'id'); + } + + /** + * Turn the listing's query string into query conditions. + * + * Every filter is switched on by a truthy value, so a zero -- a price + * filter of 0, say -- reads as "not filtering" and is skipped. + */ + public function scopeApplyFilters(Builder $query, array $filters): void + { + $filters = collect($filters); + + if ($search = $filters->get('search')) { + $query->whereSearch($search); + } + + if ($price = $filters->get('price')) { + $query->wherePrice($price); + } + + if ($unitId = $filters->get('unit_id')) { + $query->whereUnit($unitId); + } + + if ($itemId = $filters->get('item_id')) { + // Applied last, and an OR: it widens the result set instead of + // narrowing it, reaching past every condition already on the + // query. Long-standing behaviour of the id filter, kept as is. + $query->whereItem($itemId); + } + + $sortField = $filters->get('orderByField'); + $sortDirection = $filters->get('orderBy'); + + // Either half of the sort pair is enough to switch sorting on; the + // half that was left out falls back to ascending by name. + if ($sortField || $sortDirection) { + $query->whereOrder($sortField ?: 'name', $sortDirection ?: 'asc'); + } + } + + /** + * Restrict to the company the request is acting in. + * + * Columns are table-qualified across these scopes because the listing + * joins `units`, which carries columns of the same names. + */ + public function scopeWhereCompany(Builder $query): void + { + $company = request()->header('company'); + + $query->where($this->qualifyColumn('company_id'), $company); + } + + public function scopeWhereSearch(Builder $query, string $search): Builder + { + $pattern = '%'.$search.'%'; + + return $query->where($this->qualifyColumn('name'), 'LIKE', $pattern); + } + + /** + * Exact match on the stored price, in minor units. + */ + public function scopeWherePrice(Builder $query, int $price): Builder + { + return $query->where($this->qualifyColumn('price'), $price); + } + + public function scopeWhereUnit(Builder $query, int $unit_id): Builder + { + return $query->where($this->qualifyColumn('unit_id'), $unit_id); + } + + /** + * Pull one specific item into the result set -- see the OR note in + * applyFilters(). The column is left unqualified here. + */ + public function scopeWhereItem(Builder $query, int $item_id): void + { + $query->orWhere('id', '=', $item_id); + } + + /** + * Sort input arrives from the query string, so it goes through the + * sanitiser; anything that is not a plain column name is replaced with + * the fallback below. + */ + public function scopeWhereOrder(Builder $query, string $orderByField, string $orderBy): void + { + SafeOrderBy::apply($query, $orderByField, $orderBy, 'created_at'); + } + + /** + * `limit=all` opts out of pagination and returns the plain collection. + * + * @return Collection|LengthAwarePaginator + */ + public function scopePaginateData(Builder $query, string $limit) + { + return $limit === 'all' ? $query->get() : $query->paginate($limit); + } + + /** + * The creation date rendered with a company's date-format setting, so a + * client never has to know the setting itself. + * + * The format is read for the company the request is acting in rather than + * the item's own company; for cross-company reads the two can differ. + */ + public function getFormattedCreatedAtAttribute(mixed $value): string + { + $company = request()->header('company'); + + return Carbon::parse($this->created_at) + ->translatedFormat(CompanySetting::getSetting('carbon_date_format', $company)); + } +} diff --git a/app/Domains/Catalog/Models/Unit.php b/app/Domains/Catalog/Models/Unit.php new file mode 100644 index 00000000..75fa74b8 --- /dev/null +++ b/app/Domains/Catalog/Models/Unit.php @@ -0,0 +1,112 @@ +belongsTo(Company::class, 'company_id', 'id'); + } + + /** + * Items measured in this unit. A non-empty set pins the unit: deletion is + * refused while any of them remain. + */ + public function items(): HasMany + { + return $this->hasMany(Item::class, 'unit_id', 'id'); + } + + /** + * Turn the listing's query string into query conditions. + * + * A `company_id` entry merely switches the company scope on -- the value + * submitted with it is discarded, because the scope reads the company + * from the request header instead. + */ + public function scopeApplyFilters(Builder $query, array $filters): Builder + { + $filters = collect($filters); + + if ($search = $filters->get('search')) { + $query->whereSearch($search); + } + + if ($unitId = $filters->get('unit_id')) { + $query->whereUnit($unitId); + } + + if ($filters->get('company_id')) { + $query->whereCompany(); + } + + return $query; + } + + /** + * Restrict to the company the request is acting in; callers get no say in + * which company that is. + */ + public function scopeWhereCompany(Builder $query): void + { + $company = request()->header('company'); + + $query->where('company_id', $company); + } + + /** + * Pull one specific unit into the result set. It is an OR clause, so it + * adds to whatever the query already matched rather than narrowing it. + */ + public function scopeWhereUnit(Builder $query, int $unit_id): void + { + $query->orWhere('id', '=', $unit_id); + } + + public function scopeWhereSearch(Builder $query, string $search): Builder + { + $pattern = '%'.$search.'%'; + + return $query->where('name', 'LIKE', $pattern); + } + + /** + * `limit=all` opts out of pagination and hands back the collection. + * + * @return LengthAwarePaginator|Collection + */ + public function scopePaginateData(Builder $query, string $limit) + { + return $limit === 'all' ? $query->get() : $query->paginate($limit); + } +} diff --git a/app/Domains/Catalog/Policies/ItemPolicy.php b/app/Domains/Catalog/Policies/ItemPolicy.php new file mode 100644 index 00000000..942dd0e0 --- /dev/null +++ b/app/Domains/Catalog/Policies/ItemPolicy.php @@ -0,0 +1,83 @@ +sameCompany($user, $item); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-item', Item::class); + } + + public function update(User $user, Item $item): bool + { + return BouncerFacade::can('edit-item', $item) && $this->sameCompany($user, $item); + } + + public function delete(User $user, Item $item): bool + { + return $this->mayRemove($user, $item); + } + + /** + * Items are not soft-deleted, so neither restoring nor erasing is + * reachable in practice; both answer with the delete rule. + */ + public function restore(User $user, Item $item): bool + { + return $this->mayRemove($user, $item); + } + + public function forceDelete(User $user, Item $item): bool + { + return $this->mayRemove($user, $item); + } + + /** + * The model-level counterpart for removing several items at once. It is + * company-blind by construction -- there is no row here to check + * membership against -- and the bulk endpoint does not consult it today, + * gating on the bare "delete multiple items" permission instead. + */ + public function deleteMultiple(User $user) + { + return BouncerFacade::can('delete-item', Item::class); + } + + private function mayRemove(User $user, Item $item): bool + { + return BouncerFacade::can('delete-item', $item) && $this->sameCompany($user, $item); + } + + private function sameCompany(User $user, Item $item): bool + { + return $user->hasCompany($item->company_id); + } +} diff --git a/app/Domains/Catalog/Policies/UnitPolicy.php b/app/Domains/Catalog/Policies/UnitPolicy.php new file mode 100644 index 00000000..7597b68a --- /dev/null +++ b/app/Domains/Catalog/Policies/UnitPolicy.php @@ -0,0 +1,76 @@ +mayViewItems(); + } + + public function view(User $user, Unit $unit): bool + { + return $this->mayViewItems() && $this->sameCompany($user, $unit); + } + + public function create(User $user): bool + { + return $this->mayViewItems(); + } + + public function update(User $user, Unit $unit): bool + { + return $this->mayViewItems() && $this->sameCompany($user, $unit); + } + + public function delete(User $user, Unit $unit): bool + { + return $this->mayViewItems() && $this->sameCompany($user, $unit); + } + + /** + * Units are not soft-deleted, so restoring and erasing are unreachable; + * both mirror the delete rule. + */ + public function restore(User $user, Unit $unit): bool + { + return $this->mayViewItems() && $this->sameCompany($user, $unit); + } + + public function forceDelete(User $user, Unit $unit): bool + { + return $this->mayViewItems() && $this->sameCompany($user, $unit); + } + + private function mayViewItems(): bool + { + return BouncerFacade::can('view-item', Item::class); + } + + private function sameCompany(User $user, Unit $unit): bool + { + return $user->hasCompany($unit->company_id); + } +} diff --git a/app/Domains/Contacts/Http/Controllers/Company/CustomerStatsController.php b/app/Domains/Contacts/Http/Controllers/Company/CustomerStatsController.php new file mode 100644 index 00000000..92bbdf27 --- /dev/null +++ b/app/Domains/Contacts/Http/Controllers/Company/CustomerStatsController.php @@ -0,0 +1,48 @@ +authorize('view', $customer); + + $companyId = $request->header('company'); + $previousYear = $request->has('previous_year'); + + $chartData = $this->customerStatsProvider->get($customer, $companyId, $previousYear); + + // The row is read a second time instead of reusing the bound instance. + // Nothing the provider does requires it, but the reload is what the + // resource ends up rendering, so it stays. + $fresh = Customer::query()->find($customer->id); + + $this->customerStatementQuery->hydrateAccountSummaries([$fresh]); + + return (new CustomerResource($fresh)) + ->additional(['meta' => ['chartData' => $chartData]]); + } +} diff --git a/app/Domains/Contacts/Http/Controllers/Company/CustomersController.php b/app/Domains/Contacts/Http/Controllers/Company/CustomersController.php new file mode 100644 index 00000000..b37e1a2b --- /dev/null +++ b/app/Domains/Contacts/Http/Controllers/Company/CustomersController.php @@ -0,0 +1,153 @@ +authorize('viewAny', Customer::class); + + $perPage = $request->has('limit') ? $request->limit : 10; + $filters = $request->all(); + + $customers = Customer::query() + ->with('creator') + ->whereCompany() + ->applyFilters($filters) + ->paginateData($perPage); + + $this->withAccountSummaries($customers); + + $companyTotal = Customer::whereCompany()->count(); + + return CustomerResource::collection($customers)->additional([ + 'meta' => ['customer_total_count' => $companyTotal], + ]); + } + + /** + * File a new contact. + * + * Address blocks and custom-field values ride along inside the service's + * transaction; the request object decides whether either was supplied. + */ + public function store(CustomerRequest $request) + { + $this->authorize('create', Customer::class); + + $created = $this->customerService->create( + $request->customerAttributes(), + $request->shippingAddress(), + $request->billingAddress(), + $request->customFields(), + ); + + $this->withAccountSummaries([$created]); + + return new CustomerResource($created); + } + + public function show(Customer $customer) + { + $this->authorize('view', $customer); + + $this->withAccountSummaries([$customer]); + + return new CustomerResource($customer); + } + + /** + * Overwrite a contact. + * + * Two rules live in the service rather than the request: a currency change + * is refused once any document exists, and the address rows are replaced + * wholesale — so an update carrying no address block at all leaves the + * contact with none. + */ + public function update(CustomerRequest $request, Customer $customer) + { + $this->authorize('update', $customer); + + $saved = $this->customerService->update( + $customer, + $request->customerAttributes(), + $request->shippingAddress(), + $request->billingAddress(), + $request->customFields(), + ); + + $this->withAccountSummaries([$saved]); + + return new CustomerResource($saved); + } + + /** + * Erase a batch of contacts together with everything filed against them — + * estimates, invoices, payments, expenses, recurring invoices, addresses — + * in a single transaction. + * + * The submitted ids were checked against the customers table globally, but + * are narrowed to the active company here, so an id belonging to somebody + * else clears validation and is then quietly dropped from the batch. + */ + public function delete(DeleteCustomersRequest $request) + { + $this->authorize('delete multiple customers'); + + $targets = Customer::whereCompany()->whereIn('id', $request->ids)->pluck('id'); + + $this->customerService->delete($targets); + + return response()->json(['success' => true]); + } + + /** + * Hang the account summaries on the models that are about to be rendered. + * + * A paginator cannot be handed over as-is: cast to an array it yields the + * envelope, not the rows. Everything else — a collection, or a one-element + * array wrapping a single model — goes straight through. + */ + private function withAccountSummaries(mixed $customers): void + { + if ($customers instanceof LengthAwarePaginator) { + $customers = $customers->getCollection(); + } + + $this->customerStatementQuery->hydrateAccountSummaries($customers); + } +} diff --git a/app/Domains/Contacts/Http/Controllers/CountriesController.php b/app/Domains/Contacts/Http/Controllers/CountriesController.php new file mode 100644 index 00000000..1046074a --- /dev/null +++ b/app/Domains/Contacts/Http/Controllers/CountriesController.php @@ -0,0 +1,23 @@ +json([ + 'message' => 'Password reset email sent.', + 'data' => $response, + ]); + } + + /** + * Report that no link went out. + * + * Every reason the broker can give (address unknown, request throttled, + * mail undeliverable) collapses into the same plain-text refusal, in + * place of the framework's validation error. + * + * @param string $response + */ + protected function sendResetLinkFailedResponse(Request $request, $response) + { + return response('Email could not be sent to this email address.', Response::HTTP_FORBIDDEN); + } +} diff --git a/app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/LoginController.php b/app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/LoginController.php new file mode 100644 index 00000000..4ddf2b93 --- /dev/null +++ b/app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/LoginController.php @@ -0,0 +1,77 @@ +contactFor($request->email, $company); + + // Credentials are weighed before the portal switch on purpose, so a + // caller who gets both wrong is told only that the pair was wrong. + if ($customer === null || ! Hash::check($request->password, $customer->password)) { + $this->refuse(self::REJECTED_CREDENTIALS); + } + + if (! $customer->enable_portal) { + $this->refuse(self::PORTAL_CLOSED); + } + + auth()->guard('customer')->login($customer); + + return response()->json([ + 'success' => true, + ]); + } + + /** + * Find the contact holding this address inside the given company. + * + * The comparison is made on the lower-cased column so that capitalising + * an address differently from how it was stored still gets the contact + * in, on every database engine the app supports. + */ + private function contactFor(string $email, Company $company): ?Customer + { + return Customer::query() + ->whereRaw('LOWER(email) = ?', [strtolower($email)]) + ->where('company_id', $company->getKey()) + ->first(); + } + + /** + * Abort the attempt, pinning the reason to the email field. + */ + private function refuse(string $message): never + { + throw ValidationException::withMessages(['email' => [$message]]); + } +} diff --git a/app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php b/app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php new file mode 100644 index 00000000..38044cf2 --- /dev/null +++ b/app/Domains/Contacts/Http/Controllers/CustomerPortal/Auth/ResetPasswordController.php @@ -0,0 +1,92 @@ +setAttribute('password', $password); + + $rotated = Str::random(60); + $user->setRememberToken($rotated); + + $user->save(); + + Event::dispatch(new PasswordReset($user)); + } + + /** + * Confirm that the token was spent and the password replaced. + * + * @param string $response + */ + protected function sendResetResponse(Request $request, $response) + { + return response()->json([ + 'message' => 'Password reset successfully.', + ]); + } + + /** + * Refuse a token that was missing, expired, mismatched or forged. + * + * @param string $response + */ + protected function sendResetFailedResponse(Request $request, $response) + { + return response('Failed, Invalid Token.', Response::HTTP_FORBIDDEN); + } +} diff --git a/app/Domains/Contacts/Http/Controllers/CustomerPortal/BootstrapController.php b/app/Domains/Contacts/Http/Controllers/CustomerPortal/BootstrapController.php new file mode 100644 index 00000000..572dcb80 --- /dev/null +++ b/app/Domains/Contacts/Http/Controllers/CustomerPortal/BootstrapController.php @@ -0,0 +1,64 @@ +user(); + + // Flatten the registered portal menu to plain title/link pairs. The + // per-item signed-in check is how this has always been written; it also + // means $menu is never assigned - and therefore reads as undefined - if + // the menu happens to carry no items at all. Left as is on purpose. + $portalMenu = \Menu::get('customer_portal_menu'); + + foreach ($portalMenu->items->toArray() as $entry) { + if (! $customer) { + continue; + } + + $menu[] = [ + 'title' => $entry->title, + 'link' => $entry->link->path['url'], + ]; + } + + $companyId = $customer->company_id; + $bookkeepingCurrency = CompanySetting::getSetting('currency', $companyId); + $contactCurrency = Currency::find($customer->currency_id); + $enabledModules = Module::query()->where('enabled', true)->pluck('name'); + + return CustomerResource::make($customer)->additional([ + 'meta' => [ + 'menu' => $menu, + 'current_customer_currency' => $contactCurrency, + 'current_company_currency' => $bookkeepingCurrency ? Currency::find($bookkeepingCurrency) : null, + 'modules' => $enabledModules, + 'current_company_language' => CompanySetting::getSetting('language', $companyId), + ], + ]); + } +} diff --git a/app/Domains/Contacts/Http/Middleware/CustomerPortalMiddleware.php b/app/Domains/Contacts/Http/Middleware/CustomerPortalMiddleware.php new file mode 100644 index 00000000..530b50c4 --- /dev/null +++ b/app/Domains/Contacts/Http/Middleware/CustomerPortalMiddleware.php @@ -0,0 +1,37 @@ +guard('customer'); + + if ($portal->user()->enable_portal) { + return $next($request); + } + + // Access was withdrawn mid-session: tear the session down too, so the + // SPA lands back on the login form instead of retrying. + $portal->logout(); + + return response('Unauthorized.', Response::HTTP_UNAUTHORIZED); + } +} diff --git a/app/Domains/Contacts/Http/Requests/CustomerPortal/CustomerLoginRequest.php b/app/Domains/Contacts/Http/Requests/CustomerPortal/CustomerLoginRequest.php new file mode 100644 index 00000000..8fabe050 --- /dev/null +++ b/app/Domains/Contacts/Http/Requests/CustomerPortal/CustomerLoginRequest.php @@ -0,0 +1,34 @@ + 'required|string', + 'password' => 'required|string', + ]; + } +} diff --git a/app/Domains/Contacts/Http/Requests/CustomerPortal/CustomerProfileRequest.php b/app/Domains/Contacts/Http/Requests/CustomerPortal/CustomerProfileRequest.php new file mode 100644 index 00000000..8531ae0e --- /dev/null +++ b/app/Domains/Contacts/Http/Requests/CustomerPortal/CustomerProfileRequest.php @@ -0,0 +1,140 @@ + + */ + private const ADDRESS_KEYS = [ + 'name', + 'address_street_1', + 'address_street_2', + 'city', + 'state', + 'country_id', + 'zip', + 'phone', + 'fax', + ]; + + /** + * The portal guard has already vetted the caller, and a contact is only + * ever handed its own record, so there is nothing further to check. + */ + public function authorize(): bool + { + return true; + } + + /** + * Constraints applied to the submitted profile patch. + */ + public function rules(): array + { + $rules = [ + 'name' => ['nullable'], + 'password' => ['nullable', 'min:8'], + 'email' => ['nullable', new IdnEmail, $this->emailIsFree()], + ]; + + foreach (['billing', 'shipping'] as $block) { + foreach (self::ADDRESS_KEYS as $key) { + $rules["{$block}.{$key}"] = ['nullable']; + } + } + + $rules['customer_avatar'] = ['nullable', 'file', 'mimes:gif,jpg,png', 'max:20000']; + $rules['is_customer_avatar_removed'] = ['nullable', 'boolean']; + + return $rules; + } + + /** + * The profile columns the controller may hand straight to the model. + * + * @return array + */ + public function customerAttributes(): array + { + return $this->safe()->only([ + 'name', + 'email', + 'password', + ]); + } + + /** + * The submitted shipping block, or null when none was sent. + * + * @return array|null + */ + public function shippingAddress(): ?array + { + return $this->addressBlock('shipping', Address::SHIPPING_TYPE); + } + + /** + * The submitted billing block, or null when none was sent. + * + * @return array|null + */ + public function billingAddress(): ?array + { + return $this->addressBlock('billing', Address::BILLING_TYPE); + } + + /** + * No two contacts of the same company may share an address. + * + * Both operands are read from the ambient request exactly as they always + * have been: the tenant comes from the `company` request header (which the + * slug-scoped portal routes do not send) and the row to skip comes from the + * default guard rather than the portal one. Left untouched deliberately. + */ + private function emailIsFree(): Unique + { + return Rule::unique('customers') + ->where('company_id', $this->header('company')) + ->ignore(auth()->id(), 'id'); + } + + /** + * Lift one address block off the payload and stamp its type onto it. + * + * Anything that did not arrive as an array - absent, null, a scalar - comes + * back as null, which is how the caller tells "replace this address" apart + * from "leave this address alone". + * + * @return array|null + */ + private function addressBlock(string $block, string $type): ?array + { + $submitted = $this->input($block); + + if (! is_array($submitted)) { + return null; + } + + return collect($submitted) + ->merge(['type' => $type]) + ->toArray(); + } +} diff --git a/app/Domains/Contacts/Http/Requests/CustomerRequest.php b/app/Domains/Contacts/Http/Requests/CustomerRequest.php new file mode 100644 index 00000000..490e5f11 --- /dev/null +++ b/app/Domains/Contacts/Http/Requests/CustomerRequest.php @@ -0,0 +1,196 @@ +> + */ + public function rules(): array + { + $rules = [ + 'name' => ['required'], + 'email' => $this->emailRules(), + ]; + + foreach (self::OPTIONAL_FIELDS as $field) { + $rules[$field] = ['nullable']; + } + + $rules['enable_portal'] = ['boolean']; + $rules['currency_id'] = ['nullable']; + + foreach (['billing', 'shipping'] as $block) { + foreach (self::ADDRESS_FIELDS as $field) { + $rules[$block.'.'.$field] = ['nullable']; + } + } + + return $rules; + } + + /** + * The row to persist: the allow-listed columns of the validated payload, + * with authorship and tenancy stamped on top of whatever was sent. + * + * @return array + */ + public function customerAttributes(): array + { + $attributes = Arr::only($this->validated(), self::PERSISTED_FIELDS); + + $attributes['creator_id'] = $this->user()->id; + $attributes['company_id'] = $this->header('company'); + + return $attributes; + } + + /** + * The shipping block, ready for the addresses table. + * + * @return array|null + */ + public function shippingAddress(): ?array + { + return $this->addressBlock('shipping', Address::SHIPPING_TYPE); + } + + /** + * The billing block, ready for the addresses table. + * + * @return array|null + */ + public function billingAddress(): ?array + { + return $this->addressBlock('billing', Address::BILLING_TYPE); + } + + /** + * Custom-field values, or null when none came in. An empty array counts as + * none, so the writer is never called for nothing. + * + * @return array|null + */ + public function customFields(): ?array + { + $values = $this->input('customFields'); + + if (! is_array($values) || $values === []) { + return null; + } + + return $values; + } + + /** + * Optional, but once given it has to parse — internationalised domains + * included — and be unclaimed by another contact of the same company. + * + * On an update carrying an address the contact being edited is excused + * from that check. The verb test is exact, so the same payload sent as + * PATCH would collide with the contact's own row; kept as it stands. + * + * @return array + */ + private function emailRules(): array + { + $unclaimed = Rule::unique('customers')->where('company_id', $this->header('company')); + + if ($this->email != null && $this->isMethod('PUT')) { + $unclaimed->ignore($this->route('customer')->id); + } + + return [new IdnEmail, 'nullable', $unclaimed]; + } + + /** + * One address block tagged with its type, or null when the payload has no + * such block — a block whose every field is null counts as no block. + * + * @return array|null + */ + private function addressBlock(string $key, string $type): ?array + { + $block = $this->input($key); + + if (! is_array($block) || Arr::where($block, fn ($value): bool => isset($value)) === []) { + return null; + } + + return array_merge($block, ['type' => $type]); + } +} diff --git a/app/Domains/Contacts/Http/Requests/DeleteCustomersRequest.php b/app/Domains/Contacts/Http/Requests/DeleteCustomersRequest.php new file mode 100644 index 00000000..9b2f0400 --- /dev/null +++ b/app/Domains/Contacts/Http/Requests/DeleteCustomersRequest.php @@ -0,0 +1,39 @@ +> + */ + public function rules(): array + { + return [ + 'ids' => ['required'], + 'ids.*' => ['required', Rule::exists('customers', 'id')], + ]; + } +} diff --git a/app/Domains/Contacts/Http/Resources/AddressResource.php b/app/Domains/Contacts/Http/Resources/AddressResource.php new file mode 100644 index 00000000..02efcfd5 --- /dev/null +++ b/app/Domains/Contacts/Http/Resources/AddressResource.php @@ -0,0 +1,45 @@ + $this->id, + 'name' => $this->name, + 'address_street_1' => $this->address_street_1, + 'address_street_2' => $this->address_street_2, + 'city' => $this->city, + 'state' => $this->state, + 'country_id' => $this->country_id, + 'zip' => $this->zip, + 'phone' => $this->phone, + 'fax' => $this->fax, + 'type' => $this->type, + 'user_id' => $this->user_id, + 'company_id' => $this->company_id, + 'customer_id' => $this->customer_id, + 'country' => $this->when( + $this->country()->exists(), + fn () => new CountryResource($this->country) + ), + 'user' => $this->when( + $this->user()->exists(), + fn () => new UserResource($this->user) + ), + ]; + } +} diff --git a/app/Domains/Contacts/Http/Resources/CountryResource.php b/app/Domains/Contacts/Http/Resources/CountryResource.php new file mode 100644 index 00000000..1412b91e --- /dev/null +++ b/app/Domains/Contacts/Http/Resources/CountryResource.php @@ -0,0 +1,25 @@ + $this->id, + 'code' => $this->code, + 'name' => $this->name, + 'phone_code' => $this->phone_code, + ]; + } +} diff --git a/app/Domains/Contacts/Http/Resources/CustomerPortal/AddressResource.php b/app/Domains/Contacts/Http/Resources/CustomerPortal/AddressResource.php new file mode 100644 index 00000000..7f8bd21e --- /dev/null +++ b/app/Domains/Contacts/Http/Resources/CustomerPortal/AddressResource.php @@ -0,0 +1,45 @@ + $this->id, + 'name' => $this->name, + 'address_street_1' => $this->address_street_1, + 'address_street_2' => $this->address_street_2, + 'city' => $this->city, + 'state' => $this->state, + 'country_id' => $this->country_id, + 'zip' => $this->zip, + 'phone' => $this->phone, + 'fax' => $this->fax, + 'type' => $this->type, + 'user_id' => $this->user_id, + 'company_id' => $this->company_id, + 'customer_id' => $this->customer_id, + 'country' => $this->when( + $this->country()->exists(), + fn () => new CountryResource($this->country) + ), + 'user' => $this->when( + $this->user()->exists(), + fn () => new UserResource($this->user) + ), + ]; + } +} diff --git a/app/Domains/Contacts/Http/Resources/CustomerPortal/CountryResource.php b/app/Domains/Contacts/Http/Resources/CustomerPortal/CountryResource.php new file mode 100644 index 00000000..71f86cf5 --- /dev/null +++ b/app/Domains/Contacts/Http/Resources/CustomerPortal/CountryResource.php @@ -0,0 +1,25 @@ + $this->id, + 'code' => $this->code, + 'name' => $this->name, + 'phonecode' => $this->phonecode, + ]; + } +} diff --git a/app/Domains/Contacts/Http/Resources/CustomerPortal/CustomerResource.php b/app/Domains/Contacts/Http/Resources/CustomerPortal/CustomerResource.php new file mode 100644 index 00000000..2fc49de7 --- /dev/null +++ b/app/Domains/Contacts/Http/Resources/CustomerPortal/CustomerResource.php @@ -0,0 +1,65 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone, + 'contact_name' => $this->contact_name, + 'company_name' => $this->company_name, + 'website' => $this->website, + 'enable_portal' => $this->enable_portal, + 'currency_id' => $this->currency_id, + 'company_id' => $this->company_id, + 'facebook_id' => $this->facebook_id, + 'google_id' => $this->google_id, + 'github_id' => $this->github_id, + 'formatted_created_at' => $this->formattedCreatedAt, + 'avatar' => $this->avatar, + 'prefix' => $this->prefix, + 'tax_id' => $this->tax_id, + 'billing' => $this->when( + $this->billingAddress()->exists(), + fn () => new AddressResource($this->billingAddress) + ), + 'shipping' => $this->when( + $this->shippingAddress()->exists(), + fn () => new AddressResource($this->shippingAddress) + ), + 'fields' => $this->when( + $this->fields()->exists(), + fn () => CustomFieldValueResource::collection($this->fields) + ), + 'company' => $this->when( + $this->company()->exists(), + fn () => new CompanyResource($this->company) + ), + 'currency' => $this->when( + $this->currency()->exists(), + fn () => new CurrencyResource($this->currency) + ), + ]; + } +} diff --git a/app/Domains/Contacts/Http/Resources/CustomerResource.php b/app/Domains/Contacts/Http/Resources/CustomerResource.php new file mode 100644 index 00000000..8dca233f --- /dev/null +++ b/app/Domains/Contacts/Http/Resources/CustomerResource.php @@ -0,0 +1,77 @@ + $this->id, + 'name' => $this->name, + 'email' => $this->email, + 'phone' => $this->phone, + 'contact_name' => $this->contact_name, + 'company_name' => $this->company_name, + 'website' => $this->website, + 'enable_portal' => $this->enable_portal, + 'password_added' => (bool) $this->password, + 'currency_id' => $this->currency_id, + 'company_id' => $this->company_id, + 'facebook_id' => $this->facebook_id, + 'google_id' => $this->google_id, + 'github_id' => $this->github_id, + 'created_at' => $this->created_at, + 'formatted_created_at' => $this->formattedCreatedAt, + 'updated_at' => $this->updated_at, + 'avatar' => $this->avatar, + 'due_amount' => $this->due_amount, + 'base_due_amount' => $this->base_due_amount, + 'invoice_due_amount' => $this->invoice_due_amount, + 'base_invoice_due_amount' => $this->base_invoice_due_amount, + 'available_credit' => $this->available_credit, + 'base_available_credit' => $this->base_available_credit, + 'account_balance' => $this->account_balance, + 'base_account_balance' => $this->base_account_balance, + 'prefix' => $this->prefix, + 'tax_id' => $this->tax_id, + 'billing' => $this->when( + $this->billingAddress()->exists(), + fn () => new AddressResource($this->billingAddress) + ), + 'shipping' => $this->when( + $this->shippingAddress()->exists(), + fn () => new AddressResource($this->shippingAddress) + ), + 'fields' => $this->when( + $this->fields()->exists(), + fn () => CustomFieldValueResource::collection($this->fields) + ), + 'company' => $this->when( + $this->company()->exists(), + fn () => new CompanyResource($this->company) + ), + 'currency' => $this->when( + $this->currency()->exists(), + fn () => new CurrencyResource($this->currency) + ), + ]; + } +} diff --git a/app/Domains/Contacts/Models/Address.php b/app/Domains/Contacts/Models/Address.php new file mode 100644 index 00000000..49d84b14 --- /dev/null +++ b/app/Domains/Contacts/Models/Address.php @@ -0,0 +1,88 @@ +belongsTo(Customer::class, 'customer_id'); + } + + /** + * Company the address was recorded under. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * User the address belongs to, for addresses owned by a team member. + */ + public function user(): BelongsTo + { + return $this->belongsTo(User::class, 'user_id'); + } + + /** + * Country reference row this address sits in. + */ + public function country(): BelongsTo + { + return $this->belongsTo(Country::class, 'country_id'); + } + + /** + * Country name rendered in the language the application is running in. + * + * Falls back to the name stored in the reference table whenever the ICU + * catalogue has nothing for the stored code. + */ + public function getCountryNameAttribute(): ?string + { + $country = $this->country; + + if (! $country) { + return null; + } + + try { + return Countries::getName($country->code, app()->getLocale()); + } catch (\Exception $exception) { + return $country->name; + } + } +} diff --git a/app/Domains/Contacts/Models/Country.php b/app/Domains/Contacts/Models/Country.php new file mode 100644 index 00000000..4b6e6602 --- /dev/null +++ b/app/Domains/Contacts/Models/Country.php @@ -0,0 +1,29 @@ +hasMany(Address::class, 'country_id'); + } +} diff --git a/app/Domains/Contacts/Models/Customer.php b/app/Domains/Contacts/Models/Customer.php new file mode 100644 index 00000000..8c933eaa --- /dev/null +++ b/app/Domains/Contacts/Models/Customer.php @@ -0,0 +1,376 @@ + 'boolean', + ]; + } + + /** + * Company the contact was created under. + */ + public function company(): BelongsTo + { + return $this->belongsTo(Company::class, 'company_id'); + } + + /** + * Currency every document for this contact is issued in. + */ + public function currency(): BelongsTo + { + return $this->belongsTo(Currency::class, 'currency_id'); + } + + /** + * Author of the record, linked through the creator_id column. + */ + public function creator(): BelongsTo + { + return $this->belongsTo(self::class, 'creator_id'); + } + + /** + * Every postal address recorded for the contact. + */ + public function addresses(): HasMany + { + return $this->hasMany(Address::class, 'customer_id'); + } + + /** + * The address invoices are billed to. + */ + public function billingAddress(): HasOne + { + return $this->addressOfType(Address::BILLING_TYPE); + } + + /** + * The address goods are shipped to. + */ + public function shippingAddress(): HasOne + { + return $this->addressOfType(Address::SHIPPING_TYPE); + } + + /** + * Estimates raised for the contact. + */ + public function estimates(): HasMany + { + return $this->hasMany(Estimate::class, 'customer_id'); + } + + /** + * Invoices raised for the contact. + */ + public function invoices(): HasMany + { + return $this->hasMany(Invoice::class, 'customer_id'); + } + + /** + * Recurring invoice schedules set up for the contact. + */ + public function recurringInvoices(): HasMany + { + return $this->hasMany(RecurringInvoice::class, 'customer_id'); + } + + /** + * Payments received from the contact. + */ + public function payments(): HasMany + { + return $this->hasMany(Payment::class, 'customer_id'); + } + + /** + * Expenses booked against the contact. + */ + public function expenses(): HasMany + { + return $this->hasMany(Expense::class, 'customer_id'); + } + + /** + * Mail sent to the contact. + */ + public function emailLogs(): MorphMany + { + return $this->morphMany(EmailLog::class, 'mailable'); + } + + /** + * Creation date written in the company's configured date format and in the + * language the application is running in. + * + * @param mixed $value + */ + public function getFormattedCreatedAtAttribute($value) + { + $format = CompanySetting::getSetting('carbon_date_format', $this->company_id); + + return Carbon::parse($this->created_at)->translatedFormat($format); + } + + /** + * Public URL of the avatar, or the number zero when none is attached. + */ + public function getAvatarAttribute() + { + $image = $this->getMedia('customer_avatar')->first(); + + return $image ? asset($image->getUrl()) : 0; + } + + /** + * Hash a password on assignment. + * + * A null value is ignored so that saving the model without a password does + * not wipe the stored hash. + * + * @param mixed $value + */ + public function setPasswordAttribute($value) + { + if ($value == null) { + return; + } + + $this->attributes['password'] = bcrypt($value); + } + + /** + * Deliver a portal password reset link. + */ + public function sendPasswordResetNotification(mixed $token): void + { + $notification = new CustomerMailResetPasswordNotification($token); + + $this->notify($notification); + } + + /** + * Return the whole result set for the sentinel limit "all", otherwise a + * page of the requested size. + */ + public function scopePaginateData($query, $limit) + { + return $limit == 'all' ? $query->get() : $query->paginate($limit); + } + + /** + * Narrow to the company the current request is acting on. + */ + public function scopeWhereCompany($query) + { + $company = request()->header('company'); + + return $query->where($this->qualifyColumn('company_id'), $company); + } + + /** + * Run every listed filter that carries a value. + */ + public function scopeApplyFilters($query, array $filters) + { + $scopes = [ + 'search' => 'whereSearch', + 'contact_name' => 'whereContactName', + 'display_name' => 'whereDisplayName', + 'customer_id' => 'whereCustomer', + 'phone' => 'wherePhone', + ]; + + foreach ($scopes as $filter => $scope) { + $value = $filters[$filter] ?? null; + + if ($value) { + $query->{$scope}($value); + } + } + + $sortField = $filters['orderByField'] ?? null; + $sortDirection = $filters['orderBy'] ?? null; + + if ($sortField || $sortDirection) { + $query->whereOrder($sortField ?: 'name', $sortDirection ?: 'asc'); + } + } + + /** + * Keep only contacts matching every whitespace-separated term, a term + * counting as matched when it appears in the name, the email or the phone. + */ + public function scopeWhereSearch($query, $search) + { + $terms = explode(' ', $search); + + foreach ($terms as $term) { + $query->where(function ($match) use ($term) { + $needle = self::wildcard($term); + + $match->where('name', 'LIKE', $needle) + ->orWhere('email', 'LIKE', $needle) + ->orWhere('phone', 'LIKE', $needle); + }); + } + } + + /** + * Partial match on the contact person. + */ + public function scopeWhereContactName($query, $contactName) + { + return $query->where('contact_name', 'LIKE', self::wildcard($contactName)); + } + + /** + * Partial match on the name the contact is displayed under. + */ + public function scopeWhereDisplayName($query, $displayName) + { + return $query->where('name', 'LIKE', self::wildcard($displayName)); + } + + /** + * Partial match on the phone number. + */ + public function scopeWherePhone($query, $phone) + { + return $query->where('phone', 'LIKE', self::wildcard($phone)); + } + + /** + * Pull in one specific contact. + */ + public function scopeWhereCustomer($query, $customer_id) + { + $query->orWhere($this->qualifyColumn('id'), $customer_id); + } + + /** + * Sort by a caller-supplied column, sanitised before it reaches SQL and + * falling back to the creation timestamp. + */ + public function scopeWhereOrder($query, $orderByField, $orderBy) + { + return SafeOrderBy::apply($query, $orderByField, $orderBy, 'created_at'); + } + + /** + * Restrict to contacts invoiced inside a date range, when the caller gave + * both ends of it. + */ + public function scopeApplyInvoiceFilters($query, array $filters) + { + $from = $filters['from_date'] ?? null; + $to = $filters['to_date'] ?? null; + + if ($from && $to) { + $query->invoicesBetween( + Carbon::createFromFormat('Y-m-d', $from), + Carbon::createFromFormat('Y-m-d', $to) + ); + } + } + + /** + * Restrict to contacts holding at least one invoice dated inside the + * inclusive range. + */ + public function scopeInvoicesBetween($query, $start, $end) + { + $range = [$start->format('Y-m-d'), $end->format('Y-m-d')]; + + $query->whereHas('invoices', function ($invoices) use ($range) { + $invoices->whereBetween('invoice_date', $range); + }); + } + + /** + * The single address the contact keeps for the given role. + */ + private function addressOfType(string $type): HasOne + { + return $this->hasOne(Address::class, 'customer_id')->where('type', $type); + } + + /** + * Wrap a term for a substring LIKE comparison. + */ + private static function wildcard($term): string + { + return '%'.$term.'%'; + } +} diff --git a/app/Domains/Contacts/Notifications/CustomerMailResetPasswordNotification.php b/app/Domains/Contacts/Notifications/CustomerMailResetPasswordNotification.php new file mode 100644 index 00000000..bd9712bc --- /dev/null +++ b/app/Domains/Contacts/Notifications/CustomerMailResetPasswordNotification.php @@ -0,0 +1,77 @@ +company->slug; + $resetUrl = url("/{$slug}/customer/reset/password/".$this->token); + $minutes = config('auth.passwords.users.expire'); + + $opening = 'Hello! You are receiving this email because we received a password reset request for your account.'; + $expiry = 'This password reset link will expire in '.$minutes.' minutes'; + $closing = 'If you did not request a password reset, no further action is required.'; + + return (new MailMessage) + ->subject('Reset Password Notification') + ->line($opening) + ->action('Reset Password', $resetUrl) + ->line($expiry) + ->line($closing); + } + + /** + * Nothing is stored for the database channel, which is never used. + * + * @param mixed $notifiable + */ + public function toArray($notifiable): array + { + return []; + } +} diff --git a/app/Domains/Contacts/Policies/CustomerPolicy.php b/app/Domains/Contacts/Policies/CustomerPolicy.php new file mode 100644 index 00000000..3a178d3d --- /dev/null +++ b/app/Domains/Contacts/Policies/CustomerPolicy.php @@ -0,0 +1,85 @@ +sameCompany($user, $customer); + } + + public function create(User $user): bool + { + return BouncerFacade::can('create-customer', Customer::class); + } + + public function update(User $user, Customer $customer): bool + { + return BouncerFacade::can('edit-customer', $customer) && $this->sameCompany($user, $customer); + } + + public function delete(User $user, Customer $customer): bool + { + return $this->mayRemove($user, $customer); + } + + /** + * Contacts are not soft-deleted, so neither restoring nor erasing is + * reachable; both defer to the delete ability regardless. + */ + public function restore(User $user, Customer $customer): bool + { + return $this->mayRemove($user, $customer); + } + + public function forceDelete(User $user, Customer $customer): bool + { + return $this->mayRemove($user, $customer); + } + + /** + * Batch removal. Class-level, so there is no company half to check. + * + * Unused in practice: the bulk endpoint authorises the bare "delete + * multiple customers" ability string, which Bouncer settles before the + * gate ever looks for a policy method. Left in place, missing return type + * included. + */ + public function deleteMultiple(User $user) + { + return BouncerFacade::can('delete-customer', Customer::class); + } + + private function mayRemove(User $user, Customer $customer): bool + { + return BouncerFacade::can('delete-customer', $customer) && $this->sameCompany($user, $customer); + } + + private function sameCompany(User $user, Customer $customer): bool + { + return $user->hasCompany($customer->company_id); + } +}