Compare commits
63
Commits
f720610084
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c6490f25c | ||
|
|
bf8f89ff08 | ||
|
|
02fb586ae2 | ||
|
|
ce3396adcc | ||
|
|
6b72532331 | ||
|
|
a3e11a6ad1 | ||
|
|
6313817cd4 | ||
|
|
f9fba50093 | ||
|
|
c50b53cea0 | ||
|
|
7ee6b4969e | ||
|
|
1ef65523d3 | ||
|
|
738b727242 | ||
|
|
068ac93e40 | ||
|
|
53d642bbb7 | ||
|
|
f0ecd16cb0 | ||
|
|
2a4dd82643 | ||
|
|
d33cfd6ccc | ||
|
|
d2b1d1e8cc | ||
|
|
ccd9cd9c94 | ||
|
|
f310eeba9b | ||
|
|
f956aee811 | ||
|
|
2146dd3f46 | ||
|
|
393cda8785 | ||
|
|
4fbf5c6b53 | ||
|
|
cdd2202839 | ||
|
|
ee97ae20f8 | ||
|
|
b9dd4fc0e5 | ||
|
|
ed1c8363fd | ||
|
|
c2f27b3b98 | ||
|
|
8b3e923c56 | ||
|
|
308f16f6cc | ||
|
|
ede7555d43 | ||
|
|
d2d67b43c1 | ||
|
|
f731947b51 | ||
|
|
4c06d102bb | ||
|
|
73b85eb947 | ||
|
|
707aa65ddc | ||
|
|
fe8c451739 | ||
|
|
655d40107a | ||
|
|
c8c4204a63 | ||
|
|
8af67a01d6 | ||
|
|
4577c80e0c | ||
|
|
662b0ac3c9 | ||
|
|
984a4465f8 | ||
|
|
579eae5097 | ||
|
|
1899d76def | ||
|
|
6a473b2ea0 | ||
|
|
50adc8e2de | ||
|
|
4748eda41e | ||
|
|
16ecf2dc66 | ||
|
|
f5a3d2594d | ||
|
|
987386ea87 | ||
|
|
df1b93261f | ||
|
|
4386ede68e | ||
|
|
c115ab8774 | ||
|
|
b6c652bbf9 | ||
|
|
6945669e51 | ||
|
|
ef7bd051fe | ||
|
|
1330d6efe2 | ||
|
|
10e0261fd2 | ||
|
|
d4348b3ba9 | ||
|
|
46a5d0ee55 | ||
|
|
3c9ce65335 |
@@ -0,0 +1,14 @@
|
||||
# TODO: delete this file if unused
|
||||
# This is on the only .env file commited to git.
|
||||
# It's purpose is to provide an environment from which it can operate the app and do full development.
|
||||
|
||||
DATABASE_URL=postgres://app_client:app_password@postgres:5432/inventory_2?sslmode=disable
|
||||
TEST_DATABASE_URL=postgres://app_client:app_password@postgres:5432/inventory_2_test?sslmode=disable
|
||||
|
||||
# AUTH0_DOMAIN / AUTH0_CLIENT_ID / AUTH0_CLIENT_SECRET / AUTH0_CALLBACK_URL
|
||||
# are intentionally omitted: with DEV_AUTH_ENABLED=true below, real Auth0
|
||||
# login never runs, so config.Load() doesn't require them.
|
||||
|
||||
DEV_AUTH_ENABLED=true
|
||||
|
||||
PORT=8090
|
||||
@@ -0,0 +1,19 @@
|
||||
name: Gitea Actions Demo
|
||||
run-name: ${{ gitea.actor }} is testing out Gitea Actions 🚀
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
Explore-Gitea-Actions:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- run: echo "🎉 The job was automatically triggered by a ${{ gitea.event_name }} event."
|
||||
- run: echo "🐧 This job is now running on a ${{ runner.os }} server hosted by Gitea!"
|
||||
- run: echo "🔎 The name of your branch is ${{ gitea.ref }} and your repository is ${{ gitea.repository }}."
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- run: echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||
- run: echo "🖥️ The workflow is now ready to test your code on the runner."
|
||||
- name: List files in the repository
|
||||
run: |
|
||||
ls ${{ gitea.workspace }}
|
||||
- run: echo "🍏 This job's status is ${{ job.status }}."
|
||||
@@ -0,0 +1,142 @@
|
||||
name: Claude Assistant for Gitea
|
||||
|
||||
env:
|
||||
DATABASE_URL: postgres://postgres:postgres@postgres:5432/inventory_2?sslmode=disable
|
||||
TEST_DATABASE_URL: postgres://postgres:postgres@postgres:5432/inventory_2_test?sslmode=disable
|
||||
|
||||
on:
|
||||
# Trigger on issue comments (works on both issues and pull requests in Gitea)
|
||||
issue_comment:
|
||||
types: [created]
|
||||
# Trigger on issues being opened or assigned
|
||||
issues:
|
||||
types: [opened, assigned]
|
||||
# Note: pull_request_review_comment has limited support in Gitea
|
||||
# Use issue_comment instead which covers PR comments
|
||||
|
||||
jobs:
|
||||
claude-assistant:
|
||||
# Basic trigger detection - check for @claude in comments or issue body
|
||||
if: |
|
||||
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
|
||||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned'))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: write
|
||||
pull-requests: write
|
||||
issues: write
|
||||
# Note: Gitea Actions may not require id-token: write for basic functionality
|
||||
|
||||
services:
|
||||
postgres:
|
||||
image: postgres
|
||||
env:
|
||||
POSTGRES_PASSWORD: postgres
|
||||
options: >-
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
#- name: apk update
|
||||
# run: |
|
||||
# apk update
|
||||
|
||||
- name: Install psql
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y postgresql-client
|
||||
#apk add postgresql-client
|
||||
|
||||
- name: Setup golang environment
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 'stable'
|
||||
check-latest: true
|
||||
token: ${{ gitea.token }}
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
- name: Install golang-migrate
|
||||
run: |
|
||||
go install -tags 'postgres' github.com/golang-migrate/migrate/v4/cmd/migrate@latest
|
||||
|
||||
- name: Migrate schema up
|
||||
run: |
|
||||
migrate -path database_migrations -database "$DATABASE_URL" up
|
||||
|
||||
## TODO: eliminate, if not needed
|
||||
#- name: Setup node environment
|
||||
# uses: actions/setup-node@v7
|
||||
# with:
|
||||
# # Version Spec of the version to use in SemVer notation.
|
||||
# #pk It also admits such aliases as lts/*, latest, nightly and canary builds
|
||||
# # Examples: 12.x, 10.15.1, >=10.15.0, lts/Hydrogen, 16-nightly, latest, node
|
||||
# node-version: 24
|
||||
|
||||
# # Set this option if you want the action to check for the latest available version
|
||||
# # that satisfies the version spec.
|
||||
# # It will only get affect for lts Nodejs versions (12.x, >=10.15.0, lts/Hydrogen).
|
||||
# # Default: false
|
||||
# #check-latest: false
|
||||
|
||||
# package-manager-cache: false # Maybe add caching later
|
||||
|
||||
# # Used to pull node distributions from https://github.com/actions/node-versions.
|
||||
# # Since there's a default, this is typically not supplied by the user.
|
||||
# # When running this action on github.com, the default value is sufficient.
|
||||
# # When running on GHES, you can pass a personal access token for github.com if you are experiencing rate limiting.
|
||||
# #
|
||||
# # We recommend using a service account with the least permissions necessary. Also
|
||||
# # when generating a new PAT, select the least scopes necessary.
|
||||
# #
|
||||
# # [Learn more about creating and using encrypted secrets](https://help.github.com/en/actions/automating-your-workflow-with-github-actions/creating-and-using-encrypted-secrets)
|
||||
# #
|
||||
# # Default: ${{ github.server_url == 'https://github.com' && github.token || '' }}
|
||||
# token: ${{ secrets.GITEA_TOKEN }}
|
||||
|
||||
- name: Run Claude Assistant
|
||||
uses: markwylde/claude-code-gitea-action@gitea
|
||||
with:
|
||||
allowed_tools: |
|
||||
penpot-self-hosted__execute_code
|
||||
penpot-self-hosted__high_level_overview
|
||||
penpot-self-hosted__penpot_api_info
|
||||
penpot-self-hosted__export_shape
|
||||
penpot_self_hosted__execute_code
|
||||
penpot_self_hosted__high_level_overview
|
||||
penpot_self_hosted__penpot_api_info
|
||||
penpot_self_hosted__export_shape
|
||||
Bash(go:*)
|
||||
Bash(gofmt:*)
|
||||
Bash(./tailwind.sh)
|
||||
Bash(node -v)
|
||||
Bash(npm install)
|
||||
Bash(psql:*)
|
||||
gitea_api_url: "https://gitea.inventory-plus-plus.com/api/v1"
|
||||
gitea_token: ${{ secrets.GITEA_TOKEN }} # Use standard workflow token
|
||||
# anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
# Prefer claude_code_oauth_token over anthropic_api_key (cheaper!)
|
||||
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
timeout_minutes: "60"
|
||||
trigger_phrase: "@claude"
|
||||
assignee_trigger: "@claude"
|
||||
# Needed due to bug: https://github.com/anthropics/claude-code-action/issues/1416
|
||||
model: "claude-sonnet-5"
|
||||
# Optional: Customize for Gitea environment
|
||||
custom_instructions: |
|
||||
You are working in a Gitea environment. Be aware that:
|
||||
- Some GitHub Actions features may behave differently
|
||||
- Focus on core functionality and avoid advanced GitHub-specific features
|
||||
- Use standard git operations when possible
|
||||
env:
|
||||
GITEA_SERVER_URL: "https://gitea.inventory-plus-plus.com"
|
||||
NODE_VERSION: 24.x
|
||||
POSTGRES_HOST: postgres
|
||||
POSTGRES_PORT: 5432
|
||||
@@ -0,0 +1,28 @@
|
||||
name: Tests
|
||||
run-name: tests on ${{ gitea.ref }}
|
||||
on: [push]
|
||||
|
||||
jobs:
|
||||
Go tests:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Description
|
||||
run: |
|
||||
echo "🧑🔬 $ {{ gitea.actor }} pushed branch ${{ gitea.ref }} ...0️0️1️0️1️0️1️0️ ... beginning tests"
|
||||
- name: Check out repository code
|
||||
uses: actions/checkout@v4
|
||||
- name: Update
|
||||
run: |
|
||||
echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||
echo "🖥️ The job is now ready to test your go code on the runner."
|
||||
- name: Setup golang environment
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: 'stable'
|
||||
check-latest: true
|
||||
token: ${{ gitea.token }}
|
||||
permissions:
|
||||
contents: read
|
||||
- name: Run go tests
|
||||
run: |
|
||||
go test ./...
|
||||
+2
-1
@@ -4,4 +4,5 @@
|
||||
node_modules
|
||||
|
||||
.env
|
||||
.env.example
|
||||
.env.*
|
||||
!.env.gitea-claude-bot-dev
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"penpot-self-hosted": {
|
||||
"description": "Set here, even though set globally, so it is also available for the gitea-claude action bot",
|
||||
"type": "http",
|
||||
"url": "https://penpot.inventory-plus-plus.com/mcp/stream?userToken=${PENPOT_MCP_USER_TOKEN}"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
# AGENTS.md
|
||||
|
||||
Go backend for a multi-platform ecommerce inventory-sync tool. Etsy is the
|
||||
one real, live platform integration; everything else (Amazon, BigCartel,
|
||||
Ebay, Ecwid, Shopify, SquareOnline, Squarespace, Tiktok, WalmartMarketplace,
|
||||
Wix, WooCommerce, Zoho) exists only as a **mock simulation layer** used for
|
||||
development, demos, and testing the sync/reporting logic without needing
|
||||
real store credentials. See `README.md` for the product-level roadmap.
|
||||
|
||||
## Platform integration priority
|
||||
|
||||
When picking the next mock platform to turn into a real integration (after
|
||||
Etsy), use this weighted ranking of the 12 not-yet-live mock platforms. It
|
||||
scores each on GMV/market opportunity (45%), API/inventory-sync completeness
|
||||
(30%), growth trajectory (15%), and integration cost (10%) - see
|
||||
`STORE_API_RESEARCH.md`'s "Weighted priority ranking" section for the full
|
||||
scoring table, per-criterion reasoning, and the market-size/API research
|
||||
it's built on.
|
||||
|
||||
1. **Shopify** (9.15/10) - cleanest/most complete API (webhooks for both
|
||||
order and inventory events, modern GraphQL), still-strong GMV growth
|
||||
(+29-35% YoY), and a proven self-serve distribution channel (the Shopify
|
||||
App Store, where inventory-sync apps are an established category) that
|
||||
no other platform here has an equivalent of.
|
||||
2. **Amazon** (8.85/10) - largest raw dollar opportunity (~$575B in 2025
|
||||
third-party GMV), and inventory-sync pain (suppressed listings, FBA
|
||||
stranded-inventory fees) is one of the sharpest problems this tool could
|
||||
solve there. `domains/amazon` already has the most real infrastructure
|
||||
of any mock platform (the `ProcessEvents` LISTEN/NOTIFY loop, SSE
|
||||
wiring), so a real SP-API integration reuses that instead of starting
|
||||
from zero. Scores lower than Shopify mainly because SP-API auth (LWA +
|
||||
AWS SigV4) and rate limiting are the most complex of any platform here,
|
||||
and seller growth has gone flat.
|
||||
3. **Tiktok Shop** (7.15/10) - hypergrowth (global GMV nearly doubled in
|
||||
2025, projected to double again in 2026) is what earns it this spot, but
|
||||
its API details are the least confirmed of anything researched -
|
||||
`partner.tiktokshop.com` didn't yield readable docs during this pass.
|
||||
Needs a dedicated research pass to confirm exact webhook/endpoint names
|
||||
before treating this ranking as actionable.
|
||||
4. **Walmart Marketplace** (6.35/10) - smaller in absolute GMV than the top
|
||||
three, but growing ~50% YoY and a natural second-marketplace target for
|
||||
sellers already on Amazon; full order+inventory webhook coverage.
|
||||
5. **Wix** (6.35/10) - tied with Walmart on score but behind it on the
|
||||
growth tiebreaker; full inventory-webhook coverage and an easy API, just
|
||||
a smaller/less certain GMV number.
|
||||
6. **WooCommerce** (5.95/10) - large store count (~4-6M) but no
|
||||
platform-wide GMV figure exists (self-hosted plugin, no central ledger),
|
||||
and no dedicated inventory webhook (relies on a `product.updated` proxy).
|
||||
7. **Square Online** (5.90/10) - full inventory-webhook coverage and
|
||||
well-documented APIs (shared with the rest of Square's product line),
|
||||
but its ecommerce-specific GMV can't be isolated from Square's much
|
||||
larger in-person POS business.
|
||||
8. **Ebay** (5.75/10) - most sellers of any platform here (18.3M) but the
|
||||
lowest GMV-per-seller by far, plus no dedicated inventory-change webhook.
|
||||
9. **Squarespace** (4.75/10) - smaller GMV, no inventory webhook.
|
||||
10. **Zoho** (3.85/10) - tiny confirmed store count (~2,196 globally).
|
||||
11. **Ecwid** (3.20/10) - shrinking (-20% YoY store count).
|
||||
12. **Big Cartel** (1.50/10) - last, and not close: no inventory API or
|
||||
webhook at all is a structural dealbreaker for this tool's core use
|
||||
case, independent of its (also declining, -41% YoY) market size.
|
||||
|
||||
(BigCommerce, which appears in the API-capability table in
|
||||
`STORE_API_RESEARCH.md`, is excluded here - it isn't one of this codebase's
|
||||
actual mock platforms; that table predates the platform list settling on
|
||||
Tiktok instead.)
|
||||
|
||||
Etsy isn't in the ranking above (it's already built), but scores 3.85/10 as
|
||||
a reference point if run through the same rubric - mid-pack-and-declining
|
||||
GMV, and the *only* platform researched with zero webhook/push support of
|
||||
any kind (order or inventory - confirmed directly against
|
||||
`domains/platforms/etsy/generated_client`, not external docs). That's not a
|
||||
retroactive case against having built Etsy - it was presumably chosen for
|
||||
reasons this rubric doesn't score (an existing relationship, an easier path
|
||||
to developer credentials) - but it's a reminder that the lack of any
|
||||
webhook system makes real order/inventory sync a polling loop, which is
|
||||
exactly the kind of integration cost this rubric undercounts once a
|
||||
platform is more than superficially wired up. Note also that the OAuth
|
||||
connection flow is the only part of Etsy that's actually built so far
|
||||
(`domains/platforms/etsy/etsy.go`) - polling-based receipt/inventory sync
|
||||
against the live API doesn't exist yet.
|
||||
|
||||
## Setup
|
||||
|
||||
1. Postgres running locally, with an `app_client` role/database matching
|
||||
`.env.example`'s `DATABASE_URL`.
|
||||
2. `cp .env.example .env` and fill in real values (Auth0, Etsy). For local
|
||||
dev-only work you don't need real Auth0/Etsy secrets if `DEV_AUTH_ENABLED`
|
||||
is `true` (see Auth below) - `changeme` placeholders are fine.
|
||||
3. Create the dedicated test database once: `createdb -O app_client
|
||||
inventory_2_test`. `make test` migrates it automatically after that.
|
||||
|
||||
## Commands
|
||||
|
||||
- `make dev` (default: bare `make`) - applies pending migrations, starts the
|
||||
Tailwind watcher in the background, runs the server in the foreground.
|
||||
Ctrl-C stops both.
|
||||
- `make run` - just `go run .`, no migrations or CSS.
|
||||
- `make test` - runs the full test suite against the dedicated
|
||||
`inventory_2_test` database (migrates it first). Safe to run anytime;
|
||||
never touches real data.
|
||||
- `make test-against-dev-db` - same suite, pointed at the real dev database
|
||||
instead. Useful for checking behavior against real data; tests clean up
|
||||
after themselves via `t.Cleanup`, but it's still touching your dev DB.
|
||||
- `make migrate-up` / `migrate-down` / `migrate-version` - wrap the
|
||||
`migrate` CLI using `DATABASE_URL` from `.env`, so the DSN is never
|
||||
hand-typed. `migrate-test-*` variants target `TEST_DATABASE_URL`.
|
||||
- `go build ./...`, `go vet ./...`, `gofmt -l .` - standard, expected clean
|
||||
before considering work done.
|
||||
- `go test ./... -race` - the test suite is safe to run this way (see
|
||||
Testing below for what made that true).
|
||||
|
||||
## Architecture
|
||||
|
||||
- `domains/` - business logic, one package per bounded concern
|
||||
(`accounts`, `authentication`, `raw_events`, `reports`, `amazon`,
|
||||
`platforms/etsy`). Each domain owns its own DB access; there's no shared
|
||||
ORM/repository layer. Looking to loosely follow CQRS: writes go through
|
||||
domain `Store` methods, reads are mostly separate query methods on the
|
||||
same `Store`.
|
||||
- `domains/raw_events` - the [event-sourcing](https://martinfowler.com/eaaDev/EventSourcing.html)
|
||||
store: `Save` appends an `Event`, `LoadEventsForStore` replays a
|
||||
platform+store's series. Other domains (`reports`,
|
||||
`domains/accounts/mocks.go`) read from views/queries that project over
|
||||
this event series rather than mutating their own standalone state. See
|
||||
also the [CQRS](https://martinfowler.com/bliki/CQRS.html) note above.
|
||||
- `domains/accounts/mocks.go` - the mock-platform simulation layer:
|
||||
`CreateMockShop`, `CreateMockListing`, `SaveNewMockSale` /
|
||||
`SaveNewMockRefund` / `SaveNewMockInventoryReset`, etc. These are the
|
||||
*real* entry points production code (and the simulate-sale/refund/
|
||||
inventory UI) uses - prefer them over hand-rolled SQL when writing tests
|
||||
or new features that need mock shop/listing/event data.
|
||||
- `domains/amazon/mock.go` - the one platform with a live *background
|
||||
processor* on top of the mock layer: a Postgres `LISTEN`/`NOTIFY`-driven
|
||||
loop (`(*Mocks).ProcessEvents`) that reacts to newly inserted mock events
|
||||
and dispatches them to a `MockEventListener` (wired to Server-Sent Events
|
||||
in `main.go`, so the UI updates live). No other platform has this yet;
|
||||
if adding one, read this file's history/comments first - it went through
|
||||
several correctness passes (reconnect-on-failure, ack-based retry,
|
||||
configurable poll fallback) worth not re-discovering from scratch.
|
||||
- `server/` - HTTP layer (Gin). `server/api` is the JSON/HTMX API,
|
||||
`server/ui` renders HTML pages, `server/auth` is session/auth middleware,
|
||||
`server/sse` is the Server-Sent Events plumbing.
|
||||
- `config/config.go` - all runtime configuration comes from environment
|
||||
variables (loaded from `.env` via `godotenv`), never hardcoded. If you
|
||||
add a new required external dependency (a new secret, a new service
|
||||
URL), add it here, not as a literal in the code that uses it.
|
||||
- `internal/testdb` - shared test fixtures: `Pool(t)` (connects via
|
||||
`TEST_DATABASE_URL`, skips the test if unset), `Logger()`, `NewUserID(t)`,
|
||||
`SeedOAuthUser`/`SeedOAuthSession`.
|
||||
|
||||
## Database & migrations
|
||||
|
||||
- Migrations live in `database_migrations/`, run via `golang-migrate`
|
||||
(`migrate` CLI). Every migration needs a paired `.up.sql`/`.down.sql`.
|
||||
- **Migration files can drift from the live schema, and drift can mean
|
||||
"unrunnable," not just "stale docs."** This has happened at least twice:
|
||||
an interval-literal syntax error that failed on any fresh database, and a
|
||||
column that existed in the live dev DB but not in what the migration
|
||||
files would produce (fixed by adding a migration that reconciles the two,
|
||||
then `migrate force <version>` on the DB that already matched by hand).
|
||||
When something about the schema seems off, verify against the *live* DB
|
||||
(`psql \d`, `pg_get_viewdef(...)`) before trusting the migration files,
|
||||
and *especially* before assuming a fresh database would come up the same
|
||||
way the long-running dev one does.
|
||||
- The mock-platform schema (`mock.*`) follows a consistent per-platform
|
||||
pattern: `mock.shop_<platform>`, `mock.shop_<platform>_listings`, and for
|
||||
Amazon specifically `mock.shop_<platform>_events` (the background
|
||||
processor's queue). Some platforms also have a recursive
|
||||
`mock.shop_<platform>_listing_counts` view (built on
|
||||
`mock.shop_<platform>_listing_event_sequence`) that computes a running
|
||||
inventory count from `mock.raw_shop_events` - it's real per-platform
|
||||
logic living in SQL, not a passthrough, and worth reading with
|
||||
`pg_get_viewdef(..., true)` rather than assuming it matches another
|
||||
platform's shape.
|
||||
- **Platform-string casing is inconsistent and matters.** Most
|
||||
`accounts.Platform` constants are lowercase/snake_case (`"amazon"`,
|
||||
`"big_cartel"`, ...) matching what SQL triggers/views expect, but
|
||||
`Etsy`/`Tiktok`/`Wix` are capitalized Go-side (`"Etsy"`, not `"etsy"`).
|
||||
Some SQL objects for those platforms expect the capitalized form (e.g.
|
||||
the Etsy listing-counts view filters on `'Etsy'`), others expect
|
||||
lowercase (e.g. the Etsy `raw_shop_events` trigger checks `'etsy'`).
|
||||
Don't assume; check the actual SQL (`pg_get_viewdef`, trigger
|
||||
definitions) for the specific object you're relying on before writing a
|
||||
fixture or a query.
|
||||
- The `mock_shop_<platform>_event_inserted` `NOTIFY` channels are global,
|
||||
not scoped by shop/account. Any insert into `raw_shop_events` for that
|
||||
platform notifies *every* currently-listening connection for that
|
||||
platform, regardless of which test or code path caused it. See Testing
|
||||
below for how this bit two test packages at once.
|
||||
|
||||
## Testing
|
||||
|
||||
- Tests are real integration tests against a real Postgres database
|
||||
(`internal/testdb.Pool(t)`), not mocks/stubs of the DB layer - this
|
||||
codebase is mostly thin `Store` methods wrapping SQL, so testing the Go
|
||||
code in isolation would miss most of the actual risk.
|
||||
- Assertions use `github.com/stretchr/testify`'s `assert`/`require`
|
||||
throughout - `require` for anything the rest of the test depends on
|
||||
(halts immediately), `assert` for independent checks. Use
|
||||
`require.Eventually` for "poll until true or timeout" patterns instead of
|
||||
hand-rolling one.
|
||||
- **`require.Eventually`'s condition function runs on a separate
|
||||
goroutine.** Never call `require.*`/`t.Fatal*` (which invoke
|
||||
`t.FailNow()`) from inside one - Go's testing package requires `FailNow`
|
||||
to only be called from the test's own goroutine; calling it from a
|
||||
spawned one doesn't panic cleanly, it just silently reports the wrong
|
||||
thing. If a helper used inside an `Eventually` closure needs to check an
|
||||
error, give it a `*testing.T`-free variant that returns `(value, error)`
|
||||
and let the closure decide what "not yet satisfied" means.
|
||||
- Every test cleans up its own rows via `t.Cleanup`, in FK-safe order
|
||||
(children before parents). Use unique, randomly-generated IDs
|
||||
(`testdb.NewUserID(t)`, `uuid.NewString()`) so tests are safe to run
|
||||
concurrently with themselves and with real dev data under
|
||||
`test-against-dev-db`.
|
||||
- **Pick a mock platform deliberately when writing new fixtures that touch
|
||||
`mock.raw_shop_events`.** `domains/amazon`'s tests process *every*
|
||||
unprocessed event system-wide (that's correct behavior for its
|
||||
background worker, not a bug) and react to the global Amazon NOTIFY
|
||||
channel - any other package's tests that insert Amazon-platform mock
|
||||
events risk being picked up by `domains/amazon`'s tests when `go test
|
||||
./...` runs both packages' binaries concurrently (Go's default).
|
||||
`domains/reports`, for example, deliberately uses Etsy as its example
|
||||
platform for exactly this reason. If you're not testing
|
||||
`domains/amazon` itself, don't use Amazon as your fixture platform.
|
||||
- Never disable/re-enable a shared DB trigger to simulate a "notify never
|
||||
fires" scenario in a test, even temporarily - it's unsafe to do against
|
||||
a database anything else might be using concurrently (including a real
|
||||
`make dev` session someone has running). If you need to prove a
|
||||
timing/fallback path works, find a way to construct the scenario through
|
||||
normal writes instead (see `domains/amazon/mock_test.go`'s poll-fallback
|
||||
test for an example: it reuses the retry mechanism to guarantee a second
|
||||
dispatch can only come from the poll timer, without touching any
|
||||
trigger).
|
||||
- Killing a real Postgres backend connection (`pg_terminate_backend`,
|
||||
found via `pg_stat_activity`) is a legitimate, safe way to test
|
||||
reconnect/failure-recovery logic when properly scoped - see
|
||||
`domains/amazon/mock_test.go`'s `terminateListenConnection`. Be aware its
|
||||
PID lookup matches on query text globally, not "this test's connection
|
||||
specifically" - check for other live processes (e.g. a running `make
|
||||
dev`) before running a test like this against a shared database.
|
||||
|
||||
## Auth
|
||||
|
||||
- Real auth is Auth0-backed OIDC (`domains/authentication`). For local
|
||||
dev, set `DEV_AUTH_ENABLED=true` and hit `GET
|
||||
/api/auth/dev-login?user_id=whoever` to mint a real session (writes
|
||||
directly to `oauth_users`/`oauth_tokens`, same shape a real login
|
||||
produces) without any Auth0 round-trip. Different `user_id` values let
|
||||
you test multiple identities/accounts side by side. Never enable this
|
||||
outside local development.
|
||||
|
||||
## Gitea
|
||||
|
||||
- This project is hosted on a self-hosted **Gitea** instance at
|
||||
`gitea.inventory-plus-plus.com` (repo:
|
||||
`angel/inventory-plus-plus`), not GitHub - the `origin` remote points at
|
||||
it over SSH. Issues, pull requests, wiki, and CI (Gitea Actions - see the
|
||||
Tests badge at the top of `README.md`) all live there rather than on
|
||||
GitHub, even though the tooling/workflow (Actions YAML, PR-based review)
|
||||
looks GitHub-shaped.
|
||||
- A Gitea MCP server is available in agent sessions (tools prefixed
|
||||
`mcp__angel__...` - e.g. `issue_read`/`issue_write`,
|
||||
`pull_request_read`/`pull_request_write`,
|
||||
`list_pull_requests`/`list_issues`, `list_branches`, `wiki_read`/
|
||||
`wiki_write`) for reading/managing issues, PRs, branches, releases, and
|
||||
the wiki without shelling out to `git`/`gh`. There is no GitHub CLI
|
||||
(`gh`) equivalent here - use these MCP tools or `git` directly instead.
|
||||
- The Gitea MCP server has **no Projects API** - it cannot read or modify
|
||||
Gitea Project boards. Don't attempt to automate Project-board changes
|
||||
(e.g. moving an issue between columns) through it; that has to be done
|
||||
manually in the Gitea UI.
|
||||
|
||||
## Commit conventions
|
||||
|
||||
- Commit messages: imperative mood subject line, no period, body explains
|
||||
*why* (what problem existed, what the fix changes) rather than
|
||||
re-describing the diff. One logical change per commit - e.g. a bugfix
|
||||
found while doing unrelated work gets its own commit, not folded into
|
||||
the original task's.
|
||||
- Work summaries: see `work-summaries/` for dated records of past
|
||||
sessions' changes and reasoning - useful context before touching an area
|
||||
someone else (human or agent) recently worked on.
|
||||
@@ -1,99 +1,79 @@
|
||||
# V2 - Attempt 2
|
||||
## Inventory++ [](https://gitea.inventory-plus-plus.com/angel/inventory-plus-plus/actions?workflow=&scoped_workflow_source_repo_id=0&actor=0&status=0&branch=master)
|
||||
|
||||
WIP!
|
||||
|
||||
This is an application intended to ease the process of managing multiple online stores.
|
||||
The benefits provided by this application will be to automatically manage shared inventory between stores,
|
||||
reducing the amount of time needed to synchronize inventory between stores.
|
||||
|
||||
|
||||
# Roadmap
|
||||
## Where things live
|
||||
|
||||
- [ ] Etsy (WIP)
|
||||
- [ ] GET ETSY AUTH (WIP)
|
||||
- [x] move auth state stuff to database (out of cache)
|
||||
- [x] only generate a sign up link IF they click the link on the accounts page
|
||||
- [ ] get api key approved
|
||||
- [ ] automatically clean up access tokens and state when expired
|
||||
- [ ] access tokens
|
||||
- [ ] state
|
||||
- [?] Get new access token using refresh token flow
|
||||
- [ ] make a FK between the etsy_store_events table and etsy_users table (store_id columns don't match types)
|
||||
- [ ] Auth0
|
||||
- [ ] get off dev api key?
|
||||
- [x] Get new access token using refresh token flow
|
||||
- [ ] test
|
||||
- [x] when token is expired, redirect them to the login page, then redirect them back to where they were heading to.
|
||||
- [ ] Social connections login
|
||||
- [x] automatically clean up access tokens and state when expired
|
||||
- [x] access tokens
|
||||
- [x] state
|
||||
- [ ] Complete this design document?
|
||||
- [ ] Complete defining this roadmap checklist
|
||||
- [ ] Website displaying an audit of store events
|
||||
- [ ] Start with just a list of events for a given store (use a static test store)
|
||||
- [ ] ...
|
||||
- [ ] Dark mode
|
||||
- [ ] don't let a listing be in multiple sync groups
|
||||
- [ ] Next stores on the list (at least hypothetically)
|
||||
- Shopify
|
||||
- WooCommerce
|
||||
- BigCommerce
|
||||
- Wix
|
||||
- Squarespace
|
||||
- Square Online
|
||||
- Zoho
|
||||
- Ecwid
|
||||
- Big Cartel
|
||||
Bigger marketplaces:
|
||||
- Amazon
|
||||
- Walmart Marketplace
|
||||
- Ebay
|
||||
Documentation is split up rather than kept in one big doc - start here:
|
||||
|
||||
## Nice to haves
|
||||
- **[`AGENTS.md`](./AGENTS.md)** - the engineering reference: setup, `make` commands, architecture, database/migration gotchas, testing conventions, auth, and the researched platform-integration priority ranking (which platform to build next, and why).
|
||||
- **[`ROADMAP.md`](./ROADMAP.md)** - in-progress and planned work, as a checklist.
|
||||
- **[`research/platforms.md`](./research/platforms.md)** - the underlying API-capability and market-size research the platform-priority ranking is based on.
|
||||
- **[`domains/platforms/etsy/COMPLIANCE.md`](./domains/platforms/etsy/COMPLIANCE.md)** - Etsy API usage obligations to keep satisfied when touching the Etsy integration.
|
||||
- **[`diagrams/`](./diagrams)** - architecture and schema diagrams, linked from the Diagrams section below.
|
||||
|
||||
- [ ] Drop in a good logger
|
||||
- [ ] log all errors caught by the http server
|
||||
|
||||
## Constraints
|
||||
|
||||
- [ ] Etsy
|
||||
- [ ] API Licensed Uses and Restrictions:
|
||||
- [ ] Link directly back to the product information and/or image Content on Etsy, where the Application utilizes product information and/or images.
|
||||
- [ ] Provide a prominently displayed email address on Your Application for third parties to contact You with any questions or issues. You shall respond to such inquiries in a timely manner.
|
||||
- [ ] Use commercially reasonable efforts to provide a terms of service and privacy policy in a visible location on your Application.
|
||||
- [ ] Display item Content or product information and/or images which is more than six (6) hours older than such information is on the Website, and other Etsy Content cannot be more than twenty-four (24) hours older than such Content on the Website.
|
||||
- [ ] Use the API in a manner that exceeds reasonable request volume or constitutes excessive or abusive usage. Users are allocated by default, 10,000 calls per day.
|
||||
- [ ] You shall not use or alter any text, logos, Etsy's Trademarks, Etsy's signature colors, Etsy's layout, or a confusingly similar layout to Etsy's layout in such a way which may suggest endorsement or affiliation by Etsy.
|
||||
- [ ] Any use of the Etsy logo or Etsy's Trademarks must be used in its entirety and must not be altered or used in a misleading way.
|
||||
- [ ] You shall not use a mark which is confusingly similar to Etsy's Trademarks.
|
||||
- [ ] Any use of the Etsy logo or Etsy's Trademarks in Your Application shall be less prominent than the logo or mark that primarily describes the Application and Your use of the Etsy logo shall not imply any endorsement or affiliation by Etsy.
|
||||
- [ ] You may publicize, issue press or blog releases of Your Application only if You state that it was created using the Etsy API and that You in no way imply that Your Application is endorsed or certified by Etsy.
|
||||
- [ ] You must place or display the following notice prominently on Your Application:
|
||||
"The term 'Etsy' is a trademark of Etsy, Inc. This application uses the Etsy API but is not endorsed or certified by Etsy, Inc."
|
||||
- [ ] Immediately report any security deficiencies You discover to Etsy by emailing developer@etsy.com.
|
||||
This project is hosted on a self-hosted [Gitea](https://gitea.inventory-plus-plus.com/angel/inventory-plus-plus) instance (not GitHub) - issues, pull requests, CI (the Tests badge above), and the wiki all live there. See `AGENTS.md`'s Gitea section for details, including the Gitea MCP server tools available to AI agents.
|
||||
|
||||
|
||||
Looking to follow the [CQRS](https://martinfowler.com/bliki/CQRS.html) pattern.
|
||||
## Deploying
|
||||
|
||||
The database will follow the [event sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) database pattern.
|
||||
All events (or commands) will be stored in a respective event series, and all database reads will be from views that are projections, reductions, aggregations of those event series.
|
||||
The application runs locally, from this directory.
|
||||
It is deployed simply by running either from the root of the project,
|
||||
```
|
||||
make run
|
||||
```
|
||||
or
|
||||
```
|
||||
go run .
|
||||
```
|
||||
|
||||
See `AGENTS.md` for the full setup/`make` command reference (migrations, tests, dev auth, etc).
|
||||
|
||||
|
||||
## Application/directory structure
|
||||
- /internal
|
||||
- /site: website
|
||||
- /webhooks: webhooks for platform events
|
||||
- /domains: packages for each domain
|
||||
- /store_events: storing and events
|
||||
- /platforms: ecommerce platform domains
|
||||
- /tiktok: interface with tiktok
|
||||
- ... etc
|
||||
- ... etc
|
||||
## Technology
|
||||
|
||||

|
||||
### Languages
|
||||
|
||||
## Website hierarchy
|
||||
#### Server side
|
||||
Golang, Go Templates
|
||||
|
||||
- /site
|
||||
#### Front end
|
||||
HTML, CSS, Javascript
|
||||
|
||||
### Databases
|
||||
Postgres, in docker
|
||||
|
||||
#### Management
|
||||
Using [migrate](https://github.com/golang-migrate/migrate) to manage build out the database schema, and to run
|
||||
migrations.
|
||||
|
||||
### Tooling
|
||||
|
||||
#### Server side
|
||||
- github.com/angelbeltran/templater: for wiring up template directories for serving over the web and improving
|
||||
the task of composing template together.
|
||||
- go templates: generating html declaratively from the server.
|
||||
- github.com/jackc/pgx/v5: for postgres db interfacing
|
||||
|
||||
#### Front end
|
||||
- htmx: for strong hypermedia support
|
||||
- hyperscript: for minimal, inline scripting, with strong integration with htmx
|
||||
- tailwind: for styling the front end, using tried and testing styling paradigms, conventions, and templates.
|
||||
|
||||
|
||||
# Architectural and Software Diagrams
|
||||
|
||||
## Application structure
|
||||
|
||||
See `AGENTS.md`'s Architecture section for the current, maintained breakdown of packages/directories.
|
||||
|
||||

|
||||
|
||||
## Database schemas
|
||||
|
||||
**public**
|
||||
@@ -105,39 +85,28 @@ All events (or commands) will be stored in a respective event series, and all da
|
||||
|
||||
## Events
|
||||
|
||||
`domains/raw_events` is the event-sourcing store behind this - see `AGENTS.md`'s Architecture section.
|
||||
|
||||
### Event Sourcing Architecture
|
||||

|
||||
|
||||
|
||||
### Event Structure
|
||||
|
||||

|
||||
|
||||
|
||||
### Store Event Database Tables
|
||||
|
||||

|
||||
|
||||
|
||||
## Platform: Etsy
|
||||
|
||||
### Signing up
|
||||
|
||||
**TODO: need an account page that can create accounts ahead of time - force users to create an account first!**
|
||||
|
||||

|
||||
|
||||
***TODO: create a page that will take billing information and include it in this process***
|
||||
|
||||
### Getting a new refresh token
|
||||
|
||||
***TODO***
|
||||
|
||||
|
||||
### Models
|
||||
|
||||

|
||||
|
||||
See `ROADMAP.md` for what's still unbuilt in the Etsy flow (an account-creation page ahead of OAuth, billing info collection, refresh-token handling), and `domains/platforms/etsy/COMPLIANCE.md` for API usage obligations.
|
||||
|
||||
|
||||
## All Diagrams
|
||||
|
||||
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
# Roadmap
|
||||
|
||||
- [ ] Etsy (WIP)
|
||||
- [ ] GET ETSY AUTH (WIP)
|
||||
- [x] move auth state stuff to database (out of cache)
|
||||
- [x] only generate a sign up link IF they click the link on the accounts page
|
||||
- [ ] get api key approved
|
||||
- [ ] automatically clean up access tokens and state when expired
|
||||
- [ ] access tokens
|
||||
- [ ] state
|
||||
- [ ] account-creation page ahead of OAuth (force users to create an account before connecting a store)
|
||||
- [ ] collect billing information as part of that account-creation flow
|
||||
- [?] Get new access token using refresh token flow
|
||||
- [ ] make a FK between the etsy_store_events table and etsy_users table (store_id columns don't match types)
|
||||
- [ ] build polling-based order/inventory sync against the live Etsy API - only the OAuth connection flow is implemented so far (see `research/platforms.md`'s Etsy notes: API v3 has no webhook/push system, so this has to be a poll loop)
|
||||
- see `domains/platforms/etsy/COMPLIANCE.md` for Etsy API usage obligations to keep satisfied along the way
|
||||
- [ ] Auth0
|
||||
- [ ] get off dev api key?
|
||||
- [x] Get new access token using refresh token flow
|
||||
- [ ] test
|
||||
- [x] when token is expired, redirect them to the login page, then redirect them back to where they were heading to.
|
||||
- [ ] Social connections login
|
||||
- [x] automatically clean up access tokens and state when expired
|
||||
- [x] access tokens
|
||||
- [x] state
|
||||
- [ ] Complete this design document?
|
||||
- [ ] Complete defining this roadmap checklist
|
||||
- [ ] Website displaying an audit of store events
|
||||
- [ ] Start with just a list of events for a given store (use a static test store)
|
||||
- [ ] ...
|
||||
- [ ] Dark mode
|
||||
- [ ] don't let a listing be in multiple sync groups
|
||||
- [ ] Next platform to build out beyond Etsy - see `AGENTS.md`'s "Platform integration priority" section for the researched, weighted ranking (currently: Shopify, then Amazon, then Tiktok Shop) instead of picking from the raw platform list
|
||||
|
||||
## Nice to haves
|
||||
|
||||
- [ ] Drop in a good logger
|
||||
- [ ] log all errors caught by the http server
|
||||
@@ -1,46 +0,0 @@
|
||||
# Store API Research
|
||||
|
||||
https://docs.google.com/spreadsheets/d/1xWfXn-wbiHTBeqyhnq0b46_sgLGyiYc_5N5mXKYD1R8/edit?gid=0#gid=0
|
||||
|
||||
|
||||
|
||||
| Platform | Order Event: placed | Order Event: changed | Products List | Products Look up | Inventory Look up | Inventory Update | Inventory Event: change |
|
||||
|----------|----------|----------|----------|----------|----------|----------|----------|
|
||||
| Shopify | orders/create | orders/cancelled, orders/delete, orders/paid, etc | /queries/products | /queries/product | /queries/product | /mutations/inventorySetQuantities | inventory_levels/update |
|
||||
| WooCommerce | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /wp-json/wc/v3/products` | `GET /wp-json/wc/v3/products/<id>` | `stock_quantity` field on product resource | `PUT /wp-json/wc/v3/products/<id>` (stock_quantity), or `/products/batch` | `product.updated` (no dedicated inventory webhook) |
|
||||
| BigCommerce | webhook `store/order/created` | webhook `store/order/updated`, `store/order/statusUpdated` | `GET /v3/catalog/products` | `GET /v3/catalog/products/{product_id}` | `GET /v3/inventory/items` | `PUT /v3/inventory/adjustments/absolute` (also `/relative`) | `store/product/inventory/updated` |
|
||||
| Wix | webhook `wix.ecom.v1.order.created` | webhook `wix.ecom.v1.order.updated` (also `.canceled`) | Query Products (Catalog V3) | Get Product | Query Inventory Items | Update Inventory Variants | `wix.stores.catalog.v3.inventory_item.updated` |
|
||||
| Squarespace | webhook `order.create` | webhook `order.update` (FULFILLED, REFUNDED, CANCELED, MARKED_PENDING, EMAIL_UPDATED) | `GET /v2/commerce/products` | `GET /v2/commerce/products/{productIdCsvs}` | `GET /1.0/commerce/inventory/{variantIdCsvs}` | `POST /1.0/commerce/inventory/adjustments` | N/A (no inventory webhook topic) |
|
||||
| Square Online | webhook `order.created` | webhook `order.updated`, `order.fulfillment.updated` | `GET /v2/catalog/list` | `GET /v2/catalog/object/{object_id}` | `POST /v2/inventory/counts/batch-retrieve` | `POST /v2/inventory/changes/batch-create` (BatchChangeInventory) | webhook `inventory.count.updated` |
|
||||
| Zoho | webhook `salesorder.created` (Zoho Commerce) | webhook `salesorder.confirmed, .cancelled, .declined, .shipped, .delivered` | `GET /store/api/v1/products` | `GET /store/api/v1/products/{product_id}` | `GET /store/api/v1/variants` (`stock_on_hand`, `actual_available_stock`) | `POST /store/api/v1/inventoryadjustments` | N/A (no inventory/stock webhook event) |
|
||||
| Ecwid | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /api/v3/{storeId}/products` | `GET /api/v3/{storeId}/products/{productId}` | `GET /api/v3/{storeId}/products/{productId}` (`quantity`/`unlimited`) | `PUT /api/v3/{storeId}/products/{productId}/inventory` (`quantityDelta`) | `product.updated` webhook |
|
||||
| Big Cartel | webhook `order.create` (app-approved) | webhook `order.update` (app-approved) | `GET /v1/accounts/{account_id}/products` | `GET /v1/accounts/{account_id}/products/{id}` | N/A — no dedicated inventory field/endpoint | N/A — no inventory update endpoint | N/A — no inventory-specific webhook |
|
||||
| Amazon | `ORDER_CHANGE` notification (SP-API) | `ORDER_CHANGE` notification (same type, status delta) | `searchCatalogItems` (GET `/catalog/2022-04-01/items`) | `getCatalogItem` (GET `/catalog/2022-04-01/items/{asin}`) | `getInventorySummaries` (FBA Inventory API, GET `/fba/inventory/v1/summaries`) | `patchListingsItem` (PATCH `/listings/2021-08-01/items/{sellerId}/{sku}`) | `FBA_INVENTORY_AVAILABILITY_CHANGES` notification |
|
||||
| Walmart Marketplace | PO created event (webhook) | Order intent to cancel / PO line auto-cancelled event (webhook); status flow Created→Acknowledged→Shipped→Delivered/Cancelled | `GET /v3/items` (getAllItems) | `GET /v3/items/{id}` (getAnItem) | `GET /v3/inventory?sku={sku}` | `PUT /v3/inventory` (also bulk via `POST /v3/feeds`) | Inventory OOS event (webhook) |
|
||||
| Ebay | `FixedPriceTransaction` / `ItemSold` (Platform Notifications, legacy Trading API) | `ItemMarkedShipped` notification; also `getOrders` filtered by `lastmodifieddate` (Fulfillment API) | `GET /sell/inventory/v1/inventory_item` (getInventoryItems) | `GET /sell/inventory/v1/inventory_item/{sku}` (getInventoryItem) | `GET /sell/inventory/v1/inventory_item/{sku}` (availability.shipToLocationAvailability) | `POST /sell/inventory/v1/bulk_update_price_quantity` (bulkUpdatePriceQuantity) | N/A — no dedicated inventory-change topic found |
|
||||
|
||||
|
||||
|
||||
| Platform | Docs | Webhooks | API |
|
||||
|----------|----------|----------|----------|
|
||||
| Shopify | https://shopify.dev/docs/api | https://shopify.dev/docs/api/webhooks/latest?reference=toml | https://shopify.dev/docs/api/admin-graphql/latest |
|
||||
| WooCommerce | https://developer.woocommerce.com/docs/apis/rest-api/ | https://developer.woocommerce.com/docs/apis/rest-api/v2/webhooks/ | https://developer.woocommerce.com/docs/apis/rest-api/v3/products/ |
|
||||
| BigCommerce | https://developer.bigcommerce.com/docs | https://developer.bigcommerce.com/docs/integrations/webhooks/overview | https://developer.bigcommerce.com/docs/rest-catalog/products |
|
||||
| Wix | https://dev.wix.com/docs | https://dev.wix.com/docs/build-apps/develop-your-app/api-integrations/events-and-webhooks/about-webhooks | https://dev.wix.com/docs/api-reference |
|
||||
| Squarespace | https://developers.squarespace.com/commerce-apis/overview | https://developers.squarespace.com/commerce-apis/webhooksubscriptions-overview | https://developers.squarespace.com/commerce-apis/overview |
|
||||
| Square Online | https://developer.squareup.com/docs | https://developer.squareup.com/docs/webhooks/overview | https://developer.squareup.com/reference/square |
|
||||
| Zoho | https://www.zoho.com/commerce/api/introduction.html | https://www.zoho.com/commerce/api/webhooks.html | https://www.zoho.com/commerce/api/apis-list.html |
|
||||
| Ecwid | https://docs.ecwid.com/ | https://docs.ecwid.com/webhook-automations | https://api-docs.ecwid.com/reference |
|
||||
| Big Cartel | https://developers.bigcartel.com/ | https://developers.bigcartel.com/api/v1 (webhooks section, no standalone page) | https://developers.bigcartel.com/api/v1 |
|
||||
| Amazon | https://developer-docs.amazon.com/sp-api/docs/welcome | https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide | https://developer-docs.amazon.com/sp-api/reference |
|
||||
| Walmart Marketplace | https://developer.walmart.com/ | https://developer.walmart.com/doc/us/mp/us-mp-notifications/ | https://developer.walmart.com/us-marketplace/docs/inventory-api-overview |
|
||||
| Ebay | https://developer.ebay.com/develop | https://developer.ebay.com/api-docs/commerce/notification/overview.html | https://developer.ebay.com/api-docs/sell/inventory/overview.html |
|
||||
|
||||
### Notes / caveats from research
|
||||
|
||||
- **Square Online**: no separate API — orders, catalog, and inventory are handled by Square's core Seller APIs (developer.squareup.com), the same ones used across all Square products. Unrelated to Squarespace despite the name.
|
||||
- **Zoho**: "Zoho Commerce" (commerce.zoho.com) is the storefront product comparable to Shopify/Squarespace and owns the order/product/webhook APIs listed above. Zoho Inventory is a separate warehouse/stock-management app with its own API but no documented webhook support.
|
||||
- **Big Cartel**: no true inventory API — only an `inventory_enabled` flag and `quantity_gte`/`quantity_lte` filters on products. No endpoint to set stock and no inventory-change webhook. Webhook access is gated per-app approval; exact topic names are inferred from integration examples since Big Cartel has no canonical published list.
|
||||
- **Amazon SP-API**: no separate "placed" vs "changed" order topics — both flow through a single `ORDER_CHANGE` notification, differentiated by payload content.
|
||||
- **WooCommerce / Ecwid**: neither has a dedicated inventory-change webhook; stock changes surface via the general `product.updated` event instead.
|
||||
- **Ebay**: order-event names are less certain — developer.ebay.com pages repeatedly failed to load during research, so those values come from documented Platform Notifications event types found via search rather than a directly confirmed doc page.
|
||||
+69
-17
@@ -3,6 +3,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
@@ -25,13 +26,32 @@ type Config struct {
|
||||
// login session for any user_id without going through Auth0. Must
|
||||
// never be true outside local development.
|
||||
DevAuthEnabled bool
|
||||
|
||||
Port int
|
||||
}
|
||||
|
||||
var envFlagPtr = flag.String("env", "", "")
|
||||
|
||||
func init() {
|
||||
flag.Parse()
|
||||
}
|
||||
|
||||
// Load reads a .env file, if present, into the process environment, then
|
||||
// reads the required configuration values from the environment.
|
||||
func Load() (Config, error) {
|
||||
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf("failed to load .env file: %w", err)
|
||||
dev, err := checkEnvFlag()
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
|
||||
if dev {
|
||||
if err := godotenv.Load(".env.dev"); err != nil && !os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf("failed to load .env.dev file: %w", err)
|
||||
}
|
||||
} else {
|
||||
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
|
||||
return Config{}, fmt.Errorf("failed to load .env file: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var (
|
||||
@@ -49,21 +69,8 @@ func Load() (Config, error) {
|
||||
|
||||
cfg.DatabaseURL = required("DATABASE_URL")
|
||||
|
||||
cfg.Auth0Domain = required("AUTH0_DOMAIN")
|
||||
cfg.Auth0ClientID = required("AUTH0_CLIENT_ID")
|
||||
cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET")
|
||||
cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL")
|
||||
|
||||
cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING")
|
||||
cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return Config{}, fmt.Errorf(
|
||||
"missing required environment variables: %v (see .env.example)",
|
||||
missing,
|
||||
)
|
||||
}
|
||||
|
||||
// DEV_AUTH_ENABLED must be known before the Auth0 vars are read below,
|
||||
// since it decides whether those are required at all.
|
||||
if raw := os.Getenv("DEV_AUTH_ENABLED"); raw != "" {
|
||||
devAuthEnabled, err := strconv.ParseBool(raw)
|
||||
if err != nil {
|
||||
@@ -72,5 +79,50 @@ func Load() (Config, error) {
|
||||
cfg.DevAuthEnabled = devAuthEnabled
|
||||
}
|
||||
|
||||
// With DEV_AUTH_ENABLED, real Auth0 login/logout never runs (dev-login
|
||||
// mints sessions locally instead), so these are unused and optional.
|
||||
if cfg.DevAuthEnabled {
|
||||
cfg.Auth0Domain = os.Getenv("AUTH0_DOMAIN")
|
||||
cfg.Auth0ClientID = os.Getenv("AUTH0_CLIENT_ID")
|
||||
cfg.Auth0ClientSecret = os.Getenv("AUTH0_CLIENT_SECRET")
|
||||
cfg.Auth0CallbackURL = os.Getenv("AUTH0_CALLBACK_URL")
|
||||
} else {
|
||||
cfg.Auth0Domain = required("AUTH0_DOMAIN")
|
||||
cfg.Auth0ClientID = required("AUTH0_CLIENT_ID")
|
||||
cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET")
|
||||
cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL")
|
||||
}
|
||||
|
||||
cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING")
|
||||
cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET")
|
||||
|
||||
portStr := required("PORT")
|
||||
|
||||
if len(missing) > 0 {
|
||||
return Config{}, fmt.Errorf(
|
||||
"missing required environment variables: %v (see .env.example)",
|
||||
missing,
|
||||
)
|
||||
}
|
||||
|
||||
if port, err := strconv.Atoi(portStr); err != nil {
|
||||
return Config{}, fmt.Errorf("invalid port number: %w", err)
|
||||
} else {
|
||||
cfg.Port = port
|
||||
}
|
||||
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
func checkEnvFlag() (dev bool, err error) {
|
||||
switch env := *envFlagPtr; env {
|
||||
case "dev":
|
||||
return true, nil
|
||||
case "prd":
|
||||
return false, nil
|
||||
case "":
|
||||
return false, fmt.Errorf("no env flag specified: must be one of dev, prd")
|
||||
default:
|
||||
return false, fmt.Errorf("invalid env flag specified: must be one of dev, prd: %q", env)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,9 +2,11 @@ package accounts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
@@ -21,30 +23,18 @@ func TestCreateAccount(t *testing.T) {
|
||||
email := userID + "@example.com"
|
||||
|
||||
acct, err := store.CreateAccount(ctx, userID, email)
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
if acct.AccountID == 0 {
|
||||
t.Error("CreateAccount() returned a zero AccountID")
|
||||
}
|
||||
if acct.UserID != userID {
|
||||
t.Errorf("CreateAccount() UserID = %q, want %q", acct.UserID, userID)
|
||||
}
|
||||
if acct.Email != email {
|
||||
t.Errorf("CreateAccount() Email = %q, want %q", acct.Email, email)
|
||||
}
|
||||
assert.NotZero(t, acct.AccountID, "CreateAccount() returned a zero AccountID")
|
||||
assert.Equal(t, userID, acct.UserID, "CreateAccount() UserID")
|
||||
assert.Equal(t, email, acct.Email, "CreateAccount() Email")
|
||||
|
||||
got, err := store.GetAccount(ctx, acct.AccountID)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccount() error = %v", err)
|
||||
}
|
||||
if got != acct {
|
||||
t.Errorf("GetAccount() = %+v, want %+v", got, acct)
|
||||
}
|
||||
require.NoError(t, err, "GetAccount()")
|
||||
assert.Equal(t, acct, got, "GetAccount()")
|
||||
}
|
||||
|
||||
func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
|
||||
@@ -56,17 +46,13 @@ func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
|
||||
testdb.SeedOAuthUser(t, pool, userID)
|
||||
|
||||
acct, err := store.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("first CreateAccount() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "first CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
_, err = store.CreateAccount(ctx, userID, userID+"-other@example.com")
|
||||
if !errors.Is(err, consts.ErrConflict) {
|
||||
t.Fatalf("second CreateAccount() error = %v, want %v", err, consts.ErrConflict)
|
||||
}
|
||||
require.ErrorIs(t, err, consts.ErrConflict, "second CreateAccount()")
|
||||
}
|
||||
|
||||
func TestGetAccount_NotFound(t *testing.T) {
|
||||
@@ -75,9 +61,7 @@ func TestGetAccount_NotFound(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := store.GetAccount(ctx, -1)
|
||||
if !errors.Is(err, consts.ErrNotFound) {
|
||||
t.Fatalf("GetAccount() error = %v, want %v", err, consts.ErrNotFound)
|
||||
}
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccount()")
|
||||
}
|
||||
|
||||
func TestGetUserAndAccountByAccessToken(t *testing.T) {
|
||||
@@ -90,34 +74,22 @@ func TestGetUserAndAccountByAccessToken(t *testing.T) {
|
||||
|
||||
// before an account exists: user resolves, account does not.
|
||||
user, acct, err := store.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserAndAccountByAccessToken() before account creation: error = %v", err)
|
||||
}
|
||||
if user.UserID != userID {
|
||||
t.Errorf("GetUserAndAccountByAccessToken() UserID = %q, want %q", user.UserID, userID)
|
||||
}
|
||||
if acct != nil {
|
||||
t.Errorf("GetUserAndAccountByAccessToken() Account = %+v, want nil before an account is created", acct)
|
||||
}
|
||||
require.NoError(t, err, "GetUserAndAccountByAccessToken() before account creation")
|
||||
assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID")
|
||||
assert.Nil(t, acct, "GetUserAndAccountByAccessToken() Account should be nil before an account is created")
|
||||
|
||||
created, err := store.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
if err != nil {
|
||||
t.Fatalf("CreateAccount() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", created.AccountID)
|
||||
})
|
||||
|
||||
// after an account exists: both resolve.
|
||||
user, acct, err = store.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetUserAndAccountByAccessToken() after account creation: error = %v", err)
|
||||
}
|
||||
if user.UserID != userID {
|
||||
t.Errorf("GetUserAndAccountByAccessToken() UserID = %q, want %q", user.UserID, userID)
|
||||
}
|
||||
if acct == nil || acct.AccountID != created.AccountID {
|
||||
t.Errorf("GetUserAndAccountByAccessToken() Account = %+v, want AccountID %d", acct, created.AccountID)
|
||||
require.NoError(t, err, "GetUserAndAccountByAccessToken() after account creation")
|
||||
assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID")
|
||||
if assert.NotNil(t, acct, "GetUserAndAccountByAccessToken() Account should be set after an account is created") {
|
||||
assert.Equal(t, created.AccountID, acct.AccountID, "GetUserAndAccountByAccessToken() Account.AccountID")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,7 +99,5 @@ func TestGetUserAndAccountByAccessToken_UnknownToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, err := store.GetUserAndAccountByAccessToken(ctx, "no-such-token-"+testdb.NewUserID(t))
|
||||
if !errors.Is(err, consts.ErrNotFound) {
|
||||
t.Fatalf("GetUserAndAccountByAccessToken() error = %v, want %v", err, consts.ErrNotFound)
|
||||
}
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetUserAndAccountByAccessToken()")
|
||||
}
|
||||
|
||||
@@ -217,7 +217,11 @@ func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-
|
||||
return fmt.Errorf("error occurred while waiting for the next notification: %w", err)
|
||||
}
|
||||
|
||||
ch <- struct{}{}
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
+99
-152
@@ -8,6 +8,8 @@ import (
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
@@ -70,9 +72,15 @@ func (s *notifySpy) ackAt(t *testing.T, i int) {
|
||||
s.mu.Lock()
|
||||
ack := s.acks[i]
|
||||
s.mu.Unlock()
|
||||
if err := ack(context.Background()); err != nil {
|
||||
t.Fatalf("ack() error = %v", err)
|
||||
}
|
||||
require.NoError(t, ack(context.Background()), "ack()")
|
||||
}
|
||||
|
||||
// completedCountSnapshot is safe to call from another goroutine (e.g. from
|
||||
// inside require.Eventually's condition), unlike helpers that call t.Fatal.
|
||||
func (s *notifySpy) completedCountSnapshot() int {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.completedCount
|
||||
}
|
||||
|
||||
// waitForCount blocks until at least n Notify calls have fully completed
|
||||
@@ -80,20 +88,9 @@ func (s *notifySpy) ackAt(t *testing.T, i int) {
|
||||
// more than once with increasing n.
|
||||
func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
deadline := time.Now().Add(timeout)
|
||||
for {
|
||||
s.mu.Lock()
|
||||
got := s.completedCount
|
||||
s.mu.Unlock()
|
||||
|
||||
if got >= n {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out after %v waiting for %d total completed notifications (got %d so far)", timeout, n, got)
|
||||
}
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
return s.completedCountSnapshot() >= n
|
||||
}, timeout, 5*time.Millisecond, "timed out waiting for %d total completed notifications (got %d)", n, s.completedCountSnapshot())
|
||||
}
|
||||
|
||||
// insertRawAmazonEvent inserts directly into mock.raw_shop_events, the same
|
||||
@@ -109,9 +106,7 @@ func insertRawAmazonEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID stri
|
||||
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||
VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb)
|
||||
`, shopID, eventID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert raw amazon event: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "insert raw amazon event")
|
||||
}
|
||||
|
||||
// insertRawAmazonEvents bulk-inserts n events for shopID in one statement,
|
||||
@@ -123,22 +118,27 @@ func insertRawAmazonEvents(t *testing.T, pool *pgxpool.Pool, shopID string, n in
|
||||
SELECT 'amazon', $1, NOW() + (s || ' milliseconds')::interval, 'evt-' || s, '{}'::jsonb
|
||||
FROM generate_series(1, $2) AS s
|
||||
`, shopID, n)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to insert %d raw amazon events: %v", n, err)
|
||||
}
|
||||
require.NoError(t, err, "insert %d raw amazon events", n)
|
||||
}
|
||||
|
||||
func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||
t.Helper()
|
||||
// queryIsProcessed has no *testing.T dependency, so it's safe to call from
|
||||
// require.Eventually's condition function (which testify runs on a
|
||||
// separate goroutine - t.Fatal/require must only be called from the main
|
||||
// test goroutine). isProcessed wraps it for direct, main-goroutine use.
|
||||
func queryIsProcessed(ctx context.Context, pool *pgxpool.Pool, shopID, eventID string) (bool, error) {
|
||||
var processed bool
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT processed_at IS NOT NULL
|
||||
FROM mock.shop_amazon_events
|
||||
WHERE shop_id = $1 AND event_id = $2
|
||||
`, shopID, eventID).Scan(&processed)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check processed state: %v", err)
|
||||
}
|
||||
return processed, err
|
||||
}
|
||||
|
||||
func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||
t.Helper()
|
||||
processed, err := queryIsProcessed(context.Background(), pool, shopID, eventID)
|
||||
require.NoError(t, err, "check processed state")
|
||||
return processed
|
||||
}
|
||||
|
||||
@@ -152,9 +152,7 @@ func isNotified(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||
FROM mock.shop_amazon_events
|
||||
WHERE shop_id = $1 AND event_id = $2
|
||||
`, shopID, eventID).Scan(¬ified)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to check notified state: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "check notified state")
|
||||
return notified
|
||||
}
|
||||
|
||||
@@ -164,9 +162,7 @@ func countUnprocessed(t *testing.T, pool *pgxpool.Pool, shopID string) int {
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
SELECT count(*) FROM mock.shop_amazon_events WHERE shop_id = $1 AND processed_at IS NULL
|
||||
`, shopID).Scan(&n)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to count unprocessed events: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "count unprocessed events")
|
||||
return n
|
||||
}
|
||||
|
||||
@@ -197,12 +193,33 @@ func terminateListenConnection(t *testing.T, pool *pgxpool.Pool) {
|
||||
ORDER BY backend_start DESC
|
||||
LIMIT 1
|
||||
`, eventChannelName).Scan(&pid)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to find the LISTEN connection's backend pid: %v", err)
|
||||
}
|
||||
require.NoError(t, err, "find the LISTEN connection's backend pid")
|
||||
|
||||
if _, err := pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid); err != nil {
|
||||
t.Fatalf("failed to terminate backend %d: %v", pid, err)
|
||||
_, err = pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid)
|
||||
require.NoError(t, err, "terminate backend %d", pid)
|
||||
}
|
||||
|
||||
// waitReady blocks until m signals it's actively listening, failing the
|
||||
// test if that doesn't happen within timeout.
|
||||
func waitReady(t *testing.T, m *Mocks, timeout time.Duration) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(timeout):
|
||||
require.Fail(t, "ProcessEvents() did not become ready (LISTEN registered)", "within %v", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
// waitStopped blocks until ProcessEvents returns on errCh, asserting it
|
||||
// returns a nil error, failing the test if that doesn't happen within
|
||||
// timeout.
|
||||
func waitStopped(t *testing.T, errCh <-chan error, timeout time.Duration) {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
require.NoError(t, err, "ProcessEvents() after context cancellation")
|
||||
case <-time.After(timeout):
|
||||
require.Fail(t, "ProcessEvents() did not return", "within %v of context cancellation", timeout)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -218,15 +235,12 @@ func TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches(t *testing.T)
|
||||
const n = 150 // exceeds the 100-row LIMIT per batch inside processUnprocessedEvents
|
||||
insertRawAmazonEvents(t, pool, shopID, n)
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() error = %v", err)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
|
||||
|
||||
spy.waitForCount(t, n, 5*time.Second)
|
||||
|
||||
if got := countUnprocessed(t, pool, shopID); got != 0 {
|
||||
t.Errorf("countUnprocessed() = %d, want 0 (all %d events should be processed across multiple 100-row batches)", got, n)
|
||||
}
|
||||
assert.Zero(t, countUnprocessed(t, pool, shopID),
|
||||
"countUnprocessed() should be 0 (all %d events should be processed across multiple 100-row batches)", n)
|
||||
}
|
||||
|
||||
func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) {
|
||||
@@ -239,18 +253,14 @@ func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) {
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() error = %v", err)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
|
||||
|
||||
// with no listener, nothing ever acks - the event is expected to stay
|
||||
// "notified" forever, not silently marked processed.
|
||||
if isProcessed(t, pool, shopID, "evt-1") {
|
||||
t.Error("event was marked processed despite no listener being configured to ack it")
|
||||
}
|
||||
if !isNotified(t, pool, shopID, "evt-1") {
|
||||
t.Error("event should be in the notified state after being handed off with no listener to ack it")
|
||||
}
|
||||
assert.False(t, isProcessed(t, pool, shopID, "evt-1"),
|
||||
"event was marked processed despite no listener being configured to ack it")
|
||||
assert.True(t, isNotified(t, pool, shopID, "evt-1"),
|
||||
"event should be in the notified state after being handed off with no listener to ack it")
|
||||
}
|
||||
|
||||
// TestProcessUnprocessedEvents_RetriesUnackedNotification is the core of
|
||||
@@ -271,47 +281,29 @@ func TestProcessUnprocessedEvents_RetriesUnackedNotification(t *testing.T) {
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() (1st pass) error = %v", err)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (1st pass)")
|
||||
spy.waitForCount(t, 1, 2*time.Second)
|
||||
|
||||
if isProcessed(t, pool, shopID, "evt-1") {
|
||||
t.Fatal("event was marked processed despite the listener never acking")
|
||||
}
|
||||
if !isNotified(t, pool, shopID, "evt-1") {
|
||||
t.Fatal("event should be in the notified state after the first dispatch")
|
||||
}
|
||||
require.False(t, isProcessed(t, pool, shopID, "evt-1"), "event was marked processed despite the listener never acking")
|
||||
require.True(t, isNotified(t, pool, shopID, "evt-1"), "event should be in the notified state after the first dispatch")
|
||||
|
||||
// still within notifyRetryAfter: shouldn't be re-notified yet.
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() (immediate 2nd pass) error = %v", err)
|
||||
}
|
||||
if got := len(spy.eventsSnapshot()); got != 1 {
|
||||
t.Fatalf("listener was notified %d times before notifyRetryAfter elapsed, want 1", got)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (immediate 2nd pass)")
|
||||
require.Len(t, spy.eventsSnapshot(), 1, "listener should not have been notified again before notifyRetryAfter elapsed")
|
||||
|
||||
time.Sleep(60 * time.Millisecond) // past notifyRetryAfter
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() (3rd pass, after retry window) error = %v", err)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (3rd pass, after retry window)")
|
||||
spy.waitForCount(t, 2, 2*time.Second)
|
||||
|
||||
// the listener "finishes" the first notification late, via the ack it
|
||||
// was originally handed - not a fresh one from the retry.
|
||||
spy.ackAt(t, 0)
|
||||
|
||||
if !isProcessed(t, pool, shopID, "evt-1") {
|
||||
t.Fatal("event should be processed once any recorded ack for it is called")
|
||||
}
|
||||
require.True(t, isProcessed(t, pool, shopID, "evt-1"), "event should be processed once any recorded ack for it is called")
|
||||
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
t.Fatalf("processUnprocessedEvents() (4th pass, after ack) error = %v", err)
|
||||
}
|
||||
if got := len(spy.eventsSnapshot()); got != 2 {
|
||||
t.Fatalf("listener was notified again after being acked: got %d calls, want 2", got)
|
||||
}
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (4th pass, after ack)")
|
||||
assert.Len(t, spy.eventsSnapshot(), 2, "listener should not be notified again after being acked")
|
||||
}
|
||||
|
||||
// TestProcessEvents_PollFallbackPicksUpRetryDueEvents proves the poll
|
||||
@@ -341,11 +333,7 @@ func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
|
||||
}
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
@@ -358,14 +346,7 @@ func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
|
||||
spy.waitForCount(t, 2, 3*time.Second)
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not return within 5s of context cancellation")
|
||||
}
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ReactsToNotification drives the actual long-running
|
||||
@@ -389,36 +370,24 @@ func TestProcessEvents_ReactsToNotification(t *testing.T) {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
|
||||
}
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !isProcessed(t, pool, shopID, "evt-1") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire (the 1-minute poll fallback would eventually catch it, but this test intentionally doesn't wait that long)")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
|
||||
return processed
|
||||
}, 5*time.Second, 20*time.Millisecond,
|
||||
"event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire "+
|
||||
"(the 1-minute poll fallback would eventually catch it, but this test intentionally doesn't wait that long)")
|
||||
|
||||
spy.waitForCount(t, 1, 2*time.Second)
|
||||
if got := spy.eventsSnapshot()[0]; got.StoreID != shopID || got.EventID != "evt-1" {
|
||||
t.Errorf("listener notified with %+v, want StoreID=%q EventID=%q", got, shopID, "evt-1")
|
||||
}
|
||||
got := spy.eventsSnapshot()[0]
|
||||
assert.Equal(t, shopID, got.StoreID, "listener notified with unexpected StoreID")
|
||||
assert.Equal(t, "evt-1", got.EventID, "listener notified with unexpected EventID")
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not return within 5s of context cancellation")
|
||||
}
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in
|
||||
@@ -436,21 +405,10 @@ func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
|
||||
}
|
||||
waitReady(t, m, 5*time.Second)
|
||||
cancel()
|
||||
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not return within 5s of context cancellation")
|
||||
}
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's
|
||||
@@ -474,11 +432,7 @@ func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
select {
|
||||
case <-m.Ready():
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not become ready (LISTEN registered) within 5s")
|
||||
}
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
terminateListenConnection(t, pool)
|
||||
|
||||
@@ -486,27 +440,20 @@ func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
|
||||
// backoff is 1s) - it must not have returned because of this.
|
||||
select {
|
||||
case err := <-errCh:
|
||||
t.Fatalf("ProcessEvents() returned (err = %v) after its LISTEN connection was killed, want it to reconnect and keep running", err)
|
||||
require.Fail(t, "ProcessEvents() returned after its LISTEN connection was killed, want it to reconnect and keep running",
|
||||
"err = %v", err)
|
||||
case <-time.After(2 * time.Second):
|
||||
}
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for !isProcessed(t, pool, shopID, "evt-1") {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - reconnection did not restore the reactive path")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
require.Eventually(t, func() bool {
|
||||
processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
|
||||
return processed
|
||||
}, 5*time.Second, 20*time.Millisecond,
|
||||
"event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - "+
|
||||
"reconnection did not restore the reactive path")
|
||||
|
||||
cancel()
|
||||
select {
|
||||
case err := <-errCh:
|
||||
if err != nil {
|
||||
t.Fatalf("ProcessEvents() returned error = %v after context cancellation, want nil", err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("ProcessEvents() did not return within 5s of context cancellation")
|
||||
}
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
@@ -45,7 +45,10 @@ type (
|
||||
}
|
||||
)
|
||||
|
||||
// New instantiates the *Authenticator.
|
||||
// New instantiates an *Authenticator backed by a real Auth0 tenant: it makes
|
||||
// an OIDC discovery call against domain, so login/callback/logout are fully
|
||||
// functional. Use NewDev instead when DEV_AUTH_ENABLED is set and no real
|
||||
// Auth0 app is configured.
|
||||
func New(
|
||||
ctx context.Context,
|
||||
db *pgxpool.Pool,
|
||||
@@ -75,6 +78,18 @@ func New(
|
||||
}, nil
|
||||
}
|
||||
|
||||
// NewDev instantiates an *Authenticator with no real Auth0 tenant behind it:
|
||||
// no OIDC discovery call is made, and Provider/Config are left zero-valued.
|
||||
// Only DevLogin is safe to call on the result - Exchange, VerifyIDToken, and
|
||||
// GetLogoutURL all assume a real Auth0 setup and will misbehave. Only use
|
||||
// this from a route gated on an explicit dev-mode flag.
|
||||
func NewDev(db *pgxpool.Pool, logger *logging.Logger) *Authenticator {
|
||||
return &Authenticator{
|
||||
log: logger,
|
||||
db: db,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error {
|
||||
for {
|
||||
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil {
|
||||
|
||||
@@ -2,10 +2,12 @@ package authentication
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
@@ -22,36 +24,21 @@ func TestDevLogin(t *testing.T) {
|
||||
})
|
||||
|
||||
accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("DevLogin() error = %v", err)
|
||||
}
|
||||
if accessToken == "" {
|
||||
t.Fatal("DevLogin() returned an empty access token")
|
||||
}
|
||||
if !expiration.After(time.Now().Add(30 * 24 * time.Hour)) {
|
||||
t.Errorf("DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration)
|
||||
}
|
||||
require.NoError(t, err, "DevLogin()")
|
||||
require.NotEmpty(t, accessToken, "DevLogin() returned an empty access token")
|
||||
assert.True(t, expiration.After(time.Now().Add(30*24*time.Hour)),
|
||||
"DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration)
|
||||
|
||||
claims, err := auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v", err)
|
||||
}
|
||||
if claims.Name != "Test User" {
|
||||
t.Errorf("claims.Name = %q, want %q", claims.Name, "Test User")
|
||||
}
|
||||
require.NoError(t, err, "GetAccessTokenClaimsAndExpiration()")
|
||||
assert.Equal(t, "Test User", claims.Name, "claims.Name")
|
||||
// Postgres timestamptz has microsecond precision, so the round-tripped
|
||||
// value loses the sub-microsecond portion of Go's nanosecond clock.
|
||||
if diff := claims.Expiration.Sub(expiration); diff > time.Millisecond || diff < -time.Millisecond {
|
||||
t.Errorf("claims.Expiration = %v, want ~%v (diff %v)", claims.Expiration, expiration, diff)
|
||||
}
|
||||
assert.WithinDuration(t, expiration, claims.Expiration, time.Millisecond, "claims.Expiration")
|
||||
|
||||
_, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken)
|
||||
if err != nil {
|
||||
t.Fatalf("getRefreshTokenForAccessToken() error = %v", err)
|
||||
}
|
||||
if tokenType != "dev" {
|
||||
t.Errorf("tokenType = %q, want %q", tokenType, "dev")
|
||||
}
|
||||
require.NoError(t, err, "getRefreshTokenForAccessToken()")
|
||||
assert.Equal(t, "dev", tokenType, "tokenType")
|
||||
}
|
||||
|
||||
// DevLogin should be safe to call more than once for the same user_id -
|
||||
@@ -69,18 +56,12 @@ func TestDevLogin_SameUserIDTwice(t *testing.T) {
|
||||
})
|
||||
|
||||
token1, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("first DevLogin() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "first DevLogin()")
|
||||
|
||||
token2, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
if err != nil {
|
||||
t.Fatalf("second DevLogin() error = %v", err)
|
||||
}
|
||||
require.NoError(t, err, "second DevLogin()")
|
||||
|
||||
if token1 == token2 {
|
||||
t.Fatalf("DevLogin() returned the same access token twice: %q", token1)
|
||||
}
|
||||
assert.NotEqual(t, token1, token2, "DevLogin() returned the same access token twice")
|
||||
}
|
||||
|
||||
func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
|
||||
@@ -89,7 +70,5 @@ func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t))
|
||||
if !errors.Is(err, consts.ErrNotFound) {
|
||||
t.Fatalf("GetAccessTokenClaimsAndExpiration() error = %v, want %v", err, consts.ErrNotFound)
|
||||
}
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccessTokenClaimsAndExpiration()")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
# Etsy API compliance checklist
|
||||
|
||||
Obligations from Etsy's API Licensed Uses and Restrictions. Anything
|
||||
touching `domains/platforms/etsy` or the Etsy-facing UI should keep these
|
||||
satisfied before shipping.
|
||||
|
||||
- [ ] Link directly back to the product information and/or image Content on Etsy, where the Application utilizes product information and/or images.
|
||||
- [ ] Provide a prominently displayed email address on Your Application for third parties to contact You with any questions or issues. You shall respond to such inquiries in a timely manner.
|
||||
- [ ] Use commercially reasonable efforts to provide a terms of service and privacy policy in a visible location on your Application.
|
||||
- [ ] Display item Content or product information and/or images which is more than six (6) hours older than such information is on the Website, and other Etsy Content cannot be more than twenty-four (24) hours older than such Content on the Website.
|
||||
- [ ] Use the API in a manner that exceeds reasonable request volume or constitutes excessive or abusive usage. Users are allocated by default, 10,000 calls per day.
|
||||
- [ ] You shall not use or alter any text, logos, Etsy's Trademarks, Etsy's signature colors, Etsy's layout, or a confusingly similar layout to Etsy's layout in such a way which may suggest endorsement or affiliation by Etsy.
|
||||
- [ ] Any use of the Etsy logo or Etsy's Trademarks must be used in its entirety and must not be altered or used in a misleading way.
|
||||
- [ ] You shall not use a mark which is confusingly similar to Etsy's Trademarks.
|
||||
- [ ] Any use of the Etsy logo or Etsy's Trademarks in Your Application shall be less prominent than the logo or mark that primarily describes the Application and Your use of the Etsy logo shall not imply any endorsement or affiliation by Etsy.
|
||||
- [ ] You may publicize, issue press or blog releases of Your Application only if You state that it was created using the Etsy API and that You in no way imply that Your Application is endorsed or certified by Etsy.
|
||||
- [ ] You must place or display the following notice prominently on Your Application:
|
||||
"The term 'Etsy' is a trademark of Etsy, Inc. This application uses the Etsy API but is not endorsed or certified by Etsy, Inc."
|
||||
- [ ] Immediately report any security deficiencies You discover to Etsy by emailing developer@etsy.com.
|
||||
@@ -7,6 +7,8 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
@@ -39,33 +41,20 @@ func TestSaveAndLoadEventsForStore(t *testing.T) {
|
||||
Payload: json.RawMessage(`{"n":2}`),
|
||||
}
|
||||
|
||||
if err := store.Save(ctx, &older); err != nil {
|
||||
t.Fatalf("Save() older event error = %v", err)
|
||||
}
|
||||
if err := store.Save(ctx, &newer); err != nil {
|
||||
t.Fatalf("Save() newer event error = %v", err)
|
||||
}
|
||||
require.NoError(t, store.Save(ctx, &older), "Save() older event")
|
||||
require.NoError(t, store.Save(ctx, &newer), "Save() newer event")
|
||||
|
||||
got, err := store.LoadEventsForStore(ctx, platform, storeID)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsForStore() error = %v", err)
|
||||
}
|
||||
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("LoadEventsForStore() returned %d events, want 2: %+v", len(got), got)
|
||||
}
|
||||
require.NoError(t, err, "LoadEventsForStore()")
|
||||
require.Len(t, got, 2, "LoadEventsForStore()")
|
||||
|
||||
// ordered event_timestamp DESC - newest first.
|
||||
if got[0].EventID != "evt-2" || got[1].EventID != "evt-1" {
|
||||
t.Errorf("LoadEventsForStore() order = [%s, %s], want [evt-2, evt-1]", got[0].EventID, got[1].EventID)
|
||||
}
|
||||
assert.Equal(t, "evt-2", got[0].EventID, "LoadEventsForStore()[0]")
|
||||
assert.Equal(t, "evt-1", got[1].EventID, "LoadEventsForStore()[1]")
|
||||
|
||||
var payload struct{ N int }
|
||||
if err := json.Unmarshal(got[0].Payload, &payload); err != nil {
|
||||
t.Fatalf("failed to unmarshal LoadEventsForStore()[0].Payload = %s: %v", got[0].Payload, err)
|
||||
}
|
||||
if payload.N != 2 {
|
||||
t.Errorf("LoadEventsForStore()[0].Payload n = %d, want 2", payload.N)
|
||||
}
|
||||
require.NoError(t, json.Unmarshal(got[0].Payload, &payload), "unmarshal LoadEventsForStore()[0].Payload")
|
||||
assert.Equal(t, 2, payload.N, "LoadEventsForStore()[0].Payload n")
|
||||
}
|
||||
|
||||
func TestLoadEventsForStore_NoEvents(t *testing.T) {
|
||||
@@ -74,10 +63,6 @@ func TestLoadEventsForStore_NoEvents(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString())
|
||||
if err != nil {
|
||||
t.Fatalf("LoadEventsForStore() error = %v", err)
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("LoadEventsForStore() = %+v, want empty", got)
|
||||
}
|
||||
require.NoError(t, err, "LoadEventsForStore()")
|
||||
assert.Empty(t, got, "LoadEventsForStore()")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
package reports_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/domains/reports"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
|
||||
// setupEtsyMockShop creates a fresh account and an Etsy mock shop for it,
|
||||
// and registers cleanup for every row it creates, in FK-safe order
|
||||
// (shop_etsy[_events] -> mock.accounts -> accounts; oauth_users cleanup is
|
||||
// handled by testdb.SeedOAuthUser itself).
|
||||
//
|
||||
// Etsy (not Amazon) is deliberately used here: domains/amazon's tests
|
||||
// process every unprocessed row in mock.shop_amazon_events system-wide
|
||||
// (that's correct production behavior for a background worker, not a
|
||||
// bug), so any test that writes Amazon-platform mock events risks being
|
||||
// picked up by domains/amazon's tests when the two packages' test
|
||||
// binaries run concurrently (go test ./... does this by default). Using
|
||||
// a different platform here keeps this package's fixtures completely off
|
||||
// domains/amazon's tables and NOTIFY channel.
|
||||
func setupEtsyMockShop(t *testing.T, pool *pgxpool.Pool, acctStore *accounts.Store) (acctID int64, shopID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
testdb.SeedOAuthUser(t, pool, userID)
|
||||
|
||||
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
id, err := acctStore.CreateMockShop(ctx, acct.AccountID, accounts.Etsy, "Test Shop")
|
||||
require.NoError(t, err, "CreateMockShop()")
|
||||
shopID = id.String()
|
||||
|
||||
t.Cleanup(func() {
|
||||
ctx := context.Background()
|
||||
pool.Exec(ctx, "DELETE FROM mock.shop_etsy_events WHERE shop_id = $1", shopID)
|
||||
pool.Exec(ctx, "DELETE FROM mock.raw_shop_events WHERE shop_id = $1", shopID)
|
||||
pool.Exec(ctx, "DELETE FROM mock.shop_etsy WHERE account_id = $1", acct.AccountID)
|
||||
pool.Exec(ctx, "DELETE FROM mock.accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
return acct.AccountID, shopID
|
||||
}
|
||||
|
||||
func insertRawShopEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string, ts time.Time, payload string) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||
`, string(accounts.Etsy), shopID, ts, eventID, payload)
|
||||
require.NoError(t, err, "insert raw shop event")
|
||||
}
|
||||
|
||||
func TestGetRawShopEvents(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
|
||||
older := time.Now().Add(-time.Hour).UTC()
|
||||
newer := time.Now().UTC()
|
||||
insertRawShopEvent(t, pool, shopID, "evt-1", older, `{"n":1}`)
|
||||
insertRawShopEvent(t, pool, shopID, "evt-2", newer, `{"n":2}`)
|
||||
|
||||
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Etsy, shopID)
|
||||
require.NoError(t, err, "GetRawShopEvents()")
|
||||
require.Len(t, got, 2, "GetRawShopEvents()")
|
||||
|
||||
// ordered event_timestamp DESC, event_id ASC - newest first.
|
||||
assert.Equal(t, "evt-2", got[0].EventID, "GetRawShopEvents()[0]")
|
||||
assert.Equal(t, "evt-1", got[1].EventID, "GetRawShopEvents()[1]")
|
||||
|
||||
assert.Equal(t, accounts.Etsy, got[0].Platform, "got[0].Platform")
|
||||
assert.Equal(t, shopID, got[0].ShopID, "got[0].ShopID")
|
||||
|
||||
var payload struct{ N int }
|
||||
require.NoError(t, json.Unmarshal(got[0].RawPayload, &payload), "unmarshal got[0].RawPayload")
|
||||
assert.Equal(t, 2, payload.N, "got[0].RawPayload n")
|
||||
}
|
||||
|
||||
func TestGetRawShopEvents_NoEvents(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
|
||||
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Etsy, shopID)
|
||||
require.NoError(t, err, "GetRawShopEvents()")
|
||||
assert.Empty(t, got, "GetRawShopEvents()")
|
||||
}
|
||||
|
||||
func TestGetRawShopEvents_UnknownShop(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
testdb.SeedOAuthUser(t, pool, userID)
|
||||
|
||||
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
_, err = reportsStore.GetRawShopEvents(ctx, acct.AccountID, accounts.Etsy, "no-such-shop-"+uuid.NewString())
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetRawShopEvents()")
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
package reports_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/domains/reports"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
|
||||
// createEtsyListing creates a listing with the given base count in shopID
|
||||
// and registers its cleanup. Must be called after setupEtsyMockShop, so
|
||||
// cleanup order (LIFO) deletes the listing before the shop it belongs to.
|
||||
func createEtsyListing(t *testing.T, acctStore *accounts.Store, acctID int64, shopID string, baseCount int64) (listingID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
pool := testdb.Pool(t)
|
||||
|
||||
listing, err := acctStore.CreateMockListing(ctx, accounts.MockListing{
|
||||
AccountShopListingIDs: accounts.AccountShopListingIDs{
|
||||
AccountShopIDs: accounts.AccountShopIDs{
|
||||
AccountIDs: accounts.AccountIDs{AccountID: acctID},
|
||||
Platform: accounts.Etsy,
|
||||
ShopID: shopID,
|
||||
},
|
||||
},
|
||||
SKU: "test-sku",
|
||||
Name: "Test Listing",
|
||||
Description: "a listing created for a test",
|
||||
Count: baseCount,
|
||||
})
|
||||
require.NoError(t, err, "CreateMockListing()")
|
||||
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), `
|
||||
DELETE FROM mock.shop_etsy_listings WHERE shop_id = $1 AND listing_id = $2
|
||||
`, shopID, listing.ListingID)
|
||||
})
|
||||
|
||||
return listing.ListingID
|
||||
}
|
||||
|
||||
// TestGetListingCountsOverTime exercises the actual running-count logic,
|
||||
// which lives in a recursive SQL view (mock.shop_etsy_listing_counts,
|
||||
// built on mock.shop_etsy_listing_event_sequence) rather than in Go: it
|
||||
// starts from the listing's base count and walks mock.raw_shop_events in
|
||||
// order, applying each as a delta (sale/refund) or an absolute reset
|
||||
// (inventory-reset). Uses the real SaveNewMock* methods to write those
|
||||
// events, the same entry points the simulate-sale/refund/inventory UI
|
||||
// uses, rather than hand-rolling the JSON payload shape.
|
||||
func TestGetListingCountsOverTime(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
listingID := createEtsyListing(t, acctStore, acctID, shopID, 100)
|
||||
|
||||
_, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Etsy, shopID, listingID, 10)
|
||||
require.NoError(t, err, "SaveNewMockSale()")
|
||||
_, err = acctStore.SaveNewMockRefund(ctx, acctID, accounts.Etsy, shopID, listingID, 5)
|
||||
require.NoError(t, err, "SaveNewMockRefund()")
|
||||
_, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Etsy, shopID, listingID, 50)
|
||||
require.NoError(t, err, "SaveNewMockInventoryReset()")
|
||||
|
||||
got, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Etsy, shopID, listingID)
|
||||
require.NoError(t, err, "GetListingCountsOverTime()")
|
||||
|
||||
wantCounts := []int64{100, 110, 105, 50}
|
||||
require.Len(t, got, len(wantCounts), "GetListingCountsOverTime()")
|
||||
|
||||
gotCounts := make([]int64, len(got))
|
||||
for i, row := range got {
|
||||
gotCounts[i] = row.Count
|
||||
}
|
||||
assert.Equal(t, wantCounts, gotCounts, "GetListingCountsOverTime() counts, full sequence: %+v", got)
|
||||
|
||||
assert.Nil(t, got[0].EventTimestamp, "got[0].EventTimestamp should be nil (the base count row)")
|
||||
for i := 1; i < len(got); i++ {
|
||||
assert.NotNil(t, got[i].EventTimestamp, "got[%d].EventTimestamp should be set (only the base row should be nil)", i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetListingCountsOverTime_UnknownListing(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
|
||||
_, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Etsy, shopID, "no-such-listing")
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetListingCountsOverTime()")
|
||||
}
|
||||
|
||||
func TestGetListingCountsReport(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||
ctx := context.Background()
|
||||
|
||||
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||
listingID := createEtsyListing(t, acctStore, acctID, shopID, 100)
|
||||
|
||||
_, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Etsy, shopID, listingID, 10)
|
||||
require.NoError(t, err, "SaveNewMockSale()")
|
||||
_, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Etsy, shopID, listingID, 20)
|
||||
require.NoError(t, err, "SaveNewMockInventoryReset()")
|
||||
|
||||
report, err := reportsStore.GetListingCountsReport(ctx, acctID, accounts.Etsy, shopID, listingID)
|
||||
require.NoError(t, err, "GetListingCountsReport()")
|
||||
|
||||
assert.Equal(t, acctID, report.AccountID, "report.AccountID")
|
||||
assert.Equal(t, accounts.Etsy, report.Platform, "report.Platform")
|
||||
assert.Equal(t, shopID, report.ShopID, "report.ShopID")
|
||||
assert.Equal(t, listingID, report.ListingID, "report.ListingID")
|
||||
|
||||
// counts over the sequence: 100 (base) -> 110 (sale +10) -> 20 (reset)
|
||||
assert.Equal(t, int64(110), report.MaxCount().Count, "MaxCount().Count")
|
||||
assert.Equal(t, int64(20), report.MinCount().Count, "MinCount().Count")
|
||||
}
|
||||
@@ -14,6 +14,7 @@ require (
|
||||
github.com/joho/godotenv v1.5.1
|
||||
github.com/lmittmann/tint v1.1.2
|
||||
github.com/oapi-codegen/runtime v1.1.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
golang.org/x/oauth2 v0.34.0
|
||||
)
|
||||
|
||||
@@ -28,6 +29,7 @@ require (
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/cloudwego/base64x v0.1.6 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
||||
@@ -60,6 +62,7 @@ require (
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||
github.com/pkg/errors v0.9.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/quic-go/qpack v0.6.0 // indirect
|
||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
|
||||
|
||||
@@ -78,17 +78,22 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
||||
return fmt.Errorf("failed to initialize database connection pool: %w", err)
|
||||
}
|
||||
|
||||
auth, err := authentication.New(
|
||||
ctx,
|
||||
connPool,
|
||||
logger.WithGroup("authenticator"),
|
||||
cfg.Auth0Domain,
|
||||
cfg.Auth0ClientID,
|
||||
cfg.Auth0ClientSecret,
|
||||
cfg.Auth0CallbackURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to construct authenticator: %w", err)
|
||||
var auth *authentication.Authenticator
|
||||
if cfg.DevAuthEnabled {
|
||||
auth = authentication.NewDev(connPool, logger.WithGroup("authenticator"))
|
||||
} else {
|
||||
auth, err = authentication.New(
|
||||
ctx,
|
||||
connPool,
|
||||
logger.WithGroup("authenticator"),
|
||||
cfg.Auth0Domain,
|
||||
cfg.Auth0ClientID,
|
||||
cfg.Auth0ClientSecret,
|
||||
cfg.Auth0CallbackURL,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to construct authenticator: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
accts := accounts.NewStore(logger.WithGroup("accounts"), connPool)
|
||||
@@ -240,7 +245,7 @@ func runServer(
|
||||
)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8082", // local
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port), // local
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
@@ -253,7 +258,7 @@ func runServer(
|
||||
defer cancel()
|
||||
defer close(alreadyShutdownCh)
|
||||
|
||||
logger.Info("server running on 8082...")
|
||||
logger.Infof("server running on %d...", cfg.Port)
|
||||
if err := srv.ListenAndServe(); err != nil {
|
||||
if !errors.Is(err, http.ErrServerClosed) {
|
||||
runningErrCh <- fmt.Errorf("server experienced error: %w", err)
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
# Platform Research
|
||||
|
||||
https://docs.google.com/spreadsheets/d/1xWfXn-wbiHTBeqyhnq0b46_sgLGyiYc_5N5mXKYD1R8/edit?gid=0#gid=0
|
||||
|
||||
|
||||
|
||||
| Platform | Order Event: placed | Order Event: changed | Products List | Products Look up | Inventory Look up | Inventory Update | Inventory Event: change |
|
||||
|----------|----------|----------|----------|----------|----------|----------|----------|
|
||||
| **Etsy (live)** | N/A — no webhook/push system in API v3 at all; poll `GET /v3/application/shops/{shop_id}/receipts` (filter `min_created`) | poll same endpoint filtered by `min_last_modified` | `GET /v3/application/shops/{shop_id}/listings` | `GET /v3/application/listings/{listing_id}` | `GET /v3/application/listings/{listing_id}/inventory` | `PUT /v3/application/listings/{listing_id}/inventory` | N/A — no inventory-change webhook; poll the inventory endpoint |
|
||||
| Shopify | orders/create | orders/cancelled, orders/delete, orders/paid, etc | /queries/products | /queries/product | /queries/product | /mutations/inventorySetQuantities | inventory_levels/update |
|
||||
| WooCommerce | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /wp-json/wc/v3/products` | `GET /wp-json/wc/v3/products/<id>` | `stock_quantity` field on product resource | `PUT /wp-json/wc/v3/products/<id>` (stock_quantity), or `/products/batch` | `product.updated` (no dedicated inventory webhook) |
|
||||
| BigCommerce | webhook `store/order/created` | webhook `store/order/updated`, `store/order/statusUpdated` | `GET /v3/catalog/products` | `GET /v3/catalog/products/{product_id}` | `GET /v3/inventory/items` | `PUT /v3/inventory/adjustments/absolute` (also `/relative`) | `store/product/inventory/updated` |
|
||||
| Wix | webhook `wix.ecom.v1.order.created` | webhook `wix.ecom.v1.order.updated` (also `.canceled`) | Query Products (Catalog V3) | Get Product | Query Inventory Items | Update Inventory Variants | `wix.stores.catalog.v3.inventory_item.updated` |
|
||||
| Squarespace | webhook `order.create` | webhook `order.update` (FULFILLED, REFUNDED, CANCELED, MARKED_PENDING, EMAIL_UPDATED) | `GET /v2/commerce/products` | `GET /v2/commerce/products/{productIdCsvs}` | `GET /1.0/commerce/inventory/{variantIdCsvs}` | `POST /1.0/commerce/inventory/adjustments` | N/A (no inventory webhook topic) |
|
||||
| Square Online | webhook `order.created` | webhook `order.updated`, `order.fulfillment.updated` | `GET /v2/catalog/list` | `GET /v2/catalog/object/{object_id}` | `POST /v2/inventory/counts/batch-retrieve` | `POST /v2/inventory/changes/batch-create` (BatchChangeInventory) | webhook `inventory.count.updated` |
|
||||
| Zoho | webhook `salesorder.created` (Zoho Commerce) | webhook `salesorder.confirmed, .cancelled, .declined, .shipped, .delivered` | `GET /store/api/v1/products` | `GET /store/api/v1/products/{product_id}` | `GET /store/api/v1/variants` (`stock_on_hand`, `actual_available_stock`) | `POST /store/api/v1/inventoryadjustments` | N/A (no inventory/stock webhook event) |
|
||||
| Ecwid | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /api/v3/{storeId}/products` | `GET /api/v3/{storeId}/products/{productId}` | `GET /api/v3/{storeId}/products/{productId}` (`quantity`/`unlimited`) | `PUT /api/v3/{storeId}/products/{productId}/inventory` (`quantityDelta`) | `product.updated` webhook |
|
||||
| Big Cartel | webhook `order.create` (app-approved) | webhook `order.update` (app-approved) | `GET /v1/accounts/{account_id}/products` | `GET /v1/accounts/{account_id}/products/{id}` | N/A — no dedicated inventory field/endpoint | N/A — no inventory update endpoint | N/A — no inventory-specific webhook |
|
||||
| Amazon | `ORDER_CHANGE` notification (SP-API) | `ORDER_CHANGE` notification (same type, status delta) | `searchCatalogItems` (GET `/catalog/2022-04-01/items`) | `getCatalogItem` (GET `/catalog/2022-04-01/items/{asin}`) | `getInventorySummaries` (FBA Inventory API, GET `/fba/inventory/v1/summaries`) | `patchListingsItem` (PATCH `/listings/2021-08-01/items/{sellerId}/{sku}`) | `FBA_INVENTORY_AVAILABILITY_CHANGES` notification |
|
||||
| Walmart Marketplace | PO created event (webhook) | Order intent to cancel / PO line auto-cancelled event (webhook); status flow Created→Acknowledged→Shipped→Delivered/Cancelled | `GET /v3/items` (getAllItems) | `GET /v3/items/{id}` (getAnItem) | `GET /v3/inventory?sku={sku}` | `PUT /v3/inventory` (also bulk via `POST /v3/feeds`) | Inventory OOS event (webhook) |
|
||||
| Ebay | `FixedPriceTransaction` / `ItemSold` (Platform Notifications, legacy Trading API) | `ItemMarkedShipped` notification; also `getOrders` filtered by `lastmodifieddate` (Fulfillment API) | `GET /sell/inventory/v1/inventory_item` (getInventoryItems) | `GET /sell/inventory/v1/inventory_item/{sku}` (getInventoryItem) | `GET /sell/inventory/v1/inventory_item/{sku}` (availability.shipToLocationAvailability) | `POST /sell/inventory/v1/bulk_update_price_quantity` (bulkUpdatePriceQuantity) | N/A — no dedicated inventory-change topic found |
|
||||
|
||||
|
||||
|
||||
| Platform | Docs | Webhooks | API |
|
||||
|----------|----------|----------|----------|
|
||||
| Etsy (live) | https://developer.etsy.com/documentation/ | N/A — no webhook/push mechanism exists in Etsy Open API v3 | https://developer.etsy.com/documentation/reference (confirmed directly against `domains/platforms/etsy/generated_client` in this repo, not external docs) |
|
||||
| Shopify | https://shopify.dev/docs/api | https://shopify.dev/docs/api/webhooks/latest?reference=toml | https://shopify.dev/docs/api/admin-graphql/latest |
|
||||
| WooCommerce | https://developer.woocommerce.com/docs/apis/rest-api/ | https://developer.woocommerce.com/docs/apis/rest-api/v2/webhooks/ | https://developer.woocommerce.com/docs/apis/rest-api/v3/products/ |
|
||||
| BigCommerce | https://developer.bigcommerce.com/docs | https://developer.bigcommerce.com/docs/integrations/webhooks/overview | https://developer.bigcommerce.com/docs/rest-catalog/products |
|
||||
| Wix | https://dev.wix.com/docs | https://dev.wix.com/docs/build-apps/develop-your-app/api-integrations/events-and-webhooks/about-webhooks | https://dev.wix.com/docs/api-reference |
|
||||
| Squarespace | https://developers.squarespace.com/commerce-apis/overview | https://developers.squarespace.com/commerce-apis/webhooksubscriptions-overview | https://developers.squarespace.com/commerce-apis/overview |
|
||||
| Square Online | https://developer.squareup.com/docs | https://developer.squareup.com/docs/webhooks/overview | https://developer.squareup.com/reference/square |
|
||||
| Zoho | https://www.zoho.com/commerce/api/introduction.html | https://www.zoho.com/commerce/api/webhooks.html | https://www.zoho.com/commerce/api/apis-list.html |
|
||||
| Ecwid | https://docs.ecwid.com/ | https://docs.ecwid.com/webhook-automations | https://api-docs.ecwid.com/reference |
|
||||
| Big Cartel | https://developers.bigcartel.com/ | https://developers.bigcartel.com/api/v1 (webhooks section, no standalone page) | https://developers.bigcartel.com/api/v1 |
|
||||
| Amazon | https://developer-docs.amazon.com/sp-api/docs/welcome | https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide | https://developer-docs.amazon.com/sp-api/reference |
|
||||
| Walmart Marketplace | https://developer.walmart.com/ | https://developer.walmart.com/doc/us/mp/us-mp-notifications/ | https://developer.walmart.com/us-marketplace/docs/inventory-api-overview |
|
||||
| Ebay | https://developer.ebay.com/develop | https://developer.ebay.com/api-docs/commerce/notification/overview.html | https://developer.ebay.com/api-docs/sell/inventory/overview.html |
|
||||
|
||||
### Notes / caveats from research
|
||||
|
||||
- **Square Online**: no separate API — orders, catalog, and inventory are handled by Square's core Seller APIs (developer.squareup.com), the same ones used across all Square products. Unrelated to Squarespace despite the name.
|
||||
- **Zoho**: "Zoho Commerce" (commerce.zoho.com) is the storefront product comparable to Shopify/Squarespace and owns the order/product/webhook APIs listed above. Zoho Inventory is a separate warehouse/stock-management app with its own API but no documented webhook support.
|
||||
- **Big Cartel**: no true inventory API — only an `inventory_enabled` flag and `quantity_gte`/`quantity_lte` filters on products. No endpoint to set stock and no inventory-change webhook. Webhook access is gated per-app approval; exact topic names are inferred from integration examples since Big Cartel has no canonical published list.
|
||||
- **Amazon SP-API**: no separate "placed" vs "changed" order topics — both flow through a single `ORDER_CHANGE` notification, differentiated by payload content.
|
||||
- **WooCommerce / Ecwid**: neither has a dedicated inventory-change webhook; stock changes surface via the general `product.updated` event instead.
|
||||
- **Etsy**: the one row in the first table sourced from this repo's actual code rather than external docs (`domains/platforms/etsy/generated_client`, generated from Etsy's own OpenAPI spec) - so it's the most reliable row here, not the least. Etsy Open API v3 has no webhook/push system whatsoever for anything, order or inventory alike; every other platform in the table has at least *some* real-time push. The live integration in this repo currently only implements the OAuth connection flow (`GenerateConnectionURLForNewAccount`/`HandleNewAuthCode` in `domains/platforms/etsy/etsy.go`) - polling-based order/inventory sync against `GetShopReceipts`/`GetListingInventory` isn't built yet.
|
||||
- **Ebay**: order-event names are less certain — developer.ebay.com pages repeatedly failed to load during research, so those values come from documented Platform Notifications event types found via search rather than a directly confirmed doc page.
|
||||
- **TikTok Shop**: not in the capability table above - `partner.tiktokshop.com/docv2` is JS-rendered and didn't return usable content via fetch, so exact webhook event names and endpoint paths aren't confirmed (unlike every other row in that table, which comes from readable docs). What is confirmed via secondary sources: TikTok Shop's Partner API has webhooks covering order, product, and inventory changes, and a Product/Inventory API for listing and stock management. Treat as directionally real but needing its own dedicated research pass - with confirmed endpoint names - before implementation.
|
||||
- **BigCommerce vs Tiktok**: the capability table above includes BigCommerce, but BigCommerce is not one of this codebase's actual mock platforms (see `AGENTS.md`'s platform list - `domains/accounts/platform.go`'s `allPlatforms` has `Tiktok`, not BigCommerce). BigCommerce's row is left in place since the research itself may still be useful, but it's excluded from the priority ranking below; Tiktok is included despite the weaker sourcing noted above, since it's a platform that actually exists in this codebase.
|
||||
|
||||
## Market-size research (2026-08-20)
|
||||
|
||||
Pulled to sanity-check which platforms are most worth integrating first, on
|
||||
top of the API-completeness comparison above. See `AGENTS.md`'s "Platform
|
||||
integration priority" section for the resulting ranking and reasoning.
|
||||
|
||||
Figures are mid-2026 estimates for FY2025 (or most recent trailing period)
|
||||
unless noted. Anything not sourced from an SEC filing is a third-party
|
||||
estimate (StoreLeads, DemandSage, Marketplace Pulse, etc.) and should be
|
||||
treated as directional, not precise - methodology varies a lot between
|
||||
sources, especially for "number of stores/sellers."
|
||||
|
||||
| Platform | Active sellers/stores | Most recent GMV | Source confidence |
|
||||
|---|---|---|---|
|
||||
| Amazon (3P) | ~1.9M active sellers | ~$575B (3P GMV, 2025); total Amazon GMV >$800B | Moderate - earnings-adjacent estimates |
|
||||
| Shopify | ~3-6.8M active stores (methodology varies) | $378.4B (FY2025, +29% YoY) | High - SEC filings |
|
||||
| eBay | ~18.3M active sellers | $79.6B (FY2025, +7% YoY) | High - SEC filings |
|
||||
| Etsy (already live) | 5.6M active sellers | $11.92B GMS (FY2025, -5.3%) | High - SEC filings |
|
||||
| Squarespace Commerce | ~353K live ecommerce sites | ~$7.2B (2026 est.) | Low - marketing estimate |
|
||||
| WooCommerce | ~4.2-6M active stores | ~$30-35B (2025 est.; average store is tiny, ~$7-8K/yr) | Low - third-party estimate, no central ledger |
|
||||
| Walmart Marketplace | ~200-250K sellers, +50% YoY growth | ~$10B (rough external estimate; Walmart doesn't break this out cleanly) | Low |
|
||||
| Wix eCommerce | ~760K-3M live stores | $4.1B-$12.4B (conflicting self-reported figures) | Low |
|
||||
| Tiktok Shop | ~15M sellers globally, ~500K registered / ~216K active in the US | $64.3B global (2025, nearly 2x 2024); $15.1B US (+68% YoY) | Low - third-party estimate, hypergrowth market |
|
||||
| BigCommerce *(not an actual mock platform here - see caveat above)* | ~37-42K *active* stores (shrinking, shifting to enterprise) | $34B+ | Moderate |
|
||||
| Square Online | n/a (bundled into Square's overall $250B GPV, mostly in-person POS) | Can't isolate | N/A |
|
||||
| Zoho Commerce | ~2,196 stores globally (285 US), +34% YoY US growth off a tiny base | Not disclosed; likely small given store count | Low |
|
||||
| Ecwid | ~130-164K live stores, declining (-20% YoY) | Not disclosed | Low |
|
||||
| Big Cartel | ~91-192K stores (estimates vary widely), declining sharply (-41% YoY in 2026 Q1) | Not disclosed; platform targets low-AOV indie sellers | Low |
|
||||
|
||||
Key takeaways:
|
||||
|
||||
- Amazon's 3P GMV ($575B) is ~1.5x Shopify's *total* GMV despite Shopify
|
||||
having 2-3x more active stores - Amazon sellers skew toward larger,
|
||||
more serious operations.
|
||||
- eBay has by far the most sellers (18.3M) but the lowest GMV-per-seller of
|
||||
any major platform here ($79.6B / 18.3M), suggesting a lot of integration
|
||||
surface for comparatively thin per-seller value - compounded by its weak
|
||||
inventory-webhook story (see table above).
|
||||
- WooCommerce can't be sized financially at all - it's a WordPress plugin,
|
||||
not a company with a ledger - so its case rests entirely on raw store
|
||||
count, not proven revenue-per-integration.
|
||||
- Walmart Marketplace is small in absolute terms but growing fast (+50%
|
||||
YoY) and is a natural "second marketplace" for sellers already on Amazon.
|
||||
|
||||
## Weighted priority ranking (2026-08-20)
|
||||
|
||||
Scores each of the 12 not-yet-live mock platforms (everything in
|
||||
`domains/accounts/platform.go`'s `allPlatforms` except Etsy) on four 0-10
|
||||
criteria, weighted and summed to a single composite score. This is a
|
||||
judgment call turned into numbers, not a precise formula - the weights and
|
||||
per-platform scores below are my read of the research above; treat the
|
||||
*ranking* as the useful output, not the second decimal place. BigCommerce is
|
||||
excluded (not an actual platform in this codebase, see caveat above).
|
||||
|
||||
Etsy itself is scored too, as a **reference row only** - it's already the
|
||||
live integration, so it's not competing for "what to build next," but
|
||||
running it through the same rubric is a useful sanity check on the model.
|
||||
|
||||
**Criteria & weights:**
|
||||
|
||||
- **GMV / market opportunity (45%)** - bucketed from the GMV figures above
|
||||
(>$500B=10, $300-500B=9, $50-100B=7, $25-50B=6, $8-15B=4, $5-8B=3,
|
||||
undisclosed-and-small=1). This is weighted highest because "lucrative"
|
||||
is fundamentally a dollar-opportunity question.
|
||||
- **API/inventory-sync completeness (30%)** - from the capability table at
|
||||
the top of this doc: full order-webhook + dedicated inventory-webhook
|
||||
coverage scores highest, missing inventory webhook scores mid, no
|
||||
inventory API at all (Big Cartel) scores near-zero. This directly gates
|
||||
how good a product experience is even possible on that platform.
|
||||
- **Growth trajectory (15%)** - YoY GMV/store growth; rewards fast-growing
|
||||
platforms (Tiktok, Walmart) and penalizes shrinking ones (Ecwid, Big
|
||||
Cartel) as a proxy for where future opportunity is heading.
|
||||
- **Integration cost, inverted (10%)** - auth complexity and existing
|
||||
codebase head start (Amazon's `domains/amazon` background-processor
|
||||
infra) score higher; gated/undocumented webhook access (Big Cartel)
|
||||
scores lowest. Weighted lowest since it affects timeline more than
|
||||
whether the integration is worth doing at all.
|
||||
|
||||
**Scores:**
|
||||
|
||||
| Platform | GMV (45%) | API (30%) | Growth (15%) | Cost (10%) | Weighted total |
|
||||
|---|---|---|---|---|---|
|
||||
| Shopify | 9 | 10 | 8 | 9 | **9.15** |
|
||||
| Amazon | 10 | 10 | 5 | 6 | **8.85** |
|
||||
| Tiktok Shop | 7 | 7 | 10 | 4 | **7.15** |
|
||||
| Walmart Marketplace | 4 | 9 | 9 | 5 | **6.35** |
|
||||
| Wix | 4 | 10 | 5 | 8 | **6.35** |
|
||||
| WooCommerce | 6 | 6 | 5 | 7 | **5.95** |
|
||||
| Square Online | 3 | 10 | 5 | 8 | **5.90** |
|
||||
| Ebay | 7 | 5 | 4 | 5 | **5.75** |
|
||||
| Squarespace | 3 | 6 | 6 | 7 | **4.75** |
|
||||
| *Etsy (reference, already live)* | *4* | *3* | *1* | *10* | *3.85* |
|
||||
| Zoho | 1 | 6 | 6 | 7 | **3.85** |
|
||||
| Ecwid | 1 | 6 | 1 | 8 | **3.20** |
|
||||
| Big Cartel | 1 | 2 | 1 | 3 | **1.50** |
|
||||
|
||||
**Ranked priority order:**
|
||||
|
||||
1. **Shopify** — 9.15
|
||||
2. **Amazon** — 8.85
|
||||
3. **Tiktok Shop** — 7.15
|
||||
4. **Walmart Marketplace** — 6.35
|
||||
5. **Wix** — 6.35 (behind Walmart on the growth tiebreaker: 9 vs 5)
|
||||
6. **WooCommerce** — 5.95
|
||||
7. **Square Online** — 5.90
|
||||
8. **Ebay** — 5.75
|
||||
9. **Squarespace** — 4.75
|
||||
10. **Etsy** — 3.85 (reference only - already live, not competing for "what to build next")
|
||||
11. **Zoho** — 3.85
|
||||
12. **Ecwid** — 3.20
|
||||
13. **Big Cartel** — 1.50
|
||||
|
||||
Notable movement from the earlier qualitative pass: Shopify edges out Amazon
|
||||
once integration cost and growth are counted, not just raw GMV - Amazon's
|
||||
dollar opportunity is still bigger, but Shopify is cheaper to build, easier
|
||||
to get first customers through (App Store), and still growing faster.
|
||||
Tiktok Shop, unresearched until this pass, lands at #3 on the strength of
|
||||
its growth rate alone - but see the sourcing caveat above before acting on
|
||||
that; its API details need a dedicated research pass before it's actually
|
||||
buildable. Big Cartel is unambiguously last: no inventory API is a
|
||||
structural dealbreaker for this specific product, independent of its market
|
||||
size.
|
||||
|
||||
**Etsy, run through the same rubric, scores 3.85 - tied with Zoho, below
|
||||
every platform except Ecwid and Big Cartel.** Its GMV is mid-pack and
|
||||
declining (-5.3% YoY), and - per the caveat above - it's the *only*
|
||||
platform researched with literally no webhook/push mechanism for anything,
|
||||
so it scores below every platform here except Big Cartel on API
|
||||
completeness too. The only criterion where it dominates is integration cost
|
||||
(10/10, since it's already built). This is a useful gut-check on the model,
|
||||
not a claim that building Etsy first was a mistake - Etsy was presumably
|
||||
chosen for reasons this rubric doesn't capture (an existing relationship, a
|
||||
founder's market knowledge, being the most approachable API to get
|
||||
developer credentials for), not for having the best growth/GMV/webhook
|
||||
profile. Worth remembering when weighing this ranking against Etsy's actual
|
||||
day-to-day integration cost, which - per the caveat above - has turned out
|
||||
to be nontrivial in practice: with no webhooks at all, real order/inventory
|
||||
sync has to be built as a polling loop, which is exactly the kind of
|
||||
integration cost this rubric's "Cost" column doesn't capture once a
|
||||
platform is more than superficially wired up.
|
||||
|
||||
Sources:
|
||||
- [Shopify Statistics 2026: Market Share, $116B GMV, Employees](https://www.chargeflow.io/blog/shopify-statistics)
|
||||
- [Shopify 2026: $378.4B GMV, Store and Seller Data](https://termsandconditionstemplate.com/shopify-statistics-2026)
|
||||
- [Amazon Third-Party Sellers Generate $575 Billion in GMV](https://english.ebrun.com/20260719/688440.shtml)
|
||||
- [Amazon GMV Surpassed $800 Billion in 2025 - Marketplace Pulse](https://www.marketplacepulse.com/articles/amazon-gmv-surpassed-800-billion-in-2025)
|
||||
- [Top 1.6% of Sellers Drive 50% of Amazon's 3P GMV](https://www.marketplacepulse.com/articles/top-16-of-sellers-drive-50-of-amazons-3p-gmv)
|
||||
- [WooCommerce Market Share 2026: 33.4% Global Stats](https://redstagfulfillment.com/what-is-woocommerces-market-share/)
|
||||
- [BigCommerce Statistics 2026](https://www.chargeflow.io/blog/bigcommerce-statistics)
|
||||
- [How many Walmart Marketplace sellers are there in 2025?](https://redstagfulfillment.com/how-many-walmart-marketplace-sellers/)
|
||||
- [Walmart Marketplace Grows 50% in One Year - Marketplace Pulse](https://www.marketplacepulse.com/articles/walmart-marketplace-grows-50-in-one-year)
|
||||
- [eBay Gross Merchandise Volume (GMV) 2018-2026 - Marketplace Pulse](https://www.marketplacepulse.com/stats/ebay-gross-merchandise-volume-gmv)
|
||||
- [eBay Statistics (2026): Active Buyers, Listings, GMV, Revenue](https://expandedramblings.com/index.php/ebay-stats/)
|
||||
- [The State of Wix in 2026](https://storeleads.app/reports/wix)
|
||||
- [Squarespace Subscriber and Revenue Statistics for 2026](https://backlinko.com/squarespace-users)
|
||||
- [Etsy, Inc. Reports Fourth Quarter and Full Year 2025 Results](https://investors.etsy.com/news-events/press-releases/detail/218/etsy-inc-reports-fourth-quarter-and-full-year-2025-results)
|
||||
- [Block Statistics (2026): Sellers, GPV, Block Revenue](https://expandedramblings.com/index.php/square-statistics/)
|
||||
@@ -35,6 +35,7 @@ func Routes(
|
||||
if devAuthEnabled {
|
||||
logger.Warn("DEV_AUTH_ENABLED is set: /api/auth/dev-login is live and lets any caller authenticate as any user_id with no credentials. Never enable this outside local development.")
|
||||
r.GET("/dev-login", response.Handler(ls.devLoginPage))
|
||||
r.GET("/dev-logout", response.Handler(ls.devLogoutPage))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,3 +136,21 @@ func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
|
||||
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
|
||||
func (s *loginSubrouter) devLogoutPage(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
host := r.Header.Get("X-Forwarded-Host")
|
||||
if host == "" {
|
||||
host = r.Host
|
||||
}
|
||||
|
||||
if ck, err := r.Cookie("access_token"); err == nil && ck != nil {
|
||||
if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil {
|
||||
s.log.Error("failed to delete auth token", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect("/ui").
|
||||
Cookie(cookies.Expired("access_token")), nil
|
||||
}
|
||||
|
||||
@@ -65,6 +65,7 @@ func NewRouter(
|
||||
reps,
|
||||
etsy,
|
||||
authM.Authenticate(),
|
||||
devAuthEnabled,
|
||||
)
|
||||
|
||||
// non-html content: scripts, styles, images, etc
|
||||
|
||||
+19
-11
@@ -24,13 +24,14 @@ import (
|
||||
|
||||
type (
|
||||
webpageRouter struct {
|
||||
log *logging.Logger
|
||||
uiPath string
|
||||
templater *templater.Templater
|
||||
rawEvents *raw_events.Store
|
||||
accts *accounts.Store
|
||||
reports *reports.Store
|
||||
etsy *etsy_platform.Platform
|
||||
log *logging.Logger
|
||||
uiPath string
|
||||
templater *templater.Templater
|
||||
rawEvents *raw_events.Store
|
||||
accts *accounts.Store
|
||||
reports *reports.Store
|
||||
etsy *etsy_platform.Platform
|
||||
devAuthEnabled bool
|
||||
}
|
||||
|
||||
// ErrTemplateNotFound is returned if the reason the template failed to compile
|
||||
@@ -49,6 +50,7 @@ func Routes(
|
||||
reps *reports.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
authenticate gin.HandlerFunc,
|
||||
devAuthEnabled bool,
|
||||
) {
|
||||
|
||||
s := &webpageRouter{
|
||||
@@ -177,10 +179,11 @@ func Routes(
|
||||
}
|
||||
},
|
||||
}),
|
||||
rawEvents: rawEvents,
|
||||
accts: accts,
|
||||
reports: reps,
|
||||
etsy: etsy,
|
||||
rawEvents: rawEvents,
|
||||
accts: accts,
|
||||
reports: reps,
|
||||
etsy: etsy,
|
||||
devAuthEnabled: devAuthEnabled,
|
||||
}
|
||||
|
||||
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate))
|
||||
@@ -219,6 +222,11 @@ func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error)
|
||||
args := []any{
|
||||
"Request",
|
||||
r,
|
||||
|
||||
// dev mode
|
||||
"DevAuthEnabled",
|
||||
s.devAuthEnabled,
|
||||
|
||||
// add services and data here
|
||||
"RawEvents",
|
||||
s.rawEvents.WithContext(ctx),
|
||||
|
||||
+106
-10
@@ -18,6 +18,7 @@
|
||||
--text-xl--line-height: calc(1.75 / 1.25);
|
||||
--text-3xl: 1.875rem;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-6xl--line-height: 1;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--radius-lg: var(--radius);
|
||||
@@ -179,9 +180,18 @@
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.invisible {
|
||||
visibility: hidden;
|
||||
}
|
||||
.visible {
|
||||
visibility: visible;
|
||||
}
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
@@ -319,6 +329,9 @@
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline {
|
||||
display: inline;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
@@ -585,6 +598,10 @@
|
||||
.font-display {
|
||||
font-family: var(--display-family);
|
||||
}
|
||||
.text-6xl {
|
||||
font-size: var(--text-6xl);
|
||||
line-height: var(--tw-leading, var(--text-6xl--line-height));
|
||||
}
|
||||
.text-lg {
|
||||
font-size: var(--text-lg);
|
||||
line-height: var(--tw-leading, var(--text-lg--line-height));
|
||||
@@ -617,12 +634,22 @@
|
||||
.capitalize {
|
||||
text-transform: capitalize;
|
||||
}
|
||||
.lowercase {
|
||||
text-transform: lowercase;
|
||||
}
|
||||
.italic {
|
||||
font-style: italic;
|
||||
}
|
||||
.underline {
|
||||
text-decoration-line: underline;
|
||||
}
|
||||
.accent-secondary {
|
||||
accent-color: var(--secondary);
|
||||
}
|
||||
.shadow {
|
||||
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.outline-1 {
|
||||
outline-style: var(--tw-outline-style);
|
||||
outline-width: 1px;
|
||||
@@ -643,11 +670,6 @@
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.not-group-focus-within\:hidden {
|
||||
&:not(*:is(:where(.group):focus-within *)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.not-group-hover\:hidden {
|
||||
&:not(*:is(:where(.group):hover *)) {
|
||||
display: none;
|
||||
@@ -656,11 +678,6 @@
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.not-group-focus\:hidden {
|
||||
&:not(*:is(:where(.group):focus *)) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.not-open\:mb-\[1em\] {
|
||||
&:not(*:is([open], :popover-open, :open)) {
|
||||
margin-bottom: 1em;
|
||||
@@ -1249,6 +1266,71 @@
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-inset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-inset-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ring-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-inset-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-ring-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-ring-inset {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ring-offset-width {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
@property --tw-ring-offset-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: #fff;
|
||||
}
|
||||
@property --tw-ring-offset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-outline-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
@@ -1346,6 +1428,20 @@
|
||||
--tw-skew-y: initial;
|
||||
--tw-border-style: solid;
|
||||
--tw-font-weight: initial;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-color: initial;
|
||||
--tw-shadow-alpha: 100%;
|
||||
--tw-inset-shadow: 0 0 #0000;
|
||||
--tw-inset-shadow-color: initial;
|
||||
--tw-inset-shadow-alpha: 100%;
|
||||
--tw-ring-color: initial;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-inset-ring-color: initial;
|
||||
--tw-inset-ring-shadow: 0 0 #0000;
|
||||
--tw-ring-inset: initial;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-outline-style: solid;
|
||||
--tw-blur: initial;
|
||||
--tw-brightness: initial;
|
||||
|
||||
+25
-11
@@ -5,6 +5,7 @@
|
||||
{{- $acctID = .Identity.Account.AccountID }}
|
||||
{{- end }}
|
||||
{{- $mockMode := .MockMode }}
|
||||
{{- $devAuthEnabled := .DevAuthEnabled }}
|
||||
|
||||
|
||||
<!DOCTYPE html>
|
||||
@@ -117,8 +118,13 @@
|
||||
>
|
||||
{{- if not $loggedIn }}
|
||||
|
||||
{{- $loginPath := "/api/auth/login" }}
|
||||
{{- if $devAuthEnabled }}
|
||||
{{- $loginPath = "/api/auth/dev-login" }}
|
||||
{{- end }}
|
||||
|
||||
{{ template "navbar-link" (props
|
||||
"Href" "/api/auth/login"
|
||||
"Href" $loginPath
|
||||
"NoHXBoost" true
|
||||
"Selected" (eq $path "/auth/login")
|
||||
"Content" "Log In"
|
||||
@@ -231,7 +237,8 @@
|
||||
<a href="mailto:contact-us@inventory-plus-plus.com" class="p-[1em] font-display text-center">Contact Us</a>
|
||||
<a href="mailto:support@inventory-plus-plus.com" class="p-[1em] font-display text-center">Support</a>
|
||||
</address>
|
||||
{{- if .Identity.Claims.Picture }}
|
||||
|
||||
{{- if (or .Identity.Claims.Picture .Identity.Claims.Name) }}
|
||||
<button
|
||||
popovertarget="identity-popover"
|
||||
class="
|
||||
@@ -244,14 +251,21 @@
|
||||
items-center
|
||||
"
|
||||
>
|
||||
<img
|
||||
src="{{ .Identity.Claims.Picture }}"
|
||||
class="rounded-[50%]"
|
||||
style="
|
||||
max-height: 3rem;
|
||||
margin: 1rem 0;
|
||||
"
|
||||
/>
|
||||
{{ if .Identity.Claims.Picture }}
|
||||
<img
|
||||
src="{{ .Identity.Claims.Picture }}"
|
||||
class="rounded-[50%]"
|
||||
style="
|
||||
max-height: 3rem;
|
||||
margin: 1rem 0;
|
||||
"
|
||||
/>
|
||||
{{ else if .Identity.Claims.Name }}
|
||||
{{ .Identity.Claims.Name }}
|
||||
{{ else }}
|
||||
User
|
||||
{{ end }}
|
||||
|
||||
</button>
|
||||
<div
|
||||
popover="auto"
|
||||
@@ -308,7 +322,7 @@
|
||||
</div>
|
||||
|
||||
<a
|
||||
href="/api/auth/logout"
|
||||
href="/api/auth/{{if $devAuthEnabled}}dev-logout{{else}}logout{{end}}"
|
||||
hx-boost="false"
|
||||
class="
|
||||
w-fit
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
{{- $loggedIn := and (and .Identity .Identity.AccessToken) true -}}
|
||||
{{- $devAuthEnabled := .DevAuthEnabled }}
|
||||
|
||||
|
||||
<section class="flex justify-center max-w-full mt-[3em] mb-[3em]">
|
||||
@@ -26,7 +27,7 @@
|
||||
Create an Account
|
||||
</a>
|
||||
{{- else }}
|
||||
<a href="/api/auth/login" hx-boost="false" class="block p-[1em] font-bold">
|
||||
<a href="/api/auth/{{if $devAuthEnabled}}dev-login{{else}}login{{end}}" hx-boost="false" class="block p-[1em] font-bold">
|
||||
New Login
|
||||
</a>
|
||||
{{- end }}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
# Work Summary — 2026-08-05 22:18
|
||||
|
||||
## Task
|
||||
First half of `domains/reports` test coverage (agreed to split into two separate tasks): `GetRawShopEvents`. The other method, `GetListingCountsOverTime`/`GetListingCountsReport`, needs a deeper fixture (a listing plus count-changing history feeding a DB view) and was deliberately left for a separate pass.
|
||||
|
||||
## Changes
|
||||
New `domains/reports/events_test.go`:
|
||||
- `setupAmazonMockShop` helper: creates a fresh account (via `testdb.SeedOAuthUser` + `accounts.Store.CreateAccount`, matching the pattern from `domains/accounts/accounts_test.go`) and an Amazon mock shop via `accounts.Store.CreateMockShop`, registering cleanup for every row it creates in FK-safe order (`mock.shop_amazon_events` / `mock.raw_shop_events` → `mock.shop_amazon` → `mock.accounts` → `accounts`; `oauth_users` cleanup comes from `SeedOAuthUser` itself). Cleans up `shop_amazon_events` too even though these tests don't touch the Amazon event processor, since any `raw_shop_events` insert for `platform='amazon'` fires the same DB trigger that populates it.
|
||||
- `TestGetRawShopEvents`: inserts two raw events at different timestamps, confirms both come back, in the right order (`event_timestamp DESC, event_id ASC`), with the right `Platform`/`ShopID`, and that `RawPayload` round-trips correctly through the `jsonb` column.
|
||||
- `TestGetRawShopEvents_NoEvents`: a valid shop with zero events returns an empty slice, not an error.
|
||||
- `TestGetRawShopEvents_UnknownShop`: an unrecognized shop ID returns `consts.ErrNotFound` (via the `accts.GetMockShop` check `GetRawShopEvents` does before querying events).
|
||||
|
||||
## Verification
|
||||
- `go build ./...` / `go vet ./...` clean.
|
||||
- `go test ./domains/reports/... -v -race`: all 3 pass.
|
||||
- 10x repeated runs (`-count=1 -race`) with no flakes, ~1.1-1.2s each.
|
||||
- `make test`: full suite green.
|
||||
- Confirmed zero leftover rows after the run across `accounts`, `mock.accounts`, and `mock.raw_shop_events` on the test DB.
|
||||
- Skipped `make test-against-dev-db`: same live `go run .` process from the previous session was still running, and this suite inserts real `mock.raw_shop_events` rows for `platform='amazon'`, which fires the same trigger/NOTIFY that live process's Amazon background handler listens on. Almost certainly harmless (unique per-test IDs, no connection manipulation involved this time, unlike the insight #4 test), but no strong need to interact with a running session's live processing loop just to re-confirm what the test-DB run already showed cleanly.
|
||||
|
||||
## Follow-ups / not done here
|
||||
- `GetListingCountsOverTime`/`GetListingCountsReport` coverage remains a separate, open task - needs an account + mock shop + `CreateMockListing` + something that actually generates count history (a simulated sale/refund/inventory change, or a direct insert) so the `listingCountsView` these methods read from has real data to aggregate. That view is real per-platform logic, not just a passthrough, so this would be the first test exercising it directly rather than just the Go code around it.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Work Summary — 2026-08-06 20:26
|
||||
|
||||
## Task
|
||||
Migrate all test files from raw `t.Error`/`t.Errorf`/`t.Fatal`/`t.Fatalf` to `github.com/stretchr/testify`'s `assert`/`require` packages, throughout the whole test suite.
|
||||
|
||||
## Scope found
|
||||
9 test files total. Three (`server/ui/svg/svg_test.go`, `server/ui/charts/bar_test.go`, `server/ui/charts/line_test.go`) are `Example` functions with no `*testing.T` parameter at all - Go's stdlib compares their output against a `// Output:` comment, a fundamentally different mechanism testify can't attach to. Nothing to convert there; confirmed via grep that none of the three contain any `t.Error`/`t.Fatal`/`*testing.T` usage.
|
||||
|
||||
The remaining 6 were converted: `domains/accounts/accounts_test.go`, `domains/authentication/dev_test.go`, `domains/raw_events/events_test.go`, `domains/amazon/mock_test.go`, `domains/reports/events_test.go`, `domains/reports/reports_test.go`.
|
||||
|
||||
## Conventions used
|
||||
- `require.*` where the original was `t.Fatal`/`t.Fatalf` (halts the test) - error checks, and any assertion a later line depends on (e.g. indexing into a slice whose length was just checked).
|
||||
- `assert.*` where the original was `t.Error`/`t.Errorf` (non-halting) - independent value checks that don't gate subsequent code.
|
||||
- `require.ErrorIs`/`require.NoError` in place of manual `errors.Is`/`if err != nil` checks.
|
||||
- `require.Eventually` in place of hand-rolled polling loops (`for !condition() { if timeout { t.Fatal }; sleep }`) in `domains/amazon/mock_test.go` - a direct, more concise match for that exact pattern, and it already existed in testify rather than needing a custom helper.
|
||||
- `assert.NotNil`/`require.NotNil` guarding a subsequent field access, matching testify's own idiom for avoiding a nil-pointer panic in a non-fatal assertion chain (`if assert.NotNil(t, x) { assert.Equal(t, ..., x.Field) }`).
|
||||
- `github.com/stretchr/testify` added as a direct dependency (`go get` + `go mod tidy`; it was already an indirect transitive dependency of something else, so it only became "direct" once actual imports existed for `go mod tidy` to key off of).
|
||||
|
||||
## A correctness issue caught and fixed during the conversion (not a testify bug - a bug in my own test code)
|
||||
`require.Eventually` runs its condition function via `go checkCond()` - a separate goroutine (confirmed by reading testify's source at `assert/assertions.go:1988`). Go's testing package requires `t.FailNow()` (which `require.*` calls internally) to only ever be invoked from the test's own goroutine; calling it from a spawned goroutine doesn't panic cleanly, it just silently fails to report the real error and can leave things in a confusing state. Two of my initial `require.Eventually(...)` calls wrapped `isProcessed(t, ...)`, which internally used `require.NoError` - exactly this hazard, latent until the underlying query ever actually errored. Fixed by splitting a `t`-free `queryIsProcessed(ctx, pool, shopID, eventID) (bool, error)` out of `isProcessed`, and having the `Eventually` closures call that directly (treating a query error as "not yet satisfied" rather than halting), while `isProcessed` itself (used from the main test goroutine elsewhere) still wraps it with `require.NoError` for a clear, immediate failure there.
|
||||
|
||||
## Verification
|
||||
- `go build ./...` / `go vet ./...` clean.
|
||||
- `gofmt -l` clean on every file touched (the only `gofmt`-flagged files repo-wide are pre-existing generated `*_with_context.go` files this task didn't touch).
|
||||
- `grep -rn "t\.Error\|t\.Fatal" --include="*_test.go" .` - only two hits, both in explanatory comments, not actual calls.
|
||||
- Every package with tests passes individually and in the safe (non-colliding) combination - see "Follow-up" below for why `domains/amazon` was verified separately from the rest rather than via one `go test ./...` run.
|
||||
|
||||
## Follow-up surfaced (separate from this task, not fixed here)
|
||||
While confirming everything with a full-suite run, `go test ./...` hung and eventually timed out inside `domains/amazon`, with a goroutine stuck in `pgxpool.Pool.Close()`'s `sync.WaitGroup.Wait`. Root-caused: `(*Mocks).processUnprocessedEvents`'s query has no `shop_id` filter - it processes every unprocessed row in `mock.shop_amazon_events` system-wide. `domains/reports`' tests also insert `platform='amazon'` raw events (unrelated fixture data, for its own listing-count tests), which the DB trigger copies into that same table. When `go test ./...` runs both packages' test binaries concurrently (the default), `domains/amazon`'s tests can end up processing - and asserting on - events that belong to a different test in a different package entirely. Confirmed directly: running `domains/amazon` + `domains/reports` together reproduced a spy receiving 4 events instead of the expected 1, three of them from `domains/reports`' fixture shop. This likely also explains the hang: more cross-package NOTIFY traffic raises the odds of hitting a narrow race in `listenForNotifications` where its notification-forwarding goroutine can block forever on an unbuffered channel send if `ProcessEvents` has just exited (context cancelled), leaking the connection and hanging `pool.Close()`.
|
||||
|
||||
This pre-dates and is unrelated to the testify conversion - it only became reachable once `domains/reports`' tests started writing `platform='amazon'` events in a recent session. Per user direction, verified the conversion by running `domains/amazon` on its own and every other tested package together (both clean, repeated runs, no leftovers), and left the underlying `mock.go` bug as an explicit follow-up task rather than fixing it as a side effect of this one.
|
||||
@@ -0,0 +1,22 @@
|
||||
# Work Summary — 2026-08-06 20:44
|
||||
|
||||
## Task
|
||||
Follow-up from the testify migration: fix the remaining test-isolation failure between `domains/amazon` and `domains/reports` (the goroutine-leak half of the original `go test ./...` hang was already fixed and committed separately as `7c65f92`).
|
||||
|
||||
## Root cause
|
||||
`domains/reports`' fixtures used `accounts.Amazon` as their example platform - same as `domains/amazon`'s own tests. Both packages' test binaries run concurrently under `go test ./...` (Go's default), both write to the shared `mock.raw_shop_events` table with `platform='amazon'`, and the DB trigger routes those into `mock.shop_amazon_events`, the exact table `domains/amazon`'s background-processing tests poll and assert on. Confirmed directly: running the two packages together, `domains/amazon`'s `TestProcessUnprocessedEvents_RetriesUnackedNotification` picked up 4 events instead of 1, three of them from `domains/reports`' fixture shop.
|
||||
|
||||
## Fix
|
||||
Switched `domains/reports`' fixtures from Amazon to Etsy (renamed `setupAmazonMockShop`→`setupEtsyMockShop`, `createAmazonListing`→`createEtsyListing`, and all table names/platform constants throughout `domains/reports/events_test.go` and `domains/reports/reports_test.go`). Test-only change, no production code touched.
|
||||
|
||||
Verified the exact casing before switching, since it mattered: `accounts.Etsy`'s Go value is `"Etsy"` (capital E) unlike most other platform constants (`"amazon"`, `"big_cartel"`, etc., all lowercase) - and separately, Etsy's `mock.raw_shop_events` *trigger* (which routes into `mock.shop_etsy_events`, the table `domains/amazon`-style processors would use) checks for lowercase `'etsy'`. Confirmed via `pg_get_viewdef` that the *listing-counts view* (`mock.shop_etsy_listing_event_sequence`, what these tests actually depend on) filters on `'Etsy'` (capital), matching the Go constant correctly - so the fixtures work correctly, and as a side effect never fire the lowercase-gated trigger at all, keeping `mock.shop_etsy_events` completely untouched by these tests regardless.
|
||||
|
||||
## Verification
|
||||
- `go build ./...` / `go vet ./...` clean.
|
||||
- `domains/reports` alone: all 6 tests still pass.
|
||||
- `domains/amazon` + `domains/reports` together, 8x repeated (`-race -count=1`): all clean, no failures.
|
||||
- `go test ./... -race` (the exact command that used to hang before the goroutine-leak fix, and would still have failed on the isolation issue afterward): now green as one command, no special-casing needed.
|
||||
- `make test`: green, zero leftover rows in `accounts`, `mock.accounts`, `mock.raw_shop_events` afterward.
|
||||
|
||||
## Follow-ups / not done here
|
||||
None specific to this fix. Combined with the goroutine-leak fix (`7c65f92`), `go test ./...` / `make test` are both reliably green as single commands again - the workaround of running `domains/amazon` separately from everything else is no longer needed.
|
||||
@@ -0,0 +1,34 @@
|
||||
# Work Summary — 2026-08-10 15:43
|
||||
|
||||
## Task
|
||||
"Play around in Penpot and see if you can generalize any of the common elements." Not a git-tracked code change — this is design-system work in the connected Penpot file. Recorded here for continuity since it's real project work.
|
||||
|
||||
## Context
|
||||
The connected Penpot file turned out to be the *unmodified* default Tailwind CSS starter template (raw, unrenamed color swatches, a breakpoint reference, a shadow reference) - no existing mockups of this app's actual UI. Clarified with the user that "generalize the common elements" meant reverse-engineering the app's *real*, already-implemented UI (`templates/components/*.html.tmpl`, `styles/typography.css`) into actual Penpot library assets, not curating the generic starter kit.
|
||||
|
||||
`styles/typography.css` turned out to hold a full custom shadcn/ui-style design system already: three OKLCH color scales (`base`, `primary`, `secondary`, 50-1000 each), ~38 semantic tokens (`background`, `card`, `accent`, `border`, `chart-1..5`, `sidebar-*`, `table-*`, etc.) with distinct light/dark values via `@media (prefers-color-scheme: dark)`, and two custom fonts (Geist 600 for headings, Alexandria 300 for body).
|
||||
|
||||
No browser extension was available to read live computed styles, so exact OKLCH→hex conversion had to be done by hand-implementing the standard algorithm (Björn Ottosson's OKLab, CSS Color 4 matrices) inside Penpot's JS sandbox, rather than relying on assumption or memory.
|
||||
|
||||
## What was built in Penpot
|
||||
- **OKLCH→hex conversion**: implemented and validated against known-correct reference points (pure black/white exactly, and `oklch(0.577 0.245 27.325)` → `#E7000B`, which independently matches shadcn/ui's well-known default destructive red) before trusting it for the full palette.
|
||||
- **Design Tokens** (Penpot's native token system, chosen over flat colors specifically so light/dark could be modeled properly):
|
||||
- `Primitives` set (40 tokens, always active): `base.50`-`base.1000`, `primary.50`-`primary.1000`, `secondary.50`-`secondary.1000`, `destructive.light`/`.dark`, `white`, `black`.
|
||||
- `Semantic/Light` and `Semantic/Dark` sets (38 tokens each): every semantic token name the app's CSS defines, referencing the right primitive per theme.
|
||||
- A `Mode` theme group (Light/Dark), Light active by default - mirrors the app's `:root` vs. `@media (prefers-color-scheme: dark)` structure. Verified references resolve correctly end-to-end.
|
||||
- **Typography**: `Display/H1`-`H4`,`H6` (Geist 600, sized from Tailwind's `text-6xl`/`3xl`/`xl`/`lg`/`sm`) and `Text/Body` (Alexandria 300, 16px). `H5` deliberately skipped - the source CSS references `--text-md`, which isn't defined anywhere (a real gap in the app's CSS, not something to guess a value for).
|
||||
- **Button component** (from `templates/components/button.html.tmpl`): a proper Penpot Variant group (`State` property: `Default`/`Hover`/`Active-Disabled`) built entirely from the token system rather than hardcoded colors - background/border/text colors all come from `color.card`/`color.border`/`color.foreground`/`color.accent`/`color.accent-secondary` tokens. Visually verified via export at each stage.
|
||||
|
||||
## A real Penpot platform quirk hit and worked around
|
||||
`penpot.createVariantFromComponents` (and the `penpotUtils.createVariantContainer` wrapper built on it) failed with a server-side validation error (`Value not valid: [object ShapeProxy]... Code: :shapes`) when passed boards where two of the three had been created via `.clone()` of a board that was *already* a component's main instance. Isolated the cause with targeted diagnostics (confirmed flex layout alone was fine; confirmed applied color tokens alone were fine; the only remaining variable - clone-of-an-existing-main-instance - was the actual cause). Fix: rebuild each variant state as an independent board from scratch rather than cloning an already-componentized one, then create each as its own component before combining. Worth remembering if building more variant groups in this file.
|
||||
|
||||
## Verification
|
||||
- OKLCH conversion validated against known reference values before trusting it for ~40 colors.
|
||||
- Token resolution spot-checked (`color.background` → `#FAFAFE`, `color.primary` → `#B8E954`, `color.destructive` → `#E7000B`), all correct.
|
||||
- Button auto-sizing confirmed (73×36, correctly hugging text + 8px padding on `p-[0.5em]` at 16px base).
|
||||
- All three button states exported and visually inspected individually and as a working variant group; no `variantError` on any of the three.
|
||||
- A Penpot plugin disconnect happened mid-session (browser-side, not caused by this work) - the already-computed, validated hex map was saved to the scratchpad before it could be lost, and reloaded directly (no recomputation needed) once reconnected.
|
||||
|
||||
## Follow-ups / not done here
|
||||
- `Accordion` (`templates/components/accordion.html.tmpl`) and `TutorialTooltip` (`templates/components/tutorial-tooltip.html.tmpl`) components not yet built - user chose to first wire the Button into a proper Variant group (done above) rather than continue to these; picking this back up is the natural next step.
|
||||
- `H5` typography intentionally left unresolved (`--text-md` undefined in source CSS) - worth a decision on the actual code side (define `--text-md`, or change `h5` to reference an existing size) independent of the Penpot work.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Work Summary — 2026-08-10 20:22
|
||||
|
||||
## Task
|
||||
Continue the Penpot design-system work: build the `Accordion` component from `templates/components/accordion.html.tmpl`, following the Button's pattern from the previous session.
|
||||
|
||||
## A false alarm, resolved
|
||||
Mid-build, token resolution appeared to produce scrambled/wrong values - including for the *already-verified-correct* Button component, which seemed to indicate live corruption. Paused and reported rather than continuing to build on top of it. Turned out to be a non-issue: the user had switched Penpot's active theme from Light to Dark while reviewing in the UI. Verified by checking the "wrong" values against the actual Dark-theme semantic mapping - every one matched exactly (`color.accent` → `base.800` → `#242237`, `color.foreground` → `base.200` → `#E6E7F3`, etc.). The token system was working correctly the whole time; the check was just comparing Dark output against Light expectations. Switched back to Light (`lightTheme.toggleActive()`) and confirmed the Button's Default fill was `#ffffff` again before continuing. No actual reversal was needed - nothing was broken.
|
||||
|
||||
## What was built
|
||||
`Accordion` component (from `accordion.html.tmpl`), as a Penpot Variant group (`State`: `Closed`/`Open`), built fresh per-state (not cloned, per the lesson from the Button session) and entirely from the existing token system:
|
||||
- **Closed**: just the summary bar - `color.accent` background, `rounded-lg`, header text left / `+` indicator right (Alexandria 700, 2em). No outer background, matching the template (`bg-card` only applies when open).
|
||||
- **Open**: the same summary bar (indicator now `−`) plus an expanded body area below, both wrapped in an outer container using `color.card` background and `rounded-lg` - matching `open:bg-card` applying to the outer `<details>`, with the body's `p-[1em]` padding around placeholder body text.
|
||||
|
||||
## Verification
|
||||
- Both states exported and visually inspected individually before combining - correct token-driven colors, correct layout (row-fill summary bar, centered/padded body).
|
||||
- Variant group verified: `isVariantContainer()` true, `State` property with `Closed`/`Open` values, no `variantError` on either.
|
||||
- Final combined export confirms both states render correctly together.
|
||||
|
||||
## Follow-ups / not done here
|
||||
- `TutorialTooltip` (`templates/components/tutorial-tooltip.html.tmpl`) is the last of the three components surveyed at the start of this work - not yet built.
|
||||
@@ -0,0 +1,20 @@
|
||||
# Work Summary — 2026-08-10 20:40
|
||||
|
||||
## Task
|
||||
Final piece of the Penpot design-system work: build `TutorialTooltip` from `templates/components/tutorial-tooltip.html.tmpl`, completing the three components surveyed at the start of this thread.
|
||||
|
||||
## What was built
|
||||
`TutorialTooltip` - the popover panel itself (not the invisible anchor wrapper `<div>`, which only carries positioning/click-handler behavior, no visual style of its own). Unlike Button and Accordion, this component has no meaningful *visual* state variants - its only dynamic behavior is show/hide (CSS `popover`/`open`) and a multi-step content walkthrough (swapping which `<span>` is visible on click), neither of which changes its appearance. Built as a single library component rather than a Variant group, which is the correct fit here, not a shortcut.
|
||||
|
||||
Structure: `color.card` background, 1px border using `color.sidebar-border` (a token not used by Button or Accordion, since this is the only component that references `--sidebar-border` in its source CSS), `rounded-lg`, a literal drop shadow (`2px 2px 2px 1px rgb(0 0 0 / 20%)` - not backed by any token in the source CSS, so applied as a direct shape shadow rather than invented as one), flex row with content text (fills available space) and a `×` close glyph (fixed, right-aligned) - mirroring the template's `grid-template-columns: 1fr max-content`.
|
||||
|
||||
## Verification
|
||||
- Colors confirmed correct before exporting (`fill: #ffffff`, `stroke: #e6e7f3` - card/sidebar-border in Light theme).
|
||||
- Visual export matches the template's intent: card panel, subtle border, drop shadow, content + close button.
|
||||
- Final full-library sweep: 3 components (`Button` and `Accordion` as Variant groups, `TutorialTooltip` as a single component, confirmed via `isVariant()`), token sets in expected state (`Primitives` + `Semantic/Light` active, `Semantic/Dark` inactive), all 6 typographies present. No leftover test/diagnostic artifacts anywhere in the file (swept with a name-based search across all pages).
|
||||
|
||||
## Outcome
|
||||
This closes out the original "generalize the common elements" request. All three reusable UI components implemented in `templates/components/` now exist as real, token-driven Penpot library components, built from the app's actual CSS values (not guessed), with the color/typography foundation they're built on independently reusable for any future component work in this file.
|
||||
|
||||
## Follow-ups / not done here
|
||||
- Two things flagged during this whole thread that are worth a decision on the *code* side, independent of Penpot: `h5 { font-size: var(--text-md) }` in `styles/typography.css` references an undefined variable (no typography asset was created for H5 as a result); and the `TutorialTooltip`'s box-shadow and `animate-pulse` (a pulsing opacity animation on the popover, not represented in the static Penpot export) aren't tied to any reusable token, unlike everything else in the file.
|
||||
Reference in New Issue
Block a user