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
7 changes: 7 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## [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}`
- `admin.offer_providers` configuration

## [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.
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.

The new write endpoints bypass the owner authorization the existing write path enforces. PUT admin/mail-templates/{key} resolves UpdateMailTemplateRequest, whose authorize() calls app(OwnerAuthorizer::class)->authorize($this->user(), $template->owner_type, $template->owner_id), and every mail-template read goes through authorizeTemplate(). CreateOfferRequest::authorize() returns true unconditionally, and revokeOffer() has no request class at all, so with the default admin.write_middleware (an empty array) any authenticated principal can POST /api/admin/offers/{provider} or DELETE /api/admin/offers/{provider}/{id} and receive 201/200 where the equivalent mail-template write returns 403. Triggering call: POST /api/admin/offers/coupons with {'attributes': {'code': 'PILOT20', 'percent_off': 20}} as a user the host's OwnerAuthorizer denies → offer is created instead of rejected. Guarding in the controller (rather than in CreateOfferRequest::authorize()) also survives a host swapping admin.requests.create_offer for a permissive request class, which StrictCreateOfferRequest shows hosts do.

Note the same gap exists on GET offers (lines 27-38): GET mail-templates is filtered through OwnerAuthorizer::scope and GET mail-templates/{key} aborts when authorizeTemplate denies, while the offers listing returns every provider's contents unfiltered.

Suggested change
{
public function createOffer(Request $request, OfferRegistry $offers, string $provider): JsonResponse
{
$this->authorizeOffers($request);
$validated = app(config('admin.requests.create_offer'))->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]);
}
private function authorizeOffers(Request $request): void
{
$owner = config('admin.owner');
abort_unless(app(OwnerAuthorizer::class)->authorize(
$request->user(),
$owner['type'],
$owner['id'],
));
}

/**
* 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);
$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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

CreateOfferRequest is the write-side counterpart of UpdateMailTemplateRequest, but the two disagree about authorization. The existing request authorizes against the configured owner:

return app(OwnerAuthorizer::class)->authorize($this->user(), $template->owner_type, $template->owner_id);

and AdminController::authorizeTemplate() does the same for reads. Here the parallel request authorizes unconditionally, and revokeOffer does not go through a request object at all. The consequence is concrete: a host that binds OwnerAuthorizer — documented in the README as the protection for the configured admin owner — gets 403 from PUT mail-templates/{key} for a denied user, but POST offers/{provider} and DELETE offers/{provider}/{id} still succeed. admin.write_middleware defaults to [], so it is not standing in for the owner check.

If offers are intentionally outside owner scope (the provider is expected to scope them), that should be stated next to the OfferProvider contract; otherwise run the same owner authorization the sibling write performs.

Suggested change
public function authorize(): bool
public function authorize(): bool
{
$owner = config('admin.owner', ['type' => 'platform', 'id' => 0]);
return app(\Whilesmart\OwnerAccess\Contracts\OwnerAuthorizer::class)
->authorize($this->user(), $owner['type'], $owner['id']);
}

{
return true;
}

/** Only the shape. What each field means is the provider's to say. */

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

OfferField exists so a provider can declare what create() needs (required, type, options) and the console can render a form from it, but nothing on the server enforces that declaration: this request validates only the attributes envelope. The established implementation of the same responsibility, UpdateMailTemplateRequest::rules(), validates every field it declares (enabled, subject, body, cta_label, cta_url).

As written, POST offers/coupons {"attributes": {}} validates, reaches OfferProvider::create([]), and every provider has to re-validate or fail with an undefined-key warning (the shipped CouponOfferProvider does $attributes['code'] directly). Building the field rules from the provider's own declaration keeps the two implementations consistent and makes OfferField::$required more than a rendering hint.

Suggested change
/** Only the shape. What each field means is the provider's to say. */
/** The envelope shape, plus the constraint each declared field asks for. */
public function rules(): array
{
$rules = ['attributes' => ['required', 'array']];
$registry = app(\Whilesmart\Admin\Support\OfferRegistry::class);
$key = (string) $this->route('provider');
$provider = $registry->has($key) ? $registry->get($key) : null;
foreach ($provider?->fields() ?? [] as $field) {
$rules["attributes.{$field->name}"] = array_values(array_filter([
$field->required ? 'required' : 'nullable',
match ($field->type) {
'number' => 'numeric',
'boolean' => 'boolean',
'date' => 'date',
'select' => 'in:'.implode(',', array_keys($field->options)),
default => 'string',
},
]));
}
return $rules;
}

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