Skip to content

Shop - #58

Merged
bahman026 merged 55 commits into
mainfrom
shop
Aug 28, 2026
Merged

Shop#58
bahman026 merged 55 commits into
mainfrom
shop

Conversation

@bahman026

Copy link
Copy Markdown
Owner

No description provided.

bahman026 and others added 30 commits August 6, 2026 19:51
The network was declared as `net` and only became shop_flow_net because
the project happened to be named shop_flow. An explicit `name:` removes
that coincidence, so admin/ and shop/ can join it as an external network
whatever project creates it.

The pg_isready and redis-cli healthchecks let the app containers wait for
real readiness rather than for the container merely to exist. Also drops a
top-level `pgdata` volume declaration that nothing referenced.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
`app` and `webserver` collide with admin's services once the root
compose.yaml merges both files into one project: Compose `include`
silently keeps the first definition and drops the second rather than
reporting a conflict.

Container names are unchanged. They now interpolate
SHOP_CONTAINER_PREFIX, because COMPOSE_PROJECT_NAME is reserved and under
the root project resolves to shop_flow for this file and admin/ alike,
which made both nginx containers claim the same name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
`app` and `webserver` collide with the storefront's services once the root
compose.yaml merges both files into one project: Compose `include`
silently keeps the first definition and drops the second rather than
reporting a conflict.

Container names are unchanged. They now interpolate
ADMIN_CONTAINER_PREFIX, because COMPOSE_PROJECT_NAME is reserved and under
the root project resolves to shop_flow for this file and shop/ alike,
which made both nginx containers claim the same name.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
The compose file falls back to the previous value when it is absent, so
existing .env files keep working; this only makes it discoverable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
The compose file falls back to the previous value when it is absent, so
existing .env files keep working; this only makes it discoverable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
fastcgi_pass referenced `app`, which stopped resolving when the service
was renamed, and nginx crash-looped on "host not found in upstream".

Keeping an `app` network alias instead is not an option: under the root
compose project both applications' php-fpm containers would answer to it
on the same network, and admin requests would round-robin into the
storefront.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
fastcgi_pass referenced `app`, which stopped resolving when the service
was renamed, and nginx crash-looped on "host not found in upstream".

Keeping an `app` network alias instead is not an option: under the root
compose project both applications' php-fpm containers would answer to it
on the same network, and storefront requests would round-robin into the
panel.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
`docker compose up -d --build` at the root now brings up the shared
Postgres and Redis plus both applications. Each app keeps its own compose
file so it can still be started on its own from <app>/docker/.

This file only merges the three and adds what cannot live in them: the
network ownership, and depends_on gating the apps on healthy db/redis,
which is inexpressible in admin/ or shop/ alone because db is not part of
those projects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
vendor/, node_modules/ and the build output are all reproduced inside the
image, so shipping the host's copies would only invalidate layers. Also
keeps .env files out of the context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
vendor/, node_modules/ and the build output are all reproduced inside the
image, so shipping the host's copies would only invalidate layers. Also
keeps .env files out of the context.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
The image is immutable, so opcache timestamp validation is pure overhead
and is turned off. max_accelerated_files is raised to 30000 because
Filament loads far more classes than the 10000 default allows, and
save_comments stays on because attributes are read via reflection.

JIT is present but disabled: it gives little for a request/response
workload and should be benchmarked before being switched on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
clear_env is off, without which php-fpm would discard the environment
Compose passes in and hide every value in the app's env_file from PHP.

Worker output and the slow log go to stderr so `docker compose logs` is
the single place to look, and pm.max_requests recycles workers to bound
the damage from any slow leak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Only the front controller may execute: any other .php path returns 404
instead of being handed to the interpreter. TLS is terminated by the proxy
in front, so this listens on plain 80 inside the Docker network.

Content-hashed Vite output under /build is served immutable, and symlinks
stay enabled because public/storage points into the uploads volume.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
The development entrypoint runs composer install, npm build and migrate on
every container start, then clears the caches. In production that is
backwards: startup would depend on the network, and two containers
starting together would race on the schema.

This builds config/event/view caches instead, which has to happen at start
rather than at build time because it bakes in the runtime environment.
route:cache is left out because routes/web.php still has two closure
routes, which Laravel cannot serialize.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
The image is immutable, so opcache timestamp validation is pure overhead
and is turned off, and the accelerated-file limit is raised well above the
10000 default.

JIT is present but disabled: it gives little for a request/response
workload and should be benchmarked before being switched on.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
clear_env is off, without which php-fpm would discard the environment
Compose passes in and hide every value in the app's env_file from PHP.

Worker output and the slow log go to stderr so `docker compose logs` is
the single place to look, and pm.max_requests recycles workers to bound
the damage from any slow leak.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Only the front controller may execute: any other .php path returns 404
instead of being handed to the interpreter. TLS is terminated by the proxy
in front, so this listens on plain 80 inside the Docker network.

Content-hashed Vite output under /build is served immutable.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
The development entrypoint runs composer install, npm build and migrate on
every container start, then clears the caches. In production that is
backwards: startup would depend on the network being reachable.

This builds the config, route, event and view caches instead, which has to
happen at start rather than at build time because it bakes in the runtime
environment. The storefront never migrates: admin owns the schema.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Targets `app` (php-fpm with the application baked in) and `web` (nginx
with only public/), so the two share every layer up to `app`.

Dependencies are built on the same PHP base as the runtime, because the
composer image lacks ext-intl and cannot resolve this app's platform
requirements. opcache is deliberately absent from the extension list: PHP
8.5 links Zend OPcache in statically, so there is no shared module to
build and installing it fails. apt retries are set because apt only warns
when an index fails to download, which otherwise surfaces much later as a
confusing "Unable to locate package".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Targets `app` (php-fpm), `web` (nginx with only public/) and `ssr` (node
running the Inertia renderer), so all three share their common layers.

The renderer gets its own target because Vite externalises npm
dependencies from an SSR build, so the bundle still needs the production
node_modules at runtime. The PHP image carries the bundle too, since
Inertia's ensure_bundle_exists check runs on the PHP side.

Dependencies are built on the same PHP base as the runtime so composer
resolves platform requirements against the production extension set.
opcache is absent from that list on purpose: PHP 8.5 links it in
statically, so installing it as a shared module fails.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Source is baked into images rather than bind-mounted, Postgres and Redis
sit on named volumes and publish no host ports, and Caddy is the only
container listening on the public interface.

The Inertia renderer runs as its own container so a crash restarts it
instead of silently dropping the storefront to client-side rendering.
Queue and scheduler containers are behind a `workers` profile because
nothing queues a job or registers a schedule yet.

The redis command is a single-line list on purpose: as a YAML folded
scalar the more-indented flags keep their newlines and end up as
unreachable lines after `exec`, which left the server with no password at
all. Its healthcheck asserts an unauthenticated PING is rejected, since a
successful authenticated PING also passes against a passwordless server
and hid exactly that bug.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Caddy obtains and renews Let's Encrypt certificates itself, so the only
requirement is working DNS and reachable ports 80/443. It also sets the
X-Forwarded-* headers the applications read through trustProxies.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Images are built before anything is stopped, then migrations run once from
a throwaway admin container, and only then are the long-running containers
replaced. Only admin migrates, because it owns the shared schema.

Images are tagged with the deployed commit so a rollback has something to
point at without rebuilding.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Ordered checklist from DNS through backups, with the reasoning for the
parts that are easy to get wrong: DNS has to be in place before Caddy
starts, the Postgres credentials only apply when the volume is first
created, and the Vite build is the memory peak on a small box.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Holds only what Compose itself interpolates — database and Redis
credentials, the two hostnames, and the image tag. The applications read
their own env files.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Redis for sessions and cache, stderr logging so nothing on disk needs
rotating, and the public disk for uploads so they land in the mounted
volume that nginx serves at /storage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Redis for sessions, because file sessions would be lost on every deploy
when the container filesystem is replaced. INERTIA_SSR_URL points at the
renderer container, and IMAGE_URL at the admin domain, which is what
serves product images.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
In production the panel sits behind Caddy, which terminates TLS. Without
this the application sees plain HTTP and generates http:// URLs and
redirects. The storefront already had it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
It holds real database and Redis credentials. The per-app .gitignore files
already cover admin/.env.production and shop/.env.production.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
Getting started now runs one command from the root, with the container and
port table, and points at infrastructure/production/ for deployment.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011tQqhzpVk2Kms4qqfhpZko
bahman026 and others added 25 commits August 20, 2026 17:25
deploy.sh builds on the server, which needs Docker Hub, deb.debian.org,
packagist and the npm registry all reachable — unusable on a host where
those are filtered. ship-images.sh cross-builds the five app images plus
the three pulled service images for linux/amd64 on a workstation,
verifies every one is actually that architecture, and streams the lot
through `docker save | ssh | docker load`. deploy-prebuilt.sh is the
server-side counterpart: deploy.sh with the build step dropped and
`--pull never` added, so a missing tag fails immediately instead of
hanging on a registry that will never answer.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
canAccessPanel() requires the super-admin or admin role, but
make:filament-user assigns none — the account it creates cannot log in.
Add the ADMIN_* variables AdminSeeder reads (config/admin.php), so a
production deploy sets them instead of shipping the admin@shopFlow.dev /
password defaults, and so the seeder — which does assign the role — has
values to seed with.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Caddy's automatic HTTPS needs to reach acme-v02.api.letsencrypt.org
directly from the VPS to request a certificate — unreachable on a host
where ACME, and every other outbound path this stack depends on
(Docker Hub, apt, npm), is filtered. Proxying through a CDN doesn't
route around it either: a CDN's edge still has to reach this origin,
and Cloudflare's cannot.

TLS_CERT_FILE / TLS_KEY_FILE point Caddy at a certificate obtained
elsewhere instead — a DNS-01 challenge run from a workstation that can
reach both Let's Encrypt and the zone's DNS provider — and mounted
read-only from infrastructure/production/certs/ (gitignored; holds a
private key). certs/README.md documents issuing and renewing it.

Also add SHOP_LEGACY_DOMAINS, a Caddy site block that 301-redirects old
or alternate storefront hostnames to SHOP_DOMAIN, and TRUSTED_PROXIES,
so X-Forwarded-Proto survives correctly if a CDN or LB ever does sit in
front of Caddy.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Cover the new deploy path end to end: why deploy.sh's on-box build fails
on a filtered host, how ship-images.sh / deploy-prebuilt.sh replace it,
why Caddy needs a manually-issued certificate instead of automatic ACME,
and the corrected first-admin-user step (AdminSeeder, not
make:filament-user — the latter creates a user with no role, and the
panel gate requires one).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The storefront never read `home_sections`. It always rendered a fixed
layout, so reordering or disabling a row in the panel changed nothing on
the site — the table, its resource, model, factory, seeder and enum were
an elaborate no-op.

What appears on the home page is controlled by banner/slider positions
instead, which the storefront does read. The create migration is deleted
so a fresh database never builds the table, and drop_home_sections_table
removes it from databases that already ran it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A position value is an opaque slug — picking `product-side` from a
dropdown was guesswork about where it would appear. Each position now
carries a description, the storefront page it sits on, the aspect ratio
that slot renders at and a recommended source size.

The form renders a wireframe of the three storefront pages and
highlights the slot the chosen position fills, mirrored into
`data-selected` by Alpine so it keeps up with the radio without a server
round-trip. Uploads are cropped to the slot's real ratio, so one
oddly-shaped image cannot stretch the layout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
`PermissionsEnum` was decorative: it named four post-shaped permissions
nothing checked, while no resource declared any authorization at all.
Filament therefore fell back to model policies, and with almost none
registered every panel user had full access to everything — settings,
gateway credentials and staff accounts included.

Permissions are now a PermissionGroupEnum (catalog / content / orders /
customers / shipping / marketing / settings) crossed with a
PermissionActionEnum, so one grant covers every resource in an area
instead of needing a permission per resource. Each resource opts in via
`AuthorizesWithPermissions` and declares its group;
`ResourcePermissionsTest` fails the build if one forgets, since an
ungated resource is reachable by anyone who can open the panel.

Where a policy exists it still applies on top, but only for the
abilities it actually implements — Laravel denies any ability a policy
omits, which would otherwise forbid viewing categories just because
CategoryPolicy defines only delete.

`login()` now seeds real roles instead of hand-rolling one, so tests
authorize exactly as the panel does, and deploy.sh runs the seeder on
every release to pick up newly added permissions.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
It was the only relationship in the schema with no foreign key — a bare
integer. Deleting a parent left its children pointing at a row that no
longer existed, and because the storefront walks `parent_id` to collect
descendants, an orphaned subtree silently vanished from category
listings instead of failing loudly.

The column is widened to bigint to match `categories.id`, any
already-orphaned child is promoted to a root, and the constraint
restricts on delete rather than cascading — cascading would take out an
entire subtree and, through products, a great deal more. This matches
`products.category_id`, which already restricted.

CategoryPolicy now refuses to delete a category while children or
products still point at it, so the panel stops offering a delete button
that could only ever produce a raw constraint error.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/ORDER.md states that stock is consumed from the moment an order is
paid and released if it is canceled or returned. The storefront upheld
that for gateway payments only. Every status change staff made in the
panel — confirming a card-to-card receipt, canceling, accepting a
return — left `varieties.inventory` untouched, so the two halves of the
same invariant disagreed.

`OrderStatusEnum::consumesStock()` is now the single definition of which
statuses hold stock, and OrderObserver adjusts inventory on any move
between the two sets. Only crossings act, so PAID -> SHIPPED and a plain
re-save change nothing. Lines are row-locked in a consistent order, the
same as DecrementInventoryAndMarkPaid.

A transition that stock cannot cover is refused rather than pushing an
unsigned column negative: EditOrder wraps the save in a transaction and
surfaces which variety fell short, instead of a 500 over an order that
had already moved.

The observer is registered by the admin app only — the storefront has
its own Order model, so a Zarinpal payment still decrements exactly once.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both copies of ORDER.md said inventory is never restocked for a return,
which stopped being true when OrderObserver landed. Documents the
consumesStock() rule, which transitions adjust stock, that the
transition is refused when stock cannot cover it, and that a damaged
return still needs a manual adjustment afterwards.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Three of the seven positions an admin could pick had no render site at
all: choosing `home-top`, `category-top`, `category-side` or
`product-side` published content that appeared nowhere, with nothing in
the panel to say so.

`SliderSlot` and `BannerSlot` replace the home-only HeroSlider and
BannerGrid. Both take the slot's data and an arrangement (hero / wide /
portrait, grid / wide / stack), render nothing when the slot is empty so
a page can hand them an empty array without guarding, and pin their own
aspect ratio to match the ratio the admin crops to.

The two lookup actions move to `App\Actions\Layout` since they now serve
the home, category and product pages rather than just the home page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A featured tag rendered as an image card in a strip, which said nothing
about what the tag actually contains. Each one is now a product carousel
resolved with the same category-plus-attribute rules the tag's own page
uses, so a row on the home page and the tag page always agree about what
belongs to it.

The grouping logic those rules need is extracted to
`Actions\Catalog\GroupAttributeIds`, shared with the category listing so
the facet rules (OR within an attribute group, AND across groups) cannot
drift between the two.

MAX_ROWS bounds the cost: each row is its own set of queries, so without
it the home page's query count grew with however many tags staff
featured. Tags matching no products are dropped rather than rendered as
an empty carousel. ProductCarousel moves out of `Components/Home` now
that the product page uses it too.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The cart previewed a discount and checkout ignored it, so a customer was
shown a saving and then charged full price — worse than not offering the
coupon at all.

`ResolveCartCoupon` now owns the session key and re-validates the code
wherever a total is shown or charged, so the cart, the payment page and
the amount actually captured cannot disagree. CreatePendingOrder records
`coupon_id` and the saving on the order and zeroes shipping for a
free-shipping coupon.

`coupons.total_used` is incremented in DecrementInventoryAndMarkPaid
rather than at order creation: an abandoned or failed order must not
burn one of a coupon's uses. If the code has stopped validating between
the cart and the payment click, the customer goes back to the cart with
the reason instead of being silently charged the undiscounted total.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every storefront enum has a twin in the admin app, and the backing values
are what the two apps agree on through the shared database. Changing one
side alone breaks the other silently — staff save a value the storefront
never recognises, with no error anywhere.

Asserts every mirrored enum has identical case names and values, and that
a new storefront enum has an admin counterpart at all. Also asserts the
comparison actually read the admin enums, so the test cannot pass by
finding nothing to check.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude Code reads CLAUDE.md, never AGENTS.md. So the 160 lines of admin
conventions and 187 lines of storefront conventions written in the two
AGENTS.md files were never reaching a session, while admin/CLAUDE.md
auto-loaded 400 lines duplicating the same Boost block — and
contradicting it, telling the reader to use `php artisan test` where
AGENTS.md says to use Pest directly. The wrong instruction was the one
in effect.

The conventions now live in ai-context/claude/{admin,shop}.md, imported
by admin/CLAUDE.md and shop/CLAUDE.md. Nested CLAUDE.md files load on
demand, so a storefront task no longer pays for Filament conventions and
an admin task no longer pays for Inertia/SSR ones; only the repo-wide
file loads every session. Imports resolve relative to the importing
file, hence `@../ai-context/...`.

Both AGENTS.md files are generated by `boost:install`, which would have
wiped hand-written content on its next run; moving the conventions out
puts them beyond its reach. They keep a pointer line, and other
assistants that do read AGENTS.md still find Boost's own guidance.

Not a submodule, unlike the HBOX setup this mirrors: one repo with one
version line has no second consumer and no per-major branch, so a plain
directory does the same job without the uninitialised-clone trap.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The schema reference still documented a `home_sections` table that was
dropped, still said only `home-main` and `home-middle` were rendered when
every banner and slider position now has a render site, described
`categories.parent_id` as a plain column after it gained a restricting
foreign key, and described coupons as preview-only after checkout began
committing them. Both IMPLEMENTATION.md copies still listed Home Sections
as built.

Also records the gap found while checking: `coupons.total_used` is
incremented only on gateway payment, so an order staff mark paid in the
panel does not count a use. Written down under `orders` rather than left
in a chat, since the rule to apply is still undecided.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The code was generated and cached but only ever written to the log, so
nobody could actually log in with a phone they did not control.

Delivery goes through the `SmsSender` contract: `SmsIrSender` posts to
sms.ir's `send/verify`, `LogSmsSender` keeps the old logging. The binding
is chosen by whether `services.sms_ir.api_key` is filled, so production
opts in by setting the key while a clone with no credentials logs the
code and spends nothing — real sending can never happen by accident.

`send/verify` rather than `send/bulk`: verify rides sms.ir's shared
service line, so it needs no rented number, reaches customers who blocked
advertising SMS at their operator, and is not restricted at night. A
login code sent as bulk would be dropped for some customers entirely.

Two details worth keeping:

- `SendOtpCode` now sends *before* it caches and returns null on refusal.
  Caching a code the customer never receives would lock them out for the
  whole TTL, unable to request another, waiting for an SMS that does not
  exist. Both controllers turn that null into an error on the mobile step.
- The expiry in the message is formatted in Asia/Tehran, not the app's UTC.
  A customer whose clock reads 14:35 being told the code is valid until
  11:05 would assume it had already died. One expiry instant is threaded
  through the SMS text, the stored expiry and the cache lifetime so they
  cannot drift apart.

Parameter names match the panel template exactly (`code`, `expireDate`);
a placeholder left without a value reaches the customer as the literal
`#expireDate#`.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Reading production logs meant `docker exec ... tail`, and there was no
answer at all to "is the server healthy" or "what is slow".

Pulse records from **both** apps into the same `pulse_*` tables, since
they share one database — a dashboard covering only the panel would miss
the storefront, which takes the real traffic. The storefront gets the
package but `config/pulse.php` there sets `path` to null, so no `/pulse`
route exists on a public site; the dashboard lives here, and the
migrations live here too because admin owns the schema.

Neither dashboard is a Filament resource, so AuthorizesWithPermissions
does not reach them, and both ship open by default while showing slow
queries, exception messages and whole stack traces. The `viewPulse` and
`viewLogViewer` gates restrict them to super-admin — `admin` is
deliberately not enough — and OperationalDashboardsTest fails the build
if either opens up, including for a storefront customer, who is a real
row in the shared `users` table.

Logging becomes `stack` = `stderr,shared`. stderr keeps `docker logs` and
Docker's rotation exactly as they were; `shared` adds the rotating file a
viewer can actually read, since a file-based viewer against
`LOG_CHANNEL=stderr` would have shown an empty page in production. The
two apps write into one named volume, each in its own directory, because
the panel cannot see into another container — the viewer then lists them
as two folders rather than one mixed stream.

Both Dockerfiles create /var/log/shopflow owned by www-data. Docker
initialises a fresh named volume from the image including ownership;
left to Docker the volume is root-owned and php-fpm silently writes
nothing at all.

`pulse:check` runs as its own container to feed the Servers card. Note
`pulse:trim` runs from the scheduler, which is still behind the workers
profile, so the pulse_* tables grow until that is started.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both register their own routes outside Filament, so nothing in the panel
pointed at them — you had to know the URLs and type them by hand.

Two sidebar links, in the top section beside Dashboard and Users rather
than filed under a heading at the bottom. They open in a new tab, so the
panel stays where it was in the original tab.

Links rather than pages embedding them: each dashboard is a full-page app
with its own stylesheet and breakpoints, and inside a panel frame their
layout collapsed into an unreadable overlap. Keeping them full-width is
worth more than keeping them inside the panel chrome.

Visibility follows the same viewPulse / viewLogViewer gates the routes
use, so a plain admin never sees a link they cannot open — the sidebar
must not become a way around the gate.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Both cost time in one session. The remote shell one is nastier than it
looks: a bash loop sent as an ssh argument fails only when it reaches
the remote, so the earlier steps of a deploy script have already run by
then. The Filament one is invisible in the other direction — the code is
right, the tests pass, and the sidebar still shows nothing.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
sms.ir's sandbox key accepts a send and delivers nothing, so with it
configured nobody can finish a login — the code exists only in Redis. This
lets a listed mobile use a known code instead, so the site is usable while
the production template is still pending approval.

It is an authentication bypass and is written to be hard to leave running
by accident: off unless OTP_FIXED_CODE is set, limited to the mobiles in
OTP_FIXED_MOBILES, and logged on every use — `warning` when scoped to
numbers, `critical` when no allowlist is set, since that case means every
account on the site can be entered with one known code.

The allowlist is the part that matters: without it a stranger could type
their own number on a public storefront and be signed in, and first login
creates the account. Tests cover that an unlisted mobile still gets a real
random code and a real send.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…renders at

Category, page, product, tag and variety images had no crop ratio, so
an odd upload could stretch a circle, blow out a page hero with no
height frame, or drift the product form's variety photo out of step
with the standalone variety form's 1:1. ImageAspectEnum centralizes the
ratio per slot (null on purpose for brand/gateway logos, drawn with
object-contain, and for receipts, where a crop could cut off the
reference number or amount).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds a fourth checkout-critical shipping method alongside the existing
courier/post/pickup rows: nationwide, pay_on_delivery=true, amount=null
— the freight is settled with the courier at delivery, same pattern as
the existing in-store pickup row.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…ing them

A product with several values in one attribute group (e.g. five shoe
sizes) rendered as five identical "سایز کفش" rows instead of one row
listing all five. Groups specs server-side by attribute group name;
each group now carries its full value list plus a highlight flag, so
a highlighted spec (e.g. material) gets visual emphasis instead of
sitting unused — `highlights` was computed but never actually
rendered anywhere in the frontend.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Adds DemoSeeder, reading only demo/data/*.json (no network calls,
idempotent, guarded against production): ~50 original products with
variants/images, categories, attributes, brands, homepage banners/
sliders, tags, a header menu, and reviews with dedicated demo-reviewer
accounts. Wired into DatabaseSeeder for local/staging only.

Images are Pexels stock photos (commercial-license, not scraped),
fetched by demo/scripts/ (tracked) into admin/storage/app/public/demo/
(gitignored, regenerated per-machine — see demo/README.md for why and
how). demo/data/*.json itself is tracked; only the fetch cache and the
Pexels API key are excluded.

CategorySeeder/AncestorSeeder/AttributeSeeder/AttributeGroupCategorySeeder
stay commented out in DatabaseSeeder for now (kept as-is, not part of
this change).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@bahman026
bahman026 merged commit 2b9678c into main Aug 28, 2026
2 of 3 checks passed
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