Conversation
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.
feat: Add offer providers
chore: Release 0.2.0
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 🚀 Key Improvements
📉 Regressions
💡 Minor Suggestions
🚨 Critical Issues
|
| // 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); |
There was a problem hiding this comment.
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.
| $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 | |||
| { | |||
There was a problem hiding this comment.
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.
| { | |
| 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); | |
| } |
Offer providers, so a host's discounts can be listed and created from the
console without this package knowing what stores them.