QA has never been able to send mail. The compose file set no SMTP variables and the mailer skips sending when it finds none, which was deliberate — a QA run must not be able to email a real customer if a fixture ever holds a real address. The cost is that four customer-facing flows have never been exercised anywhere but production: verification, password reset, favorite-sold alerts, and the cart-reminder cron that already has a known silent failure mode. MAIL_ALLOWLIST replaces the blanket mute. Unset means unrestricted, which is production and must stay so. Set means only matching recipients are delivered to; anything else is skipped with a [mail-blocked] warning naming the address and subject. An entry is either a full address, which also covers its plus-suffixed variants, or @domain for every mailbox there — plus-addressing is how these tests get written, and nobody should have to edit an allowlist to invent a new suffix mid-run. The guard sits in the mailer, not at the four call sites, so every sender is covered by construction and a fifth added later cannot bypass it by forgetting. It skips rather than throws: three callers already swallow send failures into a log, so throwing would mostly be caught anyway while risking a 500 on the signup path. The flow under test finishes and the log says why no mail arrived, which is exactly what was missing when QA was simply muted. Two details are load-bearing enough to state. Comparison is exact equality on both halves of the address rather than a suffix test, so a lookalike domain ending in an allowed one cannot get through — there is a test for that specifically. And a present-but-empty value refuses everyone rather than allowing everyone: writing MAIL_ALLOWLIST= expresses an intent to restrict, and reading it as "no restriction" would turn a typo into an outbound mail incident. This inverts the failure mode, so the allowlist is hardcoded in docker-compose.qa.yml rather than read from a stack variable. The safety property must not depend on remembering to set something in Portainer, where an omission would mean unrestricted sending from an environment full of fixtures. The comment says removing the line disables the restriction rather than the mail. QA points at Brevo, reusing the existing account rather than a separate QA sender — a deliberate choice that puts QA volume behind production's sending reputation and quota, acceptable for now. Host, port and secure are pinned in the compose because the mailer's fallbacks are Gmail's and Brevo needs 587 with STARTTLS; that mismatch fails at send time rather than at boot, which is #64's territory. Verified: 12 new unit tests on the matching function, which is where a mistake would actually be dangerous — 98 unit and 144 integration passing, lint 0 errors and 8 warnings unchanged, and the compose renders the expected values under docker compose config. Refs #87 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Redefined Designs
One-of-a-kind item storefront. React + TypeScript + antd frontend (Vite), Express + TypeScript backend, Postgres, PayPal checkout, customer accounts with GDPR-style consent handling, and an authentik-gated admin panel.
Project layout
backend/ Express + TypeScript API, Postgres access, PayPal integration frontend/ React + TypeScript + antd storefront and admin UI (Vite)
Prerequisites
- Node.js 20+
- Docker (for a local, disposable Postgres instance)
- npm
Clone
git clone https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs.git
cd redefined-designs
Running locally
Commands below are shown for PowerShell (Windows). A bash equivalent is noted wherever the syntax differs.
1. Start a local Postgres instance
The backend needs Postgres to talk to during local development. backend/docker-compose.test.yml spins up a throwaway, tmpfs-backed instance — no data persists between restarts, which is fine for local dev and tests.
cd backend
npm run db:test:up
This starts Postgres on localhost:55432, database redefined_test.
2. Run migrations
PowerShell / bash (same command, cross-platform via Node):
cd backend $env:PGHOST="localhost"; $env:PGPORT="55432"; $env:PGUSER="redefined_test"; $env:PGPASSWORD="redefined_test"; $env:PGDATABASE="redefined_test" npm install npm run migrate:up
This applies every migration in backend/migrations/ in order, tracked in a pgmigrations table so re-running is always safe (already-applied migrations are skipped).
To add a new migration:
npm run migrate:create -- descriptive-name
This generates a timestamped file in backend/migrations/ with exports.up/exports.down stubs — fill in pgm.sql(...) for both directions.
3. Configure environment variables
PowerShell:
$env:PGHOST = "localhost"
$env:PGPORT = "55432"
$env:PGUSER = "redefined_test"
$env:PGPASSWORD = "redefined_test"
$env:PGDATABASE = "redefined_test"
$env:PORT = "3000"
$env:DEMO_MODE = "true"
$env:UPLOADS_DIR = "$env:TEMP\redefined-uploads"
New-Item -ItemType Directory -Force -Path $env:UPLOADS_DIR | Out-Null
bash:
export PGHOST=localhost
export PGPORT=55432
export PGUSER=redefined_test
export PGPASSWORD=redefined_test
export PGDATABASE=redefined_test
export PORT=3000
export DEMO_MODE=true
export UPLOADS_DIR=/tmp/redefined-uploads
mkdir -p /tmp/redefined-uploads
DEMO_MODE=true enables a "Buy Now (Demo)" button on the storefront that completes a purchase without needing real PayPal credentials — useful for local development and for the Playwright tests below. To exercise real PayPal checkout locally, also set PAYPAL_CLIENT_ID, PAYPAL_CLIENT_SECRET, and PAYPAL_ENV=sandbox.
Note (PowerShell): environment variables set with $env: only last for the current terminal session/tab. If you close and reopen VS Code's terminal, you'll need to re-run step 3 before starting the backend again.
4. Run the backend
npm install
npm run dev
Runs on http://localhost:3000 with hot reload.
5. Run the frontend
Open a second terminal tab/window (the backend needs to keep running in the first one):
cd frontend
npm install
npm run dev
Runs on http://localhost:5173 and proxies /api, /uploads, /webhooks to the backend on port 3000.
6. Open it
- Storefront: http://localhost:5173
- Admin: http://localhost:5173/admin
Note: locally, /admin is directly reachable with no login gate — the authentik SSO protection only exists in the deployed environment (Nginx Proxy Manager + authentik forward-auth), not in local dev.
Running tests
Backend unit tests
No database required.
cd backend
npm run test:unit
Backend integration tests
Exercises the real Express app against a real (disposable) Postgres instance via supertest.
cd backend
npm run db:test:up
npm run test:integration
npm run db:test:down # when finished
Frontend Playwright e2e tests
Needs the backend running against a database with the schema loaded (steps 1–4 above), since these tests drive real registration/login/purchase flows through a live API.
cd frontend
npm install
npx playwright install --with-deps # first time only — installs browser binaries
npm run test:e2e
CI
Gitea Actions runs two workflows on every push to main and on pull requests:
- SonarQube Analysis (
.gitea/workflows/sonarqube.yml) — static analysis scan, plus TypeScript build checks for bothbackendandfrontend - Tests (
.gitea/workflows/tests.yml) — backend unit tests, backend integration tests (against a disposable Postgres service container), and frontend Playwright e2e tests, each posting a results summary to the job's Summary tab in Gitea Actions
Branching and commits
- All changes go on a branch off
main, namedfeature/<short-description>orfix/<short-description>— never commit directly tomain - Commit messages follow Conventional Commits syntax:
feat:,fix:,chore:,docs:,test:,ci:,refactor:, etc. - Branches are automatically deleted after a pull request is merged
Shipping a change
Every change follows the same path. QA is a required stop, not an optional one — production is not the place to discover that a deploy is wrong.
| # | Step | Gate before moving on |
|---|---|---|
| 1 | Implement on a branch, verify locally | Unit, integration, and e2e suites pass; tsc --noEmit and npm run build clean |
| 2 | Commit and push | git branch -a --contains <sha> lists the pushed branch — a commit made after a push is silently left behind otherwise |
| 3 | Open a PR and merge to main |
The merge commit contains the expected commits |
| 4 | Deploy to QA and review it | The change behaves as intended in a browser, behind authentik |
| 5 | Promote the reviewed image to production | Migrations recorded, data intact, deployed bundle is the new one |
| 6 | Stop the QA stack | — |
Steps 4 and 5 are detailed under QA environment and Production deployment. Step 5 promotes the same image that was reviewed in QA, so what ships is what was tested.
Production deployment
Production runs as a single Docker image (multi-stage build — the frontend is built to static files and served directly by the backend), deployed via Portainer behind Nginx Proxy Manager, with authentik forward-auth gating /admin. That infrastructure is homelab-specific and documented separately outside this repo.
The container applies pending migrations before starting the server, so deployed code can never be ahead of the database schema. A failed migration stops the container rather than letting it serve against a schema it doesn't match — check docker logs on the app container if it doesn't come up.
Promoting a reviewed change to production
Only after the change has been reviewed in QA.
# 1. Back up first — the container migrates the schema on its own.
sudo docker exec -t redefined-designs-db-syn pg_dump -U redefined -d redefined \
> /volume1/configs/redefined-designs/backup-$(date +%Y%m%d-%H%M).sql
ls -lh /volume1/configs/redefined-designs/backup-*.sql # size must be plausible
# 2. Promote the exact image QA reviewed, rather than rebuilding a second time.
sudo docker tag redefined-designs:qa redefined-designs:latest
# 3. Verify the image before touching the running container.
sudo docker inspect --format '{{.Config.Cmd}}' redefined-designs:latest
# must print: [sh -c node migrate.js up && node dist/server.js]
# 4. Recreate the container — restarting it would keep the old image.
sudo docker stop redefined-designs-syn
sudo docker rm redefined-designs-syn
# redeploy the stack in Portainer
# 5. Verify, in this order.
sudo docker logs redefined-designs-syn | head -30
# migration output appears BEFORE "listening on 3000",
# and "listening on 3000" appears exactly once (repeats = crash loop)
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined \
-c "SELECT name, run_on FROM pgmigrations ORDER BY run_on;"
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined \
-c "SELECT count(*) FROM items;"
# 6. Confirm the deployed artifact, not just the source.
sudo docker exec redefined-designs-syn sh -c "grep -l 'must have all' /app/public/assets/*.js"
Then load the storefront in a private window — aggressive bundle caching has produced false "still broken" reports on this project even after a correct deploy.
Rollback: migrations here have been additive, so the previous image still runs against the migrated schema — retag the old image and redeploy. Restoring the SQL dump is a last resort, not a first move.
QA environment
docker-compose.qa.yml defines a disposable QA stack for reviewing merged-but-undeployed changes online. It runs alongside production on the same NAS with its own containers, database, volumes, and host port, and is started only when a review is needed.
It differs from production deliberately: DEMO_MODE=true with no PayPal credentials (so checkout is exercisable but cannot reach live PayPal), no SMTP configuration (so it cannot email anyone), and restart: "no" (so a NAS reboot doesn't quietly bring it back up).
One-time setup on the NAS
Build the image before creating the stack. redefined-designs:qa exists only on the NAS — it is never pushed to a registry. If the stack is deployed first, Compose finds no local image, falls back to pulling from Docker Hub, and fails with a misleading pull access denied ... repository does not exist or may require 'docker login'.
cd /volume1/docker/redefined-designs
sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
sudo docker images | grep redefined-designs # confirm the qa tag is present
For the same reason, leave Portainer's "Pull latest image" toggle off when deploying or redeploying this stack. The compose file sets pull_policy: never so a missing image reports itself as missing rather than as a registry authentication problem.
Next, create the directory structure. These paths are deliberately not under /volume1/configs/redefined-designs — sharing production's Postgres directory would mean QA writing into production's database files.
sudo mkdir -p /volume1/configs/redefined-designs-qa/postgres
sudo mkdir -p /volume1/configs/redefined-designs-qa/uploads
# The official postgres image runs as uid/gid 999. Without this the DB
# container exits immediately with a data-directory permissions error.
sudo chown -R 999:999 /volume1/configs/redefined-designs-qa/postgres
sudo chmod 700 /volume1/configs/redefined-designs-qa/postgres
# Uploads are written by the app container, which runs as root.
sudo chown -R root:root /volume1/configs/redefined-designs-qa/uploads
sudo chmod 755 /volume1/configs/redefined-designs-qa/uploads
# Confirm the two environments are separate before going further.
ls -la /volume1/configs/redefined-designs-qa/
ls -la /volume1/configs/redefined-designs/
Then, in Portainer, create a stack named redefined-designs-qa from docker-compose.qa.yml, with one environment variable QA_DB_PASSWORD. The stack name matters: it becomes the compose project name, and reusing production's name would make compose reconcile the two stacks against each other and remove the production containers.
Finally, add an Nginx Proxy Manager host for qa-redefined-designs pointing at the NAS on port 32751, with authentik forward-auth on location / — the whole site, not just /admin, so nothing unreleased is publicly reachable.
Reviewing a change
cd /volume1/docker/redefined-designs
gitc() { sudo docker run --rm -it -v /volume1/docker/redefined-designs:/repo -v ~/.gitconfig-docker/.gitconfig:/root/.gitconfig -w /repo alpine/git "$@"; }
gitc pull origin main
gitc log --oneline -3
# Rebuild the QA image before restarting the stack — the stack does not build
# anything itself, and will otherwise run whatever was last tagged :qa.
sudo docker build --no-cache -t redefined-designs:qa /volume1/docker/redefined-designs
# start the redefined-designs-qa stack in Portainer
# Migrations run as the container starts — confirm before reviewing.
sudo docker logs redefined-designs-qa-syn | head -30
Stop the stack in Portainer when finished.
Resetting QA data
QA data is disposable. To start from an empty database, stop the stack first, then:
# Check the path before running this. It must contain -qa.
sudo rm -rf /volume1/configs/redefined-designs-qa/postgres/pgdata
Restarting the stack recreates the database and re-runs every migration from scratch.