Skip to content

chore: Release 0.2.0 - #3

Merged
nfebe merged 8 commits into
mainfrom
dev
Sep 22, 2026
Merged

nfebe merged 8 commits into
mainfrom
dev

Conversation

@nfebe

@nfebe nfebe commented Sep 22, 2026

Copy link
Copy Markdown
Contributor

Offer providers, so a host's discounts can be listed and created from the
console without this package knowing what stores them.

nfebe and others added 8 commits September 22, 2026 00:52
The discounts page had nothing behind it, so a product that already knew
how to discount something could not show or create one here.

A host registers a provider that answers what exists, makes one, and
stops one, and declares the fields it needs so the console renders a form
it can submit. Whatever stores the discount stays the host's business,
which is what lets one product use coupons and another use credits.

Registering nothing is still a supported state and answers with an empty
list.
An expiry built with the date library an application already uses was
refused, so a provider could not return one at all. Either form is
accepted now.

A provider class that was misspelled or did not implement the contract
disappeared without a word, and two providers claiming the same key
quietly replaced one another. Both are refused with a message naming what
is wrong.

Validation for a new offer can be replaced the way the other requests
here can, so a host can ask for its own fields without taking over the
controller.
Any authenticated caller could create or revoke a discount, and read every
registered provider's contents, where the equivalent mail template write
is refused for the same caller. The three endpoints now answer to the
configured owner the way the rest of the console does, checked before the
request object so a host replacing that object cannot drop the check.

An expiry had to be one particular date class. Immutable dates and plain
ones were refused at the door, which ruled out what many applications
already hold. Any date is taken now.

A host that published this package's configuration before offers existed
has a request map that replaces the one shipped here, so creating a
discount resolved nothing and failed. It falls back to the request this
package ships.
@nfebe
nfebe merged commit 984a814 into main Sep 22, 2026
5 checks passed
@sourceant

sourceant Bot commented Sep 22, 2026

Copy link
Copy Markdown

Code Review Summary

✨ Release 0.2.0 introduces an offer-provider abstraction so a host's discounts can be listed, created and revoked from the admin console without the package knowing where they are stored. A new OfferProvider contract (with OfferField describing the inputs a provider expects and Offer describing how a discount is shown, including an already-formatted value) is resolved through an OfferRegistry singleton that holds class names and resolves on use. The registry refuses classes that do not implement the contract and refuses two providers claiming one key. GET offers, POST offers/{provider} and DELETE offers/{provider}/{id} answer to the configured owner, the write routes honour admin.write_middleware, an unregistered provider key is a 404, and registering no provider is a supported state that answers with an empty list. New configuration (admin.offer_providers, admin.requests.create_offer) lets a host supply its own validation, defaulting to the shipped CreateOfferRequest so a host that published the config before the key existed still works. The version is bumped to 0.2.0 in composer.json, with matching CHANGELOG.md and README.md documentation, and feature tests cover listing, creation, revocation, owner authorization, request replacement, expiry date formats, config validation failures and unknown providers. Review raises two points: the shared write path validates only that attributes is an array while providers declare required fields, so a payload omitting them can reach OfferProvider::create() and fail with a 500 rather than a 422; and the owner gate is transcribed a second time in the controller rather than shared with the mail-template path.

🚀 Key Improvements

  • src/Contracts/OfferProvider.php, src/Contracts/OfferField.php and src/Support/Offer.php define a small contract that lets a host expose discounts of any shape — the console renders the form from the provider's declared fields and shows Offer::$value already formatted, since only the provider knows whether the number means a percentage, pennies or seats.
  • src/Support/OfferRegistry.php validates registrations eagerly, rejecting classes that do not implement the contract and two providers sharing a key, while holding class names rather than instances so a singleton does not keep providers and their dependencies alive for the process.
  • src/Http/Controllers/AdminController.php treats "no providers registered" as an empty list rather than an error, returns 404 for an unknown provider key instead of failing obscurely, and enforces the owner check in the controller so replacing the request class cannot drop it.
  • config/admin.php and AdminController::createOffer() fall back to the shipped request class when admin.requests.create_offer is absent, so hosts upgrading with a previously published config file can still create offers; tests/Feature/OfferProviderTest.php covers that upgrade path alongside creation, revocation, authorization and unknown providers.

📉 Regressions

  • The owner gate is now implemented a second time in AdminController::authorizeOffers() alongside the mail-template path's own check against OwnerAuthorizer, giving two independent copies of the same rule that can drift as owner resolution or 403 semantics change.

💡 Minor Suggestions

  • 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.

🚨 Critical Issues

  • 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.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Review complete. See the overview comment for a summary.

// 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']);

@@ -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);
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant