Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -0,0 +1,51 @@
|
||||
name: Claude Assistant for Gitea
|
||||
|
||||
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
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Run Claude Assistant
|
||||
uses: markwylde/claude-code-gitea-action@gitea
|
||||
with:
|
||||
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"
|
||||
@@ -2,3 +2,7 @@
|
||||
*.swp
|
||||
|
||||
node_modules
|
||||
|
||||
.env
|
||||
.env.*
|
||||
.env.example
|
||||
|
||||
@@ -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.
|
||||
@@ -0,0 +1,55 @@
|
||||
.DEFAULT_GOAL := dev
|
||||
|
||||
-include .env
|
||||
export
|
||||
|
||||
MIGRATE := migrate -path database_migrations -database "$(DATABASE_URL)"
|
||||
MIGRATE_TEST := migrate -path database_migrations -database "$(TEST_DATABASE_URL)"
|
||||
|
||||
.PHONY: dev migrate-up migrate-down migrate-version tailwind run \
|
||||
test test-against-dev-db migrate-test-up migrate-test-down migrate-test-version
|
||||
|
||||
# applies pending migrations, starts the tailwind watcher in the background,
|
||||
# then runs the server in the foreground. Ctrl-C stops both.
|
||||
dev: migrate-up
|
||||
@./tailwind.sh & \
|
||||
TW_PID=$$!; \
|
||||
trap "kill $$TW_PID 2>/dev/null" EXIT INT TERM; \
|
||||
go run .
|
||||
|
||||
migrate-up:
|
||||
$(MIGRATE) up
|
||||
|
||||
migrate-down:
|
||||
$(MIGRATE) down 1
|
||||
|
||||
migrate-version:
|
||||
$(MIGRATE) version
|
||||
|
||||
tailwind:
|
||||
./tailwind.sh
|
||||
|
||||
run:
|
||||
go run .
|
||||
|
||||
# runs the integration test suite against TEST_DATABASE_URL (the dedicated
|
||||
# inventory_2_test database by default). Applies pending migrations there
|
||||
# first.
|
||||
test: migrate-test-up
|
||||
go test ./...
|
||||
|
||||
# same as `test`, but points TEST_DATABASE_URL at the real dev database for
|
||||
# this run instead of the dedicated test database - useful for checking
|
||||
# behavior against real data. Leaves the dev DB in whatever state the tests'
|
||||
# own cleanup produces; it's still your dev data, treat it accordingly.
|
||||
test-against-dev-db: TEST_DATABASE_URL := $(DATABASE_URL)
|
||||
test-against-dev-db: test
|
||||
|
||||
migrate-test-up:
|
||||
$(MIGRATE_TEST) up
|
||||
|
||||
migrate-test-down:
|
||||
$(MIGRATE_TEST) down 1
|
||||
|
||||
migrate-test-version:
|
||||
$(MIGRATE_TEST) version
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package config loads application configuration from environment variables,
|
||||
// optionally populated from a .env file in the working directory.
|
||||
package config
|
||||
|
||||
import (
|
||||
"flag"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
|
||||
"github.com/joho/godotenv"
|
||||
)
|
||||
|
||||
type Config struct {
|
||||
DatabaseURL string
|
||||
|
||||
Auth0Domain string
|
||||
Auth0ClientID string
|
||||
Auth0ClientSecret string
|
||||
Auth0CallbackURL string
|
||||
|
||||
EtsyAPIKeystring string
|
||||
EtsyAPISharedSecret string
|
||||
|
||||
// DevAuthEnabled, when true, exposes a route that mints a local
|
||||
// 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) {
|
||||
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 (
|
||||
cfg Config
|
||||
missing []string
|
||||
)
|
||||
|
||||
required := func(key string) string {
|
||||
v := os.Getenv(key)
|
||||
if v == "" {
|
||||
missing = append(missing, key)
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
cfg.DatabaseURL = required("DATABASE_URL")
|
||||
|
||||
// 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 {
|
||||
return Config{}, fmt.Errorf("invalid DEV_AUTH_ENABLED value %q: %w", raw, err)
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -7,8 +7,8 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
func newPool(ctx context.Context) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, "postgres://app_client:app_password@localhost:5432/inventory_2?sslmode=disable")
|
||||
func newPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||
pool, err := pgxpool.New(ctx, databaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create database client: %w", err)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
CREATE TABLE oauth_login_states (
|
||||
state BYTEA NOT NULL,
|
||||
expiration TIMESTAMPTZ NOT NULL DEFAULT (NOW() + 10 'minute'),
|
||||
expiration TIMESTAMPTZ NOT NULL DEFAULT (NOW() + '10 minutes'::interval),
|
||||
|
||||
PRIMARY KEY (state)
|
||||
);
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
BEGIN;
|
||||
|
||||
|
||||
ALTER TABLE mock.shop_amazon_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_amazon_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_big_cartel_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_big_cartel_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_ebay_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_ebay_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_ecwid_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_ecwid_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_etsy_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_etsy_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_shopify_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_shopify_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_square_online_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_square_online_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_squarespace_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_squarespace_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_tiktok_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_tiktok_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_walmart_marketplace_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_walmart_marketplace_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_wix_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_wix_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_woo_commerce_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_woo_commerce_events_by_timestamp;
|
||||
ALTER TABLE mock.shop_zoho_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
DROP INDEX mock.shop_zoho_events_by_timestamp;
|
||||
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,345 @@
|
||||
BEGIN;
|
||||
|
||||
|
||||
ALTER TABLE mock.shop_amazon_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_amazon_events_by_timestamp ON mock.shop_amazon_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_amazon_events_by_timestamp_unprocessed ON mock.shop_amazon_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_big_cartel_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_big_cartel_events_by_timestamp ON mock.shop_big_cartel_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_big_cartel_events_by_timestamp_unprocessed ON mock.shop_big_cartel_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_ebay_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_ebay_events_by_timestamp ON mock.shop_ebay_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_ebay_events_by_timestamp_unprocessed ON mock.shop_ebay_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_ecwid_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_ecwid_events_by_timestamp ON mock.shop_ecwid_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_ecwid_events_by_timestamp_unprocessed ON mock.shop_ecwid_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_etsy_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_etsy_events_by_timestamp ON mock.shop_etsy_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_etsy_events_by_timestamp_unprocessed ON mock.shop_etsy_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_shopify_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_shopify_events_by_timestamp ON mock.shop_shopify_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_shopify_events_by_timestamp_unprocessed ON mock.shop_shopify_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_square_online_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_square_online_events_by_timestamp ON mock.shop_square_online_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_square_online_events_by_timestamp_unprocessed ON mock.shop_square_online_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_squarespace_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_squarespace_events_by_timestamp ON mock.shop_squarespace_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_squarespace_events_by_timestamp_unprocessed ON mock.shop_squarespace_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_tiktok_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_tiktok_events_by_timestamp ON mock.shop_tiktok_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_tiktok_events_by_timestamp_unprocessed ON mock.shop_tiktok_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_walmart_marketplace_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_walmart_marketplace_events_by_timestamp ON mock.shop_walmart_marketplace_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_walmart_marketplace_events_by_timestamp_unprocessed ON mock.shop_walmart_marketplace_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_wix_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_wix_events_by_timestamp ON mock.shop_wix_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_wix_events_by_timestamp_unprocessed ON mock.shop_wix_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_woo_commerce_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_woo_commerce_events_by_timestamp ON mock.shop_woo_commerce_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_woo_commerce_events_by_timestamp_unprocessed ON mock.shop_woo_commerce_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_zoho_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
CREATE INDEX shop_zoho_events_by_timestamp ON mock.shop_zoho_events (shop_id, event_timestamp);
|
||||
CREATE INDEX shop_zoho_events_by_timestamp_unprocessed ON mock.shop_zoho_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
|
||||
--- event processing
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_amazon_event() RETURNS TRIGGER AS $process_raw_amazon_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_amazon_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_amazon_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_amazon_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_big_cartel_event() RETURNS TRIGGER AS $process_raw_big_cartel_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_big_cartel_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_big_cartel_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_big_cartel_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_ebay_event() RETURNS TRIGGER AS $process_raw_ebay_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_ebay_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_ebay_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_ebay_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_ecwid_event() RETURNS TRIGGER AS $process_raw_ecwid_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_ecwid_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_ecwid_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_ecwid_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_etsy_event() RETURNS TRIGGER AS $process_raw_etsy_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_etsy_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_etsy_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_etsy_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_shopify_event() RETURNS TRIGGER AS $process_raw_shopify_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_shopify_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_shopify_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_shopify_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_square_online_event() RETURNS TRIGGER AS $process_raw_square_online_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_square_online_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_square_online_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_square_online_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_squarespace_event() RETURNS TRIGGER AS $process_raw_squarespace_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_squarespace_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_squarespace_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_squarespace_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_tiktok_event() RETURNS TRIGGER AS $process_raw_tiktok_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_tiktok_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_tiktok_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_tiktok_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_walmart_marketplace_event() RETURNS TRIGGER AS $process_raw_walmart_marketplace_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_walmart_marketplace_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_walmart_marketplace_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_walmart_marketplace_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_wix_event() RETURNS TRIGGER AS $process_raw_wix_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_wix_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_wix_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_wix_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_woo_commerce_event() RETURNS TRIGGER AS $process_raw_woo_commerce_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_woo_commerce_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_woo_commerce_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_woo_commerce_event$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE OR REPLACE FUNCTION mock.process_raw_zoho_event() RETURNS TRIGGER AS $process_raw_zoho_event$
|
||||
BEGIN
|
||||
INSERT INTO mock.shop_zoho_events (
|
||||
platform,
|
||||
shop_id,
|
||||
event_timestamp,
|
||||
event_id
|
||||
)
|
||||
SELECT
|
||||
NEW.platform,
|
||||
NEW.shop_id,
|
||||
NEW.event_timestamp,
|
||||
NEW.event_id;
|
||||
|
||||
PERFORM pg_notify('mock_shop_zoho_event_inserted', null);
|
||||
|
||||
RETURN NULL;
|
||||
END;
|
||||
$process_raw_zoho_event$ LANGUAGE plpgsql;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,32 @@
|
||||
BEGIN;
|
||||
|
||||
|
||||
DROP VIEW mock.shop_amazon_listing_counts;
|
||||
DROP VIEW mock.shop_amazon_listing_event_sequence;
|
||||
DROP VIEW mock.shop_big_cartel_listing_counts;
|
||||
DROP VIEW mock.shop_big_cartel_listing_event_sequence;
|
||||
DROP VIEW mock.shop_ebay_listing_counts;
|
||||
DROP VIEW mock.shop_ebay_listing_event_sequence;
|
||||
DROP VIEW mock.shop_ecwid_listing_counts;
|
||||
DROP VIEW mock.shop_ecwid_listing_event_sequence;
|
||||
DROP VIEW mock.shop_etsy_listing_counts;
|
||||
DROP VIEW mock.shop_etsy_listing_event_sequence;
|
||||
DROP VIEW mock.shop_shopify_listing_counts;
|
||||
DROP VIEW mock.shop_shopify_listing_event_sequence;
|
||||
DROP VIEW mock.shop_square_online_listing_counts;
|
||||
DROP VIEW mock.shop_square_online_listing_event_sequence;
|
||||
DROP VIEW mock.shop_squarespace_listing_counts;
|
||||
DROP VIEW mock.shop_squarespace_listing_event_sequence;
|
||||
DROP VIEW mock.shop_tiktok_listing_counts;
|
||||
DROP VIEW mock.shop_tiktok_listing_event_sequence;
|
||||
DROP VIEW mock.shop_walmart_marketplace_listing_counts;
|
||||
DROP VIEW mock.shop_walmart_marketplace_listing_event_sequence;
|
||||
DROP VIEW mock.shop_wix_listing_counts;
|
||||
DROP VIEW mock.shop_wix_listing_event_sequence;
|
||||
DROP VIEW mock.shop_woo_commerce_listing_counts;
|
||||
DROP VIEW mock.shop_woo_commerce_listing_event_sequence;
|
||||
DROP VIEW mock.shop_zoho_listing_counts;
|
||||
DROP VIEW mock.shop_zoho_listing_event_sequence;
|
||||
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,14 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE oauth_tokens
|
||||
ALTER COLUMN id_token_custom_claims_updated_at
|
||||
TYPE TEXT
|
||||
USING id_token_custom_claims_updated_at::TEXT;
|
||||
|
||||
ALTER TABLE oauth_tokens
|
||||
ADD COLUMN claims JSONB NOT NULL DEFAULT '{}'::jsonb;
|
||||
|
||||
ALTER TABLE oauth_tokens
|
||||
ALTER COLUMN claims DROP DEFAULT;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN;
|
||||
|
||||
-- Reconciles oauth_tokens with the schema the application code actually
|
||||
-- expects. Neither of these two changes was ever committed as a migration
|
||||
-- despite being applied by hand at some point - see the "claims" column
|
||||
-- below, which no application code reads or writes.
|
||||
|
||||
ALTER TABLE oauth_tokens
|
||||
DROP COLUMN claims;
|
||||
|
||||
ALTER TABLE oauth_tokens
|
||||
ALTER COLUMN id_token_custom_claims_updated_at
|
||||
TYPE TIMESTAMPTZ
|
||||
USING id_token_custom_claims_updated_at::TIMESTAMPTZ;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,18 @@
|
||||
BEGIN;
|
||||
|
||||
ALTER TABLE mock.shop_amazon_events
|
||||
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
|
||||
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
|
||||
|
||||
UPDATE mock.shop_amazon_events
|
||||
SET processed = (processed_at IS NOT NULL),
|
||||
processed_successfully = (processed_at IS NOT NULL);
|
||||
|
||||
DROP INDEX IF EXISTS mock.shop_amazon_events_by_timestamp_unprocessed;
|
||||
CREATE INDEX shop_amazon_events_by_timestamp_unprocessed ON mock.shop_amazon_events (shop_id, event_timestamp) WHERE NOT processed;
|
||||
|
||||
ALTER TABLE mock.shop_amazon_events
|
||||
DROP COLUMN notified_at,
|
||||
DROP COLUMN processed_at;
|
||||
|
||||
COMMIT;
|
||||
@@ -0,0 +1,29 @@
|
||||
BEGIN;
|
||||
|
||||
-- Replaces the boolean processed/processed_successfully pair with a
|
||||
-- three-state model driven by two nullable timestamps:
|
||||
-- unprocessed: notified_at IS NULL
|
||||
-- notified: notified_at IS NOT NULL AND processed_at IS NULL
|
||||
-- processed: processed_at IS NOT NULL
|
||||
-- The dispatcher sets notified_at each time it hands an event to the
|
||||
-- listener (initially, and again on retry if the listener never acks).
|
||||
-- Only the listener's ack, via its own timestamped write, sets
|
||||
-- processed_at - it's a separate write specifically so it never races the
|
||||
-- transaction that recorded notified_at.
|
||||
|
||||
ALTER TABLE mock.shop_amazon_events
|
||||
ADD COLUMN notified_at TIMESTAMPTZ,
|
||||
ADD COLUMN processed_at TIMESTAMPTZ;
|
||||
|
||||
UPDATE mock.shop_amazon_events
|
||||
SET processed_at = NOW()
|
||||
WHERE processed;
|
||||
|
||||
ALTER TABLE mock.shop_amazon_events
|
||||
DROP COLUMN processed,
|
||||
DROP COLUMN processed_successfully;
|
||||
|
||||
DROP INDEX IF EXISTS mock.shop_amazon_events_by_timestamp_unprocessed;
|
||||
CREATE INDEX shop_amazon_events_by_timestamp_unprocessed ON mock.shop_amazon_events (shop_id, event_timestamp) WHERE processed_at IS NULL;
|
||||
|
||||
COMMIT;
|
||||
@@ -227,6 +227,44 @@ func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Store) GetAccountIDByMockPlatformAndShopID(ctx context.Context, platform Platform, shopID string) (int64, error) {
|
||||
info, ok := getMockShopSchemaInfo(platform)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unrecognized platform: %w", consts.ErrNotFound)
|
||||
}
|
||||
|
||||
rows, err := db.db.Query(
|
||||
ctx,
|
||||
fmt.Sprintf(
|
||||
`
|
||||
SELECT
|
||||
account_id
|
||||
FROM
|
||||
%s
|
||||
WHERE
|
||||
shop_id = @shop_id
|
||||
`,
|
||||
info.shopTable,
|
||||
),
|
||||
pgx.NamedArgs{
|
||||
"shop_id": shopID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to perform query: %w", err)
|
||||
}
|
||||
|
||||
acctID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return 0, consts.ErrNotFound
|
||||
}
|
||||
return 0, fmt.Errorf("failed to scan rows: %w", err)
|
||||
}
|
||||
|
||||
return acctID, nil
|
||||
}
|
||||
|
||||
func (db *Store) GetUserAndAccountByAccessToken(ctx context.Context, accessToken string) (OAuthUser, *Account, error) {
|
||||
rows, err := db.db.Query(
|
||||
ctx,
|
||||
@@ -1430,8 +1468,6 @@ func (db *Store) SetListingInListingInMockSyncGroupBeingEdited(ctx context.Conte
|
||||
default:
|
||||
return fmt.Errorf("unexpected number of rows affected: %d", n)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
return consts.ErrNotFound
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package accounts_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
|
||||
func TestCreateAccount(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
store := accounts.NewStore(testdb.Logger(), pool)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
testdb.SeedOAuthUser(t, pool, userID)
|
||||
|
||||
email := userID + "@example.com"
|
||||
|
||||
acct, err := store.CreateAccount(ctx, userID, email)
|
||||
require.NoError(t, err, "CreateAccount()")
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||
})
|
||||
|
||||
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)
|
||||
require.NoError(t, err, "GetAccount()")
|
||||
assert.Equal(t, acct, got, "GetAccount()")
|
||||
}
|
||||
|
||||
func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
store := accounts.NewStore(testdb.Logger(), pool)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
testdb.SeedOAuthUser(t, pool, userID)
|
||||
|
||||
acct, err := store.CreateAccount(ctx, userID, userID+"@example.com")
|
||||
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")
|
||||
require.ErrorIs(t, err, consts.ErrConflict, "second CreateAccount()")
|
||||
}
|
||||
|
||||
func TestGetAccount_NotFound(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
store := accounts.NewStore(testdb.Logger(), pool)
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := store.GetAccount(ctx, -1)
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccount()")
|
||||
}
|
||||
|
||||
func TestGetUserAndAccountByAccessToken(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
store := accounts.NewStore(testdb.Logger(), pool)
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
accessToken := testdb.SeedOAuthSession(t, pool, userID)
|
||||
|
||||
// before an account exists: user resolves, account does not.
|
||||
user, acct, err := store.GetUserAndAccountByAccessToken(ctx, accessToken)
|
||||
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")
|
||||
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)
|
||||
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")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetUserAndAccountByAccessToken_UnknownToken(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
store := accounts.NewStore(testdb.Logger(), pool)
|
||||
ctx := context.Background()
|
||||
|
||||
_, _, err := store.GetUserAndAccountByAccessToken(ctx, "no-such-token-"+testdb.NewUserID(t))
|
||||
require.ErrorIs(t, err, consts.ErrNotFound, "GetUserAndAccountByAccessToken()")
|
||||
}
|
||||
@@ -0,0 +1,377 @@
|
||||
package amazon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
"ruben/inventory2/logging"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
type (
|
||||
Mocks struct {
|
||||
log *logging.Logger
|
||||
db *pgxpool.Pool
|
||||
listener MockEventListener
|
||||
|
||||
// notifyRetryAfter is how long an event can sit "notified" (handed
|
||||
// to the listener) without an ack before the dispatcher notifies
|
||||
// it again. Deliberately a field, not a stored notify_again_at
|
||||
// column, so the cadence can vary (or be tuned in tests) without
|
||||
// touching any row.
|
||||
notifyRetryAfter time.Duration
|
||||
|
||||
// pollInterval is the fallback cadence ProcessEvents' main loop
|
||||
// checks for unprocessed/retry-due events on its own, independent
|
||||
// of Postgres NOTIFY - a safety net for events that end up in
|
||||
// mock.shop_amazon_events without ever going through the
|
||||
// mock.raw_shop_events insert+trigger path that fires NOTIFY.
|
||||
pollInterval time.Duration
|
||||
|
||||
ready chan struct{}
|
||||
readyOnce sync.Once
|
||||
}
|
||||
|
||||
// MockEventListener is handed events as they arrive. Notify should
|
||||
// return promptly - any real work (including calling out to another
|
||||
// system) can happen after it returns - but it must eventually call
|
||||
// ack exactly once, when the event has been fully handled. Until ack
|
||||
// is called, the dispatcher considers the event merely "notified"
|
||||
// and will call Notify again after notifyRetryAfter, so ack may be
|
||||
// called more than once total across retries; only the first call
|
||||
// has any effect; further calls are safe (see (*Mocks).ack).
|
||||
MockEventListener interface {
|
||||
Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error
|
||||
}
|
||||
)
|
||||
|
||||
const (
|
||||
eventChannelName = "mock_shop_amazon_event_inserted"
|
||||
|
||||
defaultNotifyRetryAfter = 30 * time.Second
|
||||
defaultPollInterval = time.Minute
|
||||
|
||||
// backoff for reconnecting the LISTEN connection after it fails for a
|
||||
// reason other than shutdown (e.g. a dropped connection, a Postgres
|
||||
// restart). Doubles on each consecutive failure, capped, and resets
|
||||
// once a reconnect actually succeeds.
|
||||
initialListenReconnectBackoff = 1 * time.Second
|
||||
maxListenReconnectBackoff = 30 * time.Second
|
||||
)
|
||||
|
||||
func NewMocks(log *logging.Logger, db *pgxpool.Pool) *Mocks {
|
||||
return &Mocks{
|
||||
log: log,
|
||||
db: db,
|
||||
notifyRetryAfter: defaultNotifyRetryAfter,
|
||||
pollInterval: defaultPollInterval,
|
||||
ready: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Mocks) SetListener(l MockEventListener) *Mocks {
|
||||
m.listener = l
|
||||
return m
|
||||
}
|
||||
|
||||
// WithNotifyRetryAfter overrides how long an event can sit "notified"
|
||||
// without an ack before being notified again. Mainly useful for tests that
|
||||
// don't want to wait out the default.
|
||||
func (m *Mocks) WithNotifyRetryAfter(d time.Duration) *Mocks {
|
||||
m.notifyRetryAfter = d
|
||||
return m
|
||||
}
|
||||
|
||||
// WithPollInterval overrides how often ProcessEvents checks for
|
||||
// unprocessed/retry-due events on its own, independent of NOTIFY. Mainly
|
||||
// useful for tests that don't want to wait out the default.
|
||||
func (m *Mocks) WithPollInterval(d time.Duration) *Mocks {
|
||||
m.pollInterval = d
|
||||
return m
|
||||
}
|
||||
|
||||
// Ready returns a channel that's closed once ProcessEvents has registered
|
||||
// its Postgres LISTEN and is actively watching for notifications. Callers
|
||||
// that need to know the reactive path is live - tests in particular -
|
||||
// should wait on this instead of guessing with a sleep.
|
||||
func (m *Mocks) Ready() <-chan struct{} {
|
||||
return m.ready
|
||||
}
|
||||
|
||||
func (m *Mocks) ProcessEvents(ctx context.Context) error {
|
||||
notifCh, errCh := m.listenForNotifications(ctx)
|
||||
backoff := initialListenReconnectBackoff
|
||||
|
||||
for {
|
||||
m.log.Debug("processing events")
|
||||
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("error occurred while processing events: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
|
||||
case err := <-errCh:
|
||||
var stop bool
|
||||
if notifCh, errCh, backoff, stop = m.reconnectOrStop(ctx, err, backoff); stop {
|
||||
return nil
|
||||
}
|
||||
|
||||
case _, ok := <-notifCh:
|
||||
if !ok {
|
||||
var stop bool
|
||||
if notifCh, errCh, backoff, stop = m.reconnectOrStop(ctx, <-errCh, backoff); stop {
|
||||
return nil
|
||||
}
|
||||
continue
|
||||
}
|
||||
m.log.Debug("woke up: notification received")
|
||||
|
||||
case <-time.After(m.pollInterval):
|
||||
m.log.Debug("woke up: poll interval elapsed")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// reconnectOrStop handles a LISTEN-connection failure. A nil listenErr (or
|
||||
// ctx already being done) means this is an ordinary shutdown, not a
|
||||
// failure - stop is true and the caller should return. Otherwise it logs a
|
||||
// warning, waits out backoff, and re-establishes LISTEN: on success the
|
||||
// backoff resets to its initial value for next time; on immediate failure
|
||||
// (e.g. the pool itself is unreachable) it doubles, capped, so repeated
|
||||
// failures back off rather than hot-looping.
|
||||
func (m *Mocks) reconnectOrStop(
|
||||
ctx context.Context,
|
||||
listenErr error,
|
||||
backoff time.Duration,
|
||||
) (notifCh <-chan struct{}, errCh <-chan error, nextBackoff time.Duration, stop bool) {
|
||||
if listenErr == nil || ctx.Err() != nil {
|
||||
return nil, nil, backoff, true
|
||||
}
|
||||
|
||||
m.log.Warn("lost connection while listening for notifications; reconnecting", "error", listenErr, "retry_in", backoff)
|
||||
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return nil, nil, backoff, true
|
||||
case <-time.After(backoff):
|
||||
}
|
||||
|
||||
notifCh, errCh = m.listenForNotifications(ctx)
|
||||
if notifCh != nil {
|
||||
return notifCh, errCh, initialListenReconnectBackoff, false
|
||||
}
|
||||
|
||||
nextBackoff = backoff * 2
|
||||
if nextBackoff > maxListenReconnectBackoff {
|
||||
nextBackoff = maxListenReconnectBackoff
|
||||
}
|
||||
return notifCh, errCh, nextBackoff, false
|
||||
}
|
||||
|
||||
func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-chan error) {
|
||||
errCh := make(chan error, 1)
|
||||
|
||||
pc, err := m.db.Acquire(ctx)
|
||||
if err != nil {
|
||||
errCh <- fmt.Errorf("failed to acquire a connection: %w", err)
|
||||
return nil, errCh
|
||||
}
|
||||
|
||||
conn := pc.Conn()
|
||||
|
||||
if _, err := conn.Exec(ctx, fmt.Sprintf("LISTEN %s", eventChannelName)); err != nil {
|
||||
errCh <- fmt.Errorf("failed to start listening for notifications: %w", err)
|
||||
return nil, errCh
|
||||
}
|
||||
|
||||
m.readyOnce.Do(func() { close(m.ready) })
|
||||
|
||||
ch := make(chan struct{})
|
||||
|
||||
go func() (err error) {
|
||||
defer func() {
|
||||
pc.Release()
|
||||
if err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
close(ch)
|
||||
close(errCh)
|
||||
}()
|
||||
|
||||
for {
|
||||
if _, err := conn.WaitForNotification(ctx); err != nil {
|
||||
if errors.Is(err, context.Canceled) {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("error occurred while waiting for the next notification: %w", err)
|
||||
}
|
||||
|
||||
select {
|
||||
case ch <- struct{}{}:
|
||||
case <-ctx.Done():
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return ch, errCh
|
||||
}
|
||||
|
||||
// processUnprocessedEvents finds every event that's either brand new or
|
||||
// has been "notified" for longer than notifyRetryAfter without an ack, and
|
||||
// (re)dispatches each to the listener. It never blocks on the listener:
|
||||
// notified_at is committed first, and only then - after the transaction
|
||||
// that recorded it has actually committed - is the listener told, from a
|
||||
// goroutine that isn't tied to this function's lifetime.
|
||||
func (m *Mocks) processUnprocessedEvents(ctx context.Context) error {
|
||||
for done := false; !done; {
|
||||
var toDispatch []raw_events.Event
|
||||
|
||||
err := pgx.BeginFunc(ctx, m.db, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(
|
||||
ctx,
|
||||
`
|
||||
SELECT
|
||||
shop_id,
|
||||
event_id,
|
||||
event_timestamp
|
||||
FROM
|
||||
mock.shop_amazon_events
|
||||
WHERE
|
||||
processed_at IS NULL
|
||||
AND (notified_at IS NULL OR notified_at < @retry_after)
|
||||
ORDER BY
|
||||
shop_id, event_timestamp
|
||||
LIMIT
|
||||
100
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"retry_after": time.Now().Add(-m.notifyRetryAfter),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to to perform query: %w", err)
|
||||
}
|
||||
|
||||
evts, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
|
||||
Shop_id string
|
||||
Event_id string
|
||||
Event_timestamp time.Time
|
||||
}])
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to scan rows: %w", err)
|
||||
}
|
||||
|
||||
for _, e := range evts {
|
||||
ev := raw_events.Event{
|
||||
Platform: string(accounts.Amazon),
|
||||
StoreID: e.Shop_id,
|
||||
EventID: e.Event_id,
|
||||
EventTimestamp: e.Event_timestamp,
|
||||
}
|
||||
|
||||
if err := markNotified(ctx, tx, ev); err != nil {
|
||||
return fmt.Errorf("failed to mark event notified: %w", err)
|
||||
}
|
||||
|
||||
toDispatch = append(toDispatch, ev)
|
||||
}
|
||||
|
||||
if done = len(evts) == 0; !done {
|
||||
m.log.Infof("notified listener for %d events", len(evts))
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, e := range toDispatch {
|
||||
m.dispatch(ctx, e)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// dispatch hands e to the configured listener without blocking. The
|
||||
// listener may call ack synchronously (e.g. inline once its own work is
|
||||
// done) or from elsewhere entirely, arbitrarily later - ack does its own
|
||||
// independent, idempotent write, so it's never at risk of racing (or being
|
||||
// silently lost to) this call's context being cancelled.
|
||||
func (m *Mocks) dispatch(ctx context.Context, e raw_events.Event) {
|
||||
if m.listener == nil {
|
||||
return
|
||||
}
|
||||
|
||||
go func() {
|
||||
if err := m.listener.Notify(ctx, e, func(ackCtx context.Context) error {
|
||||
return ack(ackCtx, m.db, e)
|
||||
}); err != nil {
|
||||
m.log.Errorf("error incurred by event listener: %v", err)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
func markNotified(ctx context.Context, tx pgx.Tx, e raw_events.Event) error {
|
||||
_, err := tx.Exec(
|
||||
ctx,
|
||||
`
|
||||
UPDATE
|
||||
mock.shop_amazon_events
|
||||
SET
|
||||
notified_at = NOW()
|
||||
WHERE
|
||||
shop_id = @shop_id
|
||||
AND event_id = @event_id
|
||||
AND event_timestamp = @event_timestamp
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"shop_id": e.StoreID,
|
||||
"event_id": e.EventID,
|
||||
"event_timestamp": e.EventTimestamp,
|
||||
},
|
||||
)
|
||||
return err
|
||||
}
|
||||
|
||||
// ack records that e has been fully handled. It's idempotent - calling it
|
||||
// more than once (e.g. because a retried notification also eventually acks)
|
||||
// just leaves processed_at at whenever it was first set.
|
||||
func ack(ctx context.Context, db *pgxpool.Pool, e raw_events.Event) error {
|
||||
_, err := db.Exec(
|
||||
ctx,
|
||||
`
|
||||
UPDATE
|
||||
mock.shop_amazon_events
|
||||
SET
|
||||
processed_at = NOW()
|
||||
WHERE
|
||||
shop_id = @shop_id
|
||||
AND event_id = @event_id
|
||||
AND event_timestamp = @event_timestamp
|
||||
AND processed_at IS NULL
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"shop_id": e.StoreID,
|
||||
"event_id": e.EventID,
|
||||
"event_timestamp": e.EventTimestamp,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform query: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,459 @@
|
||||
package amazon
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"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"
|
||||
)
|
||||
|
||||
// notifySpy is a MockEventListener that records every event it's notified
|
||||
// about, along with the ack callback it was given. By default it acks
|
||||
// immediately (autoAck=true), matching a well-behaved listener; tests
|
||||
// exercising the retry path set autoAck=false to simulate a listener that
|
||||
// received the notification but hasn't finished yet, and call ackAt
|
||||
// explicitly once it "finishes".
|
||||
type notifySpy struct {
|
||||
mu sync.Mutex
|
||||
events []raw_events.Event
|
||||
acks []func(context.Context) error
|
||||
completedCount int // Notify calls that have returned, including any auto-ack
|
||||
autoAck bool
|
||||
}
|
||||
|
||||
func newNotifySpy() *notifySpy {
|
||||
return ¬ifySpy{autoAck: true}
|
||||
}
|
||||
|
||||
func (s *notifySpy) Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error {
|
||||
s.mu.Lock()
|
||||
s.events = append(s.events, e)
|
||||
s.acks = append(s.acks, ack)
|
||||
autoAck := s.autoAck
|
||||
s.mu.Unlock()
|
||||
|
||||
var ackErr error
|
||||
if autoAck {
|
||||
ackErr = ack(ctx)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.completedCount++
|
||||
s.mu.Unlock()
|
||||
|
||||
return ackErr
|
||||
}
|
||||
|
||||
func (s *notifySpy) eventsSnapshot() []raw_events.Event {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return append([]raw_events.Event(nil), s.events...)
|
||||
}
|
||||
|
||||
func (s *notifySpy) setAutoAck(v bool) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.autoAck = v
|
||||
}
|
||||
|
||||
// ackAt manually invokes the i-th recorded ack callback (0-indexed, in
|
||||
// Notify call order), simulating the listener finally finishing work it
|
||||
// had earlier only been notified about.
|
||||
func (s *notifySpy) ackAt(t *testing.T, i int) {
|
||||
t.Helper()
|
||||
s.mu.Lock()
|
||||
ack := s.acks[i]
|
||||
s.mu.Unlock()
|
||||
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
|
||||
// (including any auto-ack), cumulatively across the test - safe to call
|
||||
// more than once with increasing n.
|
||||
func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) {
|
||||
t.Helper()
|
||||
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
|
||||
// entry point real mock sale/refund/inventory simulations use. A DB trigger
|
||||
// (mock.process_raw_amazon_event, see migrations 000026/000028) copies the
|
||||
// row into mock.shop_amazon_events and fires pg_notify on
|
||||
// mock_shop_amazon_event_inserted - so this one insert exercises the exact
|
||||
// same path production traffic does, instead of faking the downstream
|
||||
// table directly.
|
||||
func insertRawAmazonEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||
VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb)
|
||||
`, shopID, eventID)
|
||||
require.NoError(t, err, "insert raw amazon event")
|
||||
}
|
||||
|
||||
// insertRawAmazonEvents bulk-inserts n events for shopID in one statement,
|
||||
// each with a distinct event_id/event_timestamp.
|
||||
func insertRawAmazonEvents(t *testing.T, pool *pgxpool.Pool, shopID string, n int) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||
SELECT 'amazon', $1, NOW() + (s || ' milliseconds')::interval, 'evt-' || s, '{}'::jsonb
|
||||
FROM generate_series(1, $2) AS s
|
||||
`, shopID, n)
|
||||
require.NoError(t, err, "insert %d raw amazon events", n)
|
||||
}
|
||||
|
||||
// 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(ctx, `
|
||||
SELECT processed_at IS NOT NULL
|
||||
FROM mock.shop_amazon_events
|
||||
WHERE shop_id = $1 AND event_id = $2
|
||||
`, shopID, eventID).Scan(&processed)
|
||||
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
|
||||
}
|
||||
|
||||
// isNotified reports whether the event has been notified (at least once)
|
||||
// but not yet acked/processed.
|
||||
func isNotified(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||
t.Helper()
|
||||
var notified bool
|
||||
err := pool.QueryRow(context.Background(), `
|
||||
SELECT notified_at IS NOT NULL AND processed_at IS NULL
|
||||
FROM mock.shop_amazon_events
|
||||
WHERE shop_id = $1 AND event_id = $2
|
||||
`, shopID, eventID).Scan(¬ified)
|
||||
require.NoError(t, err, "check notified state")
|
||||
return notified
|
||||
}
|
||||
|
||||
func countUnprocessed(t *testing.T, pool *pgxpool.Pool, shopID string) int {
|
||||
t.Helper()
|
||||
var n 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)
|
||||
require.NoError(t, err, "count unprocessed events")
|
||||
return n
|
||||
}
|
||||
|
||||
// cleanupShop registers deletion of every row this test's shopID may have
|
||||
// produced, in FK-safe order (shop_amazon_events references raw_shop_events).
|
||||
func cleanupShop(t *testing.T, pool *pgxpool.Pool, shopID string) {
|
||||
t.Cleanup(func() {
|
||||
ctx := context.Background()
|
||||
pool.Exec(ctx, `DELETE FROM mock.shop_amazon_events WHERE shop_id = $1`, shopID)
|
||||
pool.Exec(ctx, `DELETE FROM mock.raw_shop_events WHERE platform = 'amazon' AND shop_id = $1`, shopID)
|
||||
})
|
||||
}
|
||||
|
||||
// terminateListenConnection finds the backend holding this package's LISTEN
|
||||
// registration (identified by its last query text, which Postgres keeps
|
||||
// showing while the connection sits idle waiting for notifications) and
|
||||
// forcibly kills it - the same failure mode a dropped connection or a
|
||||
// Postgres restart produces, so tests can exercise real reconnect behavior
|
||||
// instead of a simulated one.
|
||||
func terminateListenConnection(t *testing.T, pool *pgxpool.Pool) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
var pid int
|
||||
err := pool.QueryRow(ctx, `
|
||||
SELECT pid FROM pg_stat_activity
|
||||
WHERE query = 'LISTEN ' || $1
|
||||
ORDER BY backend_start DESC
|
||||
LIMIT 1
|
||||
`, eventChannelName).Scan(&pid)
|
||||
require.NoError(t, err, "find the LISTEN connection's backend pid")
|
||||
|
||||
_, 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
spy := newNotifySpy()
|
||||
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := "test-shop-" + uuid.NewString()
|
||||
cleanupShop(t, pool, shopID)
|
||||
|
||||
const n = 150 // exceeds the 100-row LIMIT per batch inside processUnprocessedEvents
|
||||
insertRawAmazonEvents(t, pool, shopID, n)
|
||||
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
|
||||
|
||||
spy.waitForCount(t, n, 5*time.Second)
|
||||
|
||||
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) {
|
||||
pool := testdb.Pool(t)
|
||||
m := NewMocks(testdb.Logger(), pool) // no SetListener call
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := "test-shop-" + uuid.NewString()
|
||||
cleanupShop(t, pool, shopID)
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
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.
|
||||
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
|
||||
// insight #2's fix: a listener that receives a notification but never acks
|
||||
// gets notified again after notifyRetryAfter, and once it does ack (however
|
||||
// late), the event settles into processed and stops being retried.
|
||||
func TestProcessUnprocessedEvents_RetriesUnackedNotification(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
spy := newNotifySpy()
|
||||
spy.setAutoAck(false)
|
||||
m := NewMocks(testdb.Logger(), pool).
|
||||
SetListener(spy).
|
||||
WithNotifyRetryAfter(50 * time.Millisecond)
|
||||
ctx := context.Background()
|
||||
|
||||
shopID := "test-shop-" + uuid.NewString()
|
||||
cleanupShop(t, pool, shopID)
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (1st pass)")
|
||||
spy.waitForCount(t, 1, 2*time.Second)
|
||||
|
||||
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.
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
require.True(t, isProcessed(t, pool, shopID, "evt-1"), "event should be processed once any recorded ack for it is called")
|
||||
|
||||
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
|
||||
// branch of ProcessEvents' select actually causes reprocessing, decoupled
|
||||
// from NOTIFY entirely: after the one real insert (which does fire NOTIFY,
|
||||
// same as any other test here), nothing ever triggers another notification
|
||||
// for the rest of the test. The event becomes retry-due almost immediately
|
||||
// (notifyRetryAfter is tiny), so the *only* way it can be dispatched a
|
||||
// second time is the poll timer in the select firing on its own.
|
||||
func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
spy := newNotifySpy()
|
||||
spy.setAutoAck(false)
|
||||
m := NewMocks(testdb.Logger(), pool).
|
||||
SetListener(spy).
|
||||
WithNotifyRetryAfter(30 * time.Millisecond).
|
||||
WithPollInterval(60 * time.Millisecond)
|
||||
|
||||
shopID := "test-shop-" + uuid.NewString()
|
||||
cleanupShop(t, pool, shopID)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
// first dispatch, via the real NOTIFY.
|
||||
spy.waitForCount(t, 1, 2*time.Second)
|
||||
|
||||
// second dispatch: nothing will notify again from here on, so this can
|
||||
// only come from the poll branch of the select waking the loop up on
|
||||
// its own and finding the event retry-due.
|
||||
spy.waitForCount(t, 2, 3*time.Second)
|
||||
|
||||
cancel()
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ReactsToNotification drives the actual long-running
|
||||
// loop: LISTEN registration, a real Postgres NOTIFY fired by the DB trigger
|
||||
// on insert, WaitForNotification waking the loop, and the listener callback
|
||||
// - the full stateful path, not just the deterministic batch-processing
|
||||
// core covered above.
|
||||
func TestProcessEvents_ReactsToNotification(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
spy := newNotifySpy()
|
||||
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
|
||||
|
||||
shopID := "test-shop-" + uuid.NewString()
|
||||
cleanupShop(t, pool, shopID)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||
|
||||
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)
|
||||
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()
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in
|
||||
// isolation, without depending on NOTIFY timing at all - a fast, low-flake
|
||||
// guard against shutdown regressions (hangs, goroutine leaks) independent
|
||||
// of whether the reactive path above is working.
|
||||
func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
m := NewMocks(testdb.Logger(), pool)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
waitReady(t, m, 5*time.Second)
|
||||
cancel()
|
||||
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
|
||||
// TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's
|
||||
// fix: forcibly kills the real backend connection ProcessEvents is
|
||||
// LISTEN-ing on (the same failure mode a dropped connection or a Postgres
|
||||
// restart produces) and confirms it reconnects and keeps working on its
|
||||
// own, rather than the error propagating out of ProcessEvents entirely.
|
||||
func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
spy := newNotifySpy()
|
||||
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
|
||||
|
||||
shopID := "test-shop-" + uuid.NewString()
|
||||
cleanupShop(t, pool, shopID)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
errCh <- m.ProcessEvents(ctx)
|
||||
}()
|
||||
|
||||
waitReady(t, m, 5*time.Second)
|
||||
|
||||
terminateListenConnection(t, pool)
|
||||
|
||||
// ProcessEvents should still be running, just reconnecting (initial
|
||||
// backoff is 1s) - it must not have returned because of this.
|
||||
select {
|
||||
case err := <-errCh:
|
||||
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")
|
||||
|
||||
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()
|
||||
waitStopped(t, errCh, 5*time.Second)
|
||||
}
|
||||
@@ -17,26 +17,11 @@ import (
|
||||
"ruben/inventory2/logging"
|
||||
)
|
||||
|
||||
// TODO: move these to a config?
|
||||
const (
|
||||
// The URL of our Auth0 Tenant Domain.
|
||||
// If you're using a Custom Domain, be sure to set this to that value instead.
|
||||
AUTH0_DOMAIN = "dev-uq3gqy5bdnwxmr6d.us.auth0.com"
|
||||
|
||||
// Our Auth0 application"s Client ID.
|
||||
AUTH0_CLIENT_ID = "JEjrXTQ9fxlTLgp9RgTIACpUk8a2lqNT"
|
||||
|
||||
// Our Auth0 application"s Client Secret.
|
||||
AUTH0_CLIENT_SECRET = "83U-iWdVaNnwk9XDzteo_2VMyOq_l1siKYqg1_2E7jCzgL8MnkaxlysPMcPMGlxA"
|
||||
|
||||
// The Callback URL of our application.
|
||||
AUTH0_CALLBACK_URL = "https://inventory-plus-plus.com/api/auth/login/callback"
|
||||
)
|
||||
|
||||
type (
|
||||
// Authenticator is used to authenticate our users.
|
||||
Authenticator struct {
|
||||
log *logging.Logger
|
||||
log *logging.Logger
|
||||
domain string
|
||||
*oidc.Provider
|
||||
oauth2.Config
|
||||
db *pgxpool.Pool
|
||||
@@ -60,15 +45,19 @@ 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,
|
||||
logger *logging.Logger,
|
||||
domain, clientID, clientSecret, callbackURL string,
|
||||
) (*Authenticator, error) {
|
||||
provider, err := oidc.NewProvider(
|
||||
ctx,
|
||||
"https://"+AUTH0_DOMAIN+"/",
|
||||
"https://"+domain+"/",
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -76,11 +65,12 @@ func New(
|
||||
|
||||
return &Authenticator{
|
||||
log: logger,
|
||||
domain: domain,
|
||||
Provider: provider,
|
||||
Config: oauth2.Config{
|
||||
ClientID: AUTH0_CLIENT_ID,
|
||||
ClientSecret: AUTH0_CLIENT_SECRET,
|
||||
RedirectURL: AUTH0_CALLBACK_URL,
|
||||
ClientID: clientID,
|
||||
ClientSecret: clientSecret,
|
||||
RedirectURL: callbackURL,
|
||||
Endpoint: provider.Endpoint(),
|
||||
Scopes: []string{oidc.ScopeOpenID, "profile"},
|
||||
},
|
||||
@@ -88,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 {
|
||||
@@ -244,7 +246,7 @@ func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token)
|
||||
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
|
||||
return &url.URL{
|
||||
Scheme: "https",
|
||||
Host: AUTH0_DOMAIN,
|
||||
Host: a.domain,
|
||||
Path: "/v2/logout",
|
||||
RawQuery: url.Values{
|
||||
"returnTo": {
|
||||
@@ -253,7 +255,7 @@ func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
|
||||
Host: requestHost,
|
||||
}).String(),
|
||||
},
|
||||
"client_id": {AUTH0_CLIENT_ID},
|
||||
"client_id": {a.Config.ClientID},
|
||||
}.Encode(),
|
||||
}
|
||||
}
|
||||
@@ -267,7 +269,7 @@ func (a *Authenticator) RefreshAccessToken(
|
||||
err error,
|
||||
) {
|
||||
|
||||
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, accessToken)
|
||||
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, oldAccessToken)
|
||||
if err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
// DevLogin mints a local session for userID without going through the real
|
||||
// Auth0 OAuth flow. It writes a real oauth_users/oauth_tokens row, so every
|
||||
// other code path (identity lookup, account linking/creation, cookie
|
||||
// handling) treats it exactly like a normal login.
|
||||
//
|
||||
// Only ever call this from a route gated on an explicit dev-mode flag -
|
||||
// never wire it up unconditionally, since it lets the caller authenticate
|
||||
// as any user_id with no credentials.
|
||||
func (a *Authenticator) DevLogin(ctx context.Context, userID, name string) (accessToken string, expiration time.Time, err error) {
|
||||
tokenBytes := make([]byte, 32)
|
||||
if _, err := rand.Read(tokenBytes); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to generate access token: %w", err)
|
||||
}
|
||||
accessToken = "dev_" + hex.EncodeToString(tokenBytes)
|
||||
|
||||
// Set far in the future so the near-expiry refresh path in
|
||||
// server/auth never tries to refresh a token that has no real
|
||||
// Auth0-side refresh token behind it.
|
||||
expiration = time.Now().Add(365 * 24 * time.Hour)
|
||||
|
||||
if _, err = a.db.Exec(
|
||||
ctx,
|
||||
`
|
||||
WITH ensured_user AS (
|
||||
INSERT INTO oauth_users (user_id)
|
||||
VALUES (@user_id)
|
||||
ON CONFLICT DO NOTHING
|
||||
)
|
||||
INSERT INTO oauth_tokens (
|
||||
access_token,
|
||||
token_type,
|
||||
refresh_token,
|
||||
expiry,
|
||||
|
||||
id_token_issuer,
|
||||
id_token_audience,
|
||||
id_token_subject,
|
||||
id_token_expiry,
|
||||
id_token_issued_at,
|
||||
id_token_nonce,
|
||||
id_token_access_token_hash,
|
||||
|
||||
id_token_custom_claims_family_name,
|
||||
id_token_custom_claims_given_name,
|
||||
id_token_custom_claims_name,
|
||||
id_token_custom_claims_nickname,
|
||||
id_token_custom_claims_picture,
|
||||
id_token_custom_claims_updated_at
|
||||
)
|
||||
VALUES (
|
||||
@access_token,
|
||||
'dev',
|
||||
'',
|
||||
@expiry,
|
||||
|
||||
'dev-auth-bypass',
|
||||
@audience,
|
||||
@user_id,
|
||||
@expiry,
|
||||
NOW(),
|
||||
'',
|
||||
'',
|
||||
|
||||
@name,
|
||||
@name,
|
||||
@name,
|
||||
@name,
|
||||
'',
|
||||
NOW()
|
||||
)
|
||||
`,
|
||||
pgx.NamedArgs{
|
||||
"access_token": accessToken,
|
||||
"expiry": expiration,
|
||||
"user_id": userID,
|
||||
"name": name,
|
||||
"audience": pgtype.FlatArray[string]{"dev"},
|
||||
},
|
||||
); err != nil {
|
||||
return "", time.Time{}, fmt.Errorf("failed to save dev session: %w", err)
|
||||
}
|
||||
|
||||
return accessToken, expiration, nil
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package authentication
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
|
||||
func TestDevLogin(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
auth := &Authenticator{db: pool}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
|
||||
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
|
||||
})
|
||||
|
||||
accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
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)
|
||||
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.
|
||||
assert.WithinDuration(t, expiration, claims.Expiration, time.Millisecond, "claims.Expiration")
|
||||
|
||||
_, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken)
|
||||
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 -
|
||||
// e.g. testing multiple times as the same dev identity - since oauth_users
|
||||
// is keyed on user_id but each call mints its own oauth_tokens row.
|
||||
func TestDevLogin_SameUserIDTwice(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
auth := &Authenticator{db: pool}
|
||||
ctx := context.Background()
|
||||
|
||||
userID := testdb.NewUserID(t)
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
|
||||
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
|
||||
})
|
||||
|
||||
token1, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
require.NoError(t, err, "first DevLogin()")
|
||||
|
||||
token2, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||
require.NoError(t, err, "second DevLogin()")
|
||||
|
||||
assert.NotEqual(t, token1, token2, "DevLogin() returned the same access token twice")
|
||||
}
|
||||
|
||||
func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
auth := &Authenticator{db: pool}
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t))
|
||||
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.
|
||||
@@ -0,0 +1,68 @@
|
||||
package raw_events_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
"ruben/inventory2/internal/testdb"
|
||||
)
|
||||
|
||||
func TestSaveAndLoadEventsForStore(t *testing.T) {
|
||||
pool := testdb.Pool(t)
|
||||
store := raw_events.NewStore(testdb.Logger(), pool)
|
||||
ctx := context.Background()
|
||||
|
||||
platform := "test-platform"
|
||||
storeID := "test-store-" + uuid.NewString()
|
||||
|
||||
t.Cleanup(func() {
|
||||
pool.Exec(context.Background(), "DELETE FROM raw_store_events WHERE platform = $1 AND store_id = $2", platform, storeID)
|
||||
})
|
||||
|
||||
older := raw_events.Event{
|
||||
Platform: platform,
|
||||
StoreID: storeID,
|
||||
EventID: "evt-1",
|
||||
EventTimestamp: time.Now().Add(-time.Hour).UTC(),
|
||||
Payload: json.RawMessage(`{"n":1}`),
|
||||
}
|
||||
newer := raw_events.Event{
|
||||
Platform: platform,
|
||||
StoreID: storeID,
|
||||
EventID: "evt-2",
|
||||
EventTimestamp: time.Now().UTC(),
|
||||
Payload: json.RawMessage(`{"n":2}`),
|
||||
}
|
||||
|
||||
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)
|
||||
require.NoError(t, err, "LoadEventsForStore()")
|
||||
require.Len(t, got, 2, "LoadEventsForStore()")
|
||||
|
||||
// ordered event_timestamp DESC - newest first.
|
||||
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 }
|
||||
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) {
|
||||
pool := testdb.Pool(t)
|
||||
store := raw_events.NewStore(testdb.Logger(), pool)
|
||||
ctx := context.Background()
|
||||
|
||||
got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString())
|
||||
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()")
|
||||
}
|
||||
@@ -2,9 +2,14 @@ package reports
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"slices"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/logging"
|
||||
)
|
||||
@@ -30,3 +35,225 @@ func NewStore(logger *logging.Logger, db *pgxpool.Pool, accts *accounts.Store) *
|
||||
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
|
||||
return NewStoreWithContext(ctx, db)
|
||||
}
|
||||
|
||||
// TODO: prefix all these names with 'mock' since they're all mock stuff
|
||||
|
||||
type (
|
||||
ListingCountsOverTimeReport struct {
|
||||
AccountID int64
|
||||
Platform accounts.Platform
|
||||
ShopID string
|
||||
ListingID string
|
||||
Counts []ListingCountAtTime
|
||||
}
|
||||
|
||||
ListingCountAtTime struct {
|
||||
EventTimestamp *time.Time
|
||||
Count int64
|
||||
}
|
||||
)
|
||||
|
||||
func (r ListingCountsOverTimeReport) MaxCount() ListingCountAtTime {
|
||||
if len(r.Counts) == 0 {
|
||||
return ListingCountAtTime{}
|
||||
}
|
||||
return slices.MaxFunc(r.Counts, func(a, b ListingCountAtTime) int {
|
||||
return int(a.Count - b.Count)
|
||||
})
|
||||
}
|
||||
|
||||
func (r ListingCountsOverTimeReport) MinCount() ListingCountAtTime {
|
||||
if len(r.Counts) == 0 {
|
||||
return ListingCountAtTime{}
|
||||
}
|
||||
return slices.MinFunc(r.Counts, func(a, b ListingCountAtTime) int {
|
||||
return int(a.Count - b.Count)
|
||||
})
|
||||
}
|
||||
|
||||
func (db *Store) GetListingCountsReport(ctx context.Context, acctID int64, platform accounts.Platform, shopID, listingID string) (*ListingCountsOverTimeReport, error) {
|
||||
counts, err := db.GetListingCountsOverTime(ctx, acctID, platform, shopID, listingID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ListingCountsOverTimeReport{
|
||||
AccountID: acctID,
|
||||
Platform: platform,
|
||||
ShopID: shopID,
|
||||
ListingID: listingID,
|
||||
Counts: counts,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (db *Store) GetListingCountsOverTime(ctx context.Context, acctID int64, platform accounts.Platform, shopID, listingID string) ([]ListingCountAtTime, error) {
|
||||
info, ok := getMockShopSchemaInfo(platform)
|
||||
if !ok {
|
||||
return nil, consts.ErrNotFound
|
||||
}
|
||||
|
||||
var counts []ListingCountAtTime
|
||||
|
||||
err := pgx.BeginTxFunc(ctx, db.db, pgx.TxOptions{
|
||||
AccessMode: pgx.ReadOnly,
|
||||
}, func(tx pgx.Tx) error {
|
||||
rows, err := tx.Query(
|
||||
ctx,
|
||||
fmt.Sprintf(
|
||||
`
|
||||
SELECT
|
||||
1
|
||||
FROM
|
||||
%s
|
||||
WHERE
|
||||
shop_id = @shop_id
|
||||
AND listing_id = @listing_id
|
||||
`,
|
||||
info.listingsTable,
|
||||
),
|
||||
pgx.NamedArgs{
|
||||
"shop_id": shopID,
|
||||
"listing_id": listingID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform query to check the existing of the listing: %w", err)
|
||||
}
|
||||
|
||||
if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]); err != nil {
|
||||
return fmt.Errorf("listing not found: %w", consts.ErrNotFound)
|
||||
}
|
||||
|
||||
rows, err = tx.Query(
|
||||
ctx,
|
||||
fmt.Sprintf(
|
||||
`
|
||||
SELECT
|
||||
event_timestamp AS eventTimestamp,
|
||||
"count"
|
||||
FROM
|
||||
%s
|
||||
WHERE
|
||||
shop_id = @shop_id
|
||||
AND listing_id = @listing_id
|
||||
`,
|
||||
info.listingCountsView,
|
||||
),
|
||||
pgx.NamedArgs{
|
||||
"shop_id": shopID,
|
||||
"listing_id": listingID,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to perform query: %w", err)
|
||||
}
|
||||
|
||||
if counts, err = pgx.CollectRows(rows, pgx.RowToStructByNameLax[ListingCountAtTime]); err != nil {
|
||||
return fmt.Errorf("failed to scan rows: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
|
||||
return counts, err
|
||||
}
|
||||
|
||||
type mockShopSchemaInfo struct {
|
||||
platform accounts.Platform
|
||||
shopTable string
|
||||
listingsTable string
|
||||
listingCountsView string
|
||||
}
|
||||
|
||||
func getMockShopSchemaInfo(platform accounts.Platform) (mockShopSchemaInfo, bool) {
|
||||
for _, in := range getAllMockShopSchemaInfos() {
|
||||
if in.platform == platform {
|
||||
return in, true
|
||||
}
|
||||
}
|
||||
|
||||
return mockShopSchemaInfo{}, false
|
||||
}
|
||||
|
||||
func getAllMockShopSchemaInfos() []mockShopSchemaInfo {
|
||||
return []mockShopSchemaInfo{
|
||||
{
|
||||
platform: accounts.Amazon,
|
||||
shopTable: "mock.shop_amazon",
|
||||
listingsTable: "mock.shop_amazon_listings",
|
||||
listingCountsView: "mock.shop_amazon_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.BigCartel,
|
||||
shopTable: "mock.shop_big_cartel",
|
||||
listingsTable: "mock.shop_big_cartel_listings",
|
||||
listingCountsView: "mock.shop_big_cartel_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.Ebay,
|
||||
shopTable: "mock.shop_ebay",
|
||||
listingsTable: "mock.shop_ebay_listings",
|
||||
listingCountsView: "mock.shop_ebay_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.Ecwid,
|
||||
shopTable: "mock.shop_ecwid",
|
||||
listingsTable: "mock.shop_ecwid_listings",
|
||||
listingCountsView: "mock.shop_ecwid_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.Etsy,
|
||||
shopTable: "mock.shop_etsy",
|
||||
listingsTable: "mock.shop_etsy_listings",
|
||||
listingCountsView: "mock.shop_etsy_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.Shopify,
|
||||
shopTable: "mock.shop_shopify",
|
||||
listingsTable: "mock.shop_shopify_listings",
|
||||
listingCountsView: "mock.shop_shopify_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.SquareOnline,
|
||||
shopTable: "mock.shop_square_online",
|
||||
listingsTable: "mock.shop_square_online_listings",
|
||||
listingCountsView: "mock.shop_square_online_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.Squarespace,
|
||||
shopTable: "mock.shop_squarespace",
|
||||
listingsTable: "mock.shop_squarespace_listings",
|
||||
listingCountsView: "mock.shop_squarespace_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.Tiktok,
|
||||
shopTable: "mock.shop_tiktok",
|
||||
listingsTable: "mock.shop_tiktok_listings",
|
||||
listingCountsView: "mock.shop_tiktok_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.WalmartMarketplace,
|
||||
shopTable: "mock.shop_walmart_marketplace",
|
||||
listingsTable: "mock.shop_walmart_marketplace_listings",
|
||||
listingCountsView: "mock.shop_walmart_marketplace_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.Wix,
|
||||
shopTable: "mock.shop_wix",
|
||||
listingsTable: "mock.shop_wix_listings",
|
||||
listingCountsView: "mock.shop_wix_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.WooCommerce,
|
||||
shopTable: "mock.shop_woo_commerce",
|
||||
listingsTable: "mock.shop_woo_commerce_listings",
|
||||
listingCountsView: "mock.shop_woo_commerce_listing_counts",
|
||||
},
|
||||
{
|
||||
platform: accounts.Zoho,
|
||||
shopTable: "mock.shop_zoho",
|
||||
listingsTable: "mock.shop_zoho_listings",
|
||||
listingCountsView: "mock.shop_zoho_listing_counts",
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -23,3 +23,11 @@ func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
|
||||
func (v_ctx *StoreWithContext) GetRawShopEvents(acctID int64, platform accounts.Platform, shopID string) ([]RawShopEvent, error) {
|
||||
return v_ctx.Store.GetRawShopEvents(v_ctx.ctx, acctID, platform, shopID)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) GetListingCountsReport(acctID int64, platform accounts.Platform, shopID string, listingID string) (*ListingCountsOverTimeReport, error) {
|
||||
return v_ctx.Store.GetListingCountsReport(v_ctx.ctx, acctID, platform, shopID, listingID)
|
||||
}
|
||||
|
||||
func (v_ctx *StoreWithContext) GetListingCountsOverTime(acctID int64, platform accounts.Platform, shopID string, listingID string) ([]ListingCountAtTime, error) {
|
||||
return v_ctx.Store.GetListingCountsOverTime(v_ctx.ctx, acctID, platform, shopID, listingID)
|
||||
}
|
||||
|
||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 507 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 368 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
||||
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 539 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 675 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -11,8 +11,10 @@ require (
|
||||
github.com/coreos/go-oidc/v3 v3.17.0
|
||||
github.com/gin-gonic/gin v1.11.0
|
||||
github.com/google/uuid v1.6.0
|
||||
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
|
||||
)
|
||||
|
||||
@@ -26,14 +28,12 @@ require (
|
||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||
github.com/bytedance/sonic v1.15.0 // indirect
|
||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
|
||||
github.com/chenzhuoyu/iasm v0.9.1 // 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
|
||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
|
||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||
github.com/go-openapi/swag v0.23.0 // indirect
|
||||
@@ -42,7 +42,6 @@ require (
|
||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||
github.com/goccy/go-json v0.10.5 // indirect
|
||||
github.com/goccy/go-yaml v1.19.2 // indirect
|
||||
github.com/golang/protobuf v1.5.4 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||
@@ -63,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
|
||||
@@ -82,7 +82,6 @@ require (
|
||||
golang.org/x/sys v0.40.0 // indirect
|
||||
golang.org/x/text v0.33.0 // indirect
|
||||
golang.org/x/tools v0.41.0 // indirect
|
||||
google.golang.org/appengine v1.6.8 // indirect
|
||||
google.golang.org/protobuf v1.36.11 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
|
||||
@@ -16,29 +16,15 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
|
||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
|
||||
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
|
||||
github.com/bytedance/sonic v1.10.0-rc3 h1:uNSnscRapXTwUgTyOF0GVljYD08p9X/Lbr9MweSV3V0=
|
||||
github.com/bytedance/sonic v1.10.0-rc3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
|
||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
||||
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
|
||||
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
|
||||
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
|
||||
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
|
||||
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
|
||||
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
|
||||
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
|
||||
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
|
||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||
github.com/coreos/go-oidc/v3 v3.8.0 h1:s3e30r6VEl3/M7DTSCEuImmrfu1/1WBgA0cXkdzkrAY=
|
||||
github.com/coreos/go-oidc/v3 v3.8.0/go.mod h1:yQzSCqBnK3e6Fs5l+f5i0F8Kwf0zpH9bPEsbY00KanM=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
@@ -51,24 +37,14 @@ github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5ql
|
||||
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
|
||||
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
|
||||
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
||||
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
|
||||
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
|
||||
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
||||
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
|
||||
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
|
||||
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
||||
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
|
||||
@@ -81,15 +57,11 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
|
||||
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
|
||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
|
||||
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||
@@ -103,22 +75,14 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
|
||||
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
||||
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
|
||||
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
|
||||
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
|
||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
|
||||
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
|
||||
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
|
||||
@@ -129,23 +93,19 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
|
||||
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
|
||||
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
|
||||
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
||||
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
|
||||
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
|
||||
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
|
||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
|
||||
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||
@@ -153,8 +113,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
||||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
|
||||
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
|
||||
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
|
||||
@@ -195,8 +153,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
|
||||
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
|
||||
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
|
||||
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
||||
@@ -230,20 +186,15 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
|
||||
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
|
||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
||||
@@ -253,28 +204,17 @@ github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4Z
|
||||
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
|
||||
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
|
||||
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
|
||||
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
||||
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
||||
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
|
||||
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
|
||||
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
||||
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
@@ -282,23 +222,15 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
|
||||
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
|
||||
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
||||
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
||||
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
|
||||
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
|
||||
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
||||
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
@@ -315,44 +247,26 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
|
||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
|
||||
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
|
||||
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
|
||||
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
|
||||
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||
@@ -361,8 +275,6 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
|
||||
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
|
||||
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
|
||||
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
|
||||
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
|
||||
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -382,5 +294,3 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package testdb provides shared fixtures for integration tests that need a
|
||||
// real Postgres connection. It targets TEST_DATABASE_URL, which `make test`
|
||||
// points at a dedicated inventory_2_test database by default; override it
|
||||
// (e.g. `make test TEST_DATABASE_URL=...`) to run the same tests against
|
||||
// another database, such as the real dev one.
|
||||
package testdb
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"ruben/inventory2/logging"
|
||||
)
|
||||
|
||||
// Pool connects to TEST_DATABASE_URL. If it isn't set, the test is skipped
|
||||
// rather than failed, so `go test ./...` works without Postgres running.
|
||||
func Pool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
|
||||
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_DATABASE_URL not set; skipping integration test (see .env.example, or run via `make test`)")
|
||||
}
|
||||
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to connect to test database: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
|
||||
if err := pool.Ping(context.Background()); err != nil {
|
||||
t.Fatalf("failed to reach test database at TEST_DATABASE_URL: %v", err)
|
||||
}
|
||||
|
||||
return pool
|
||||
}
|
||||
|
||||
// Logger returns a logger that discards output, for stores that require one
|
||||
// but whose logging isn't under test.
|
||||
func Logger() *logging.Logger {
|
||||
return logging.New(slog.NewTextHandler(io.Discard, nil))
|
||||
}
|
||||
|
||||
// NewUserID returns a unique user_id for a test to use, so parallel test
|
||||
// runs (including against a shared database) never collide.
|
||||
func NewUserID(t *testing.T) string {
|
||||
t.Helper()
|
||||
return "test-" + t.Name() + "-" + uuid.NewString()
|
||||
}
|
||||
|
||||
// SeedOAuthUser inserts a bare oauth_users row so accounts/etc. FKs that
|
||||
// reference it are satisfiable, and registers cleanup. Use SeedOAuthSession
|
||||
// instead if the test also needs a valid access token.
|
||||
func SeedOAuthUser(t *testing.T, pool *pgxpool.Pool, userID string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
if _, err := pool.Exec(ctx, `INSERT INTO oauth_users (user_id) VALUES ($1)`, userID); err != nil {
|
||||
t.Fatalf("failed to seed oauth_users row: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
if _, err := pool.Exec(context.Background(), `DELETE FROM oauth_users WHERE user_id = $1`, userID); err != nil {
|
||||
t.Errorf("cleanup: failed to delete oauth_users row: %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// SeedOAuthSession inserts an oauth_users row plus a matching oauth_tokens
|
||||
// row with a freshly generated access token, mirroring the shape a real (or
|
||||
// authentication.Authenticator.DevLogin) session leaves behind. Registers
|
||||
// cleanup for both rows, in dependency order.
|
||||
func SeedOAuthSession(t *testing.T, pool *pgxpool.Pool, userID string) (accessToken string) {
|
||||
t.Helper()
|
||||
ctx := context.Background()
|
||||
|
||||
SeedOAuthUser(t, pool, userID)
|
||||
|
||||
accessToken = "test-token-" + uuid.NewString()
|
||||
|
||||
_, err := pool.Exec(ctx, `
|
||||
INSERT INTO oauth_tokens (
|
||||
access_token, token_type, refresh_token, expiry,
|
||||
id_token_issuer, id_token_audience, id_token_subject,
|
||||
id_token_expiry, id_token_issued_at, id_token_nonce, id_token_access_token_hash,
|
||||
id_token_custom_claims_family_name, id_token_custom_claims_given_name,
|
||||
id_token_custom_claims_name, id_token_custom_claims_nickname,
|
||||
id_token_custom_claims_picture, id_token_custom_claims_updated_at
|
||||
) VALUES (
|
||||
$1, 'test', '', NOW() + INTERVAL '1 hour',
|
||||
'test', '{test}', $2,
|
||||
NOW() + INTERVAL '1 hour', NOW(), '', '',
|
||||
'Test', 'User',
|
||||
'Test User', 'testuser',
|
||||
'', NOW()
|
||||
)
|
||||
`, accessToken, userID)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to seed oauth_tokens row: %v", err)
|
||||
}
|
||||
|
||||
t.Cleanup(func() {
|
||||
if _, err := pool.Exec(context.Background(), `DELETE FROM oauth_tokens WHERE access_token = $1`, accessToken); err != nil {
|
||||
t.Errorf("cleanup: failed to delete oauth_tokens row: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
return accessToken
|
||||
}
|
||||
@@ -21,18 +21,16 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
"github.com/lmittmann/tint"
|
||||
|
||||
"ruben/inventory2/config"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/domains/amazon"
|
||||
"ruben/inventory2/domains/authentication"
|
||||
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
"ruben/inventory2/domains/reports"
|
||||
"ruben/inventory2/logging"
|
||||
"ruben/inventory2/server"
|
||||
)
|
||||
|
||||
const (
|
||||
etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5"
|
||||
etsyAPISharedSecret = "jaaw0tyizf"
|
||||
"ruben/inventory2/server/sse"
|
||||
)
|
||||
|
||||
func main() {
|
||||
@@ -66,25 +64,66 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
||||
ctx, shutdown := context.WithCancel(ctx)
|
||||
defer shutdown()
|
||||
|
||||
// load configuration
|
||||
|
||||
cfg, err := config.Load()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to load configuration: %w", err)
|
||||
}
|
||||
|
||||
// connect to the database
|
||||
|
||||
connPool, err := newPool(ctx)
|
||||
connPool, err := newPool(ctx, cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize database connection pool: %w", err)
|
||||
}
|
||||
|
||||
// start background processes
|
||||
|
||||
auth, err := authentication.New(ctx, connPool, logger.WithGroup("authenticator"))
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
authErrCh := runAuthProcesses(ctx, auth)
|
||||
accts := accounts.NewStore(logger.WithGroup("accounts"), connPool)
|
||||
|
||||
// start http server
|
||||
|
||||
srvErrCh := runServer(ctx, logger, connPool, auth)
|
||||
sseQueue, srvErrCh := runServer(ctx, logger, connPool, auth, accts, cfg)
|
||||
|
||||
// start background processes
|
||||
|
||||
authErrCh := runAuthProcesses(ctx, auth)
|
||||
|
||||
eventErrCh := runEventProcessing(
|
||||
ctx,
|
||||
amazon.NewMocks(logger.WithGroup("amazon"), connPool).
|
||||
SetListener(sseQueue.NewDBEventPublisher(
|
||||
func(ctx context.Context, e raw_events.Event) (acctID int64, err error) {
|
||||
p, err := accounts.NewPlatform(e.Platform)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
return accts.GetAccountIDByMockPlatformAndShopID(ctx, p, e.StoreID)
|
||||
},
|
||||
func(acctID int64, e raw_events.Event) (eventType string, err error) {
|
||||
// TODO: fine tune the event type later (don't want a referesh on EVERY event)
|
||||
return fmt.Sprintf("accounts_%d_simulations", acctID), nil
|
||||
},
|
||||
)),
|
||||
)
|
||||
|
||||
// wait for interrupt signal or unrecoverable failure, then shutdown
|
||||
|
||||
@@ -93,8 +132,9 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
||||
|
||||
var (
|
||||
alreadyShutdown struct {
|
||||
server bool
|
||||
authProcesses bool
|
||||
server bool
|
||||
authProcesses bool
|
||||
eventProcessing bool
|
||||
}
|
||||
)
|
||||
select {
|
||||
@@ -107,6 +147,12 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
||||
if err != nil {
|
||||
logger.Error("auth processes encountered error", "error", err)
|
||||
}
|
||||
case err := <-eventErrCh:
|
||||
alreadyShutdown.eventProcessing = true
|
||||
logger.Error("event processing shutdown unexpectedly")
|
||||
if err != nil {
|
||||
logger.Error("event processing encountered error", "error", err)
|
||||
}
|
||||
case err := <-srvErrCh:
|
||||
alreadyShutdown.server = true
|
||||
logger.Error("server shutdown unexpectedly")
|
||||
@@ -128,6 +174,13 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
||||
logger.Info("auth processes shut down")
|
||||
}
|
||||
|
||||
if !alreadyShutdown.eventProcessing {
|
||||
if err := <-authErrCh; err != nil {
|
||||
errs = append(errs, fmt.Errorf("event processing experienced an error: %w", err))
|
||||
}
|
||||
logger.Info("event processing shut down")
|
||||
}
|
||||
|
||||
if !alreadyShutdown.server {
|
||||
if err := <-srvErrCh; err != nil {
|
||||
errs = append(errs, fmt.Errorf("server experienced an error: %w", err))
|
||||
@@ -151,9 +204,27 @@ func runAuthProcesses(ctx context.Context, auth *authentication.Authenticator) <
|
||||
return errCh
|
||||
}
|
||||
|
||||
func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Pool, auth *authentication.Authenticator) <-chan error {
|
||||
accts := accounts.NewStore(logger, connPool)
|
||||
func runEventProcessing(ctx context.Context, amz *amazon.Mocks) <-chan error {
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
defer close(errCh)
|
||||
|
||||
if err := amz.ProcessEvents(ctx); err != nil {
|
||||
errCh <- err
|
||||
}
|
||||
}()
|
||||
|
||||
return errCh
|
||||
}
|
||||
|
||||
func runServer(
|
||||
ctx context.Context,
|
||||
logger *logging.Logger,
|
||||
connPool *pgxpool.Pool,
|
||||
auth *authentication.Authenticator,
|
||||
accts *accounts.Store,
|
||||
cfg config.Config,
|
||||
) (*sse.Queue, <-chan error) {
|
||||
r := server.NewRouter(
|
||||
logger.WithGroup("server"),
|
||||
"./",
|
||||
@@ -165,15 +236,16 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
|
||||
func(acctID int64) string {
|
||||
return fmt.Sprintf("/oauth/account/%d/auth_code", acctID)
|
||||
},
|
||||
etsyAPIKeystring,
|
||||
etsyAPISharedSecret,
|
||||
cfg.EtsyAPIKeystring,
|
||||
cfg.EtsyAPISharedSecret,
|
||||
connPool,
|
||||
),
|
||||
auth,
|
||||
cfg.DevAuthEnabled,
|
||||
)
|
||||
|
||||
srv := &http.Server{
|
||||
Addr: ":8082", // local
|
||||
Addr: fmt.Sprintf(":%d", cfg.Port), // local
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
@@ -186,7 +258,7 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
|
||||
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)
|
||||
@@ -235,5 +307,5 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
|
||||
}
|
||||
}()
|
||||
|
||||
return errCh
|
||||
return r.GetSSEQueue(), errCh
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
@@ -0,0 +1,108 @@
|
||||
(function(){
|
||||
const defaultDebounceTimeInMs = 500;
|
||||
|
||||
let isReady = false
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
isReady = true
|
||||
})
|
||||
function onReady(fn) {
|
||||
if (isReady || document.readyState === 'complete') {
|
||||
fn()
|
||||
} else {
|
||||
document.addEventListener('DOMContentLoaded', fn)
|
||||
}
|
||||
}
|
||||
|
||||
htmx.defineExtension("resize", {
|
||||
init: (api) => {
|
||||
function hasResizeTrigger(elt) {
|
||||
for (spec of api.getTriggerSpecs(elt)) {
|
||||
if (spec.trigger === 'resize') {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDebounceTimeInMs(elt) {
|
||||
const amountAttr = elt.attributes['hx-resize-debounce'];
|
||||
if (!amountAttr) {
|
||||
return defaultDebounceTimeInMs
|
||||
}
|
||||
const amountStr = amountAttr.value
|
||||
if (!amountStr) {
|
||||
return defaultDebounceTimeInMs
|
||||
}
|
||||
|
||||
try {
|
||||
return parseInt(amountStr);
|
||||
} catch (err) {
|
||||
console.warn('invalid debounce time on trigger:', trigger);
|
||||
console.error(err);
|
||||
return defaultDebounceTimeInMs;
|
||||
}
|
||||
}
|
||||
|
||||
let nextID = 1;
|
||||
let timeoutIDs = {};
|
||||
let observers = {};
|
||||
function processNode(elt) {
|
||||
if (!hasResizeTrigger(elt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = elt['hx-resize-internal-data'];
|
||||
if (!data) {
|
||||
data = {
|
||||
id: nextID,
|
||||
};
|
||||
nextID += 1;
|
||||
|
||||
elt['hx-resize-internal-data'] = data;
|
||||
}
|
||||
|
||||
const obs = new ResizeObserver(() => {
|
||||
if (timeoutIDs[data.id]) {
|
||||
return;
|
||||
}
|
||||
|
||||
const debounceTime = getDebounceTimeInMs(elt);
|
||||
timeoutIDs[data.id] = setTimeout(() => {
|
||||
elt.dispatchEvent(new Event('resize'));
|
||||
delete timeoutIDs[data.id];
|
||||
}, debounceTime);
|
||||
});
|
||||
|
||||
obs.observe(elt);
|
||||
|
||||
observers[data.id] = obs;
|
||||
}
|
||||
|
||||
function cleanupNode(elt) {
|
||||
if (!hasResizeTrigger(elt)) {
|
||||
return;
|
||||
}
|
||||
|
||||
let data = elt['hx-resize-internal-data'];
|
||||
if (!data) {
|
||||
return;
|
||||
}
|
||||
|
||||
const obs = observers[data.id];
|
||||
if (obs) {
|
||||
obs.disconnect();
|
||||
}
|
||||
|
||||
delete observers[data.id];
|
||||
delete timeoutIDs[data.id];
|
||||
}
|
||||
|
||||
// process nodes
|
||||
htmx.on('htmx:beforeProcessNode', evt => processNode(evt.target));
|
||||
htmx.on('htmx:beforeCleanupElement', evt => cleanupNode(evt.target));
|
||||
onReady(function() {
|
||||
document.querySelectorAll('[hx-trigger]').forEach(processNode);
|
||||
})
|
||||
}
|
||||
});
|
||||
})()
|
||||
@@ -3,36 +3,43 @@ package accounts
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/google/uuid"
|
||||
|
||||
"ruben/inventory2/consts"
|
||||
"ruben/inventory2/domains/accounts"
|
||||
"ruben/inventory2/domains/reports"
|
||||
"ruben/inventory2/logging"
|
||||
"ruben/inventory2/server/auth"
|
||||
"ruben/inventory2/server/param"
|
||||
"ruben/inventory2/server/response"
|
||||
"ruben/inventory2/server/sse"
|
||||
"ruben/inventory2/server/ui/charts"
|
||||
)
|
||||
|
||||
type accountSubrouter struct {
|
||||
log *logging.Logger
|
||||
accts *accounts.Store
|
||||
pub *sse.UpdateNotificationPublisher
|
||||
log *logging.Logger
|
||||
accts *accounts.Store
|
||||
reports *reports.Store
|
||||
pub *sse.UpdateNotificationPublisher
|
||||
}
|
||||
|
||||
func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
accts *accounts.Store,
|
||||
reports *reports.Store,
|
||||
pub *sse.UpdateNotificationPublisher,
|
||||
) {
|
||||
as := &accountSubrouter{
|
||||
log: logger,
|
||||
accts: accts,
|
||||
pub: pub,
|
||||
log: logger,
|
||||
accts: accts,
|
||||
reports: reports,
|
||||
pub: pub,
|
||||
}
|
||||
|
||||
r.POST("", response.Handler(as.createAccount))
|
||||
@@ -46,6 +53,9 @@ func Routes(
|
||||
mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing))
|
||||
mockShops.DELETE("/listings/:listing-id", response.Handler(as.deleteMockListing))
|
||||
|
||||
mockListingCharts := r.Group("/:acctID/platforms/:platform/shops/mocks/:shop-id/listings/:listing-id/charts")
|
||||
mockListingCharts.GET("/counts", response.Handler(as.getCountsChartForMockListing))
|
||||
|
||||
syncGroups := r.Group("/:acctID/inventory/sync-groups", pub.Publish("/:acctID/inventory/sync-groups"))
|
||||
syncGroups.POST("", response.Handler(as.saveNewSyncGroup))
|
||||
|
||||
@@ -421,6 +431,135 @@ func (s *accountSubrouter) deleteMockListing(c *gin.Context) (response.Response,
|
||||
return response.StatusNoContent(), nil
|
||||
}
|
||||
|
||||
// TODO: accept dimensions
|
||||
// GET /:acctID/platforms/:platform/shops/mocks/:shop-id/charts/counts
|
||||
func (s *accountSubrouter) getCountsChartForMockListing(c *gin.Context) (response.Response, error) {
|
||||
s.log.Debug("ENDPOINT HIT")
|
||||
acctID := auth.GetIdentity(c).Account.AccountID
|
||||
|
||||
var (
|
||||
platform accounts.Platform
|
||||
shopID string
|
||||
listingID string
|
||||
width float64
|
||||
height float64
|
||||
timezone string
|
||||
)
|
||||
|
||||
err := param.Path("platform", param.Platform(&platform)).
|
||||
Path("shop-id", param.Text(&shopID)).
|
||||
Path("listing-id", param.Text(&listingID)).
|
||||
Form("width", param.Float64(&width)).
|
||||
Form("height", param.Float64(&height)).
|
||||
Form("timezone", param.Text(&timezone)).
|
||||
Unmarshal(c)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if width <= 0 {
|
||||
return nil, response.BadRequest().Msg("width must be positive")
|
||||
}
|
||||
if height <= 0 {
|
||||
return nil, response.BadRequest().Msg("height must be positive")
|
||||
}
|
||||
|
||||
report, err := s.reports.GetListingCountsReport(c, acctID, platform, shopID, listingID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get report: %w", err)
|
||||
}
|
||||
|
||||
/*
|
||||
bvs := make([]charts.BarValue, len(report.Counts))
|
||||
for i, v := range report.Counts {
|
||||
label := "start"
|
||||
if v.EventTimestamp != nil {
|
||||
label = v.EventTimestamp.Format(time.RFC822)
|
||||
}
|
||||
bvs[i] = charts.BarValue{
|
||||
Label: label,
|
||||
Value: float64(v.Count),
|
||||
}
|
||||
}
|
||||
|
||||
return response.StatusOK().
|
||||
HTMLReader(charts.NewBar(bvs...).SVG().GetMarkupReader()), nil
|
||||
*/
|
||||
|
||||
/*
|
||||
lvs := make([]charts.LineValue, len(report.Counts))
|
||||
for i, v := range report.Counts {
|
||||
label := "initial"
|
||||
if v.EventTimestamp != nil {
|
||||
label = v.EventTimestamp.Format(time.RFC822)
|
||||
}
|
||||
lvs[i] = charts.LineValue{
|
||||
Label: label,
|
||||
Value: float64(v.Count),
|
||||
}
|
||||
}
|
||||
*/
|
||||
|
||||
l, err := time.LoadLocation(timezone)
|
||||
if err != nil {
|
||||
s.log.Errorf("failed to parse time zone %s: %v; defaulting to UTC", timezone, err)
|
||||
l = time.UTC
|
||||
}
|
||||
|
||||
var times []time.Time
|
||||
for _, v := range report.Counts {
|
||||
if v.EventTimestamp == nil {
|
||||
continue
|
||||
}
|
||||
times = append(times, *v.EventTimestamp)
|
||||
}
|
||||
var (
|
||||
minTime time.Time
|
||||
maxTime time.Time
|
||||
initTime time.Time
|
||||
)
|
||||
if len(times) > 0 {
|
||||
minTime = slices.MinFunc(times, func(a, b time.Time) int {
|
||||
return int(a.UnixNano() - b.UnixNano())
|
||||
}).In(l)
|
||||
maxTime = slices.MaxFunc(times, func(a, b time.Time) int {
|
||||
return int(a.UnixNano() - b.UnixNano())
|
||||
}).In(l)
|
||||
|
||||
if len(times) > 1 {
|
||||
avgIntervalPerUpdate := time.Duration((maxTime.UnixNano() - minTime.UnixNano())) / time.Duration(len(times)-1)
|
||||
initTime = minTime.Add(-avgIntervalPerUpdate)
|
||||
} else {
|
||||
initTime = minTime.Add(-time.Minute)
|
||||
}
|
||||
}
|
||||
|
||||
lvs := make([]charts.TimeLineValue, len(report.Counts))
|
||||
for i, v := range report.Counts {
|
||||
ts := initTime
|
||||
label := fmt.Sprint(v.Count)
|
||||
if v.EventTimestamp != nil {
|
||||
ts = v.EventTimestamp.In(l)
|
||||
}
|
||||
|
||||
lvs[i] = charts.TimeLineValue{
|
||||
Label: label,
|
||||
Time: ts,
|
||||
Value: float64(v.Count),
|
||||
}
|
||||
}
|
||||
|
||||
return response.StatusOK().
|
||||
HTMLReader(
|
||||
//charts.NewLineChart(width, height, lvs...).
|
||||
charts.NewTimeLineChart(width, height, lvs...).
|
||||
Foreground("var(--foreground)").
|
||||
Background("var(--muted)").
|
||||
SVG().
|
||||
GetMarkupReader(),
|
||||
), nil
|
||||
}
|
||||
|
||||
// POST /:acctID/inventory/sync-groups/mock/draft/listings
|
||||
func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"ruben/inventory2/domains/accounts"
|
||||
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
"ruben/inventory2/domains/reports"
|
||||
"ruben/inventory2/logging"
|
||||
accounts_api "ruben/inventory2/server/api/accounts"
|
||||
auth_api "ruben/inventory2/server/api/auth"
|
||||
@@ -22,14 +23,17 @@ func Routes(
|
||||
auth *auth.Service,
|
||||
sq *sse.Queue,
|
||||
accts *accounts.Store,
|
||||
reps *reports.Store,
|
||||
unp *sse.UpdateNotificationPublisher,
|
||||
rawEvents *raw_events.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
devAuthEnabled bool,
|
||||
) {
|
||||
auth_api.Routes(
|
||||
r.Group("/auth"),
|
||||
logger.WithGroup("/auth"),
|
||||
auth.GetAuthenticator(),
|
||||
devAuthEnabled,
|
||||
)
|
||||
sse_api.Routes(
|
||||
r.Group("/events", auth.Authenticate()),
|
||||
@@ -40,6 +44,7 @@ func Routes(
|
||||
r.Group("/accounts", auth.Authenticate()),
|
||||
logger.WithGroup("/accounts"),
|
||||
accts,
|
||||
reps,
|
||||
unp.Group("/accounts"),
|
||||
)
|
||||
webhooks.Routes(
|
||||
|
||||
@@ -21,6 +21,7 @@ func Routes(
|
||||
r *gin.RouterGroup,
|
||||
logger *logging.Logger,
|
||||
auth *authentication.Authenticator,
|
||||
devAuthEnabled bool,
|
||||
) {
|
||||
ls := &loginSubrouter{
|
||||
log: logger,
|
||||
@@ -30,6 +31,12 @@ func Routes(
|
||||
r.GET("/login", response.Handler(ls.loginPage))
|
||||
r.GET("/login/callback", response.Handler(ls.loginCallback))
|
||||
r.GET("/logout", response.Handler(ls.logoutPage))
|
||||
|
||||
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))
|
||||
}
|
||||
}
|
||||
|
||||
func (s *loginSubrouter) loginPage(c *gin.Context) (response.Response, error) {
|
||||
@@ -75,6 +82,43 @@ func (s *loginSubrouter) loginCallback(c *gin.Context) (response.Response, error
|
||||
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
// devLoginPage mints a local session for a user_id, skipping the real Auth0
|
||||
// OAuth round-trip. Only registered when devAuthEnabled is passed to Routes.
|
||||
//
|
||||
// Query params:
|
||||
// - user_id: identity to log in as (default "dev-user"); use different
|
||||
// values to test multiple accounts side by side.
|
||||
// - name: display name for the identity (default derived from user_id).
|
||||
// - target: where to redirect after login (default "/").
|
||||
func (s *loginSubrouter) devLoginPage(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
ctx := r.Context()
|
||||
q := r.URL.Query()
|
||||
|
||||
userID := q.Get("user_id")
|
||||
if userID == "" {
|
||||
userID = "dev-user"
|
||||
}
|
||||
|
||||
name := q.Get("name")
|
||||
if name == "" {
|
||||
name = "Dev User (" + userID + ")"
|
||||
}
|
||||
|
||||
targetURI := q.Get("target")
|
||||
if targetURI == "" {
|
||||
targetURI = "/"
|
||||
}
|
||||
|
||||
accessToken, expiration, err := s.auth.DevLogin(ctx, userID, name)
|
||||
if err != nil {
|
||||
return nil, response.Errorf("failed to create dev session: %w", err)
|
||||
}
|
||||
|
||||
return response.TemporaryRedirect(targetURI).
|
||||
Cookie(cookies.AccessToken(accessToken, expiration)), nil
|
||||
}
|
||||
|
||||
func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
|
||||
r := c.Request
|
||||
|
||||
@@ -92,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
|
||||
}
|
||||
|
||||
@@ -21,6 +21,10 @@ func Int64(dst *int64) encoding.TextUnmarshaler {
|
||||
return (*int64Text)(dst)
|
||||
}
|
||||
|
||||
func Float64(dst *float64) encoding.TextUnmarshaler {
|
||||
return (*float64Text)(dst)
|
||||
}
|
||||
|
||||
func Bool(dst *bool) encoding.TextUnmarshaler {
|
||||
return (*boolText)(dst)
|
||||
}
|
||||
@@ -33,6 +37,7 @@ type (
|
||||
rawText string
|
||||
intText int
|
||||
int64Text int64
|
||||
float64Text float64
|
||||
boolText bool
|
||||
platformText accounts.Platform
|
||||
)
|
||||
@@ -60,6 +65,15 @@ func (n *int64Text) UnmarshalText(text []byte) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (n *float64Text) UnmarshalText(text []byte) error {
|
||||
i, err := strconv.ParseFloat(string(text), 64)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
*n = float64Text(i)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *boolText) UnmarshalText(text []byte) error {
|
||||
v, err := strconv.ParseBool(string(text))
|
||||
if err != nil {
|
||||
|
||||
@@ -65,7 +65,9 @@ func (s Spec) Unmarshal(c *gin.Context) error {
|
||||
for k, dst := range s.form {
|
||||
v, ok := c.GetPostForm(k)
|
||||
if !ok || v == "" {
|
||||
return response.BadRequest().Msgf("no %s provided", k)
|
||||
if v, ok = c.GetQuery(k); !ok {
|
||||
return response.BadRequest().Msgf("no %s provided", k)
|
||||
}
|
||||
}
|
||||
if err := dst.UnmarshalText([]byte(v)); err != nil {
|
||||
return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k)
|
||||
|
||||
@@ -47,6 +47,10 @@ func (b bodyRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) HTMLReader(body io.Reader) Response {
|
||||
return HTMLReader(body).wrap(b)
|
||||
}
|
||||
|
||||
func (b bodyRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(b)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,10 @@ func (c cookieRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) HTMLReader(body io.Reader) Response {
|
||||
return HTMLReader(body).wrap(c)
|
||||
}
|
||||
|
||||
func (c cookieRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(c)
|
||||
}
|
||||
|
||||
@@ -103,6 +103,10 @@ func (h headerRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) HTMLReader(body io.Reader) Response {
|
||||
return HTMLReader(body).wrap(h)
|
||||
}
|
||||
|
||||
func (h headerRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(h)
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
|
||||
type (
|
||||
htmlRes struct {
|
||||
body []byte
|
||||
res Response
|
||||
body []byte
|
||||
reader io.Reader
|
||||
res Response
|
||||
}
|
||||
)
|
||||
|
||||
@@ -24,6 +25,12 @@ func HTML(body []byte) Response {
|
||||
}
|
||||
}
|
||||
|
||||
func HTMLReader(body io.Reader) Response {
|
||||
return htmlRes{
|
||||
reader: body,
|
||||
}
|
||||
}
|
||||
|
||||
func (h htmlRes) String() string {
|
||||
if h.res != nil {
|
||||
return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res)
|
||||
@@ -46,6 +53,13 @@ func (h htmlRes) Redirect(code redirect.Code, to string) Response {
|
||||
|
||||
func (h htmlRes) HTML(body []byte) Response {
|
||||
h.body = body
|
||||
h.reader = nil
|
||||
return h
|
||||
}
|
||||
|
||||
func (h htmlRes) HTMLReader(body io.Reader) Response {
|
||||
h.body = nil
|
||||
h.reader = body
|
||||
return h
|
||||
}
|
||||
|
||||
@@ -133,7 +147,13 @@ func (h htmlRes) GetRedirect() (code redirect.Code, to string, ok bool) {
|
||||
}
|
||||
|
||||
func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) {
|
||||
return io.NopCloser(bytes.NewBuffer(h.body)), true, nil
|
||||
var r io.Reader
|
||||
if h.reader != nil {
|
||||
r = h.reader
|
||||
} else {
|
||||
r = bytes.NewBuffer(h.body)
|
||||
}
|
||||
return io.NopCloser(r), true, nil
|
||||
}
|
||||
|
||||
func (h htmlRes) getCookies() []http.Cookie {
|
||||
|
||||
@@ -49,6 +49,10 @@ func (j jsonRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) HTMLReader(body io.Reader) Response {
|
||||
return HTMLReader(body).wrap(j)
|
||||
}
|
||||
|
||||
func (j jsonRes) JSON(body any) Response {
|
||||
j.body = body
|
||||
return j
|
||||
|
||||
@@ -88,6 +88,10 @@ func (r redirectRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) HTMLReader(body io.Reader) Response {
|
||||
return HTMLReader(body).wrap(r)
|
||||
}
|
||||
|
||||
func (r redirectRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(r)
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ type (
|
||||
Redirect(code redirect.Code, to string) Response
|
||||
Body(io.ReadCloser) Response
|
||||
HTML([]byte) Response
|
||||
HTMLReader(io.Reader) Response
|
||||
JSON(any) Response
|
||||
Cookie(http.Cookie) Response
|
||||
|
||||
|
||||
@@ -64,6 +64,10 @@ func (s statusRes) HTML(body []byte) Response {
|
||||
return HTML(body).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) HTMLReader(body io.Reader) Response {
|
||||
return HTMLReader(body).wrap(s)
|
||||
}
|
||||
|
||||
func (s statusRes) JSON(body any) Response {
|
||||
return JSON(body).wrap(s)
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func NewRouter(
|
||||
reps *reports.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
authr *authentication.Authenticator,
|
||||
devAuthEnabled bool,
|
||||
) *Router {
|
||||
authM := auth.NewService(
|
||||
logger.WithGroup("auth-middleware"),
|
||||
@@ -64,6 +65,7 @@ func NewRouter(
|
||||
reps,
|
||||
etsy,
|
||||
authM.Authenticate(),
|
||||
devAuthEnabled,
|
||||
)
|
||||
|
||||
// non-html content: scripts, styles, images, etc
|
||||
@@ -95,9 +97,11 @@ func NewRouter(
|
||||
authM,
|
||||
sq,
|
||||
accts,
|
||||
reps,
|
||||
unp,
|
||||
rawEvents,
|
||||
etsy,
|
||||
devAuthEnabled,
|
||||
)
|
||||
|
||||
return &Router{
|
||||
@@ -110,6 +114,10 @@ func (r *Router) RunSSE(ctx context.Context) error {
|
||||
return r.sse.Start(ctx)
|
||||
}
|
||||
|
||||
func (r *Router) GetSSEQueue() *sse.Queue {
|
||||
return r.sse
|
||||
}
|
||||
|
||||
func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc {
|
||||
scfs := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
|
||||
return func(c *gin.Context) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
package sse
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"ruben/inventory2/domains/raw_events"
|
||||
)
|
||||
|
||||
type (
|
||||
DBEventPublisher struct {
|
||||
queue sender
|
||||
getAccountID func(context.Context, raw_events.Event) (int64, error)
|
||||
getEventType func(acctID int64, e raw_events.Event) (string, error)
|
||||
}
|
||||
)
|
||||
|
||||
func (q *Queue) NewDBEventPublisher(
|
||||
getAccountID func(context.Context, raw_events.Event) (int64, error),
|
||||
getEventType func(acctID int64, e raw_events.Event) (string, error),
|
||||
) *DBEventPublisher {
|
||||
return &DBEventPublisher{
|
||||
queue: q,
|
||||
getAccountID: getAccountID,
|
||||
getEventType: getEventType,
|
||||
}
|
||||
}
|
||||
|
||||
// Notify publishes e to the SSE queue. The publish itself is quick and
|
||||
// synchronous, so it acks inline once it succeeds - there's no separate
|
||||
// async completion to wait for here.
|
||||
func (p *DBEventPublisher) Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error {
|
||||
acctID, err := p.getAccountID(ctx, e)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to get account id: %w", err)
|
||||
}
|
||||
|
||||
et, err := p.getEventType(acctID, e)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to compute event type: %w", err)
|
||||
}
|
||||
|
||||
if err := p.queue.Send(ctx, StandardEvent(acctID, et)); err != nil {
|
||||
return fmt.Errorf("failed to send sse event to listener: %w", err)
|
||||
}
|
||||
|
||||
if err := ack(ctx); err != nil {
|
||||
return fmt.Errorf("failed to ack event: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -22,11 +22,6 @@ type (
|
||||
trimBasePath string
|
||||
basePathPattern string
|
||||
}
|
||||
|
||||
// sender is satisfied by *Queue
|
||||
sender interface {
|
||||
Send(ctx context.Context, e Event) error
|
||||
}
|
||||
)
|
||||
|
||||
func (q *Queue) NewUpdateNotificationPublisher(
|
||||
@@ -110,11 +105,7 @@ func (p *UpdateNotificationPublisher) Publish(pathPattern string) gin.HandlerFun
|
||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||
defer cancel()
|
||||
|
||||
if err := p.queue.Send(ctx, Event{
|
||||
AccountID: acctID,
|
||||
Type: e,
|
||||
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, e)),
|
||||
}); err != nil {
|
||||
if err := p.queue.Send(ctx, StandardEvent(acctID, e)); err != nil {
|
||||
p.log.Errorf("failed to send sse event to listener: %v", err)
|
||||
}
|
||||
}()
|
||||
@@ -146,8 +137,6 @@ func (p *UpdateNotificationPublisher) Push(ctx context.Context, acctID int64, ev
|
||||
wg.Wait()
|
||||
|
||||
return errors.Join(errs...)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func getPathSegments(p string) []string {
|
||||
|
||||
@@ -124,6 +124,14 @@ func (q *Queue) Send(ctx context.Context, e Event) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func StandardEvent(acctID int64, eventType string) Event {
|
||||
return Event{
|
||||
AccountID: acctID,
|
||||
Type: eventType,
|
||||
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, eventType)),
|
||||
}
|
||||
}
|
||||
|
||||
func (e *Event) Write(w http.ResponseWriter) {
|
||||
fmt.Fprintf(
|
||||
w,
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
package sse
|
||||
|
||||
import "context"
|
||||
|
||||
type (
|
||||
// sender is satisfied by *Queue
|
||||
sender interface {
|
||||
Send(ctx context.Context, e Event) error
|
||||
}
|
||||
)
|
||||
@@ -0,0 +1,159 @@
|
||||
package charts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"ruben/inventory2/server/ui/svg"
|
||||
)
|
||||
|
||||
type (
|
||||
// TODO: rename to BarChart
|
||||
Bar struct {
|
||||
values []BarValue
|
||||
classes barChartClasses
|
||||
}
|
||||
|
||||
BarValue struct {
|
||||
Label string
|
||||
Value float64
|
||||
}
|
||||
|
||||
barChartClasses struct {
|
||||
svg []string
|
||||
bar []string
|
||||
}
|
||||
)
|
||||
|
||||
func NewBar(vs ...BarValue) *Bar {
|
||||
return &Bar{
|
||||
values: vs,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Bar) WithSVGClass(classes ...string) *Bar {
|
||||
c.classes.svg = append(c.classes.svg, classes...)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Bar) WithBarClass(classes ...string) *Bar {
|
||||
c.classes.bar = append(c.classes.bar, classes...)
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *Bar) SVG() svg.MarshalerReader {
|
||||
const (
|
||||
minX = 0
|
||||
minY = 0
|
||||
maxX = 100
|
||||
maxY = 100
|
||||
rangeX = maxX - minX
|
||||
rangeY = maxY - minY
|
||||
)
|
||||
|
||||
s := svg.NewSVG().
|
||||
Attr(
|
||||
svg.Style{
|
||||
"width": "100%",
|
||||
"height": "auto",
|
||||
"padding": "1em",
|
||||
"border-width": "2px",
|
||||
},
|
||||
svg.ViewBox{
|
||||
X: minX,
|
||||
Y: minY,
|
||||
Width: maxX - minX,
|
||||
Height: maxY - minY,
|
||||
},
|
||||
svg.Class(
|
||||
strings.Join(append(
|
||||
[]string{"rounded-lg", "border-border"},
|
||||
c.classes.bar...,
|
||||
), " "),
|
||||
),
|
||||
)
|
||||
|
||||
if len(c.classes.svg) != 0 {
|
||||
s = s.Attr(svg.Class(strings.Join(c.classes.svg, " ")))
|
||||
}
|
||||
|
||||
if len(c.values) > 0 {
|
||||
minV := slices.MinFunc(c.values, func(a, b BarValue) int {
|
||||
if a.Value < b.Value {
|
||||
return -1
|
||||
}
|
||||
if a.Value > b.Value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
maxV := slices.MaxFunc(c.values, func(a, b BarValue) int {
|
||||
if a.Value < b.Value {
|
||||
return -1
|
||||
}
|
||||
if a.Value > b.Value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
})
|
||||
rangeV := maxV.Value - minV.Value
|
||||
|
||||
spacePerBar := float64(rangeX) / float64(len(c.values))
|
||||
barPadding := 0.025 * spacePerBar
|
||||
barWidth := spacePerBar - (2 * barPadding)
|
||||
fontSize := barWidth / 2
|
||||
|
||||
for i, v := range c.values {
|
||||
scaledV := (v.Value - minV.Value) * rangeY / rangeV
|
||||
barX := float64(i)*spacePerBar + barPadding
|
||||
s = s.Child(
|
||||
new(svg.Rect).
|
||||
Attr(
|
||||
svg.NewLength(barX).AsX(),
|
||||
svg.NewLength(maxY-scaledV).AsY(),
|
||||
svg.NewLength(barWidth).AsWidth(),
|
||||
svg.NewLength(scaledV).AsHeight(),
|
||||
svg.Class(
|
||||
strings.Join(append(
|
||||
[]string{"fill-accent"},
|
||||
c.classes.bar...,
|
||||
), " "),
|
||||
),
|
||||
),
|
||||
svg.NewText(fmt.Sprint(v.Value)).
|
||||
Attr(
|
||||
svg.NewLength(barX+(barWidth/2)).AsX(),
|
||||
svg.NewLength(maxY-scaledV+fontSize).AsY(),
|
||||
svg.Fill("var(--accent-foreground)"),
|
||||
svg.TextAnchorMiddle,
|
||||
// svg.NewLength(fontSize).AsFontSize(),
|
||||
svg.Style{
|
||||
"font-size": fmt.Sprintf("%vpx", fontSize),
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
if v.Label != "" {
|
||||
x := barX + (barWidth / 2)
|
||||
y := maxY - fontSize
|
||||
s = s.Child(
|
||||
svg.NewText(v.Label).
|
||||
Attr(
|
||||
svg.NewLength(x).AsX(),
|
||||
svg.NewLength(y).AsY(),
|
||||
svg.Fill("var(--accent-foreground)"),
|
||||
//svg.NewLength(fontSize).AsFontSize(),
|
||||
svg.Style{
|
||||
"font-size": fmt.Sprintf("%vpx", fontSize),
|
||||
},
|
||||
svg.DominantBaselineCentral,
|
||||
svg.TransformRotate(-90).About(x, y),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return s
|
||||
}
|
||||
@@ -0,0 +1,236 @@
|
||||
package charts
|
||||
|
||||
import (
|
||||
"ruben/inventory2/server/ui/svg"
|
||||
"slices"
|
||||
)
|
||||
|
||||
type (
|
||||
LineChart struct {
|
||||
width float64
|
||||
height float64
|
||||
foreground string
|
||||
background string
|
||||
values []LineValue
|
||||
max *float64
|
||||
min *float64
|
||||
}
|
||||
LineValue struct {
|
||||
Label string
|
||||
Value float64
|
||||
}
|
||||
)
|
||||
|
||||
func NewLineChart(width, height float64, vs ...LineValue) *LineChart {
|
||||
return &LineChart{
|
||||
width: width,
|
||||
height: height,
|
||||
values: vs,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *LineChart) Max(n float64) *LineChart {
|
||||
c.max = &n
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *LineChart) Min(n float64) *LineChart {
|
||||
c.min = &n
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *LineChart) Foreground(fg string) *LineChart {
|
||||
c.foreground = fg
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *LineChart) Background(bg string) *LineChart {
|
||||
c.background = bg
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *LineChart) SVG() svg.MarshalerReader {
|
||||
var (
|
||||
minVal float64
|
||||
maxVal float64
|
||||
)
|
||||
|
||||
fg := "black"
|
||||
if c.foreground != "" {
|
||||
fg = c.foreground
|
||||
}
|
||||
bg := c.background
|
||||
|
||||
if c.min != nil {
|
||||
minVal = *c.min
|
||||
} else if len(c.values) > 0 {
|
||||
minVal = slices.MinFunc(c.values, compareLineValues).Value
|
||||
}
|
||||
|
||||
if c.max != nil {
|
||||
maxVal = *c.max
|
||||
} else if len(c.values) > 0 {
|
||||
maxVal = slices.MaxFunc(c.values, compareLineValues).Value
|
||||
}
|
||||
|
||||
svgBg := bg
|
||||
if svgBg == "" {
|
||||
svgBg = "auto"
|
||||
}
|
||||
|
||||
s := svg.NewSVG().
|
||||
Attr(
|
||||
svg.NewLength(c.width).
|
||||
AsWidth(),
|
||||
svg.NewLength(c.height).
|
||||
AsHeight(),
|
||||
svg.Style{
|
||||
// "width": "100%",
|
||||
// "height": "100%",
|
||||
"background": svgBg,
|
||||
},
|
||||
/*
|
||||
svg.ViewBox{
|
||||
X: 0,
|
||||
Y: minVal,
|
||||
Width: float64(len(c.values) - 1),
|
||||
Height: maxVal - minVal,
|
||||
},
|
||||
*/
|
||||
svg.ViewBox{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Width: c.width,
|
||||
Height: c.height,
|
||||
},
|
||||
svg.PreserveAspectRatio{},
|
||||
)
|
||||
|
||||
g := new(svg.G).
|
||||
Attr(
|
||||
// getChartOrientationTransform(c.height),
|
||||
svg.Transform{
|
||||
// scale down to provide padding
|
||||
svg.TransformTranslate{
|
||||
X: 0.05 * c.width,
|
||||
Y: 0.05 * c.height,
|
||||
},
|
||||
svg.TransformScale{
|
||||
X: 0.9,
|
||||
Y: 0.9,
|
||||
},
|
||||
|
||||
// flip
|
||||
svg.TransformScale{
|
||||
X: 1,
|
||||
Y: -1,
|
||||
},
|
||||
svg.TransformTranslate{
|
||||
Y: -c.height,
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
// lines
|
||||
|
||||
pts := make(svg.Points, len(c.values))
|
||||
for i, p := range c.values {
|
||||
pts[i] = svg.Point{
|
||||
X: (c.width / float64(len(c.values)-1)) * float64(i),
|
||||
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
|
||||
}
|
||||
}
|
||||
|
||||
g = g.Child(
|
||||
new(svg.Polyline).Attr(
|
||||
pts,
|
||||
svg.NewLength(2).
|
||||
Unit(svg.Px).
|
||||
AsStrokeWidth(),
|
||||
svg.VectorEffectNonScalingStroke,
|
||||
svg.Stroke(fg),
|
||||
svg.Fill("none"),
|
||||
),
|
||||
)
|
||||
|
||||
// points
|
||||
|
||||
if len(c.values) > 0 {
|
||||
r := 1.0 / float64(len(c.values))
|
||||
rx := r
|
||||
ry := r
|
||||
|
||||
for i, p := range c.values {
|
||||
// dot on the line graph
|
||||
|
||||
x := pts[i].X
|
||||
y := pts[i].Y
|
||||
|
||||
ell := new(svg.Ellipse).
|
||||
Attr(
|
||||
svg.NewLength(x).
|
||||
AsCX(),
|
||||
svg.NewLength(y).
|
||||
AsCY(),
|
||||
svg.Percentage(rx).
|
||||
AsRX(),
|
||||
svg.Percentage(ry).
|
||||
AsRY(),
|
||||
svg.Stroke(fg),
|
||||
svg.NewLength(2).
|
||||
Unit(svg.Px).
|
||||
AsStrokeWidth(),
|
||||
svg.VectorEffectNonScalingStroke,
|
||||
)
|
||||
|
||||
if bg != "" {
|
||||
ell = ell.Attr(
|
||||
svg.Fill(bg),
|
||||
)
|
||||
} else {
|
||||
ell = ell.Attr(
|
||||
svg.Fill(fg),
|
||||
)
|
||||
}
|
||||
|
||||
g = g.Child(
|
||||
ell,
|
||||
|
||||
// label
|
||||
upsideDownCenteredText(p.Label, x, y).
|
||||
Attr(
|
||||
svg.NewLength(c.height*0.05).
|
||||
Unit(svg.Px).
|
||||
AsFontSize(),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
return s.Child(g)
|
||||
}
|
||||
|
||||
func compareLineValues(a, b LineValue) int {
|
||||
if a.Value < b.Value {
|
||||
return -1
|
||||
}
|
||||
if a.Value > b.Value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func upsideDownCenteredText(txt string, x, y float64) *svg.Text {
|
||||
return svg.NewText(txt).
|
||||
Attr(
|
||||
svg.TextAnchorMiddle,
|
||||
svg.DominantBaselineMiddle,
|
||||
|
||||
svg.NewLength(x).
|
||||
AsX(),
|
||||
svg.NewLength(y).
|
||||
AsY(),
|
||||
|
||||
upsideDownTransform(x, y),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<html><body><svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100" style="background: pink" viewBox="0 0 100 100" preserveAspectRatio="none"><g transform="translate(5 5) scale(0.9 0.9) scale(1 -1) translate(0 -100)"><polyline points="0,2.5 20,5 40,10 60,20 80,40 100,80" stroke-width="2px" vector-effect="non-scaling-stroke" stroke="purple" fill="none"/><ellipse cx="0" cy="2.5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="0" y="2.5" transform="translate(0 2.5) scale(1 -1) translate(-0 -2.5)" font-size="5px"></text><ellipse cx="20" cy="5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="20" y="5" transform="translate(20 5) scale(1 -1) translate(-20 -5)" font-size="5px"></text><ellipse cx="40" cy="10" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="40" y="10" transform="translate(40 10) scale(1 -1) translate(-40 -10)" font-size="5px"></text><ellipse cx="60" cy="20" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="60" y="20" transform="translate(60 20) scale(1 -1) translate(-60 -20)" font-size="5px"></text><ellipse cx="80" cy="40" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="80" y="40" transform="translate(80 40) scale(1 -1) translate(-80 -40)" font-size="5px"></text><ellipse cx="100" cy="80" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="100" y="80" transform="translate(100 80) scale(1 -1) translate(-100 -80)" font-size="5px"></text></g></svg></body></html>
|
||||
@@ -0,0 +1,40 @@
|
||||
package charts
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
func ExampleLineChart() {
|
||||
c := NewLineChart(100, 100,
|
||||
LineValue{
|
||||
Value: 1,
|
||||
},
|
||||
LineValue{
|
||||
Value: 2,
|
||||
},
|
||||
LineValue{
|
||||
Value: 4,
|
||||
},
|
||||
LineValue{
|
||||
Value: 8,
|
||||
},
|
||||
LineValue{
|
||||
Value: 16,
|
||||
},
|
||||
LineValue{
|
||||
Value: 32,
|
||||
},
|
||||
).
|
||||
Min(0).
|
||||
Max(40).
|
||||
Foreground("purple").
|
||||
Background("pink").
|
||||
SVG().
|
||||
GetMarkup()
|
||||
|
||||
output := []byte(`<html><body>` + c + `</body></html>`)
|
||||
os.WriteFile("./line_chart.html", []byte(output), 0666)
|
||||
fmt.Println(c)
|
||||
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100" style="background: pink" viewBox="0 0 100 100" preserveAspectRatio="none"><g transform="translate(5 5) scale(0.9 0.9) scale(1 -1) translate(0 -100)"><polyline points="0,2.5 20,5 40,10 60,20 80,40 100,80" stroke-width="2px" vector-effect="non-scaling-stroke" stroke="purple" fill="none"/><ellipse cx="0" cy="2.5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="0" y="2.5" transform="translate(0 2.5) scale(1 -1) translate(-0 -2.5)" font-size="5px"></text><ellipse cx="20" cy="5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="20" y="5" transform="translate(20 5) scale(1 -1) translate(-20 -5)" font-size="5px"></text><ellipse cx="40" cy="10" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="40" y="10" transform="translate(40 10) scale(1 -1) translate(-40 -10)" font-size="5px"></text><ellipse cx="60" cy="20" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="60" y="20" transform="translate(60 20) scale(1 -1) translate(-60 -20)" font-size="5px"></text><ellipse cx="80" cy="40" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="80" y="40" transform="translate(80 40) scale(1 -1) translate(-80 -40)" font-size="5px"></text><ellipse cx="100" cy="80" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="100" y="80" transform="translate(100 80) scale(1 -1) translate(-100 -80)" font-size="5px"></text></g></svg>
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
package charts
|
||||
|
||||
import "ruben/inventory2/server/ui/svg"
|
||||
|
||||
func getChartOrientationTransform(height float64) svg.Transform {
|
||||
return svg.Transform{
|
||||
svg.TransformScale{
|
||||
X: 1,
|
||||
Y: -1,
|
||||
},
|
||||
svg.TransformTranslate{
|
||||
Y: -height,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func upsideDownTransform(x, y float64) svg.Transform {
|
||||
return svg.Transform{
|
||||
svg.TransformTranslate{
|
||||
X: x,
|
||||
Y: y,
|
||||
},
|
||||
svg.TransformScale{
|
||||
X: 1,
|
||||
Y: -1,
|
||||
},
|
||||
svg.TransformTranslate{
|
||||
X: -x,
|
||||
Y: -y,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,425 @@
|
||||
package charts
|
||||
|
||||
import (
|
||||
"ruben/inventory2/server/ui/svg"
|
||||
"slices"
|
||||
"time"
|
||||
)
|
||||
|
||||
type (
|
||||
TimeLineChart struct {
|
||||
width float64
|
||||
height float64
|
||||
foreground string
|
||||
background string
|
||||
values []TimeLineValue
|
||||
max *float64
|
||||
min *float64
|
||||
}
|
||||
TimeLineValue struct {
|
||||
Label string
|
||||
Time time.Time
|
||||
Value float64
|
||||
}
|
||||
)
|
||||
|
||||
func NewTimeLineChart(width, height float64, vs ...TimeLineValue) *TimeLineChart {
|
||||
return &TimeLineChart{
|
||||
width: width,
|
||||
height: height,
|
||||
values: validValues(vs),
|
||||
}
|
||||
}
|
||||
|
||||
func validValues(vs []TimeLineValue) []TimeLineValue {
|
||||
values := make([]TimeLineValue, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
if !v.Time.IsZero() {
|
||||
values = append(values, v)
|
||||
}
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) Max(n float64) *TimeLineChart {
|
||||
c.max = &n
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) Min(n float64) *TimeLineChart {
|
||||
c.min = &n
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) Foreground(fg string) *TimeLineChart {
|
||||
c.foreground = fg
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) Background(bg string) *TimeLineChart {
|
||||
c.background = bg
|
||||
return c
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) SVG() svg.MarshalerReader {
|
||||
return svg.NewSVG().
|
||||
Attr(
|
||||
svg.NewLength(c.width).
|
||||
AsWidth(),
|
||||
svg.NewLength(c.height).
|
||||
AsHeight(),
|
||||
svg.Style{
|
||||
"background": c.getOuterSVGBackground(),
|
||||
},
|
||||
svg.ViewBox{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Width: c.width,
|
||||
Height: c.height,
|
||||
},
|
||||
svg.PreserveAspectRatio{},
|
||||
).
|
||||
Child(
|
||||
new(svg.G).
|
||||
Attr(
|
||||
svg.Transform{
|
||||
// scale down to provide padding
|
||||
svg.TransformTranslate{
|
||||
X: 0.05 * c.width,
|
||||
Y: 0.05 * c.height,
|
||||
},
|
||||
svg.TransformScale{
|
||||
X: 0.9,
|
||||
Y: 0.85, // a little more padding on the bottom
|
||||
},
|
||||
|
||||
// flip
|
||||
svg.TransformScale{
|
||||
X: 1,
|
||||
Y: -1,
|
||||
},
|
||||
svg.TransformTranslate{
|
||||
Y: -c.height,
|
||||
},
|
||||
},
|
||||
).
|
||||
Child(c.getChildren()...),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) getOuterSVGBackground() string {
|
||||
if c.background != "" {
|
||||
return c.background
|
||||
}
|
||||
return "auto"
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) getChildren() []svg.GChildren {
|
||||
pts := c.getPoints()
|
||||
|
||||
return append(
|
||||
append(
|
||||
[]svg.GChildren{
|
||||
new(svg.Polyline).Attr(
|
||||
pts,
|
||||
svg.NewLength(2).
|
||||
Unit(svg.Px).
|
||||
AsStrokeWidth(),
|
||||
svg.VectorEffectNonScalingStroke,
|
||||
svg.Stroke(c.getStroke()),
|
||||
svg.Fill("none"),
|
||||
),
|
||||
},
|
||||
c.buildDots(pts)...,
|
||||
),
|
||||
c.buildLabels(pts)...,
|
||||
)
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) getPoints() svg.Points {
|
||||
minVal, _ := c.getMinVal()
|
||||
maxVal, _ := c.getMaxVal()
|
||||
|
||||
minTime, maxTime := c.getMinAndMaxUnixTimes()
|
||||
|
||||
pts := make(svg.Points, len(c.values))
|
||||
for i, p := range c.values {
|
||||
pts[i] = svg.Point{
|
||||
X: c.width * float64(p.Time.UnixNano()-minTime) / float64(maxTime-minTime),
|
||||
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
|
||||
}
|
||||
}
|
||||
|
||||
return pts
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) getMinVal() (float64, bool) {
|
||||
if c.min != nil {
|
||||
return *c.min, true
|
||||
}
|
||||
if values := c.values; len(values) > 0 {
|
||||
return slices.MinFunc(values, compareTimeLineValues).Value, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) getMaxVal() (float64, bool) {
|
||||
if c.max != nil {
|
||||
return *c.max, true
|
||||
}
|
||||
if len(c.values) > 0 {
|
||||
return slices.MaxFunc(c.values, compareTimeLineValues).Value, true
|
||||
}
|
||||
return 0, false
|
||||
}
|
||||
|
||||
func compareTimeLineValues(a, b TimeLineValue) int {
|
||||
if a.Value < b.Value {
|
||||
return -1
|
||||
}
|
||||
if a.Value > b.Value {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) getMinAndMaxUnixTimes() (minTime, maxTime int64) {
|
||||
if len(c.values) > 0 {
|
||||
return slices.MinFunc(c.values, compareTimeLineTimeValues).Time.UnixNano(),
|
||||
slices.MaxFunc(c.values, compareTimeLineTimeValues).Time.UnixNano()
|
||||
}
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
func compareTimeLineTimeValues(a, b TimeLineValue) int {
|
||||
at := a.Time
|
||||
bt := b.Time
|
||||
an := at.UnixNano()
|
||||
bn := bt.UnixNano()
|
||||
if at.IsZero() {
|
||||
an = 0
|
||||
}
|
||||
if bt.IsZero() {
|
||||
bn = 0
|
||||
}
|
||||
return int(an - bn)
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) buildDots(pts svg.Points) []svg.GChildren {
|
||||
if len(c.values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
chn := make([]svg.GChildren, len(c.values))
|
||||
for i, p := range pts {
|
||||
chn[i] = c.buildDot(p)
|
||||
}
|
||||
|
||||
return chn
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) buildDot(p svg.Point) *svg.Circle {
|
||||
return new(svg.Circle).
|
||||
Attr(
|
||||
svg.NewLength(p.X).
|
||||
AsCX(),
|
||||
svg.NewLength(p.Y).
|
||||
AsCY(),
|
||||
svg.NewLength(0.01*min(c.width, c.height)).
|
||||
AsR(),
|
||||
svg.Stroke(c.getStroke()),
|
||||
svg.NewLength(2).
|
||||
Unit(svg.Px).
|
||||
AsStrokeWidth(),
|
||||
svg.VectorEffectNonScalingStroke,
|
||||
c.getDotFill(),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) getDotFill() svg.Fill {
|
||||
if c.background != "" {
|
||||
return svg.Fill(c.background)
|
||||
} else {
|
||||
return svg.Fill(c.getStroke())
|
||||
}
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) buildLabels(pts svg.Points) []svg.GChildren {
|
||||
if len(c.values) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
chn := make([]svg.GChildren, len(c.values))
|
||||
for i, p := range pts {
|
||||
chn[i] = c.buildLabel(c.values[i], p)
|
||||
}
|
||||
|
||||
return chn
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) buildLabel(v TimeLineValue, p svg.Point) *svg.Element[svg.SVGTag, svg.SVGAttribute, svg.SVGChildren] {
|
||||
return svg.NewSVG().
|
||||
Attr(
|
||||
svg.NewLength(p.X).
|
||||
AsX(),
|
||||
svg.NewLength(p.Y).
|
||||
AsY(),
|
||||
svg.NewLength(24).
|
||||
Unit(svg.Px).
|
||||
AsWidth(),
|
||||
svg.NewLength(24).
|
||||
Unit(svg.Px).
|
||||
AsHeight(),
|
||||
svg.Class("group"),
|
||||
svg.Style{
|
||||
"overflow": "visible",
|
||||
"fill": "var(--accent-foreground)",
|
||||
},
|
||||
svg.ViewBox{
|
||||
X: 0,
|
||||
Y: 0,
|
||||
Width: 1,
|
||||
Height: 1,
|
||||
},
|
||||
svg.PreserveAspectRatio{
|
||||
Align: &svg.AlignValue{
|
||||
X: svg.AlignMid,
|
||||
Y: svg.AlignMid,
|
||||
},
|
||||
},
|
||||
).
|
||||
Child(
|
||||
c.buildAlwaysDisplayedLabelText(v, p),
|
||||
new(svg.Rect).
|
||||
Attr(
|
||||
svg.NewLength(0.1).
|
||||
AsStrokeWidth(),
|
||||
svg.Fill("var(--muted)"),
|
||||
svg.Stroke("var(--accent-foreground)"),
|
||||
svg.NewLength(2.5).
|
||||
AsHeight(),
|
||||
svg.NewLength(-5).
|
||||
AsX(),
|
||||
svg.NewLength(-3).
|
||||
AsY(),
|
||||
svg.NewLength(10).
|
||||
AsWidth(),
|
||||
svg.Class("not-group-hover:hidden"),
|
||||
),
|
||||
c.buildHoverLabelText(v, p),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) buildAlwaysDisplayedLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
||||
return c.buildLabelText(v, p)
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) buildHoverLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
||||
labelText := c.buildLabelText(v, p).
|
||||
Attr(
|
||||
svg.Class("not-group-hover:hidden"),
|
||||
)
|
||||
|
||||
if v.Time.IsZero() {
|
||||
return labelText
|
||||
}
|
||||
|
||||
return labelText.Child(
|
||||
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
|
||||
Attr(
|
||||
svg.TextAnchorMiddle,
|
||||
svg.DominantBaselineMiddle,
|
||||
svg.Style{
|
||||
"font-size": "1px",
|
||||
},
|
||||
svg.Class("not-group-hover:hidden"),
|
||||
|
||||
svg.NewLength(0).
|
||||
AsX(),
|
||||
svg.NewLength(2.5).
|
||||
AsY(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
||||
return new(svg.G).
|
||||
Attr(
|
||||
svg.NewLength(0).
|
||||
AsX(),
|
||||
svg.NewLength(0).
|
||||
AsY(),
|
||||
upsideDownTransform(0, 0),
|
||||
).
|
||||
Child(
|
||||
svg.NewText(v.Label).
|
||||
Attr(
|
||||
svg.TextAnchorMiddle,
|
||||
svg.DominantBaselineMiddle,
|
||||
svg.Style{
|
||||
"font-size": "1px",
|
||||
},
|
||||
|
||||
svg.NewLength(0).
|
||||
AsX(),
|
||||
svg.NewLength(1.25).
|
||||
AsY(),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
/*
|
||||
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
||||
labelText := new(svg.G).
|
||||
Attr(
|
||||
svg.NewLength(0).
|
||||
AsX(),
|
||||
svg.NewLength(0).
|
||||
AsY(),
|
||||
upsideDownTransform(0, 0),
|
||||
).
|
||||
Child(
|
||||
svg.NewText(v.Label).
|
||||
Attr(
|
||||
svg.TextAnchorMiddle,
|
||||
svg.DominantBaselineMiddle,
|
||||
svg.Style{
|
||||
"font-size": "1px",
|
||||
},
|
||||
|
||||
svg.NewLength(0).
|
||||
AsX(),
|
||||
svg.NewLength(1.25).
|
||||
AsY(),
|
||||
),
|
||||
)
|
||||
|
||||
if v.Time.IsZero() {
|
||||
return labelText
|
||||
}
|
||||
|
||||
return labelText.Child(
|
||||
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
|
||||
Attr(
|
||||
svg.TextAnchorMiddle,
|
||||
svg.DominantBaselineMiddle,
|
||||
svg.Style{
|
||||
"font-size": "1px",
|
||||
},
|
||||
svg.Class("not-group-hover:hidden"),
|
||||
|
||||
svg.NewLength(0).
|
||||
AsX(),
|
||||
svg.NewLength(2.5).
|
||||
AsY(),
|
||||
),
|
||||
)
|
||||
}
|
||||
*/
|
||||
|
||||
func (c *TimeLineChart) getStroke() string {
|
||||
if c.foreground != "" {
|
||||
return c.foreground
|
||||
}
|
||||
return "black"
|
||||
}
|
||||
@@ -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
|
||||
@@ -42,13 +43,14 @@ type (
|
||||
|
||||
func Routes(
|
||||
logger *logging.Logger,
|
||||
r gin.IRoutes,
|
||||
r gin.IRouter,
|
||||
uiPath string,
|
||||
rawEvents *raw_events.Store,
|
||||
accts *accounts.Store,
|
||||
reps *reports.Store,
|
||||
etsy *etsy_platform.Platform,
|
||||
authenticate gin.HandlerFunc,
|
||||
devAuthEnabled bool,
|
||||
) {
|
||||
|
||||
s := &webpageRouter{
|
||||
@@ -101,6 +103,39 @@ func Routes(
|
||||
"multInt": func(a, b int) int {
|
||||
return a * b
|
||||
},
|
||||
"divInt": func(a, b int) int {
|
||||
return a / b
|
||||
},
|
||||
"intToFloat": func(n int) float64 {
|
||||
return float64(n)
|
||||
},
|
||||
"addInt64": func(a, b int64) int64 {
|
||||
return a + b
|
||||
},
|
||||
"subInt64": func(a, b int64) int64 {
|
||||
return a - b
|
||||
},
|
||||
"multInt64": func(a, b int64) int64 {
|
||||
return a * b
|
||||
},
|
||||
"divInt64": func(a, b int64) int64 {
|
||||
return a / b
|
||||
},
|
||||
"int64ToFloat": func(n int64) float64 {
|
||||
return float64(n)
|
||||
},
|
||||
"addFloat": func(a, b float64) float64 {
|
||||
return a + b
|
||||
},
|
||||
"subFloat": func(a, b float64) float64 {
|
||||
return a - b
|
||||
},
|
||||
"multFloat": func(a, b float64) float64 {
|
||||
return a * b
|
||||
},
|
||||
"divFloat": func(a, b float64) float64 {
|
||||
return a / b
|
||||
},
|
||||
|
||||
// html
|
||||
"rawHTML": func(s string) template.HTML {
|
||||
@@ -144,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))
|
||||
@@ -186,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),
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type (
|
||||
// Attribute is the minimum method set of of an svg attribute.
|
||||
Attribute interface {
|
||||
// PrintKey must return the svg attribute key
|
||||
PrintKey() string
|
||||
// PrintVAlue must return the svg attribute value string, if applicable
|
||||
PrintValue() (string, bool)
|
||||
}
|
||||
)
|
||||
|
||||
func PrintAttribute(a Attribute) string {
|
||||
v, ok := a.PrintValue()
|
||||
if !ok {
|
||||
return a.PrintKey()
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%s=%s", a.PrintKey(), v)
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
package svg
|
||||
|
||||
type (
|
||||
Circle = VoidElement[CircleTag, CircleAttribute]
|
||||
|
||||
CircleAttribute interface {
|
||||
Attribute
|
||||
IsCircleAttribute()
|
||||
}
|
||||
|
||||
CircleTag struct{}
|
||||
)
|
||||
|
||||
func (CircleTag) PrintTag() string {
|
||||
return "circle"
|
||||
}
|
||||
|
||||
func (CX) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (CXP) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (CY) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (CYP) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (R) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (RP) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (PathLength) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (Fill) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (Stroke) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (StrokeWidth) IsCircleAttribute() {
|
||||
}
|
||||
|
||||
func (VectorEffect) IsCircleAttribute() {
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"html"
|
||||
)
|
||||
|
||||
type (
|
||||
Class string
|
||||
)
|
||||
|
||||
func (Class) PrintKey() string {
|
||||
return "class"
|
||||
}
|
||||
|
||||
func (c Class) PrintValue() (string, bool) {
|
||||
return fmt.Sprintf(`"%s"`, html.EscapeString(string(c))), true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package svg
|
||||
|
||||
type (
|
||||
CX struct {
|
||||
*LengthAttr[CXTag]
|
||||
}
|
||||
CXP struct {
|
||||
PercentageAttr[CXTag]
|
||||
}
|
||||
|
||||
CXTag struct{}
|
||||
)
|
||||
|
||||
func (l *Length) AsCX() CX {
|
||||
return CX{LengthAttr: (*LengthAttr[CXTag])(l)}
|
||||
}
|
||||
|
||||
func (p Percentage) AsCX() CXP {
|
||||
return CXP{PercentageAttr: (PercentageAttr[CXTag])(p)}
|
||||
}
|
||||
|
||||
func (CXTag) PrintTag() string {
|
||||
return "cx"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package svg
|
||||
|
||||
type (
|
||||
CY struct {
|
||||
*LengthAttr[CYTag]
|
||||
}
|
||||
CYP struct {
|
||||
PercentageAttr[CYTag]
|
||||
}
|
||||
|
||||
CYTag struct{}
|
||||
)
|
||||
|
||||
func (l *Length) AsCY() CY {
|
||||
return CY{LengthAttr: (*LengthAttr[CYTag])(l)}
|
||||
}
|
||||
|
||||
func (p Percentage) AsCY() CYP {
|
||||
return CYP{PercentageAttr: (PercentageAttr[CYTag])(p)}
|
||||
}
|
||||
|
||||
func (CYTag) PrintTag() string {
|
||||
return "cy"
|
||||
}
|
||||
@@ -0,0 +1,217 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
D []PathCommand
|
||||
|
||||
PathCommand interface {
|
||||
PrintPathCommand() (string, string, bool)
|
||||
PrintPathCommandParameters() string
|
||||
}
|
||||
|
||||
// PathCommand implementations
|
||||
|
||||
// PathRelative makes any PathCommand and converts it into a relative command (makes lowercase)
|
||||
PathRelative[C PathCommand] struct {
|
||||
Command C
|
||||
}
|
||||
|
||||
PathMoveTo struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
PathLineTo struct {
|
||||
X, Y float64
|
||||
}
|
||||
PathHorizontalLine float64
|
||||
PathVerticalLine float64
|
||||
|
||||
PathCubicBezier []PathCubicBezierParameterTuple
|
||||
PathCubicBezierParameterTuple struct {
|
||||
X1, Y1 float64
|
||||
X2, Y2 float64
|
||||
X, Y float64
|
||||
}
|
||||
PathSmoothCubicBezier []PathSmoothCubicBezierParameterTuple
|
||||
PathSmoothCubicBezierParameterTuple struct {
|
||||
X2, Y2 float64
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
PathQuadraticBezier []PathQuadraticBezierParameterTuple
|
||||
PathQuadraticBezierParameterTuple struct {
|
||||
X1, Y1 float64
|
||||
X, Y float64
|
||||
}
|
||||
PathSmoothQuadraticBezier []PathSmoothQuadraticBezierParameterTuple
|
||||
PathSmoothQuadraticBezierParameterTuple struct {
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
PathElliptical []PathEllipticalParameterTuple
|
||||
PathEllipticalParameterTuple struct {
|
||||
RX, RY float64
|
||||
Angle float64
|
||||
LargeArc bool
|
||||
Clockwise bool
|
||||
X, Y float64
|
||||
}
|
||||
|
||||
PathClose struct{}
|
||||
)
|
||||
|
||||
func PrintPathCommand(c PathCommand) (string, bool) {
|
||||
cmd, params, ok := c.PrintPathCommand()
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return fmt.Sprintf("%s %s", cmd, params), true
|
||||
}
|
||||
|
||||
func (c PathRelative[C]) PrintPathCommand() (string, string, bool) {
|
||||
cmd, params, ok := c.Command.PrintPathCommand()
|
||||
return strings.ToLower(cmd), params, ok
|
||||
}
|
||||
|
||||
func (c PathMoveTo) PrintPathCommand() (string, string, bool) {
|
||||
return "M", fmt.Sprintf("%v,%v", c.X, c.Y), true
|
||||
}
|
||||
|
||||
func (c PathLineTo) PrintPathCommand() (string, string, bool) {
|
||||
return "L", fmt.Sprintf("%v,%v", c.X, c.Y), true
|
||||
}
|
||||
|
||||
func (c PathHorizontalLine) PrintPathCommand() (string, string, bool) {
|
||||
return "H", fmt.Sprintf("%v", float64(c)), true
|
||||
}
|
||||
|
||||
func (c PathVerticalLine) PrintPathCommand() (string, string, bool) {
|
||||
return "V", fmt.Sprintf("%v", float64(c)), false
|
||||
}
|
||||
|
||||
func (c PathCubicBezier) PrintPathCommand() (string, string, bool) {
|
||||
if len(c) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return "C", printTuples(c), true
|
||||
}
|
||||
|
||||
func (c PathCubicBezier) Append(x1, y1, x2, y2, x, y float64) PathCubicBezier {
|
||||
return append(c, PathCubicBezierParameterTuple{
|
||||
X1: x1,
|
||||
Y1: y1,
|
||||
X2: x2,
|
||||
Y2: y2,
|
||||
X: x,
|
||||
Y: y,
|
||||
})
|
||||
}
|
||||
|
||||
func (t PathCubicBezierParameterTuple) String() string {
|
||||
return fmt.Sprintf("%v,%v %v,%v %v,%v", t.X1, t.Y1, t.X2, t.Y2, t.X, t.Y)
|
||||
}
|
||||
|
||||
func (c PathSmoothCubicBezier) PrintPathCommand() (string, string, bool) {
|
||||
if len(c) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return "S", printTuples(c), true
|
||||
}
|
||||
|
||||
func (c PathSmoothCubicBezier) Append(x2, y2, x, y float64) PathSmoothCubicBezier {
|
||||
return append(c, PathSmoothCubicBezierParameterTuple{
|
||||
X2: x2,
|
||||
Y2: y2,
|
||||
X: x,
|
||||
Y: y,
|
||||
})
|
||||
}
|
||||
|
||||
func (t PathSmoothCubicBezierParameterTuple) String() string {
|
||||
return fmt.Sprintf("%v,%v %v,%v", t.X2, t.Y2, t.X, t.Y)
|
||||
}
|
||||
|
||||
func (c PathQuadraticBezier) PrintPathCommand() (string, string, bool) {
|
||||
if len(c) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return "Q", printTuples(c), true
|
||||
}
|
||||
|
||||
func (c PathQuadraticBezier) Append(x1, y1, x, y float64) PathQuadraticBezier {
|
||||
return append(c, PathQuadraticBezierParameterTuple{
|
||||
X1: x1,
|
||||
Y1: y1,
|
||||
X: x,
|
||||
Y: y,
|
||||
})
|
||||
}
|
||||
|
||||
func (t PathQuadraticBezierParameterTuple) String() string {
|
||||
return fmt.Sprintf("%v,%v %v,%v", t.X1, t.Y1, t.X, t.Y)
|
||||
}
|
||||
|
||||
func (c PathSmoothQuadraticBezier) PrintPathCommand() (string, string, bool) {
|
||||
if len(c) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return "T", printTuples(c), true
|
||||
}
|
||||
|
||||
func (c PathSmoothQuadraticBezier) Append(x, y float64) PathSmoothQuadraticBezier {
|
||||
return append(c, PathSmoothQuadraticBezierParameterTuple{
|
||||
X: x,
|
||||
Y: y,
|
||||
})
|
||||
}
|
||||
|
||||
func (t PathSmoothQuadraticBezierParameterTuple) String() string {
|
||||
return fmt.Sprintf("%v,%v", t.X, t.Y)
|
||||
}
|
||||
|
||||
func (c PathElliptical) PrintPathCommand() (string, string, bool) {
|
||||
if len(c) == 0 {
|
||||
return "", "", false
|
||||
}
|
||||
return "A", printTuples(c), true
|
||||
}
|
||||
|
||||
func (c PathElliptical) Append(rx, ry, angle float64, largeArc, clockwise bool, x, y float64) PathElliptical {
|
||||
return append(c, PathEllipticalParameterTuple{
|
||||
RX: rx,
|
||||
RY: ry,
|
||||
Angle: angle,
|
||||
LargeArc: largeArc,
|
||||
Clockwise: clockwise,
|
||||
X: x,
|
||||
Y: y,
|
||||
})
|
||||
}
|
||||
|
||||
func (t PathEllipticalParameterTuple) String() string {
|
||||
var (
|
||||
largeArcFlag int
|
||||
sweepFlag int
|
||||
)
|
||||
|
||||
if t.LargeArc {
|
||||
largeArcFlag = 1
|
||||
}
|
||||
if t.Clockwise {
|
||||
sweepFlag = 1
|
||||
}
|
||||
|
||||
return fmt.Sprintf("%v,%v,%v,%d,%d,%v,%v", t.RX, t.RY, t.Angle, largeArcFlag, sweepFlag, t.X, t.Y)
|
||||
}
|
||||
|
||||
func printTuples[S fmt.Stringer](vs []S) string {
|
||||
ss := make([]string, len(vs))
|
||||
for i, v := range vs {
|
||||
ss[i] = fmt.Sprint(v)
|
||||
}
|
||||
return strings.Join(ss, " ")
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package svg
|
||||
|
||||
import "fmt"
|
||||
|
||||
type (
|
||||
DominantBaseline int
|
||||
)
|
||||
|
||||
const (
|
||||
DominantBaselineAuto DominantBaseline = iota
|
||||
DominantBaselineTextBottom
|
||||
DominantBaselineAlphabetic
|
||||
DominantBaselineIdeographic
|
||||
DominantBaselineMiddle
|
||||
DominantBaselineCentral
|
||||
DominantBaselineMathematical
|
||||
DominantBaselineHanging
|
||||
DominantBaselineTextTop
|
||||
)
|
||||
|
||||
func (a DominantBaseline) PrintValue() (string, bool) {
|
||||
return fmt.Sprintf("%q", a), true
|
||||
}
|
||||
|
||||
func (a DominantBaseline) PrintKey() string {
|
||||
return "dominant-baseline"
|
||||
}
|
||||
|
||||
func (a DominantBaseline) String() string {
|
||||
switch a {
|
||||
case DominantBaselineTextBottom:
|
||||
return "text-bottom"
|
||||
case DominantBaselineAlphabetic:
|
||||
return "alphabetic"
|
||||
case DominantBaselineIdeographic:
|
||||
return "ideographic"
|
||||
case DominantBaselineMiddle:
|
||||
return "middle"
|
||||
case DominantBaselineCentral:
|
||||
return "central"
|
||||
case DominantBaselineMathematical:
|
||||
return "mathematical"
|
||||
case DominantBaselineHanging:
|
||||
return "hanging"
|
||||
case DominantBaselineTextTop:
|
||||
return "text-top"
|
||||
default:
|
||||
return "auto"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package svg
|
||||
|
||||
type (
|
||||
DX struct {
|
||||
*LengthAttr[DXTag]
|
||||
}
|
||||
DXP struct {
|
||||
PercentageAttr[DXTag]
|
||||
}
|
||||
|
||||
DXTag struct{}
|
||||
)
|
||||
|
||||
func (l *Length) AsDX() DX {
|
||||
return DX{LengthAttr: (*LengthAttr[DXTag])(l)}
|
||||
}
|
||||
|
||||
func (p Percentage) AsDX() DXP {
|
||||
return DXP{PercentageAttr: (PercentageAttr[DXTag])(p)}
|
||||
}
|
||||
|
||||
func (DXTag) PrintTag() string {
|
||||
return "dx"
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package svg
|
||||
|
||||
type (
|
||||
DY struct {
|
||||
*LengthAttr[DYTag]
|
||||
}
|
||||
DYP struct {
|
||||
PercentageAttr[DYTag]
|
||||
}
|
||||
|
||||
DYTag struct{}
|
||||
)
|
||||
|
||||
func (l *Length) AsDY() DY {
|
||||
return DY{LengthAttr: (*LengthAttr[DYTag])(l)}
|
||||
}
|
||||
|
||||
func (p Percentage) AsDY() DYP {
|
||||
return DYP{PercentageAttr: (PercentageAttr[DYTag])(p)}
|
||||
}
|
||||
|
||||
func (DYTag) PrintTag() string {
|
||||
return "dy"
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type (
|
||||
// Element is a convenience for implementing SVG element models.
|
||||
// Element implements MarshalerReader.
|
||||
Element[T Tag, A Attribute, C MarshalerReader] struct {
|
||||
Attributes []A
|
||||
Children []C
|
||||
}
|
||||
)
|
||||
|
||||
func (e *Element[T, A, C]) Attr(as ...A) *Element[T, A, C] {
|
||||
e.Attributes = append(e.Attributes, as...)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e *Element[T, A, C]) Child(cs ...C) *Element[T, A, C] {
|
||||
e.Children = append(e.Children, cs...)
|
||||
return e
|
||||
}
|
||||
|
||||
func (e Element[T, A, C]) GetMarkup() string {
|
||||
attrs := make([]string, len(e.Attributes))
|
||||
for i, a := range e.Attributes {
|
||||
attrs[i] = PrintAttribute(a)
|
||||
}
|
||||
|
||||
children := make([]string, len(e.Children))
|
||||
for i, c := range e.Children {
|
||||
children[i] = c.GetMarkup()
|
||||
}
|
||||
|
||||
tag := e.PrintTag()
|
||||
|
||||
return fmt.Sprintf(
|
||||
`<%s %s>%s</%s>`,
|
||||
tag,
|
||||
strings.Join(attrs, " "),
|
||||
strings.Join(children, ""),
|
||||
tag,
|
||||
)
|
||||
}
|
||||
|
||||
func (e Element[T, A, C]) GetMarkupReader() io.Reader {
|
||||
return newElementReader(e)
|
||||
}
|
||||
|
||||
func (e Element[T, _, _]) PrintTag() string {
|
||||
var t T
|
||||
return t.PrintTag()
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
package svg
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
)
|
||||
|
||||
type (
|
||||
elementReader[T Tag, A Attribute, C MarshalerReader] struct {
|
||||
element Element[T, A, C]
|
||||
buf *bytes.Buffer
|
||||
|
||||
openTagRead bool
|
||||
numChildrenRead int
|
||||
childrenRead bool
|
||||
childReader io.Reader
|
||||
closeTagRead bool
|
||||
}
|
||||
)
|
||||
|
||||
func newElementReader[T Tag, A Attribute, C MarshalerReader](e Element[T, A, C]) *elementReader[T, A, C] {
|
||||
return &elementReader[T, A, C]{
|
||||
element: e,
|
||||
}
|
||||
}
|
||||
|
||||
func (e *elementReader[T, A, C]) Read(p []byte) (totalRead int, err error) {
|
||||
if e.buf == nil {
|
||||
// just started reading.
|
||||
// buffer the open tag
|
||||
|
||||
e.buf = new(bytes.Buffer)
|
||||
|
||||
// bytes.Buffer never returns an error on Write()
|
||||
e.buf.WriteByte('<')
|
||||
e.buf.WriteString(e.element.PrintTag())
|
||||
for _, a := range e.element.Attributes {
|
||||
e.buf.WriteByte(' ')
|
||||
e.buf.WriteString(PrintAttribute(a))
|
||||
}
|
||||
e.buf.WriteByte('>')
|
||||
}
|
||||
|
||||
if !e.openTagRead {
|
||||
// read the open tag
|
||||
|
||||
n, err := e.buf.Read(p)
|
||||
totalRead += n
|
||||
if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) {
|
||||
return totalRead, err
|
||||
}
|
||||
|
||||
// done reading the open tag
|
||||
|
||||
e.openTagRead = true
|
||||
e.buf.Reset()
|
||||
|
||||
p = p[n:]
|
||||
}
|
||||
|
||||
// read the children
|
||||
|
||||
for ; e.numChildrenRead < len(e.element.Children); e.numChildrenRead += 1 {
|
||||
if e.childReader == nil {
|
||||
e.childReader = e.element.Children[e.numChildrenRead].GetMarkupReader()
|
||||
}
|
||||
|
||||
// read the child
|
||||
|
||||
n, err := e.childReader.Read(p)
|
||||
totalRead += n
|
||||
if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) {
|
||||
return totalRead, err
|
||||
}
|
||||
|
||||
// done reading the child
|
||||
|
||||
e.childReader = nil
|
||||
|
||||
p = p[n:]
|
||||
}
|
||||
|
||||
if !e.childrenRead {
|
||||
// done reading the children
|
||||
|
||||
e.childrenRead = true
|
||||
|
||||
// buffer the close tag
|
||||
|
||||
e.buf.Write([]byte("</"))
|
||||
e.buf.WriteString(e.element.PrintTag())
|
||||
e.buf.WriteByte('>')
|
||||
}
|
||||
|
||||
if !e.closeTagRead {
|
||||
n, err := e.buf.Read(p)
|
||||
totalRead += n
|
||||
if (err != nil && !errors.Is(err, io.EOF)) || n >= len(p) {
|
||||
return totalRead, err
|
||||
}
|
||||
|
||||
// done writing the close tag
|
||||
|
||||
e.closeTagRead = true
|
||||
return totalRead, err
|
||||
}
|
||||
|
||||
return 0, io.EOF
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
package svg
|
||||
|
||||
type (
|
||||
Ellipse = VoidElement[EllipseTag, EllipseAttribute]
|
||||
|
||||
EllipseTag struct{}
|
||||
|
||||
EllipseAttribute interface {
|
||||
Attribute
|
||||
IsEllipseAttribute()
|
||||
}
|
||||
)
|
||||
|
||||
func (EllipseTag) PrintTag() string {
|
||||
return "ellipse"
|
||||
}
|
||||
|
||||
func (RX) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (RXP) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (RY) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (RYP) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (CX) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (CXP) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (CY) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (CYP) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (PathLength) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (Stroke) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (StrokeWidth) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (Fill) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (Style) IsEllipseAttribute() {
|
||||
}
|
||||
|
||||
func (VectorEffect) IsEllipseAttribute() {
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
package svg
|
||||
|
||||
import "fmt"
|
||||
|
||||
type (
|
||||
Fill string
|
||||
)
|
||||
|
||||
func (Fill) PrintKey() string {
|
||||
return "fill"
|
||||
}
|
||||
|
||||
func (f Fill) PrintValue() (string, bool) {
|
||||
return fmt.Sprintf(`%q`, string(f)), true
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package svg
|
||||
|
||||
type (
|
||||
FontSize struct {
|
||||
*LengthAttr[FontSizeTag]
|
||||
}
|
||||
FontSizeP struct {
|
||||
PercentageAttr[FontSizeTag]
|
||||
}
|
||||
|
||||
FontSizeTag struct{}
|
||||
)
|
||||
|
||||
func (l *Length) AsFontSize() FontSize {
|
||||
return FontSize{LengthAttr: (*LengthAttr[FontSizeTag])(l)}
|
||||
}
|
||||
|
||||
func (p Percentage) AsFontSize() FontSizeP {
|
||||
return FontSizeP{PercentageAttr: (PercentageAttr[FontSizeTag])(p)}
|
||||
}
|
||||
|
||||
func (FontSizeTag) PrintTag() string {
|
||||
return "font-size"
|
||||
}
|
||||