15 KiB
Redefined Designs — Project Context
Environment
This is a self-hosted application running on a Synology NAS, deployed and managed via Portainer (Docker Compose stacks through Portainer's UI, not raw docker compose CLI on the host). This is a homelab project, not a cloud-hosted SaaS — deployment is manual, there's no CI/CD pipeline to production, and infrastructure decisions are shaped by NAS constraints (Synology's permission model, limited CPU/RAM, no exposed Postgres port, etc.).
Repo: https://gitea.bermudalamb.synology.me/bermudalamb/redefined-designs (self-hosted Gitea, also on the NAS)
Production URL: https://redefined-designs.bermudalamb.synology.me
Container names: redefined-designs-syn (app), redefined-designs-db-syn (Postgres)
NAS repo path: /volume1/docker/redefined-designs
What this is
A storefront for one-of-a-kind items (quantity 1 per item — once sold, it's gone). Public storefront + cart + checkout, with an authentik-SSO-gated admin panel for inventory/customer/settings management.
Tech stack
- Backend: Express + TypeScript, Postgres (
pg), single Docker image - Frontend: React + TypeScript + antd (Vite build), served as static files by the backend in production
- Auth: two separate systems —
- Customers: email/password, bcrypt, session cookie (
rd_session), custom-built (not authentik) - Admin: authentik SSO via Nginx Proxy Manager forward-auth, gated only on
/adminand/api/admin/*— the storefront itself is public
- Customers: email/password, bcrypt, session cookie (
- Payments: PayPal Orders v2 API — real multi-item cart checkout, single transaction, itemized breakdown
- Shipping validation: USPS Addresses API (OAuth2/REST —
apis.usps.com, not the deprecated Web Tools XML API), US-only, optional/graceful if unconfigured - Email: nodemailer via Gmail SMTP — optional, degrades gracefully if unconfigured (logs a warning, skips sending)
- Scheduled jobs:
node-cronin-process (cart expiry sweep every 5 min viasetInterval, daily cart-reminder emails at 9am via cron) - DB migrations:
node-pg-migrate— NOT Entity Framework (this is a Node/TS backend, EF doesn't apply here). Migration files live inbackend/migrations/, run vianode migrate.js up(bundled into the production image specifically so it can be invoked viadocker execagainst the live container). There's noinit.sqlanymore — it was deleted when migrations were introduced; the migration files are the single source of schema truth. - Testing: Jest (unit + integration against a disposable tmpfs Postgres via
node-pg-migrate), Playwright (e2e) - CI: Gitea Actions —
sonarqube.yml(static analysis + TS build check) andtests.yml(unit/integration/e2e with job summaries posted to Gitea's step summary)
Data model (key tables)
items— one-of-a-kind inventory.status:available | reserved | solditem_images— multiple images per item (front/back/etc.), orderedcustomers— accounts, withmarketing_consent(explicit opt-in, GDPR-style — unchecked by default, timestamped, unsubscribe token)admin_settings— key/value store, currently justcart_expiry_hours(admin-configurable, default 24)carts/cart_items— one cart per customer, items reserved with the admin-configurable expiry; unique constraint onitem_idmeans an item can only be in one cart at a timeshipping_addresses— per-customer, USPS-validated when USPS creds are configuredcheckouts/checkout_items— one row per multi-item PayPal/demo transaction, snapshotting item+price at purchase timeorders— per-item purchase records, linked tocheckout_idandcustomer_id
Key architectural decisions
- Cart replaced the old single-item "Buy Now" flow entirely. Items are added to cart (immediately flips
item.statustoreservedinside a DB transaction withFOR UPDATElocking — no double-sell race), then checked out together in one real multi-item PayPal transaction (onepurchase_unit, itemizeditems[]breakdown, single capture). Old single-item route files (routes/paypal.ts,routes/demo.ts) still exist on disk but are unmounted fromapp.ts— safe to delete, never got cleaned up. - Demo mode (
DEMO_MODE=trueenv var): lets the whole site — including full cart checkout — work with zero PayPal/USPS credentials configured. Same "degrade gracefully when a third-party integration isn't configured" pattern applies to PayPal, USPS, and SMTP — none of them crash the app if unset, they just no-op or skip. - Account creation is prompted at add-to-cart, not gated earlier — clicking "Add to Cart" while logged out opens an inline modal (register or login tabs), and on success the item is added automatically.
- GDPR/consent handling: marketing consent is never pre-checked, is timestamped with the exact consent text shown, and customers have working self-service data export + account deletion endpoints.
- Admin panel security boundary: NPM's Advanced nginx config only wraps
auth_requestaroundlocation ~ ^/(admin|api/admin)— everything else (storefront, cart, checkout, PayPal webhook) bypasses authentik entirely. Deliberate split — don't accidentally widen or narrow that regex without checking both directions. - Cookie
secureflag is gated onNODE_ENV === 'production', not hardcodedtrue— otherwise integration tests (plain HTTP, no TLS) silently fail to persist sessions. app.ts/server.tssplit:app.tsexports the Express app with no.listen()call, so tests can import and exercise it viasupertestwithout binding a port.server.tsis the actual entry point — importsapp, adds the cart-expiry sweep and cron job, calls.listen(). Any new background job or process-level concern goes inserver.ts, notapp.ts.
Deployment workflow — read this before touching the NAS
This is where nearly every real time-sink in this project's history happened. The process:
- Edit files locally in VS Code, on a branch, using PowerShell
git(not SSH). - Push, open a PR in Gitea, merge into
main. - SSH into the NAS, pull the repo, rebuild the Docker image, redeploy the Portainer stack.
Hard-won lessons from this project's history — internalize these
- The NAS's local git clone does NOT have a real
gitbinary. DSM doesn't ship one. Git operations on the NAS go through a throwawayalpine/gitDocker container, wrapped in a shell function:
gitc() {
sudo docker run --rm -it \
-v /volume1/docker/redefined-designs:/repo \
-v ~/.gitconfig-docker/.gitconfig:/root/.gitconfig \
-w /repo \
alpine/git "$@"
}
This function must be redefined every new SSH session — it only lives in shell memory, doesn't persist to ~/.bashrc (never got added there). Always redefine it first thing after SSH'ing in, before running any gitc command.
- Git identity and
safe.directoryare NOT in the container image — they're mounted from~/.gitconfig-docker/.gitconfigon the host, which is what makes them persist acrossgitcinvocations (eachgitccall is a brand-new container). If that file is ever missing, recreate it:
[safe] directory = /repo [user] email = thomlamb@gmail.com name = Thom Lamb
- The NAS git working directory will show every file as "modified" even right after a clean pull, if
core.fileModeisn't disabled. Synology forces broad-rwxrwxrwx+permissions on all files regardless of what git committed, and git tracks the executable bit as part of a file's tracked "mode" by default — so every file looks dirty even when content is byte-identical. Fix once per repo:
gitc config core.fileMode false
This is a real trap: git status showing a long "modified" list after a pull does NOT necessarily mean there are real local edits — check core.fileMode before assuming a merge conflict or lost work.
- If
gitc pullrefuses with "local changes would be overwritten," don't try to cherry-pick which files to discard — on this project, that approach has repeatedly failed to fully resolve since the modified-file list is often long and partially spurious (seecore.fileModeabove). Just reset the NAS clone to match origin exactly, since the NAS is never the source of truth for code:
gitc fetch origin
gitc reset --hard origin/main
- Docker layer caching has caused multiple "the fix didn't work" false alarms. After any code change, rebuild with
--no-cacheand actually watch the output — don't assumedocker buildpicked up new files just because the command succeeded:
sudo docker build --no-cache -t redefined-designs:latest /volume1/docker/redefined-designs
Confirm it reaches Successfully tagged redefined-designs:latest. A build that errors partway through (e.g. a TypeScript compile error) leaves the OLD image running untouched — Docker doesn't warn loudly about this, it just silently keeps serving stale code.
docker execdoes not expand shell globs (*.js,*.sql) unless you explicitly invoke a shell:
# WRONG — "No such file or directory"
sudo docker exec redefined-designs-syn grep -l "text" /app/public/assets/*.js
# RIGHT
sudo docker exec redefined-designs-syn sh -c "grep -l 'text' /app/public/assets/*.js"
-
Verify a fix actually deployed before declaring victory — the recurring failure pattern this project has hit repeatedly is: code is correct in the repo → but the branch was never actually merged, OR the merge happened but the NAS never pulled, OR the pull happened but the Docker image was never rebuilt, OR the rebuild happened but used a stale layer cache. Each of these produces the identical symptom ("I made the fix but the UI/behavior didn't change"). The reliable way to root-cause it, in order:
git log main --oneline(local/PowerShell) — is the commit actually merged?gitc log --oneline(NAS) — did the NAS actually pull it?grepthe actual source file on the NAS disk for a distinguishing string from the fix- Rebuild with
--no-cache, watch it complete docker execinto the container andgrepthe built output (/app/public/assets/*.jsfor frontend,/app/dist/*.jsfor backend) for the same string — this is the only step that confirms the deployed artifact, not just the source, has the fix- Hard-refresh / incognito window in the browser (aggressive JS bundle caching on both desktop and mobile browsers has caused false "still broken" reports even after a correct deploy)
-
Files must be at the repo root where the Dockerfile expects them. The
Dockerfilelives at the repo root (notbackend/Dockerfile— that was a real, repeat point of confusion this project hit multiple times).docker build ... /volume1/docker/redefined-designsuses whateverDockerfilesits directly in that directory. -
Always double check
git statusshows a file as staged before committing, and check it again after committing to confirm a clean working tree before pushing. This project has repeatedly had files silently left out of a commit (most notablyAdmin.tsxonce, and the entire cart feature's branch/push never happening at all despite believing it had) — the cost of that mistake compounds badly (hours of debugging "why doesn't the deployed code match what I was shown"), so the few seconds to verifygit statusat each step is well worth it.
Standard deploy sequence, once code is confirmed on main
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 # confirm the expected commit is at the top
sudo docker build --no-cache -t redefined-designs:latest /volume1/docker/redefined-designs
sudo docker stop redefined-designs-syn
sudo docker rm redefined-designs-syn
# redeploy the stack in Portainer UI
Running migrations against the live DB (via Portainer-managed container)
sudo docker exec redefined-designs-syn node migrate.js up
sudo docker exec -it redefined-designs-db-syn psql -U redefined -d redefined -c "SELECT name, run_on FROM pgmigrations ORDER BY run_on;"
Conventions
- Every commit message follows Conventional Commits:
feat:,fix:,chore:,docs:,test:,ci:,refactor:, etc. - Never commit directly to
main. Always branch:feature/<short-description>orfix/<short-description>. Open a PR, merge, branch auto-deletes (repo setting is on). - Local dev/editing happens in VS Code, pushed via PowerShell
git— the NAS-sidegitcworkflow is only for pulling already-merged code down to deploy, never for authoring changes.
Testing
- Unit tests:
cd backend && npm run test:unit— no DB required - Integration tests:
npm run db:test:up(disposable tmpfs Postgres viadocker-compose.test.yml) →npm run migrate:up→npm run test:integration→npm run db:test:down - E2e (Playwright): needs backend running against a migrated DB;
cd frontend && npm run test:e2e - CI (
tests.yml) runs all three as separate jobs, each posting a pass/fail summary to Gitea's job Summary tab. Thefrontend-e2ejob runsnode migrate.js upagainst a Postgres service container — same migration mechanism as everywhere else, no schema duplication anywhere in the project anymore.
Known gaps / natural next steps
- No CD — Gitea Actions runs tests/analysis but doesn't deploy. Manual NAS rebuild is still required after every merge.
- SonarQube CI still uses the
admintoken, not a dedicatedgitea-ciuser (flagged early on, never circled back to). - USPS/PayPal credentials: verify these are actually populated in the Portainer stack env vars before assuming checkout works end-to-end in production —
DEMO_MODEmay still be the only thing actually exercised live. - Double opt-in for marketing email was deliberately deferred (single opt-in + easy unsubscribe judged sufficient for now).
- Daily cart reminder emails fire via
node-croninside the app process at 9am container-local time — if the container restarts frequently, reminders could silently stop firing with no alerting on that failure mode. - Old single-item checkout route files (
backend/src/routes/paypal.ts,backend/src/routes/demo.ts) are dead code, unmounted but never deleted — safe cleanup opportunity. backend/src/routes/shippingAddresses.tsUSPS OAuth token format was implemented against the current (2026) USPS Addresses API docs at time of writing, using a JSON-bodyclient_credentialsrequest — if USPS changes their API again, this is the first place to check.
Where to look first for common tasks
- Add/change a DB table → new file in
backend/migrations/vianpm run migrate:create -- descriptive-name, fill inpgm.sql(...)for up/down - Change cart/checkout behavior →
backend/src/routes/cartCheckout.ts(the whole reserve → checkout → complete lifecycle lives here) - Change admin-configurable settings →
admin_settingstable +backend/src/routes/adminSettings.ts+frontend/src/admin/Settings.tsx - NPM/authentik/DSM reverse-proxy config for this app → not in this repo; documented in the broader homelab's Claude Project knowledge base, not here