Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,11 @@
## [0.2.0] - 2026-09-22

### Added
- Offer providers, so a host's discounts can be listed and created from the console without the package knowing what stores them (`OfferProvider`, `OfferField`, `Offer`)
- `GET offers`, `POST offers/{provider}`, `DELETE offers/{provider}/{id}`, all answering to the configured owner the way the mail template endpoints do
- `admin.offer_providers` configuration, refused when a class does not implement the contract or when two providers claim one key
- `admin.requests.create_offer`, so a host can validate a new discount its own way

## [0.1.0] - 2026-08-29
- Shared administration user directory and configurable automatic email templates
- Host-configurable models, resources, requests, routes, middleware and user provider
Expand Down
55 changes: 55 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,3 +36,58 @@ return [
```

Each provider implements `Whilesmart\Engagement\Contracts\MetricProvider` and returns count, sum, ratio, series, or ranking metrics. Adding a provider automatically adds its measurements to the shared admin page.

## Offers

A discount is stored differently in every product, so the console asks a
provider rather than a table. Register one and the discounts page lists what it
holds and renders a form from the fields it declares.

```php
return [
'offer_providers' => [
App\Billing\CouponOfferProvider::class,
],
];
```

```php
use Whilesmart\Admin\Contracts\OfferField;
use Whilesmart\Admin\Contracts\OfferProvider;
use Whilesmart\Admin\Support\Offer;

class CouponOfferProvider implements OfferProvider
{
public function key(): string
{
return 'coupons';
}

public function label(): string
{
return 'Coupons';
}

public function fields(): array
{
return [
new OfferField('code', 'Code', required: true),
new OfferField('percent_off', 'Percent off', 'number', required: true),
new OfferField('expires_at', 'Expires', 'date'),
];
}

public function all(): array { /* Offer[] */ }

public function create(array $attributes): Offer { /* ... */ }

public function revoke(string $id): void { /* ... */ }
}
```

`Offer::$value` is already formatted, because only the provider knows whether
20 means a percentage, pennies or seats. Revoking stops an offer being redeemed
again and leaves redemptions already made standing.

Registering no provider is a supported state: the endpoint answers with an
empty list and the page says so.
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
"name": "whilesmart/eloquent-admin",
"description": "Shared measurements, user directory and automatic email templates for Laravel applications.",
"type": "library",
"version": "0.1.0",
"version": "0.2.0",
"license": "MIT",
"authors": [{ "name": "Whilesmart Team" }],
"require": {
Expand Down
10 changes: 9 additions & 1 deletion config/admin.php
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<?php

use Whilesmart\Admin\Http\Controllers\AdminController;
use Whilesmart\Admin\Http\Requests\CreateOfferRequest;
use Whilesmart\Admin\Http\Requests\UpdateMailTemplateRequest;
use Whilesmart\Admin\Http\Resources\AdminUserResource;
use Whilesmart\Admin\Http\Resources\MailTemplateResource;
Expand All @@ -18,9 +19,16 @@
'user_search_columns' => ['first_name', 'last_name', 'email'],
'user_provider' => EloquentAdminUserProvider::class,
'models' => ['mail_template' => MailTemplate::class],
'requests' => ['update_mail_template' => UpdateMailTemplateRequest::class],
'requests' => [
'update_mail_template' => UpdateMailTemplateRequest::class,
'create_offer' => CreateOfferRequest::class,
],
'resources' => ['user' => AdminUserResource::class, 'mail_template' => MailTemplateResource::class],
'controller' => AdminController::class,
// Classes implementing OfferProvider. Registering one puts its discounts
// on the console; registering none leaves that page saying so.
'offer_providers' => [],

'templates' => [],
'tokens' => ['first_name', 'last_name', 'name', 'email'],
'registration_template' => 'welcome',
Expand Down
3 changes: 3 additions & 0 deletions routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

$controller = config('admin.controller');
Route::get('metrics', [$controller, 'metrics']);
Route::get('offers', [$controller, 'offers']);
Route::post('offers/{provider}', [$controller, 'createOffer'])->middleware(config('admin.write_middleware', []));
Route::delete('offers/{provider}/{id}', [$controller, 'revokeOffer'])->middleware(config('admin.write_middleware', []));
Route::get('users', [$controller, 'users']);
Route::get('users/{id}', [$controller, 'user']);
Route::get('mail-templates', [$controller, 'templates']);
Expand Down
6 changes: 6 additions & 0 deletions src/AdminServiceProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use InvalidArgumentException;
use Whilesmart\Admin\Contracts\AdminUserProvider;
use Whilesmart\Admin\Listeners\SendRegistrationEmail;
use Whilesmart\Admin\Support\OfferRegistry;
use Whilesmart\UserAuthentication\Events\UserRegisteredEvent;

class AdminServiceProvider extends ServiceProvider
Expand All @@ -23,6 +24,11 @@ public function register(): void

return $app->make($provider);
});

$this->app->singleton(
OfferRegistry::class,
fn () => new OfferRegistry((array) config('admin.offer_providers', [])),
);
}

public function boot(): void
Expand Down
35 changes: 35 additions & 0 deletions src/Contracts/OfferField.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<?php

namespace Whilesmart\Admin\Contracts;

/**
* One input a provider needs in order to create an offer.
*/
final class OfferField
{
/**
* @param 'text'|'number'|'date'|'select'|'boolean' $type
* @param array<int|string, string> $options For 'select', value => label.
*/
public function __construct(
public readonly string $name,
public readonly string $label,
public readonly string $type = 'text',
public readonly bool $required = false,
public readonly ?string $help = null,
public readonly array $options = [],
) {}

/** @return array<string, mixed> */
public function toArray(): array
{
return [
'name' => $this->name,
'label' => $this->label,
'type' => $this->type,
'required' => $this->required,
'help' => $this->help,
'options' => $this->options,
];
}
}
37 changes: 37 additions & 0 deletions src/Contracts/OfferProvider.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
<?php

namespace Whilesmart\Admin\Contracts;

use Whilesmart\Admin\Support\Offer;

/**
* Lists and creates a host's discounts without knowing what stores them.
*/
interface OfferProvider
{
/** Stable machine key, unique across registered providers. */
public function key(): string;

/** Human label, shown as the section heading. */
public function label(): string;

/**
* The inputs create() expects, which the console renders as a form.
*
* @return array<int, OfferField>
*/
public function fields(): array;

/**
* @return Offer[]
*/
public function all(): array;

/**
* @param array<string, mixed> $attributes Keyed by the field names above.
*/
public function create(array $attributes): Offer;

/** Stop an offer being redeemed again. Redemptions already made stand. */
public function revoke(string $id): void;
}
71 changes: 71 additions & 0 deletions src/Http/Controllers/AdminController.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,12 @@
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;
use Whilesmart\Admin\Contracts\AdminUserProvider;
use Whilesmart\Admin\Contracts\OfferProvider;
use Whilesmart\Admin\Http\Requests\CreateOfferRequest;
use Whilesmart\Admin\Http\Resources\AdminUserResource;
use Whilesmart\Admin\Mail\TemplateMail;
use Whilesmart\Admin\Models\MailTemplate;
use Whilesmart\Admin\Support\OfferRegistry;
use Whilesmart\Admin\Support\TemplateRegistry;
use Whilesmart\Engagement\EngagementManager;
use Whilesmart\Engagement\Support\ClientRegistry;
Expand All @@ -17,6 +20,74 @@

class AdminController extends Controller
{

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a second transcription of the owner gate that the mail-template path already implements (UpdateMailTemplateRequest::authorize() and the controller's template authorization both abort/deny against OwnerAuthorizer). Two independent copies of the same rule means the offer endpoints and the template endpoints can drift — e.g. a change to owner resolution or to the 403 semantics applied to one path will not reach the other, even though both are documented as answering to the configured owner. Extract the gate into a single private helper and have the template path use the same helper, so there is exactly one place that decides who may see or change admin-owned data.

Suggested change
{
private function authorizeOffers(Request $request): void
{
$owner = config('admin.owner');
$this->authorizeOwner($request, $owner['type'], $owner['id']);
}
/**
* The single owner gate for this controller. The mail template methods
* should delegate to this too, so both sections answer to one rule.
*/
private function authorizeOwner(Request $request, string $ownerType, mixed $ownerId): void
{
abort_unless(app(OwnerAuthorizer::class)->authorize(
$request->user(),
$ownerType,
$ownerId,
), 403);
}

/**
* Every registered provider, its fields, and its offers.
*
* A host with none registered gets an empty list, not an error.
*/
public function offers(Request $request, OfferRegistry $offers): JsonResponse
{
$this->authorizeOffers($request);

return response()->json([
'success' => true,
'data' => array_values(array_map(fn ($provider) => [
'key' => $provider->key(),
'label' => $provider->label(),
'fields' => array_map(fn ($field) => $field->toArray(), $provider->fields()),
'offers' => array_map(fn ($offer) => $offer->toArray(), $provider->all()),
], $offers->all())),
]);
}

public function createOffer(Request $request, OfferRegistry $offers, string $provider): JsonResponse
{
$this->authorizeOffers($request);

// Defaulted rather than read straight out: a host that published this
// config before the key existed has an array that wins over the one
// shipped here, and would resolve nothing.
$requestClass = config('admin.requests.create_offer', CreateOfferRequest::class);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The shared write path validates only the shape of the payload (CreateOfferRequest::rules() requires attributes to be an array), while the provider contract advertises per-field requiredness that the console renders (OfferField::$required, serialized by OfferField::toArray() and surfaced as data.N.fields.N.required). A caller that skips the console form — or that posts {"attributes": {}} — passes validation and reaches OfferProvider::create(), which indexes the fields it declared as required (e.g. $attributes['code']) and then constructs a typed Offer property from the missing value, turning what should be a 422 into a 500. The mail-template path does not have this gap because UpdateMailTemplateRequest::rules() enforces its fields. Derive the required entries from the provider's own fields() and validate them through the request's existing validator, rather than introducing a second validator class.

Suggested change
$requestClass = config('admin.requests.create_offer', CreateOfferRequest::class);
$requestClass = config('admin.requests.create_offer', CreateOfferRequest::class);
$validated = app($requestClass)->validated();
$offerProvider = $this->offerProvider($offers, $provider);
// The fields a provider declares are what the console renders as a
// form; hold a caller that skips the form to the same requiredness.
$required = [];
foreach ($offerProvider->fields() as $field) {
if ($field->required) {
$required['attributes.'.$field->name] = ['required'];
}
}
if ($required !== []) {
$request->validate($required);
}
$offer = $offerProvider->create($validated['attributes']);

$validated = app($requestClass)->validated();
$offer = $this->offerProvider($offers, $provider)->create($validated['attributes']);

return response()->json(['success' => true, 'data' => $offer->toArray()], 201);
}

public function revokeOffer(Request $request, OfferRegistry $offers, string $provider, string $id): JsonResponse
{
$this->authorizeOffers($request);

$this->offerProvider($offers, $provider)->revoke($id);

return response()->json(['success' => true]);
}

/**
* Offers answer to the configured owner, the way a mail template answers to
* its own. Checked here rather than in the request, so a host swapping the
* request class cannot drop it.
*/
private function authorizeOffers(Request $request): void
{
$owner = config('admin.owner');

abort_unless(app(OwnerAuthorizer::class)->authorize(
$request->user(),
$owner['type'],
$owner['id'],
), 403);
}

private function offerProvider(OfferRegistry $offers, string $key): OfferProvider
{
$provider = $offers->get($key);

abort_if($provider === null, 404, 'No offer provider is registered under that key.');

return $provider;
}

public function metrics(Request $request, EngagementManager $engagement, ClientRegistry $clients): JsonResponse
{
$granularity = in_array($request->query('granularity'), ['day', 'week', 'month'], true)
Expand Down
21 changes: 21 additions & 0 deletions src/Http/Requests/CreateOfferRequest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Whilesmart\Admin\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;

class CreateOfferRequest extends FormRequest
{
public function authorize(): bool
{
return true;
}

/** Only the shape. What each field means is the provider's to say. */
public function rules(): array
{
return [
'attributes' => ['required', 'array'],
];
}
}
44 changes: 44 additions & 0 deletions src/Support/Offer.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php

namespace Whilesmart\Admin\Support;

use DateTimeInterface;

/**
* One discount, as the console shows it. A provider keeps whatever else its
* own storage holds.
*/
final class Offer
{
/**
* @param string $value Already formatted, because only the provider knows
* whether 20 means a percentage, pennies, or seats.
* @param DateTimeInterface|null $expiresAt Whatever date the provider holds.
* @param array<string, mixed> $meta
*/
public function __construct(
public readonly string $id,
public readonly string $code,
public readonly string $value,
public readonly ?DateTimeInterface $expiresAt = null,
public readonly ?int $redemptions = null,
public readonly ?int $maxRedemptions = null,
public readonly bool $active = true,
public readonly array $meta = [],
) {}

/** @return array<string, mixed> */
public function toArray(): array
{
return [
'id' => $this->id,
'code' => $this->code,
'value' => $this->value,
'expires_at' => $this->expiresAt?->format(DateTimeInterface::ATOM),
'redemptions' => $this->redemptions,
'max_redemptions' => $this->maxRedemptions,
'active' => $this->active,
'meta' => $this->meta,
];
}
}
Loading
Loading