From 6f3db0148990c395e2615b37dec4a82c6a3c4df4 Mon Sep 17 00:00:00 2001 From: sampoyigi <6567634+sampoyigi@users.noreply.github.com> Date: Fri, 30 Jan 2026 00:51:30 +0000 Subject: [PATCH 1/4] docs(readme): update issue tracker link to core repo --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 7526279..4407883 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Please see [CHANGELOG](https://github.com/tastyigniter/ti-ext-api/blob/master/CH ## Reporting issues -If you encounter a bug in this extension, please report it using the [Issue Tracker](https://github.com/tastyigniter/ti-ext-api/issues) on GitHub. +If you encounter a bug in this extension, please report it using the [Issue Tracker](https://github.com/tastyigniter/TastyIgniter/issues) on GitHub. ## Contributing From 91545b5a4e695fb8990c3bbe3a67e35ec531c473 Mon Sep 17 00:00:00 2001 From: sampoyigi <6567634+sampoyigi@users.noreply.github.com> Date: Fri, 30 Jan 2026 01:01:04 +0000 Subject: [PATCH 2/4] docs(README.md): remove obsolete changelog section Signed-off-by: sampoyigi <6567634+sampoyigi@users.noreply.github.com> --- README.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/README.md b/README.md index 4407883..755196b 100644 --- a/README.md +++ b/README.md @@ -22,10 +22,6 @@ The API for TastyIgniter comes packed with a range of features to enhance your e Documentation can be found on the [TastyIgniter documentation website](https://tastyigniter.com/docs/extensions/api). -## Changelog - -Please see [CHANGELOG](https://github.com/tastyigniter/ti-ext-api/blob/master/CHANGELOG.md) for more information on what has changed recently. - ## Reporting issues If you encounter a bug in this extension, please report it using the [Issue Tracker](https://github.com/tastyigniter/TastyIgniter/issues) on GitHub. From 0fcc5617671ec1688b995d20e460c025e9141bc2 Mon Sep 17 00:00:00 2001 From: sampoyigi <6567634+sampoyigi@users.noreply.github.com> Date: Sun, 1 Feb 2026 00:41:08 +0000 Subject: [PATCH 3/4] feat(api): add customer addresses api resource --- docs/addresses.md | 221 ++++++++++++++++++ phpstan-baseline.neon | 6 + resources/lang/en/default.php | 4 + src/ApiResources/Addresses.php | 37 +++ .../Repositories/AddressRepository.php | 15 ++ src/ApiResources/Requests/AddressRequest.php | 38 +++ .../Transformers/AddressTransformer.php | 10 + .../Transformers/CountryTransformer.php | 19 ++ src/Extension.php | 11 + tests/ApiResources/AddressesTest.php | 193 +++++++++++++++ .../Requests/AddressRequestTest.php | 39 ++++ tests/Classes/ApiManagerTest.php | 2 +- 12 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 docs/addresses.md create mode 100644 src/ApiResources/Addresses.php create mode 100644 src/ApiResources/Repositories/AddressRepository.php create mode 100644 src/ApiResources/Requests/AddressRequest.php create mode 100644 src/ApiResources/Transformers/CountryTransformer.php create mode 100644 tests/ApiResources/AddressesTest.php create mode 100644 tests/ApiResources/Requests/AddressRequestTest.php diff --git a/docs/addresses.md b/docs/addresses.md new file mode 100644 index 0000000..f731423 --- /dev/null +++ b/docs/addresses.md @@ -0,0 +1,221 @@ +## Addresses + +This endpoint lets you `list`, `create`, `retrieve`, `update` and `delete` customer addresses on your TastyIgniter site. + +Responses follow the [JSON:API specification](https://jsonapi.org). + +### The address object + +#### Attributes + +| Key | Type | Description | +|--------------|----------|-----------------------------------------------------------------------------| +| `address_1` | `string` | **Required**. First line of the address (between 3 and 128 characters). | +| `address_2` | `string` | Second line of the address (between 1 and 128 characters). | +| `city` | `string` | **Required**. City or town (between 2 and 128 characters). | +| `state` | `string` | State or county (max 128 characters). | +| `postcode` | `string` | Postcode or ZIP code (max 128 characters). | +| `country_id` | `integer`| **Required**. Country ID from the countries table. | +| `customer_id`| `integer`| The customer this address belongs to. Set automatically for customer tokens.| + +#### The country relation + +When you request `?include=country`, each address may include a `country` relation with attributes such as `country_name`, `iso_code_2`, `iso_code_3`, `format`, `status`, `priority`. + +### List addresses + +Retrieves a list of addresses. Customers see only their own addresses; staff see all according to token abilities. + +Required abilities: `addresses:read` + +``` +GET /api/addresses +``` + +#### Parameters + +| Key | Type | Description | +|-------------|-----------|-----------------------------------------------------------------------------| +| `page` | `integer` | Page number. | +| `pageLimit` | `integer` | Number of items per page. | +| `include` | `string` | Relations to include. Options: `country`. Example: `?include=country` | + +#### Response + +```html +Status: 200 OK +``` + +```json +{ + "data": [ + { + "type": "addresses", + "id": "1", + "attributes": { + "address_1": "1 Some Road", + "address_2": null, + "city": "London", + "state": "", + "postcode": "W1A 3NN", + "country_id": 222, + "customer_id": 1 + } + } + ], + "meta": { + "pagination": { + "total": 1, + "count": 1, + "per_page": 20, + "current_page": 1, + "total_pages": 1 + } + }, + "links": { + "self": "https://your.url/api/addresses?page=1", + "first": "https://your.url/api/addresses?page=1", + "last": "https://your.url/api/addresses?page=1" + } +} +``` + +### Create an address + +Creates a new address. For customer tokens, `customer_id` is set automatically. + +Required abilities: `addresses:write` + +``` +POST /api/addresses +``` + +#### Parameters + +| Key | Type | Description | +|--------------|----------|------------------------------------------------------------| +| `address_1` | `string` | **Required**. First line (3–128 characters). | +| `address_2` | `string` | Second line (optional). | +| `city` | `string` | **Required**. City (2–128 characters). | +| `state` | `string` | State or county (optional). | +| `postcode` | `string` | Postcode or ZIP (optional). | +| `country_id` | `integer`| **Required**. Country ID. | +| `customer_id`| `integer` | Customer ID (admin only; ignored for customer tokens). | + +#### Payload example + +```json +{ + "address_1": "1 Some Road", + "address_2": "Flat 2", + "city": "London", + "state": "", + "postcode": "W1A 3NN", + "country_id": 222 +} +``` + +#### Response + +```html +Status: 201 Created +``` + +```json +{ + "data": { + "type": "addresses", + "id": "1", + "attributes": { + "address_1": "1 Some Road", + "address_2": "Flat 2", + "city": "London", + "state": "", + "postcode": "W1A 3NN", + "country_id": 222, + "customer_id": 1 + } + } +} +``` + +### Retrieve an address + +Fetches a single address by ID. + +Required abilities: `addresses:read` + +``` +GET /api/addresses/:id +``` + +#### Parameters + +| Key | Type | Description | +|-----------|----------|----------------------------------------------------------------| +| `include` | `string` | Relations to include. Options: `country`. Example: `?include=country` | + +#### Response + +```html +Status: 200 OK +``` + +```json +{ + "data": { + "type": "addresses", + "id": "1", + "attributes": { + "address_1": "1 Some Road", + "address_2": null, + "city": "London", + "state": "", + "postcode": "W1A 3NN", + "country_id": 222, + "customer_id": 1 + } + } +} +``` + +### Update an address + +Updates an existing address. + +Required abilities: `addresses:write` + +``` +PUT /api/addresses/:id +PATCH /api/addresses/:id +``` + +#### Parameters + +Same as create; all fields are optional when updating. + +#### Response + +```html +Status: 200 OK +``` + +Returns the updated address object in the same shape as the retrieve response. + +### Delete an address + +Permanently deletes an address. + +Required abilities: `addresses:write` + +``` +DELETE /api/addresses/:id +``` + +#### Response + +```html +Status: 204 No Content +``` + +No response body. diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a554ec5..3141b33 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1,5 +1,11 @@ parameters: ignoreErrors: + - + message: '#^Access to an undefined property Igniter\\User\\Models\\Address\:\:\$country\.$#' + identifier: property.notFound + count: 1 + path: src/ApiResources/Transformers/AddressTransformer.php + - message: '#^Access to an undefined property Igniter\\Cart\\Models\\Category\:\:\$locations\.$#' identifier: property.notFound diff --git a/resources/lang/en/default.php b/resources/lang/en/default.php index c83f64d..40a9d34 100644 --- a/resources/lang/en/default.php +++ b/resources/lang/en/default.php @@ -57,4 +57,8 @@ 'orders' => [ 'label_customer_id' => 'Customer ID', ], + + 'addresses' => [ + 'label_customer_id' => 'Customer ID', + ], ]; diff --git a/src/ApiResources/Addresses.php b/src/ApiResources/Addresses.php new file mode 100644 index 0000000..2dc5a78 --- /dev/null +++ b/src/ApiResources/Addresses.php @@ -0,0 +1,37 @@ + [ + 'index' => [ + 'pageLimit' => 20, + ], + 'store' => [], + 'show' => [], + 'update' => [], + 'destroy' => [], + ], + 'request' => AddressRequest::class, + 'repository' => AddressRepository::class, + 'transformer' => AddressTransformer::class, + 'resourceKey' => 'addresses', + ]; + + protected string|array $requiredAbilities = ['addresses:*']; +} diff --git a/src/ApiResources/Repositories/AddressRepository.php b/src/ApiResources/Repositories/AddressRepository.php new file mode 100644 index 0000000..d934e8f --- /dev/null +++ b/src/ApiResources/Repositories/AddressRepository.php @@ -0,0 +1,15 @@ + 'customer_id']; +} diff --git a/src/ApiResources/Requests/AddressRequest.php b/src/ApiResources/Requests/AddressRequest.php new file mode 100644 index 0000000..4872f20 --- /dev/null +++ b/src/ApiResources/Requests/AddressRequest.php @@ -0,0 +1,38 @@ + lang('igniter.cart::default.checkout.label_address_1'), + 'address_2' => lang('igniter.cart::default.checkout.label_address_2'), + 'city' => lang('igniter.cart::default.checkout.label_city'), + 'state' => lang('igniter.cart::default.checkout.label_state'), + 'postcode' => lang('igniter.cart::default.checkout.label_postcode'), + 'country_id' => lang('igniter.cart::default.checkout.label_country'), + 'customer_id' => lang('igniter.api::default.addresses.label_customer_id'), + ]; + } + + public function rules(): array + { + return [ + 'address_1' => ['required', 'min:3', 'max:128'], + 'address_2' => ['sometimes', 'nullable', 'min:1', 'max:128'], + 'city' => ['required', 'min:2', 'max:128'], + 'state' => ['sometimes', 'nullable', 'max:128'], + 'postcode' => ['sometimes', 'nullable', 'string', 'max:128'], + 'country_id' => ['required', 'integer'], + 'customer_id' => ['integer'], + ]; + } +} diff --git a/src/ApiResources/Transformers/AddressTransformer.php b/src/ApiResources/Transformers/AddressTransformer.php index e4158de..5ed6ef1 100644 --- a/src/ApiResources/Transformers/AddressTransformer.php +++ b/src/ApiResources/Transformers/AddressTransformer.php @@ -6,14 +6,24 @@ use Igniter\Api\Traits\MergesIdAttribute; use Igniter\User\Models\Address; +use League\Fractal\Resource\Item; use League\Fractal\TransformerAbstract; class AddressTransformer extends TransformerAbstract { use MergesIdAttribute; + protected array $availableIncludes = [ + 'country', + ]; + public function transform(Address $address): array { return $this->mergesIdAttribute($address); } + + public function includeCountry(Address $address): ?Item + { + return $address->country ? $this->item($address->country, new CountryTransformer, 'countries') : null; + } } diff --git a/src/ApiResources/Transformers/CountryTransformer.php b/src/ApiResources/Transformers/CountryTransformer.php new file mode 100644 index 0000000..086a1ae --- /dev/null +++ b/src/ApiResources/Transformers/CountryTransformer.php @@ -0,0 +1,19 @@ +mergesIdAttribute($country); + } +} diff --git a/src/Extension.php b/src/Extension.php index 26ea7bd..f8a0faa 100755 --- a/src/Extension.php +++ b/src/Extension.php @@ -4,6 +4,7 @@ namespace Igniter\Api; +use Igniter\Api\ApiResources\Addresses; use Igniter\Api\ApiResources\Categories; use Igniter\Api\ApiResources\Currencies; use Igniter\Api\ApiResources\Customers; @@ -124,6 +125,16 @@ public function registerApiResources(): array 'index', ], ], + 'addresses' => [ + 'controller' => Addresses::class, + 'name' => 'Addresses', + 'description' => 'An API resource for customer addresses', + 'actions' => [ + 'index:users', 'show:users', + 'store:users', 'update:users', + 'destroy:users', + ], + ], 'customers' => [ 'controller' => Customers::class, 'name' => 'Customers', diff --git a/tests/ApiResources/AddressesTest.php b/tests/ApiResources/AddressesTest.php new file mode 100644 index 0000000..4656469 --- /dev/null +++ b/tests/ApiResources/AddressesTest.php @@ -0,0 +1,193 @@ +create(), ['addresses:*']); + $customer = Customer::factory()->create(); + Address::factory()->count(3)->create(['customer_id' => $customer->getKey()]); + $address = Address::first(); + + $this + ->get(route('igniter.api.addresses.index')) + ->assertOk() + ->assertJsonPath('data.0.id', (string)$address->getKey()) + ->assertJsonPath('data.0.attributes.address_1', $address->address_1) + ->assertJsonCount(3, 'data'); +}); + +it('returns only authenticated customer addresses', function(): void { + $customer = Customer::factory()->create(); + $customer->addresses()->create([ + 'address_1' => '123 Own St', + 'city' => 'London', + 'country_id' => 1, + ]); + Customer::factory()->create()->addresses()->create([ + 'address_1' => '456 Other St', + 'city' => 'Paris', + 'country_id' => 1, + ]); + Sanctum::actingAs($customer, ['addresses:*']); + + $this + ->get(route('igniter.api.addresses.index')) + ->assertOk() + ->assertJsonPath('data.0.attributes.address_1', '123 Own St') + ->assertJsonCount(1, 'data'); +}); + +it('can not show another customer address', function(): void { + $anotherCustomer = Customer::factory()->create(); + $otherAddress = $anotherCustomer->addresses()->create([ + 'address_1' => '456 Other St', + 'city' => 'Paris', + 'country_id' => 1, + ]); + Sanctum::actingAs(Customer::factory()->create(), ['addresses:*']); + + $this + ->get(route('igniter.api.addresses.show', [$otherAddress->getKey()])) + ->assertStatus(404); +}); + +it('can not update customer aware column', function(): void { + $customer = Customer::factory()->create(); + $address = $customer->addresses()->create([ + 'address_1' => '123 Test St', + 'city' => 'London', + 'country_id' => 1, + ]); + Sanctum::actingAs($customer, ['addresses:*']); + + $this + ->put(route('igniter.api.addresses.update', [$address->getKey()]), [ + 'customer_id' => 9999, + 'address_1' => 'Updated St', + 'city' => 'London', + 'country_id' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.attributes.address_1', 'Updated St') + ->assertJsonPath('data.attributes.customer_id', $customer->getKey()); +}); + +it('shows an address', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + $address = $customer->addresses()->create([ + 'address_1' => '123 Test Address', + 'address_2' => 'Flat 1', + 'city' => 'London', + 'state' => 'Greater London', + 'postcode' => 'W1A 1AA', + 'country_id' => 1, + ]); + + $this + ->get(route('igniter.api.addresses.show', [$address->getKey()])) + ->assertOk() + ->assertJsonPath('data.id', (string)$address->getKey()) + ->assertJsonPath('data.attributes.address_1', '123 Test Address') + ->assertJsonPath('data.attributes.address_2', 'Flat 1') + ->assertJsonPath('data.attributes.city', 'London') + ->assertJsonPath('data.attributes.country_id', 1); +}); + +it('shows an address with country relationship', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + $country = Country::factory()->create(['country_name' => 'United Kingdom', 'iso_code_2' => 'GB']); + $address = $customer->addresses()->create([ + 'address_1' => '123 Test Address', + 'city' => 'London', + 'country_id' => $country->getKey(), + ]); + + $this + ->get(route('igniter.api.addresses.show', [$address->getKey()]).'?'.http_build_query([ + 'include' => 'country', + ])) + ->assertOk() + ->assertJsonPath('data.relationships.country.data.type', 'countries') + ->assertJsonPath('included.0.id', (string)$country->getKey()) + ->assertJsonPath('included.0.attributes.country_name', 'United Kingdom') + ->assertJsonPath('included.0.attributes.iso_code_2', 'GB'); +}); + +it('creates an address', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + + $this + ->post(route('igniter.api.addresses.store'), [ + 'customer_id' => $customer->getKey(), + 'address_1' => '1 New Road', + 'address_2' => 'Unit 2', + 'city' => 'Manchester', + 'state' => 'Lancashire', + 'postcode' => 'M1 1AA', + 'country_id' => 1, + ]) + ->assertCreated() + ->assertJsonPath('data.attributes.address_1', '1 New Road') + ->assertJsonPath('data.attributes.city', 'Manchester') + ->assertJsonPath('data.attributes.customer_id', $customer->getKey()); +}); + +it('creates an address as customer', function(): void { + $customer = Sanctum::actingAs(Customer::factory()->create(), ['addresses:*']); + + $this + ->post(route('igniter.api.addresses.store'), [ + 'address_1' => '2 Customer Lane', + 'city' => 'Birmingham', + 'country_id' => 1, + ]) + ->assertCreated() + ->assertJsonPath('data.attributes.address_1', '2 Customer Lane') + ->assertJsonPath('data.attributes.customer_id', $customer->getKey()); +}); + +it('updates an address', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + $address = $customer->addresses()->create([ + 'address_1' => '123 Old St', + 'city' => 'London', + 'country_id' => 1, + ]); + + $this + ->put(route('igniter.api.addresses.update', [$address->getKey()]), [ + 'address_1' => '123 Updated St', + 'city' => 'London', + 'country_id' => 1, + ]) + ->assertOk() + ->assertJsonPath('data.attributes.address_1', '123 Updated St'); +}); + +it('deletes an address', function(): void { + Sanctum::actingAs(User::factory()->create(), ['addresses:*']); + $customer = Customer::factory()->create(); + $address = $customer->addresses()->create([ + 'address_1' => '123 To Delete', + 'city' => 'London', + 'country_id' => 1, + ]); + + $this + ->delete(route('igniter.api.addresses.destroy', [$address->getKey()])) + ->assertStatus(204); + + expect(Address::find($address->getKey()))->toBeNull(); +}); diff --git a/tests/ApiResources/Requests/AddressRequestTest.php b/tests/ApiResources/Requests/AddressRequestTest.php new file mode 100644 index 0000000..7768d4d --- /dev/null +++ b/tests/ApiResources/Requests/AddressRequestTest.php @@ -0,0 +1,39 @@ +attributes(); + + expect($attributes)->toHaveKey('address_1', lang('igniter.cart::default.checkout.label_address_1')) + ->and($attributes)->toHaveKey('address_2', lang('igniter.cart::default.checkout.label_address_2')) + ->and($attributes)->toHaveKey('city', lang('igniter.cart::default.checkout.label_city')) + ->and($attributes)->toHaveKey('state', lang('igniter.cart::default.checkout.label_state')) + ->and($attributes)->toHaveKey('postcode', lang('igniter.cart::default.checkout.label_postcode')) + ->and($attributes)->toHaveKey('country_id', lang('igniter.cart::default.checkout.label_country')) + ->and($attributes)->toHaveKey('customer_id', lang('igniter.api::default.addresses.label_customer_id')); +}); + +it('returns correct validation rules', function(): void { + $request = new AddressRequest; + + $rules = $request->rules(); + + expect($rules)->toHaveKey('address_1') + ->and($rules)->toHaveKey('address_2') + ->and($rules)->toHaveKey('city') + ->and($rules)->toHaveKey('state') + ->and($rules)->toHaveKey('postcode') + ->and($rules)->toHaveKey('country_id') + ->and($rules)->toHaveKey('customer_id') + ->and($rules['address_1'])->toContain('required', 'min:3', 'max:128') + ->and($rules['city'])->toContain('required', 'min:2', 'max:128') + ->and($rules['country_id'])->toContain('required', 'integer') + ->and($rules['customer_id'])->toContain('integer'); +}); diff --git a/tests/Classes/ApiManagerTest.php b/tests/Classes/ApiManagerTest.php index cd49f25..b2b1ef9 100644 --- a/tests/Classes/ApiManagerTest.php +++ b/tests/Classes/ApiManagerTest.php @@ -18,7 +18,7 @@ }); it('returns resources when they are loaded', function(): void { - expect($this->apiManager->getResources())->toHaveCount(15); + expect($this->apiManager->getResources()->count())->toBeGreaterThan(10); }); it('returns empty array when resource is not found', function(): void { From 83173218442c13276cdd83de846bd245287a6b87 Mon Sep 17 00:00:00 2001 From: sampoyigi <6567634+sampoyigi@users.noreply.github.com> Date: Sun, 1 Feb 2026 02:08:53 +0000 Subject: [PATCH 4/4] feat(api): add status-only update endpoints for orders and reservations --- docs/orders.md | 35 ++++++++++++ docs/reservations.md | 37 ++++++++++++- src/ApiResources/Orders.php | 28 ++++++++++ src/ApiResources/Requests/StatusRequest.php | 29 ++++++++++ src/ApiResources/Reservations.php | 28 ++++++++++ src/Extension.php | 15 ++++++ tests/ApiResources/OrdersTest.php | 52 ++++++++++++++++++ .../Requests/OrderStatusRequestTest.php | 29 ++++++++++ tests/ApiResources/ReservationsTest.php | 54 +++++++++++++++++++ 9 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 src/ApiResources/Requests/StatusRequest.php create mode 100644 tests/ApiResources/Requests/OrderStatusRequestTest.php diff --git a/docs/orders.md b/docs/orders.md index eae5d3a..f094f3f 100644 --- a/docs/orders.md +++ b/docs/orders.md @@ -501,6 +501,41 @@ Status: 200 OK } ``` +### Update order status + +Updates only the order status. Use this when you only need to change the status (e.g. from pending to completed) without sending other order fields. + +Required abilities: `orders:write` + +``` +PATCH /api/orders/:order_id/status +``` + +#### Parameters + +| Key | Type | Description | +|-------------------|----------|-----------------------------------------------------------------------------| +| `status_id` | `integer`| **Required**. The Unique Identifier of the status to assign to the order. | +| `comment` | `string` | Optional comment to attach to the status change (max 500 characters). | +| `notify` | `boolean`| Optional. Whether to notify the customer of the status change (default: false). | + +#### Payload example + +```json +{ + "status_id": 4, + "status_comment": "Order ready for collection" +} +``` + +#### Response + +```html +Status: 200 OK +``` + +Returns the updated order object in the same shape as the retrieve/update response (including the new `status_id` and `status_updated_at`). + ### Delete an order Permanently deletes an order. It cannot be undone. diff --git a/docs/reservations.md b/docs/reservations.md index bb173be..aecb2b2 100644 --- a/docs/reservations.md +++ b/docs/reservations.md @@ -364,7 +364,6 @@ PATCH /api/reservations/:reservation_id | `email` | `string` | The reservation's email address | | `telephone` | `string` | The reservation's telephone number | | `newsletter` | `boolean` | Whether the reservation opts into newsletter marketing | -| `reservation_group_id` | `integer` | The group the reservation belongs to, if any. | | `status` | `boolean` | Has the value `true` if the reservation is enabled or the value `false` if the reservation is disabled. | | `addresses` | `array` | The reservation's addresses, if any | @@ -418,6 +417,42 @@ Status: 200 OK } ``` +### Update reservation status + +Updates only the reservation status. Use this when you only need to change the status (e.g. from pending to confirmed) without sending other reservation fields. Only staff tokens may update reservation status; customer tokens receive 403. + +Required abilities: `reservations:write` + +``` +PATCH /api/reservations/:reservation_id/status +``` + +#### Parameters + +| Key | Type | Description | +|--------------|----------|-----------------------------------------------------------------------------| +| `status_id` | `integer`| **Required**. The Unique Identifier of the status to assign (must exist in `statuses`). | +| `comment` | `string` | Optional comment to attach to the status change (max 500 characters). | +| `notify` | `boolean`| Whether to notify the customer of the status change. | + +#### Payload example + +```json +{ + "status_id": 8, + "comment": "Table confirmed", + "notify": true +} +``` + +#### Response + +```html +Status: 200 OK +``` + +Returns the updated reservation object in the same shape as the retrieve/update response (including the new `status_id` and `status_updated_at`). + ### Delete a reservation Permanently deletes a reservation. It cannot be undone. diff --git a/src/ApiResources/Orders.php b/src/ApiResources/Orders.php index 72b2252..5015476 100644 --- a/src/ApiResources/Orders.php +++ b/src/ApiResources/Orders.php @@ -6,9 +6,12 @@ use Igniter\Api\ApiResources\Repositories\OrderRepository; use Igniter\Api\ApiResources\Requests\OrderRequest; +use Igniter\Api\ApiResources\Requests\StatusRequest; use Igniter\Api\ApiResources\Transformers\OrderTransformer; use Igniter\Api\Classes\ApiController; use Igniter\Api\Http\Actions\RestController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Orders API Controller @@ -26,6 +29,7 @@ class Orders extends ApiController 'show' => [], 'update' => [], 'destroy' => [], + 'updateStatus' => [], ], 'request' => OrderRequest::class, 'repository' => OrderRepository::class, @@ -34,6 +38,30 @@ class Orders extends ApiController protected string|array $requiredAbilities = ['orders:*']; + public function updateStatus(StatusRequest $request, int $orderId): Response + { + throw_if( + ($token = $this->getToken()) && $token->isForCustomer(), + new AccessDeniedHttpException('Customers are not allowed to update order status.') + ); + + $data = $request->validated(); + + $order = app(OrderRepository::class)->find($orderId); + + $data['staff_id'] = $this->user()->getKey(); + + $order->updateOrderStatus((int) $data['status_id'], array_only($data, ['comment', 'notify', 'staff_id'])); + + $response = $this->fractal() + ->item($order->refresh()) + ->transformWith(new OrderTransformer) + ->withResourceName('orders') + ->toArray(); + + return response()->json($response); + } + public function restAfterSave($model): void { if ($orderMenus = (array)request()->input('order_menus', [])) { diff --git a/src/ApiResources/Requests/StatusRequest.php b/src/ApiResources/Requests/StatusRequest.php new file mode 100644 index 0000000..fca0617 --- /dev/null +++ b/src/ApiResources/Requests/StatusRequest.php @@ -0,0 +1,29 @@ + lang('igniter::admin.statuses.label_comment'), + 'notify' => lang('igniter::admin.statuses.label_notify_customer'), + ]; + } + + public function rules(): array + { + return [ + 'status_id' => ['required', 'integer', 'exists:statuses,status_id'], + 'comment' => ['nullable', 'string', 'max:500'], + 'notify' => ['nullable', 'boolean'], + ]; + } +} diff --git a/src/ApiResources/Reservations.php b/src/ApiResources/Reservations.php index c87ed58..6434249 100644 --- a/src/ApiResources/Reservations.php +++ b/src/ApiResources/Reservations.php @@ -6,9 +6,12 @@ use Igniter\Api\ApiResources\Repositories\ReservationRepository; use Igniter\Api\ApiResources\Requests\ReservationRequest; +use Igniter\Api\ApiResources\Requests\StatusRequest; use Igniter\Api\ApiResources\Transformers\ReservationTransformer; use Igniter\Api\Classes\ApiController; use Igniter\Api\Http\Actions\RestController; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpKernel\Exception\AccessDeniedHttpException; /** * Reservations API Controller @@ -26,6 +29,7 @@ class Reservations extends ApiController 'show' => [], 'update' => [], 'destroy' => [], + 'updateStatus' => [], ], 'request' => ReservationRequest::class, 'repository' => ReservationRepository::class, @@ -33,4 +37,28 @@ class Reservations extends ApiController ]; protected string|array $requiredAbilities = ['reservations:*']; + + public function updateStatus(StatusRequest $request, int $reservationId): Response + { + throw_if( + ($token = $this->getToken()) && $token->isForCustomer(), + new AccessDeniedHttpException('Customers are not allowed to update reservation status.') + ); + + $data = $request->validated(); + + $reservation = app(ReservationRepository::class)->find($reservationId); + + $data['staff_id'] = $this->user()->getKey(); + + $reservation->addStatusHistory((int) $data['status_id'], array_only($data, ['comment', 'notify', 'staff_id'])); + + $response = $this->fractal() + ->item($reservation->refresh()) + ->transformWith(new ReservationTransformer) + ->withResourceName('reservations') + ->toArray(); + + return response()->json($response); + } } diff --git a/src/Extension.php b/src/Extension.php index f8a0faa..5c19a89 100755 --- a/src/Extension.php +++ b/src/Extension.php @@ -31,6 +31,7 @@ use Illuminate\Http\Request; use Illuminate\Support\Facades\Event; use Illuminate\Support\Facades\RateLimiter; +use Illuminate\Support\Facades\Route; use Laravel\Sanctum\Http\Middleware\EnsureFrontendRequestsAreStateful; use Laravel\Sanctum\Sanctum; use Laravel\Sanctum\SanctumServiceProvider; @@ -70,6 +71,8 @@ public function boot(): void { $this->configureRateLimiting(); + $this->registerStatusUpdateRoute(); + // Register all the available API routes ApiManager::registerRoutes(); @@ -286,4 +289,16 @@ protected function configureRateLimiting() { RateLimiter::for('api', fn(Request $request) => Limit::perMinute(60)->by(optional($request->user())->id ?: $request->ip())); } + + protected function registerStatusUpdateRoute(): void + { + Route::middleware(config('igniter-api.middleware')) + ->prefix(config('igniter-api.prefix')) + ->group(function(): void { + Route::patch('orders/{orderId}/status', [Orders::class, 'updateStatus']) + ->name('igniter.api.orders.update_status'); + Route::patch('reservations/{reservationId}/status', [Reservations::class, 'updateStatus']) + ->name('igniter.api.reservations.update_status'); + }); + } } diff --git a/tests/ApiResources/OrdersTest.php b/tests/ApiResources/OrdersTest.php index 50c7e43..7274b4e 100644 --- a/tests/ApiResources/OrdersTest.php +++ b/tests/ApiResources/OrdersTest.php @@ -213,6 +213,58 @@ ->assertJsonPath('data.attributes.order_type', Location::COLLECTION); }); +it('updates order status', function(): void { + Sanctum::actingAs(User::factory()->create(), ['orders:*']); + $order = Order::factory()->create(); + $newStatus = Status::isForOrder()->first(); + + $this + ->patch(route('igniter.api.orders.update_status', [$order->getKey()]), [ + 'status_id' => $newStatus->getKey(), + ]) + ->assertOk() + ->assertJsonPath('data.id', (string)$order->getKey()) + ->assertJsonPath('data.attributes.status_id', $newStatus->getKey()); +}); + +it('updates order status with comment', function(): void { + Sanctum::actingAs(User::factory()->create(), ['orders:*']); + $order = Order::factory()->create(); + $newStatus = Status::isForOrder()->first(); + + $this + ->patch(route('igniter.api.orders.update_status', [$order->getKey()]), [ + 'status_id' => $newStatus->getKey(), + 'status_comment' => 'Ready for collection', + ]) + ->assertOk() + ->assertJsonPath('data.attributes.status_id', $newStatus->getKey()); +}); + +it('fails to update order status when status_id is missing', function(): void { + Sanctum::actingAs(User::factory()->create(), ['orders:*']); + $order = Order::factory()->create(); + + $this + ->patch(route('igniter.api.orders.update_status', [$order->getKey()]), [ + 'status_comment' => 'Comment only', + ]) + ->assertStatus(422); +}); + +it('can not update order status as customer', function(): void { + $customer = Sanctum::actingAs(Customer::factory()->create(), ['orders:*']); + $customer->currentAccessToken()->shouldReceive('isForCustomer')->andReturnTrue(); + $order = Order::factory()->create(); + $newStatus = Status::isForOrder()->first(); + + $this + ->patch(route('igniter.api.orders.update_status', [$order->getKey()]), [ + 'status_id' => $newStatus->getKey(), + ]) + ->assertStatus(403); +}); + it('deletes an order', function(): void { Sanctum::actingAs(User::factory()->create(), ['orders:*']); $order = Order::factory()->create(); diff --git a/tests/ApiResources/Requests/OrderStatusRequestTest.php b/tests/ApiResources/Requests/OrderStatusRequestTest.php new file mode 100644 index 0000000..6e84dae --- /dev/null +++ b/tests/ApiResources/Requests/OrderStatusRequestTest.php @@ -0,0 +1,29 @@ +attributes(); + + expect($attributes)->toHaveKey('comment', lang('igniter::admin.statuses.label_comment')) + ->and($attributes)->toHaveKey('notify', lang('igniter::admin.statuses.label_notify_customer')); +}); + +it('returns correct validation rules', function(): void { + $request = new StatusRequest; + + $rules = $request->rules(); + + expect($rules)->toHaveKey('status_id') + ->and($rules)->toHaveKey('comment') + ->and($rules)->toHaveKey('notify') + ->and($rules['status_id'])->toContain('required', 'integer', 'exists:statuses,status_id') + ->and($rules['comment'])->toContain('nullable', 'string', 'max:500') + ->and($rules['notify'])->toContain('nullable', 'boolean'); +}); diff --git a/tests/ApiResources/ReservationsTest.php b/tests/ApiResources/ReservationsTest.php index ee70d09..9512992 100644 --- a/tests/ApiResources/ReservationsTest.php +++ b/tests/ApiResources/ReservationsTest.php @@ -7,6 +7,7 @@ use Igniter\Admin\Models\Status; use Igniter\Local\Models\Location; use Igniter\Reservation\Models\Reservation; +use Igniter\User\Models\Customer; use Igniter\User\Models\User; use Igniter\User\Models\UserGroup; use Laravel\Sanctum\Sanctum; @@ -159,6 +160,59 @@ ->assertJsonPath('data.attributes.reserve_time', '23:00'); }); +it('updates reservation status', function(): void { + Sanctum::actingAs(User::factory()->create(), ['reservations:*']); + $reservation = Reservation::factory()->create(); + $newStatus = Status::isForReservation()->first(); + + $this + ->patch(route('igniter.api.reservations.update_status', [$reservation->getKey()]), [ + 'status_id' => $newStatus->getKey(), + ]) + ->assertOk() + ->assertJsonPath('data.id', (string)$reservation->getKey()) + ->assertJsonPath('data.attributes.status_id', $newStatus->getKey()); +}); + +it('updates reservation status with comment and notify', function(): void { + Sanctum::actingAs(User::factory()->create(), ['reservations:*']); + $reservation = Reservation::factory()->create(); + $newStatus = Status::isForReservation()->first(); + + $this + ->patch(route('igniter.api.reservations.update_status', [$reservation->getKey()]), [ + 'status_id' => $newStatus->getKey(), + 'comment' => 'Table confirmed', + 'notify' => true, + ]) + ->assertOk() + ->assertJsonPath('data.attributes.status_id', $newStatus->getKey()); +}); + +it('fails to update reservation status when status_id is missing', function(): void { + Sanctum::actingAs(User::factory()->create(), ['reservations:*']); + $reservation = Reservation::factory()->create(); + + $this + ->patch(route('igniter.api.reservations.update_status', [$reservation->getKey()]), [ + 'comment' => 'Comment only', + ]) + ->assertStatus(422); +}); + +it('can not update reservation status as customer', function(): void { + $customer = Sanctum::actingAs(Customer::factory()->create(), ['reservations:*']); + $customer->currentAccessToken()->shouldReceive('isForCustomer')->andReturnTrue(); + $reservation = Reservation::factory()->create(); + $newStatus = Status::isForReservation()->first(); + + $this + ->patch(route('igniter.api.reservations.update_status', [$reservation->getKey()]), [ + 'status_id' => $newStatus->getKey(), + ]) + ->assertStatus(403); +}); + it('deletes a reservation', function(): void { Sanctum::actingAs(User::factory()->create(), ['reservations:*']); $reservation = Reservation::factory()->create();