Compare commits

...
29 Commits
Author SHA1 Message Date
angelandClaude Sonnet 5 c41d5953c5 auth: split dev-mode auth constructor and wire up dev-login/logout UI
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 34s
Squeamish about New()'s empty-domain-string sentinel for "dev mode, skip
OIDC discovery" - split into New (always makes a real OIDC discovery
call, all params required) and NewDev (no ctx/domain/credentials at all,
since none are used). main.go now branches on cfg.DevAuthEnabled to pick
the right constructor instead of main.go/config.go coordinating on when
it's safe to pass empty strings.

Also finishes out the dev-auth flow this enables: config.Load reads a
DEV_AUTH_ENABLED-aware env file and only requires Auth0 vars when dev
auth is off; a PORT config var replaces the hardcoded :8082; and the nav
UI (layout/index templates, ui router) points login/logout links at
/api/auth/dev-login and a new /api/auth/dev-logout route when dev auth
is enabled, so the whole login/logout loop works locally without a real
Auth0 app.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 00:24:04 -06:00
angel 6b6b631417 penpot design work summaries 2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 2555967358 docs: fill in AGENTS.md
Setup, common commands, architecture overview, and the database/testing
gotchas actually hit while working in this repo recently: migration
files that can drift to the point of being unrunnable (not just
stale), inconsistent platform-string casing between Go constants and
SQL objects, global (unscoped) mock-platform NOTIFY channels, the
require.Eventually-runs-on-a-goroutine hazard, and why domains/reports'
fixtures use Etsy rather than Amazon. CLAUDE.md already points here via
@AGENTS.md, so no change needed there.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 7dcfc41188 reports: switch test fixtures from Amazon to Etsy to fix test isolation
domains/reports' fixtures used Amazon as their example platform - same
as domains/amazon's own tests. Both packages' test binaries run
concurrently under go test ./... by default, both wrote to the shared
mock.raw_shop_events table with platform='amazon', and the DB trigger
routed those into mock.shop_amazon_events - the exact table
domains/amazon's background-processing tests poll and assert on.
Confirmed directly: running the two packages together, domains/amazon's
TestProcessUnprocessedEvents_RetriesUnackedNotification picked up 4
events instead of 1, three of them from domains/reports' fixture shop.

Switched to Etsy instead (test-only change, no production code
touched). Verified the casing first since it mattered here:
accounts.Etsy's Go value is "Etsy" (capital), and separately Etsy's
raw_shop_events trigger checks for lowercase 'etsy' - but the view
these tests actually depend on (mock.shop_etsy_listing_event_sequence)
filters on 'Etsy', matching the Go constant, confirmed via
pg_get_viewdef. So the fixtures work correctly and, as a side effect,
never fire the lowercase-gated trigger at all - keeping
mock.shop_etsy_events untouched by these tests regardless.

Combined with the previous commit's goroutine-leak fix, go test ./...
and make test are both reliably green as single commands again.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 30296ee221 amazon: fix a goroutine/connection leak on ProcessEvents shutdown
listenForNotifications' notification-forwarding goroutine sent on ch
unconditionally after each WaitForNotification. If ProcessEvents' main
loop had already exited (ctx cancelled) at the exact moment this
goroutine had a notification to forward, nobody was left reading from
the unbuffered channel - the send blocked forever, the goroutine never
reached its deferred pc.Release(), and the pooled connection leaked
permanently. Real in production (a shutdown racing an in-flight
NOTIFY), not just a test artifact.

Surfaced by a go test ./... hang inside domains/amazon (a goroutine
stuck in pgxpool.Pool.Close's WaitGroup.Wait) - increased cross-package
NOTIFY traffic from domains/reports' Amazon-platform fixtures made the
race easy to hit, but didn't cause it.

Fixed with a select alongside the send so the goroutine notices
ctx.Done() instead of blocking forever when nobody's listening anymore.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 3a1d05857e tests: migrate assertions to testify's assert/require
Replaces raw t.Error/t.Errorf/t.Fatal/t.Fatalf across every test file
that has any (domains/accounts, domains/authentication,
domains/raw_events, domains/amazon, domains/reports x2) with testify's
assert (non-halting) / require (halting) equivalents. The three
Example-based tests (server/ui/svg, server/ui/charts) have no
*testing.T at all - nothing to convert there.

require.Eventually replaces several hand-rolled polling loops in
domains/amazon/mock_test.go. Its condition function runs on a separate
goroutine (confirmed in testify's source), so calling require.* from
inside one - which two of the new Eventually calls initially did, via
the isProcessed helper - is unsafe per Go's testing rules (t.FailNow
must only be called from the test's own goroutine). Fixed by splitting
a *testing.T-free queryIsProcessed(ctx, pool, shopID, eventID) out of
isProcessed for use inside those closures specifically.

github.com/stretchr/testify promoted from an indirect to a direct
dependency (go.mod only - it was already present transitively, so
go.sum is unchanged).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angel e0735854ce stubbed CLAUDE.md and AGENTS.md 2026-08-20 00:24:04 -06:00
angel 16a3551d08 updated readme 2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 f9f9dfa25b reports: add test coverage for GetListingCountsOverTime/Report
mock.shop_amazon_listing_counts is a recursive view that computes a
running inventory count: starting from the listing's base count in
mock.shop_amazon_listings, it walks mock.raw_shop_events in order,
applying each event as a delta (sale/refund) or an absolute reset
(inventory-reset), per mock.shop_amazon_listing_event_sequence's
interpretation of each row's JSON payload. This is the first test to
exercise that view directly rather than just the Go code around it.

Fixture uses the real SaveNewMockSale/SaveNewMockRefund/
SaveNewMockInventoryReset methods - the same entry points the
simulate-sale/refund/inventory UI uses - rather than hand-rolling the
JSON payload shape, so the test tracks the real payload contract.

Covers the full running-count sequence (base -> sale -> refund ->
reset), the unknown-listing ErrNotFound case, and
GetListingCountsReport's MaxCount/MinCount over that sequence.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 d05732d60c reports: add test coverage for GetRawShopEvents
Covers the happy path (ordering by event_timestamp DESC/event_id ASC,
Platform/ShopID fields, RawPayload round-tripping through jsonb), the
empty-shop case, and the unknown-shop ErrNotFound case (via the
accts.GetMockShop check GetRawShopEvents does before querying).

setupAmazonMockShop creates a real account + Amazon mock shop via the
same Store methods the app uses (CreateAccount, CreateMockShop) and
registers cleanup in FK-safe order.

GetListingCountsOverTime/GetListingCountsReport coverage is a separate,
larger task (needs a listing plus count-changing history feeding a DB
view) - not done here.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 57136cfc8b amazon: reconnect the LISTEN connection on failure instead of dying
A dropped connection, a Postgres restart, or any other error on the
dedicated LISTEN connection previously propagated all the way out of
ProcessEvents, and main.go's top-level shutdown logic treats that
error channel firing the same as a fatal server error - taking down
the entire application over a hiccup on one background connection that
has nothing to do with serving HTTP traffic. This matters more with
eleven other platforms already sharing the same trigger+notify shape
in the migrations with no Go processor yet.

Adds (*Mocks).reconnectOrStop: on a real failure (not an ordinary
shutdown), logs a warning and re-establishes LISTEN after a backoff
that starts at 1s, caps at 30s, doubles on repeated immediate
failures, and resets once a reconnect actually succeeds.
ProcessEvents' select no longer returns on a LISTEN error - it loops
back in with fresh channels instead.

Verified against a genuinely killed connection (pg_terminate_backend,
targeting the backend via pg_stat_activity matched on its LISTEN
query text), not a simulated one - both in a manual check and in the
new TestProcessEvents_ReconnectsAfterListenConnectionDrops test.

Closes out all four insights from the domains/amazon design review.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 af46cd1270 amazon: make the poll fallback interval configurable and observable
ProcessEvents' safety-net poll was a bare time.Minute literal inline in
its select statement - no way to verify the fallback path works without
waiting 60+ seconds in a test, no way to tune the cadence without a code
change, and no way to tell afterward whether a given loop iteration was
triggered by a real NOTIFY or by the poll timer.

Adds Mocks.pollInterval (default time.Minute) and a WithPollInterval(d)
builder, mirroring WithNotifyRetryAfter's existing pattern - deploy-time
configurable via construction, not a live/API-adjustable knob, and not
wired through .env, matching how notifyRetryAfter already works.

Adds a Debug log line on each of the two meaningful wake-up branches so
which path fired is now observable.

New test proves the poll branch actually works without waiting or
touching the shared DB trigger (which would be unsafe against the real
dev DB): it reuses the retry mechanism from the previous commit so a
second dispatch can only come from the poll timer, since nothing else
ever notifies again for the rest of the test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 1fd75ea675 amazon: replace fire-and-forget listener notifications with ack-based retry
Dispatching an event to the listener previously happened from an
un-awaited goroutine, with only a log line on failure - once
processed_at was set, a dropped or failed notification was permanently
and silently lost, with no way to tell it had happened.

Replaces the processed/processed_successfully booleans with a
notified_at/processed_at pair (migration 000031): the dispatcher sets
notified_at and hands the event to MockEventListener.Notify, which now
also receives an ack callback the listener calls whenever it's truly
done, synchronously or arbitrarily later. Anything still "notified" but
unacked past notifyRetryAfter (a tunable field, not a stored
per-row timestamp) gets notified again on the dispatcher's normal
poll/reactive loop - no new retry mechanism needed. ack is idempotent,
since a late ack from an earlier attempt and one from a retry can both
eventually fire for the same event.

Dispatch is deferred until after the transaction that recorded
notified_at has actually committed, so ack's independent write can't
race a still-open transaction it implicitly depends on being visible.

The real SSE listener (server/sse/db_event_publisher.go) acks inline,
since its work is synchronous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angelandClaude Sonnet 5 e063fb66d4 amazon: add a Ready() signal for ProcessEvents' LISTEN registration
Tests (and any other caller) previously had no way to know when the
Postgres LISTEN behind the reactive event-processing loop had actually
registered, forcing a guessed sleep before relying on it. Mocks now
exposes Ready() <-chan struct{}, closed once listenForNotifications
successfully issues LISTEN. Purely additive - ProcessEvents' signature
is unchanged.

Updates the integration tests to wait on Ready() instead of a flat
sleep, which also cut TestProcessEvents_ReactsToNotification's runtime
from ~0.25s to ~0.06s with no flakes across repeated runs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:24:04 -06:00
angel 4d43b7e039 store api research 2026-08-20 00:24:04 -06:00
angel 9c9b33e34f Claude-assisted improvements (untested)
- dev auth flow (side-step OAuth)
- db event processing integration tests
- dev scripts (eg Makefile)
- db / test db migration setup scripts.
2026-08-20 00:24:04 -06:00
angel fb2f4255f4 prototyped svg reports
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Failing after 15s
2026-08-20 00:23:54 -06:00
angel 55a3694d89 amazon events published as server side event 2026-08-20 00:23:54 -06:00
angel 40afd176e7 mock amazon event processing stubbed 2026-08-20 00:23:54 -06:00
angel 9f86b8e95c bad logs
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 20s
2026-08-20 00:23:41 -06:00
angel fa3db63ad2 protoype: list raw events on mock mode reports page 2026-08-20 00:23:41 -06:00
angel 0e0422545b mock events saved to db 2026-08-20 00:23:41 -06:00
angel e4e2bd992b mock events db schema 2026-08-20 00:23:41 -06:00
angel 20bbbe33ca regenerated diagrams 2026-08-20 00:23:41 -06:00
angel 496310c93a fixed mock mode button; shortened simulation tabs 2026-08-20 00:23:41 -06:00
angel 25615fabc6 MockMode: made global in templates; increase bort of navbar in mock mode 2026-08-20 00:23:41 -06:00
angel f30d4a3140 stubbed simulations page content: set inventory 2026-08-20 00:23:41 -06:00
angel 793ca05fd4 stubbed simulations page content: refund 2026-08-20 00:23:41 -06:00
angel 9971ff3266 stubbed simulations page content: sale 2026-08-20 00:23:41 -06:00
177 changed files with 10765 additions and 263 deletions
+3
View File
@@ -2,3 +2,6 @@
*.swp
node_modules
.env
.env.example
+183
View File
@@ -0,0 +1,183 @@
# 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.
## 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/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.
## Commit conventions
- Commit messages: imperative mood subject line, no period, body explains
*why* (what problem existed, what the fix changes) rather than
re-describing the diff. One logical change per commit - e.g. a bugfix
found while doing unrelated work gets its own commit, not folded into
the original task's.
- Work summaries: see `work-summaries/` for dated records of past
sessions' changes and reasoning - useful context before touching an area
someone else (human or agent) recently worked on.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+55
View File
@@ -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
+59 -1
View File
@@ -1,4 +1,62 @@
# V2 - Attempt 2
##
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.
# Deploying
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 .
```
# Development
## Testing
There's a Makefile with a number of operations for running and testing the application
## Technology
### Languages
#### Server side
Golang, Go Templates,
#### 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.
# Roadmap
+46
View File
@@ -0,0 +1,46 @@
# Store API Research
https://docs.google.com/spreadsheets/d/1xWfXn-wbiHTBeqyhnq0b46_sgLGyiYc_5N5mXKYD1R8/edit?gid=0#gid=0
| Platform | Order Event: placed | Order Event: changed | Products List | Products Look up | Inventory Look up | Inventory Update | Inventory Event: change |
|----------|----------|----------|----------|----------|----------|----------|----------|
| Shopify | orders/create | orders/cancelled, orders/delete, orders/paid, etc | /queries/products | /queries/product | /queries/product | /mutations/inventorySetQuantities | inventory_levels/update |
| WooCommerce | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /wp-json/wc/v3/products` | `GET /wp-json/wc/v3/products/<id>` | `stock_quantity` field on product resource | `PUT /wp-json/wc/v3/products/<id>` (stock_quantity), or `/products/batch` | `product.updated` (no dedicated inventory webhook) |
| BigCommerce | webhook `store/order/created` | webhook `store/order/updated`, `store/order/statusUpdated` | `GET /v3/catalog/products` | `GET /v3/catalog/products/{product_id}` | `GET /v3/inventory/items` | `PUT /v3/inventory/adjustments/absolute` (also `/relative`) | `store/product/inventory/updated` |
| Wix | webhook `wix.ecom.v1.order.created` | webhook `wix.ecom.v1.order.updated` (also `.canceled`) | Query Products (Catalog V3) | Get Product | Query Inventory Items | Update Inventory Variants | `wix.stores.catalog.v3.inventory_item.updated` |
| Squarespace | webhook `order.create` | webhook `order.update` (FULFILLED, REFUNDED, CANCELED, MARKED_PENDING, EMAIL_UPDATED) | `GET /v2/commerce/products` | `GET /v2/commerce/products/{productIdCsvs}` | `GET /1.0/commerce/inventory/{variantIdCsvs}` | `POST /1.0/commerce/inventory/adjustments` | N/A (no inventory webhook topic) |
| Square Online | webhook `order.created` | webhook `order.updated`, `order.fulfillment.updated` | `GET /v2/catalog/list` | `GET /v2/catalog/object/{object_id}` | `POST /v2/inventory/counts/batch-retrieve` | `POST /v2/inventory/changes/batch-create` (BatchChangeInventory) | webhook `inventory.count.updated` |
| Zoho | webhook `salesorder.created` (Zoho Commerce) | webhook `salesorder.confirmed, .cancelled, .declined, .shipped, .delivered` | `GET /store/api/v1/products` | `GET /store/api/v1/products/{product_id}` | `GET /store/api/v1/variants` (`stock_on_hand`, `actual_available_stock`) | `POST /store/api/v1/inventoryadjustments` | N/A (no inventory/stock webhook event) |
| Ecwid | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /api/v3/{storeId}/products` | `GET /api/v3/{storeId}/products/{productId}` | `GET /api/v3/{storeId}/products/{productId}` (`quantity`/`unlimited`) | `PUT /api/v3/{storeId}/products/{productId}/inventory` (`quantityDelta`) | `product.updated` webhook |
| Big Cartel | webhook `order.create` (app-approved) | webhook `order.update` (app-approved) | `GET /v1/accounts/{account_id}/products` | `GET /v1/accounts/{account_id}/products/{id}` | N/A — no dedicated inventory field/endpoint | N/A — no inventory update endpoint | N/A — no inventory-specific webhook |
| Amazon | `ORDER_CHANGE` notification (SP-API) | `ORDER_CHANGE` notification (same type, status delta) | `searchCatalogItems` (GET `/catalog/2022-04-01/items`) | `getCatalogItem` (GET `/catalog/2022-04-01/items/{asin}`) | `getInventorySummaries` (FBA Inventory API, GET `/fba/inventory/v1/summaries`) | `patchListingsItem` (PATCH `/listings/2021-08-01/items/{sellerId}/{sku}`) | `FBA_INVENTORY_AVAILABILITY_CHANGES` notification |
| Walmart Marketplace | PO created event (webhook) | Order intent to cancel / PO line auto-cancelled event (webhook); status flow Created→Acknowledged→Shipped→Delivered/Cancelled | `GET /v3/items` (getAllItems) | `GET /v3/items/{id}` (getAnItem) | `GET /v3/inventory?sku={sku}` | `PUT /v3/inventory` (also bulk via `POST /v3/feeds`) | Inventory OOS event (webhook) |
| Ebay | `FixedPriceTransaction` / `ItemSold` (Platform Notifications, legacy Trading API) | `ItemMarkedShipped` notification; also `getOrders` filtered by `lastmodifieddate` (Fulfillment API) | `GET /sell/inventory/v1/inventory_item` (getInventoryItems) | `GET /sell/inventory/v1/inventory_item/{sku}` (getInventoryItem) | `GET /sell/inventory/v1/inventory_item/{sku}` (availability.shipToLocationAvailability) | `POST /sell/inventory/v1/bulk_update_price_quantity` (bulkUpdatePriceQuantity) | N/A — no dedicated inventory-change topic found |
| Platform | Docs | Webhooks | API |
|----------|----------|----------|----------|
| Shopify | https://shopify.dev/docs/api | https://shopify.dev/docs/api/webhooks/latest?reference=toml | https://shopify.dev/docs/api/admin-graphql/latest |
| WooCommerce | https://developer.woocommerce.com/docs/apis/rest-api/ | https://developer.woocommerce.com/docs/apis/rest-api/v2/webhooks/ | https://developer.woocommerce.com/docs/apis/rest-api/v3/products/ |
| BigCommerce | https://developer.bigcommerce.com/docs | https://developer.bigcommerce.com/docs/integrations/webhooks/overview | https://developer.bigcommerce.com/docs/rest-catalog/products |
| Wix | https://dev.wix.com/docs | https://dev.wix.com/docs/build-apps/develop-your-app/api-integrations/events-and-webhooks/about-webhooks | https://dev.wix.com/docs/api-reference |
| Squarespace | https://developers.squarespace.com/commerce-apis/overview | https://developers.squarespace.com/commerce-apis/webhooksubscriptions-overview | https://developers.squarespace.com/commerce-apis/overview |
| Square Online | https://developer.squareup.com/docs | https://developer.squareup.com/docs/webhooks/overview | https://developer.squareup.com/reference/square |
| Zoho | https://www.zoho.com/commerce/api/introduction.html | https://www.zoho.com/commerce/api/webhooks.html | https://www.zoho.com/commerce/api/apis-list.html |
| Ecwid | https://docs.ecwid.com/ | https://docs.ecwid.com/webhook-automations | https://api-docs.ecwid.com/reference |
| Big Cartel | https://developers.bigcartel.com/ | https://developers.bigcartel.com/api/v1 (webhooks section, no standalone page) | https://developers.bigcartel.com/api/v1 |
| Amazon | https://developer-docs.amazon.com/sp-api/docs/welcome | https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide | https://developer-docs.amazon.com/sp-api/reference |
| Walmart Marketplace | https://developer.walmart.com/ | https://developer.walmart.com/doc/us/mp/us-mp-notifications/ | https://developer.walmart.com/us-marketplace/docs/inventory-api-overview |
| Ebay | https://developer.ebay.com/develop | https://developer.ebay.com/api-docs/commerce/notification/overview.html | https://developer.ebay.com/api-docs/sell/inventory/overview.html |
### Notes / caveats from research
- **Square Online**: no separate API — orders, catalog, and inventory are handled by Square's core Seller APIs (developer.squareup.com), the same ones used across all Square products. Unrelated to Squarespace despite the name.
- **Zoho**: "Zoho Commerce" (commerce.zoho.com) is the storefront product comparable to Shopify/Squarespace and owns the order/product/webhook APIs listed above. Zoho Inventory is a separate warehouse/stock-management app with its own API but no documented webhook support.
- **Big Cartel**: no true inventory API — only an `inventory_enabled` flag and `quantity_gte`/`quantity_lte` filters on products. No endpoint to set stock and no inventory-change webhook. Webhook access is gated per-app approval; exact topic names are inferred from integration examples since Big Cartel has no canonical published list.
- **Amazon SP-API**: no separate "placed" vs "changed" order topics — both flow through a single `ORDER_CHANGE` notification, differentiated by payload content.
- **WooCommerce / Ecwid**: neither has a dedicated inventory-change webhook; stock changes surface via the general `product.updated` event instead.
- **Ebay**: order-event names are less certain — developer.ebay.com pages repeatedly failed to load during research, so those values come from documented Platform Notifications event types found via search rather than a directly confirmed doc page.
+128
View File
@@ -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)
}
}
+2 -2
View File
@@ -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,63 @@
BEGIN;
--- event processing
DROP TRIGGER amazon_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_amazon_event;
DROP TRIGGER big_cartel_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_big_cartel_event;
DROP TRIGGER ebay_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_ebay_event;
DROP TRIGGER ecwid_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_ecwid_event;
DROP TRIGGER etsy_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_etsy_event;
DROP TRIGGER shopify_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_shopify_event;
DROP TRIGGER square_online_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_square_online_event;
DROP TRIGGER squarespace_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_squarespace_event;
DROP TRIGGER tiktok_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_tiktok_event;
DROP TRIGGER walmart_marketplace_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_walmart_marketplace_event;
DROP TRIGGER wix_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_wix_event;
DROP TRIGGER woo_commerce_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_woo_commerce_event;
DROP TRIGGER zoho_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_zoho_event;
-- event tables
DROP TABLE mock.shop_amazon_events;
DROP TABLE mock.shop_big_cartel_events;
DROP TABLE mock.shop_ebay_events;
DROP TABLE mock.shop_ecwid_events;
DROP TABLE mock.shop_etsy_events;
DROP TABLE mock.shop_shopify_events;
DROP TABLE mock.shop_square_online_events;
DROP TABLE mock.shop_squarespace_events;
DROP TABLE mock.shop_tiktok_events;
DROP TABLE mock.shop_walmart_marketplace_events;
DROP TABLE mock.shop_wix_events;
DROP TABLE mock.shop_woo_commerce_events;
DROP TABLE mock.shop_zoho_events;
DROP TABLE mock.raw_shop_events;
COMMIT;
@@ -0,0 +1,464 @@
BEGIN;
-- raw shop events
CREATE TABLE mock.raw_shop_events (
platform TEXT NOT NULL,
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
raw_payload JSONB NOT NULL,
PRIMARY KEY (platform, shop_id, event_id, event_timestamp)
);
-- processed event tables
CREATE TABLE mock.shop_amazon_events (
platform TEXT NOT NULL DEFAULT 'amazon' CHECK (platform = 'amazon'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_big_cartel_events (
platform TEXT NOT NULL DEFAULT 'big_cartel' CHECK (platform = 'big_cartel'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_ebay_events (
platform TEXT NOT NULL DEFAULT 'ebay' CHECK (platform = 'ebay'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_ecwid_events (
platform TEXT NOT NULL DEFAULT 'ecwid' CHECK (platform = 'ecwid'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_etsy_events (
platform TEXT NOT NULL DEFAULT 'etsy' CHECK (platform = 'etsy'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_shopify_events (
platform TEXT NOT NULL DEFAULT 'shopify' CHECK (platform = 'shopify'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_square_online_events (
platform TEXT NOT NULL DEFAULT 'square_online' CHECK (platform = 'square_online'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_squarespace_events (
platform TEXT NOT NULL DEFAULT 'squarespace' CHECK (platform = 'squarespace'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_tiktok_events (
platform TEXT NOT NULL DEFAULT 'tiktok' CHECK (platform = 'tiktok'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_walmart_marketplace_events (
platform TEXT NOT NULL DEFAULT 'walmart_marketplace' CHECK (platform = 'walmart_marketplace'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_wix_events (
platform TEXT NOT NULL DEFAULT 'wix' CHECK (platform = 'wix'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_woo_commerce_events (
platform TEXT NOT NULL DEFAULT 'woo_commerce' CHECK (platform = 'woo_commerce'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_zoho_events (
platform TEXT NOT NULL DEFAULT 'zoho' CHECK (platform = 'zoho'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
--- 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;
RETURN NULL;
END;
$process_raw_amazon_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER amazon_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'amazon')
EXECUTE FUNCTION mock.process_raw_amazon_event();
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;
RETURN NULL;
END;
$process_raw_big_cartel_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER big_cartel_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'big_cartel')
EXECUTE FUNCTION mock.process_raw_big_cartel_event();
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;
RETURN NULL;
END;
$process_raw_ebay_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER ebay_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'ebay')
EXECUTE FUNCTION mock.process_raw_ebay_event();
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;
RETURN NULL;
END;
$process_raw_ecwid_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER ecwid_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'ecwid')
EXECUTE FUNCTION mock.process_raw_ecwid_event();
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;
RETURN NULL;
END;
$process_raw_etsy_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER etsy_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'etsy')
EXECUTE FUNCTION mock.process_raw_etsy_event();
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;
RETURN NULL;
END;
$process_raw_shopify_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER shopify_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'shopify')
EXECUTE FUNCTION mock.process_raw_shopify_event();
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;
RETURN NULL;
END;
$process_raw_square_online_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER square_online_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'square_online')
EXECUTE FUNCTION mock.process_raw_square_online_event();
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;
RETURN NULL;
END;
$process_raw_squarespace_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER squarespace_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'squarespace')
EXECUTE FUNCTION mock.process_raw_squarespace_event();
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;
RETURN NULL;
END;
$process_raw_tiktok_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER tiktok_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'tiktok')
EXECUTE FUNCTION mock.process_raw_tiktok_event();
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;
RETURN NULL;
END;
$process_raw_walmart_marketplace_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER walmart_marketplace_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'walmart_marketplace')
EXECUTE FUNCTION mock.process_raw_walmart_marketplace_event();
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;
RETURN NULL;
END;
$process_raw_wix_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER wix_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'wix')
EXECUTE FUNCTION mock.process_raw_wix_event();
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;
RETURN NULL;
END;
$process_raw_woo_commerce_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER woo_commerce_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'woo_commerce')
EXECUTE FUNCTION mock.process_raw_woo_commerce_event();
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;
RETURN NULL;
END;
$process_raw_zoho_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER zoho_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'zoho')
EXECUTE FUNCTION mock.process_raw_zoho_event();
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
DROP TABLE mock.sale_form_shop_selection;
DROP TABLE mock.refund_form_shop_selection;
DROP TABLE mock.count_form_shop_selection;
COMMIT;
@@ -0,0 +1,23 @@
BEGIN;
CREATE TABLE mock.sale_form_shop_selection (
account_id INTEGER PRIMARY KEY,
platform PLATFORM NOT NULL,
shop_id TEXT NOT NULL
);
CREATE TABLE mock.refund_form_shop_selection (
account_id INTEGER PRIMARY KEY,
platform PLATFORM NOT NULL,
shop_id TEXT NOT NULL
);
CREATE TABLE mock.count_form_shop_selection (
account_id INTEGER PRIMARY KEY,
platform PLATFORM NOT NULL,
shop_id TEXT NOT NULL
);
COMMIT;
@@ -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;
File diff suppressed because it is too large Load Diff
@@ -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;
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 471 KiB

+140
View File
@@ -9,6 +9,16 @@ entity "**accounts**" {
*""user_id"": //text [FK]//
}
entity "**raw_shop_events**" {
+ ""platform"": //text [PK]//
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK]//
+ ""event_id"": //text [PK]//
--
*""raw_payload"": //jsonb //
*""parsed"": //boolean //
}
entity "**shop_amazon**" {
+ ""account_id"": //integer [PK][FK]//
+ ""shop_id"": //text [PK]//
@@ -17,6 +27,14 @@ entity "**shop_amazon**" {
*""name"": //text //
}
entity "**shop_amazon_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_amazon_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -63,6 +81,14 @@ entity "**shop_big_cartel**" {
*""name"": //text //
}
entity "**shop_big_cartel_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_big_cartel_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -109,6 +135,14 @@ entity "**shop_ebay**" {
*""name"": //text //
}
entity "**shop_ebay_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_ebay_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -155,6 +189,14 @@ entity "**shop_ecwid**" {
*""name"": //text //
}
entity "**shop_ecwid_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_ecwid_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -201,6 +243,14 @@ entity "**shop_etsy**" {
*""name"": //text //
}
entity "**shop_etsy_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_etsy_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -247,6 +297,14 @@ entity "**shop_shopify**" {
*""name"": //text //
}
entity "**shop_shopify_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_shopify_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -293,6 +351,14 @@ entity "**shop_square_online**" {
*""name"": //text //
}
entity "**shop_square_online_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_square_online_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -339,6 +405,14 @@ entity "**shop_squarespace**" {
*""name"": //text //
}
entity "**shop_squarespace_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_squarespace_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -385,6 +459,14 @@ entity "**shop_tiktok**" {
*""name"": //text //
}
entity "**shop_tiktok_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_tiktok_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -431,6 +513,14 @@ entity "**shop_walmart_marketplace**" {
*""name"": //text //
}
entity "**shop_walmart_marketplace_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_walmart_marketplace_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -477,6 +567,14 @@ entity "**shop_wix**" {
*""name"": //text //
}
entity "**shop_wix_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_wix_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -523,6 +621,14 @@ entity "**shop_woo_commerce**" {
*""name"": //text //
}
entity "**shop_woo_commerce_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_woo_commerce_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -569,6 +675,14 @@ entity "**shop_zoho**" {
*""name"": //text //
}
entity "**shop_zoho_events**" {
+ ""store_id"": //text [PK]//
+ ""event_timestamp"": //timestamp with time zone [PK][FK]//
+ ""event_id"": //text [PK]//
--
*""platform"": //text //
}
entity "**shop_zoho_listings**" {
+ ""shop_id"": //text [PK][FK]//
+ ""listing_id"": //text [PK]//
@@ -654,6 +768,8 @@ entity "**sync_groups**" {
"**shop_amazon**" }-- "**public.oauth_users**"
"**shop_amazon_events**" ||-|| "**raw_shop_events**"
"**shop_amazon_listings**" }-- "**accounts**"
"**shop_amazon_listings**" ||-|| "**shop_amazon**"
@@ -684,6 +800,8 @@ entity "**sync_groups**" {
"**shop_big_cartel**" }-- "**public.oauth_users**"
"**shop_big_cartel_events**" ||-|| "**raw_shop_events**"
"**shop_big_cartel_listings**" }-- "**accounts**"
"**shop_big_cartel_listings**" ||-|| "**shop_big_cartel**"
@@ -714,6 +832,8 @@ entity "**sync_groups**" {
"**shop_ebay**" }-- "**public.oauth_users**"
"**shop_ebay_events**" ||-|| "**raw_shop_events**"
"**shop_ebay_listings**" }-- "**accounts**"
"**shop_ebay_listings**" ||-|| "**shop_ebay**"
@@ -744,6 +864,8 @@ entity "**sync_groups**" {
"**shop_ecwid**" }-- "**public.oauth_users**"
"**shop_ecwid_events**" ||-|| "**raw_shop_events**"
"**shop_ecwid_listings**" }-- "**accounts**"
"**shop_ecwid_listings**" ||-|| "**shop_ecwid**"
@@ -774,6 +896,8 @@ entity "**sync_groups**" {
"**shop_etsy**" }-- "**public.oauth_users**"
"**shop_etsy_events**" ||-|| "**raw_shop_events**"
"**shop_etsy_listings**" }-- "**accounts**"
"**shop_etsy_listings**" ||-|| "**shop_etsy**"
@@ -804,6 +928,8 @@ entity "**sync_groups**" {
"**shop_shopify**" }-- "**public.oauth_users**"
"**shop_shopify_events**" ||-|| "**raw_shop_events**"
"**shop_shopify_listings**" }-- "**accounts**"
"**shop_shopify_listings**" ||-|| "**shop_shopify**"
@@ -834,6 +960,8 @@ entity "**sync_groups**" {
"**shop_square_online**" }-- "**public.oauth_users**"
"**shop_square_online_events**" ||-|| "**raw_shop_events**"
"**shop_square_online_listings**" }-- "**accounts**"
"**shop_square_online_listings**" ||-|| "**shop_square_online**"
@@ -864,6 +992,8 @@ entity "**sync_groups**" {
"**shop_squarespace**" }-- "**public.oauth_users**"
"**shop_squarespace_events**" ||-|| "**raw_shop_events**"
"**shop_squarespace_listings**" }-- "**accounts**"
"**shop_squarespace_listings**" ||-|| "**shop_squarespace**"
@@ -894,6 +1024,8 @@ entity "**sync_groups**" {
"**shop_tiktok**" }-- "**public.oauth_users**"
"**shop_tiktok_events**" ||-|| "**raw_shop_events**"
"**shop_tiktok_listings**" }-- "**accounts**"
"**shop_tiktok_listings**" ||-|| "**shop_tiktok**"
@@ -924,6 +1056,8 @@ entity "**sync_groups**" {
"**shop_walmart_marketplace**" }-- "**public.oauth_users**"
"**shop_walmart_marketplace_events**" ||-|| "**raw_shop_events**"
"**shop_walmart_marketplace_listings**" }-- "**accounts**"
"**shop_walmart_marketplace_listings**" ||-|| "**shop_walmart_marketplace**"
@@ -954,6 +1088,8 @@ entity "**sync_groups**" {
"**shop_wix**" }-- "**public.oauth_users**"
"**shop_wix_events**" ||-|| "**raw_shop_events**"
"**shop_wix_listings**" }-- "**accounts**"
"**shop_wix_listings**" ||-|| "**shop_wix**"
@@ -984,6 +1120,8 @@ entity "**sync_groups**" {
"**shop_woo_commerce**" }-- "**public.oauth_users**"
"**shop_woo_commerce_events**" ||-|| "**raw_shop_events**"
"**shop_woo_commerce_listings**" }-- "**accounts**"
"**shop_woo_commerce_listings**" ||-|| "**shop_woo_commerce**"
@@ -1014,6 +1152,8 @@ entity "**sync_groups**" {
"**shop_zoho**" }-- "**public.oauth_users**"
"**shop_zoho_events**" ||-|| "**raw_shop_events**"
"**shop_zoho_listings**" }-- "**accounts**"
"**shop_zoho_listings**" ||-|| "**shop_zoho**"
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+8
View File
@@ -51,6 +51,12 @@ entity "**etsy_users**" {
*""shop_id"": //integer //
}
entity "**mock_mode**" {
+ ""account_id"": //integer [PK][FK]//
--
*""mock_mode"": //boolean //
}
entity "**oauth_login_states**" {
+ ""state"": //bytea [PK]//
--
@@ -149,6 +155,8 @@ entity "**wix_store_events**" {
"**etsy_users**" }-- "**accounts**"
"**mock_mode**" ||-|| "**accounts**"
"**oauth_tokens**" }-- "**oauth_users**"
"**sync_group_listing_drafts**" }-- "**accounts**"
+332 -2
View File
@@ -5,11 +5,13 @@ package accounts
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
@@ -225,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,
@@ -1428,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
@@ -1461,6 +1499,290 @@ func (db *Store) DeleteListingInListingInMockSyncGroupBeingEdited(ctx context.Co
return nil
}
// if err := s.accts.SetShopInMockSaleForm(c, acctID, platform, shopID); err != nil {
func (db *Store) SetShopInMockSaleForm(ctx context.Context, acctID int64, platform Platform, shopID string) error {
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.sale_form_shop_selection (
account_id,
platform,
shop_id
)
VALUES (
@account_id,
@platform,
@shop_id
)
ON CONFLICT (account_id) DO UPDATE
SET platform = EXCLUDED.platform,
shop_id = EXCLUDED.shop_id
`,
pgx.NamedArgs{
"account_id": acctID,
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (db *Store) SetShopInMockRefundForm(ctx context.Context, acctID int64, platform Platform, shopID string) error {
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.refund_form_shop_selection (
account_id,
platform,
shop_id
)
VALUES (
@account_id,
@platform,
@shop_id
)
ON CONFLICT (account_id) DO UPDATE
SET platform = EXCLUDED.platform,
shop_id = EXCLUDED.shop_id
`,
pgx.NamedArgs{
"account_id": acctID,
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (db *Store) SetShopInMockCountForm(ctx context.Context, acctID int64, platform Platform, shopID string) error {
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.count_form_shop_selection (
account_id,
platform,
shop_id
)
VALUES (
@account_id,
@platform,
@shop_id
)
ON CONFLICT (account_id) DO UPDATE
SET platform = EXCLUDED.platform,
shop_id = EXCLUDED.shop_id
`,
pgx.NamedArgs{
"account_id": acctID,
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
type MockEventFormValues struct {
SaleShopID string
SalePlatform Platform
RefundShopID string
RefundPlatform Platform
CountShopID string
CountPlatform Platform
}
func (db *Store) GetMockEventFormValues(ctx context.Context, acctID int64) (*MockEventFormValues, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
sf.platform AS sale_platform,
sf.shop_id AS sale_shop_id,
rf.platform AS refund_platform,
rf.shop_id AS refund_shop_id,
cf.platform AS count_platform,
cf.shop_id AS count_shop_id
FROM
mock.sale_form_shop_selection AS sf
FULL OUTER JOIN
mock.refund_form_shop_selection AS rf
USING
(account_id)
FULL OUTER JOIN
mock.count_form_shop_selection AS cf
USING
(account_id)
WHERE
sf.account_id = @account_id
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
v, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[struct {
Sale_shop_id pgtype.Text
Sale_platform *Platform
Refund_shop_id pgtype.Text
Refund_platform *Platform
Count_shop_id pgtype.Text
Count_platform *Platform
}])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &MockEventFormValues{}, nil
}
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
return &MockEventFormValues{
SaleShopID: v.Sale_shop_id.String,
SalePlatform: deref(v.Sale_platform),
RefundShopID: v.Refund_shop_id.String,
RefundPlatform: deref(v.Refund_platform),
CountShopID: v.Count_shop_id.String,
CountPlatform: deref(v.Count_platform),
}, nil
}
func (db *Store) SaveNewMockSale(ctx context.Context, acctID int64, platform Platform, shopID, listingID string, count int) (uuid.UUID, error) {
eventID := uuid.New()
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.raw_shop_events (
platform,
shop_id,
event_timestamp,
event_id,
raw_payload
)
VALUES (
@platform,
@shop_id,
NOW(),
@event_id,
@raw_payload
)
`,
pgx.NamedArgs{
"platform": platform,
"shop_id": shopID,
"event_id": eventID,
"raw_payload": json.RawMessage(fmt.Sprintf(`{
"type": "sale",
"listingID": %q,
"count": %d
}`, listingID, count)),
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query: %w", err)
}
return eventID, nil
}
func (db *Store) SaveNewMockRefund(ctx context.Context, acctID int64, platform Platform, shopID, listingID string, count int) (uuid.UUID, error) {
eventID := uuid.New()
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.raw_shop_events (
platform,
shop_id,
event_timestamp,
event_id,
raw_payload
)
VALUES (
@platform,
@shop_id,
NOW(),
@event_id,
@raw_payload
)
`,
pgx.NamedArgs{
"platform": platform,
"shop_id": shopID,
"event_id": eventID,
"raw_payload": json.RawMessage(fmt.Sprintf(`{
"type": "refund",
"listingID": %q,
"count": %d
}`, listingID, count)),
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query: %w", err)
}
return eventID, nil
}
func (db *Store) SaveNewMockInventoryReset(ctx context.Context, acctID int64, platform Platform, shopID, listingID string, count int) (uuid.UUID, error) {
eventID := uuid.New()
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.raw_shop_events (
platform,
shop_id,
event_timestamp,
event_id,
raw_payload
)
VALUES (
@platform,
@shop_id,
NOW(),
@event_id,
@raw_payload
)
`,
pgx.NamedArgs{
"platform": platform,
"shop_id": shopID,
"event_id": eventID,
"raw_payload": json.RawMessage(fmt.Sprintf(`{
"type": "inventory-reset",
"listingID": %q,
"count": %d
}`, listingID, count)),
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query: %w", err)
}
return eventID, nil
}
// additional context
func (db *Store) GetAccountPointerByUserID(ctx context.Context, userID string) (*Account, error) {
@@ -1481,3 +1803,11 @@ func (db *Store) beginReadonlyTxn(ctx context.Context, cb func(pgx.Tx) error) er
func (db *Store) beginTxn(ctx context.Context, cb func(pgx.Tx) error) error {
return pgx.BeginFunc(ctx, db.db, cb)
}
func deref[T any](ptr *T) T {
if ptr != nil {
return *ptr
}
var zero T
return zero
}
+103
View File
@@ -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()")
}
+16
View File
@@ -105,6 +105,22 @@ func (v_ctx *StoreWithContext) DeleteListingInListingInMockSyncGroupBeingEdited(
return v_ctx.Store.DeleteListingInListingInMockSyncGroupBeingEdited(v_ctx.ctx, acctID, orderIndex)
}
func (v_ctx *StoreWithContext) SetShopInMockSaleForm(acctID int64, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInMockSaleForm(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) SetShopInMockRefundForm(acctID int64, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInMockRefundForm(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) SetShopInMockCountForm(acctID int64, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInMockCountForm(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) GetMockEventFormValues(acctID int64) (*MockEventFormValues, error) {
return v_ctx.Store.GetMockEventFormValues(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID)
}
-8
View File
@@ -1235,11 +1235,3 @@ func (db *Store) listMockSyncGroupListings(ctx context.Context, tx pgx.Tx, acctI
return listings, nil
}
func deref[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}
+377
View File
@@ -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
}
+459
View File
@@ -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 &notifySpy{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(&notified)
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)
}
+27 -25
View File
@@ -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)
}
+97
View File
@@ -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
}
+74
View File
@@ -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()")
}
+68
View File
@@ -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()")
}
+80
View File
@@ -0,0 +1,80 @@
package reports
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
)
type (
RawShopEvent struct {
Platform accounts.Platform
ShopID string
EventTimestamp time.Time
EventID string
RawPayload json.RawMessage
}
)
func (db *Store) GetRawShopEvents(ctx context.Context, acctID int64, platform accounts.Platform, shopID string) ([]RawShopEvent, error) {
if _, err := db.accts.GetMockShop(ctx, acctID, platform, shopID); err != nil {
if errors.Is(err, consts.ErrNotFound) {
return nil, fmt.Errorf("shop not found: %w", err)
}
return nil, fmt.Errorf("failed to look up shop: %w", err)
}
rows, err := db.db.Query(
ctx,
`
SELECT
event_timestamp,
event_id,
raw_payload
FROM
mock.raw_shop_events
WHERE
platform = @platform
AND shop_id = @shop_id
ORDER BY
event_timestamp DESC,
event_id ASC
`,
pgx.NamedArgs{
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Event_timestamp time.Time
Event_id string
Raw_payload json.RawMessage
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
evts := make([]RawShopEvent, len(vs))
for i, v := range vs {
evts[i] = RawShopEvent{
Platform: platform,
ShopID: shopID,
EventTimestamp: v.Event_timestamp,
EventID: v.Event_id,
RawPayload: v.Raw_payload,
}
}
return evts, nil
}
+129
View File
@@ -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()")
}
+259
View File
@@ -0,0 +1,259 @@
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"
)
//go:generate concurry -s Store
type (
Store struct {
log *logging.Logger
db *pgxpool.Pool
accts *accounts.Store
}
)
func NewStore(logger *logging.Logger, db *pgxpool.Pool, accts *accounts.Store) *Store {
return &Store{
log: logger,
db: db,
accts: accts,
}
}
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",
},
}
}
+128
View File
@@ -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")
}
+33
View File
@@ -0,0 +1,33 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package reports
import (
"context"
"ruben/inventory2/domains/accounts"
)
type StoreWithContext struct {
ctx context.Context
*Store
}
func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
Store: v,
}
}
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)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -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"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -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"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+4 -5
View File
@@ -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
+4 -94
View File
@@ -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=
+115
View File
@@ -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
}
+98 -22
View File
@@ -21,17 +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() {
@@ -65,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
@@ -92,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 {
@@ -106,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")
@@ -127,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))
@@ -150,26 +204,48 @@ 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 {
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"),
"./",
raw_events.NewStore(logger.WithGroup("raw-event-store"), connPool),
accounts.NewStore(logger, connPool),
accts,
reports.NewStore(logger, connPool, accts),
etsy_platform.NewPlatform(
logger,
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,
}
@@ -182,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)
@@ -231,5 +307,5 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
}
}()
return errCh
return r.GetSSEQueue(), errCh
}
+108
View File
@@ -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);
})
}
});
})()
+297 -6
View File
@@ -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))
@@ -78,6 +88,14 @@ func Routes(
mockSyncGroupBeingEdited.PUT("/listings/:orderIndex/shop", response.Handler(as.setShopInListingInMockSyncGroupBeingEdited))
mockSyncGroupBeingEdited.PUT("/listings/:orderIndex/listing", response.Handler(as.setListingInListingInMockSyncGroupBeingEdited))
mockSyncGroupBeingEdited.DELETE("/listings/:orderIndex", response.Handler(as.deleteListingInListingInMockSyncGroupBeingEdited))
simulations := r.Group("/:acctID/simulations", pub.Publish("/:acctID/simulations"))
simulations.PUT("/sale/form/shop", response.Handler(as.setShopInMockSaleForm))
simulations.POST("/sale", response.Handler(as.postNewMockSale))
simulations.PUT("/refund/form/shop", response.Handler(as.setShopInMockRefundForm))
simulations.POST("/refund", response.Handler(as.postNewMockRefund))
simulations.PUT("/count/form/shop", response.Handler(as.setShopInMockCountForm))
simulations.PUT("/shops/:shopID/listings/:listingID/count", response.Handler(as.setMockListingCount))
}
// POST /
@@ -413,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
@@ -736,6 +883,150 @@ func (s *accountSubrouter) deleteListingInListingInMockSyncGroupBeingEdited(c *g
return response.StatusOK(), nil
}
// PUT /:acctID/simulations/sale/form/shop
func (s *accountSubrouter) setShopInMockSaleForm(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Unmarshal(c); err != nil {
return nil, err
}
if err := s.accts.SetShopInMockSaleForm(c, acctID, platform, shopID); err != nil {
return nil, fmt.Errorf("failed to set shop in mock sale form: %w", err)
}
return response.StatusOK(), nil
}
// POST /:acctID/simulations/sale
func (s *accountSubrouter) postNewMockSale(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
count int
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Form("listing-id", param.Text(&listingID)).
Form("count", param.Int(&count)).
Unmarshal(c); err != nil {
return nil, err
}
if _, err := s.accts.SaveNewMockSale(c, acctID, platform, shopID, listingID, count); err != nil {
return nil, fmt.Errorf("failed to save new mock sale: %w", err)
}
return response.StatusCreated(), nil
}
// PUT /:acctID/simulations/refund/form/shop
func (s *accountSubrouter) setShopInMockRefundForm(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Unmarshal(c); err != nil {
return nil, err
}
if err := s.accts.SetShopInMockRefundForm(c, acctID, platform, shopID); err != nil {
return nil, fmt.Errorf("failed to set shop in mock refund form: %w", err)
}
return response.StatusOK(), nil
}
// POST /:acctID/simulations/refund
func (s *accountSubrouter) postNewMockRefund(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
count int
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Form("listing-id", param.Text(&listingID)).
Form("count", param.Int(&count)).
Unmarshal(c); err != nil {
return nil, err
}
if _, err := s.accts.SaveNewMockRefund(c, acctID, platform, shopID, listingID, count); err != nil {
return nil, fmt.Errorf("failed to save new mock refund: %w", err)
}
return response.StatusCreated(), nil
}
// PUT /:acctID/simulations/count/form/shop
func (s *accountSubrouter) setShopInMockCountForm(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Unmarshal(c); err != nil {
return nil, err
}
if err := s.accts.SetShopInMockCountForm(c, acctID, platform, shopID); err != nil {
return nil, fmt.Errorf("failed to set shop in mock count form: %w", err)
}
return response.StatusOK(), nil
}
// PUT /:acctID/simulations/shops/:shopID/listings/:listingID/count
func (s *accountSubrouter) setMockListingCount(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
count int
)
if err := param.Form("platform", param.Platform(&platform)).
Path("shopID", param.Text(&shopID)).
Path("listingID", param.Text(&listingID)).
Form("count", param.Int(&count)).
Unmarshal(c); err != nil {
return nil, err
}
if _, err := s.accts.SaveNewMockInventoryReset(c, acctID, platform, shopID, listingID, count); err != nil {
return nil, fmt.Errorf("failed to save new mock inventory reset: %w", err)
}
return response.StatusOK(), nil
}
func lowerSnakeCase(s accounts.Platform) string {
return strings.ToLower(strings.Join(strings.Split(string(s), " "), "_"))
}
+5
View File
@@ -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(
+62
View File
@@ -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
}
+14
View File
@@ -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 {
+3 -1
View File
@@ -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)
+4
View File
@@ -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)
}
+4
View File
@@ -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)
}
+4
View File
@@ -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)
}
+23 -3
View File
@@ -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 {
+4
View File
@@ -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
+4
View File
@@ -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)
}
+1
View File
@@ -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
+4
View File
@@ -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)
}
+11
View File
@@ -12,6 +12,7 @@ import (
"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/api"
"ruben/inventory2/server/auth"
@@ -30,8 +31,10 @@ func NewRouter(
contentDir string,
rawEvents *raw_events.Store,
accts *accounts.Store,
reps *reports.Store,
etsy *etsy_platform.Platform,
authr *authentication.Authenticator,
devAuthEnabled bool,
) *Router {
authM := auth.NewService(
logger.WithGroup("auth-middleware"),
@@ -59,8 +62,10 @@ func NewRouter(
"/ui",
rawEvents,
accts,
reps,
etsy,
authM.Authenticate(),
devAuthEnabled,
)
// non-html content: scripts, styles, images, etc
@@ -92,9 +97,11 @@ func NewRouter(
authM,
sq,
accts,
reps,
unp,
rawEvents,
etsy,
devAuthEnabled,
)
return &Router{
@@ -107,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) {
+51
View File
@@ -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
}
+1 -12
View File
@@ -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 {
+8
View File
@@ -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,
+10
View File
@@ -0,0 +1,10 @@
package sse
import "context"
type (
// sender is satisfied by *Queue
sender interface {
Send(ctx context.Context, e Event) error
}
)
+159
View File
@@ -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
}
File diff suppressed because one or more lines are too long
+236
View File
@@ -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),
)
}
+1
View File
@@ -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>
+40
View File
@@ -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>
}
+32
View File
@@ -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,
},
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

+425
View File
@@ -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"
}
+68 -11
View File
@@ -16,6 +16,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"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/response"
@@ -23,12 +24,14 @@ import (
type (
webpageRouter struct {
log *logging.Logger
uiPath string
templater *templater.Templater
rawEvents *raw_events.Store
accts *accounts.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
@@ -40,12 +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{
@@ -98,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 {
@@ -141,9 +179,11 @@ func Routes(
}
},
}),
rawEvents: rawEvents,
accts: accts,
etsy: etsy,
rawEvents: rawEvents,
accts: accts,
reports: reps,
etsy: etsy,
devAuthEnabled: devAuthEnabled,
}
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate))
@@ -170,10 +210,23 @@ func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error)
r := c.Request
ctx := r.Context()
id := auth.GetIdentity(ctx)
var mockMode bool
if id.Account != nil {
var err error
if mockMode, err = s.accts.GetMockMode(ctx, id.Account.AccountID); err != nil {
return nil, err
}
}
args := []any{
"Request",
r,
// dev mode
"DevAuthEnabled",
s.devAuthEnabled,
// add services and data here
"RawEvents",
s.rawEvents.WithContext(ctx),
@@ -181,12 +234,16 @@ func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error)
newURLCalculator(r.URL),
"Accounts",
s.accts.WithContext(ctx),
"Reports",
s.reports.WithContext(ctx),
"Etsy",
s.etsy.WithContext(ctx),
"MockMode",
mockMode,
// auth tooling
"Identity",
auth.GetIdentity(ctx),
id,
"Auth",
newTemplateAuthenticator(r),
}
+24
View File
@@ -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)
}
+49
View File
@@ -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() {
}
+18
View File
@@ -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
}
+24
View File
@@ -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"
}
+24
View File
@@ -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"
}
+217
View File
@@ -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, " ")
}

Some files were not shown because too many files have changed in this diff Show More