Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c41d5953c5 | ||
|
|
6b6b631417 | ||
|
|
2555967358 | ||
|
|
7dcfc41188 | ||
|
|
30296ee221 | ||
|
|
3a1d05857e | ||
|
|
e0735854ce | ||
|
|
16a3551d08 | ||
|
|
f9f9dfa25b | ||
|
|
d05732d60c | ||
|
|
57136cfc8b | ||
|
|
af46cd1270 | ||
|
|
1fd75ea675 | ||
|
|
e063fb66d4 | ||
|
|
4d43b7e039 | ||
|
|
9c9b33e34f | ||
|
|
fb2f4255f4 | ||
|
|
55a3694d89 | ||
|
|
40afd176e7 | ||
|
|
9f86b8e95c | ||
|
|
fa3db63ad2 | ||
|
|
0e0422545b | ||
|
|
e4e2bd992b | ||
|
|
20bbbe33ca | ||
|
|
496310c93a | ||
|
|
25615fabc6 | ||
|
|
f30d4a3140 | ||
|
|
793ca05fd4 | ||
|
|
9971ff3266 | ||
|
|
46a5d0ee55 |
@@ -0,0 +1,28 @@
|
|||||||
|
name: Tests
|
||||||
|
run-name: tests on ${{ gitea.ref }}
|
||||||
|
on: [push]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
Go tests:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Description
|
||||||
|
run: |
|
||||||
|
echo "🧑🔬 $ {{ gitea.actor }} pushed branch ${{ gitea.ref }} ...0️0️1️0️1️0️1️0️ ... beginning tests"
|
||||||
|
- name: Check out repository code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
- name: Update
|
||||||
|
run: |
|
||||||
|
echo "💡 The ${{ gitea.repository }} repository has been cloned to the runner."
|
||||||
|
echo "🖥️ The job is now ready to test your go code on the runner."
|
||||||
|
- name: Setup golang environment
|
||||||
|
uses: actions/setup-go@v7
|
||||||
|
with:
|
||||||
|
go-version: 'stable'
|
||||||
|
check-latest: true
|
||||||
|
token: ${{ gitea.token }}
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
- name: Run go tests
|
||||||
|
run: |
|
||||||
|
go test ./...
|
||||||
@@ -2,3 +2,6 @@
|
|||||||
*.swp
|
*.swp
|
||||||
|
|
||||||
node_modules
|
node_modules
|
||||||
|
|
||||||
|
.env
|
||||||
|
.env.example
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
.DEFAULT_GOAL := dev
|
||||||
|
|
||||||
|
-include .env
|
||||||
|
export
|
||||||
|
|
||||||
|
MIGRATE := migrate -path database_migrations -database "$(DATABASE_URL)"
|
||||||
|
MIGRATE_TEST := migrate -path database_migrations -database "$(TEST_DATABASE_URL)"
|
||||||
|
|
||||||
|
.PHONY: dev migrate-up migrate-down migrate-version tailwind run \
|
||||||
|
test test-against-dev-db migrate-test-up migrate-test-down migrate-test-version
|
||||||
|
|
||||||
|
# applies pending migrations, starts the tailwind watcher in the background,
|
||||||
|
# then runs the server in the foreground. Ctrl-C stops both.
|
||||||
|
dev: migrate-up
|
||||||
|
@./tailwind.sh & \
|
||||||
|
TW_PID=$$!; \
|
||||||
|
trap "kill $$TW_PID 2>/dev/null" EXIT INT TERM; \
|
||||||
|
go run .
|
||||||
|
|
||||||
|
migrate-up:
|
||||||
|
$(MIGRATE) up
|
||||||
|
|
||||||
|
migrate-down:
|
||||||
|
$(MIGRATE) down 1
|
||||||
|
|
||||||
|
migrate-version:
|
||||||
|
$(MIGRATE) version
|
||||||
|
|
||||||
|
tailwind:
|
||||||
|
./tailwind.sh
|
||||||
|
|
||||||
|
run:
|
||||||
|
go run .
|
||||||
|
|
||||||
|
# runs the integration test suite against TEST_DATABASE_URL (the dedicated
|
||||||
|
# inventory_2_test database by default). Applies pending migrations there
|
||||||
|
# first.
|
||||||
|
test: migrate-test-up
|
||||||
|
go test ./...
|
||||||
|
|
||||||
|
# same as `test`, but points TEST_DATABASE_URL at the real dev database for
|
||||||
|
# this run instead of the dedicated test database - useful for checking
|
||||||
|
# behavior against real data. Leaves the dev DB in whatever state the tests'
|
||||||
|
# own cleanup produces; it's still your dev data, treat it accordingly.
|
||||||
|
test-against-dev-db: TEST_DATABASE_URL := $(DATABASE_URL)
|
||||||
|
test-against-dev-db: test
|
||||||
|
|
||||||
|
migrate-test-up:
|
||||||
|
$(MIGRATE_TEST) up
|
||||||
|
|
||||||
|
migrate-test-down:
|
||||||
|
$(MIGRATE_TEST) down 1
|
||||||
|
|
||||||
|
migrate-test-version:
|
||||||
|
$(MIGRATE_TEST) version
|
||||||
@@ -1,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
|
# Roadmap
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// Package config loads application configuration from environment variables,
|
||||||
|
// optionally populated from a .env file in the working directory.
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"flag"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/joho/godotenv"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Config struct {
|
||||||
|
DatabaseURL string
|
||||||
|
|
||||||
|
Auth0Domain string
|
||||||
|
Auth0ClientID string
|
||||||
|
Auth0ClientSecret string
|
||||||
|
Auth0CallbackURL string
|
||||||
|
|
||||||
|
EtsyAPIKeystring string
|
||||||
|
EtsyAPISharedSecret string
|
||||||
|
|
||||||
|
// DevAuthEnabled, when true, exposes a route that mints a local
|
||||||
|
// login session for any user_id without going through Auth0. Must
|
||||||
|
// never be true outside local development.
|
||||||
|
DevAuthEnabled bool
|
||||||
|
|
||||||
|
Port int
|
||||||
|
}
|
||||||
|
|
||||||
|
var envFlagPtr = flag.String("env", "", "")
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
flag.Parse()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load reads a .env file, if present, into the process environment, then
|
||||||
|
// reads the required configuration values from the environment.
|
||||||
|
func Load() (Config, error) {
|
||||||
|
dev, err := checkEnvFlag()
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if dev {
|
||||||
|
if err := godotenv.Load(".env.dev"); err != nil && !os.IsNotExist(err) {
|
||||||
|
return Config{}, fmt.Errorf("failed to load .env.dev file: %w", err)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
|
||||||
|
return Config{}, fmt.Errorf("failed to load .env file: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
cfg Config
|
||||||
|
missing []string
|
||||||
|
)
|
||||||
|
|
||||||
|
required := func(key string) string {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
missing = append(missing, key)
|
||||||
|
}
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.DatabaseURL = required("DATABASE_URL")
|
||||||
|
|
||||||
|
// DEV_AUTH_ENABLED must be known before the Auth0 vars are read below,
|
||||||
|
// since it decides whether those are required at all.
|
||||||
|
if raw := os.Getenv("DEV_AUTH_ENABLED"); raw != "" {
|
||||||
|
devAuthEnabled, err := strconv.ParseBool(raw)
|
||||||
|
if err != nil {
|
||||||
|
return Config{}, fmt.Errorf("invalid DEV_AUTH_ENABLED value %q: %w", raw, err)
|
||||||
|
}
|
||||||
|
cfg.DevAuthEnabled = devAuthEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
// With DEV_AUTH_ENABLED, real Auth0 login/logout never runs (dev-login
|
||||||
|
// mints sessions locally instead), so these are unused and optional.
|
||||||
|
if cfg.DevAuthEnabled {
|
||||||
|
cfg.Auth0Domain = os.Getenv("AUTH0_DOMAIN")
|
||||||
|
cfg.Auth0ClientID = os.Getenv("AUTH0_CLIENT_ID")
|
||||||
|
cfg.Auth0ClientSecret = os.Getenv("AUTH0_CLIENT_SECRET")
|
||||||
|
cfg.Auth0CallbackURL = os.Getenv("AUTH0_CALLBACK_URL")
|
||||||
|
} else {
|
||||||
|
cfg.Auth0Domain = required("AUTH0_DOMAIN")
|
||||||
|
cfg.Auth0ClientID = required("AUTH0_CLIENT_ID")
|
||||||
|
cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET")
|
||||||
|
cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL")
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING")
|
||||||
|
cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET")
|
||||||
|
|
||||||
|
portStr := required("PORT")
|
||||||
|
|
||||||
|
if len(missing) > 0 {
|
||||||
|
return Config{}, fmt.Errorf(
|
||||||
|
"missing required environment variables: %v (see .env.example)",
|
||||||
|
missing,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if port, err := strconv.Atoi(portStr); err != nil {
|
||||||
|
return Config{}, fmt.Errorf("invalid port number: %w", err)
|
||||||
|
} else {
|
||||||
|
cfg.Port = port
|
||||||
|
}
|
||||||
|
|
||||||
|
return cfg, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkEnvFlag() (dev bool, err error) {
|
||||||
|
switch env := *envFlagPtr; env {
|
||||||
|
case "dev":
|
||||||
|
return true, nil
|
||||||
|
case "prd":
|
||||||
|
return false, nil
|
||||||
|
case "":
|
||||||
|
return false, fmt.Errorf("no env flag specified: must be one of dev, prd")
|
||||||
|
default:
|
||||||
|
return false, fmt.Errorf("invalid env flag specified: must be one of dev, prd: %q", env)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
|
|
||||||
func newPool(ctx context.Context) (*pgxpool.Pool, error) {
|
func newPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
|
||||||
pool, err := pgxpool.New(ctx, "postgres://app_client:app_password@localhost:5432/inventory_2?sslmode=disable")
|
pool, err := pgxpool.New(ctx, databaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("failed to create database client: %w", err)
|
return nil, fmt.Errorf("failed to create database client: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
CREATE TABLE oauth_login_states (
|
CREATE TABLE oauth_login_states (
|
||||||
state BYTEA NOT NULL,
|
state BYTEA NOT NULL,
|
||||||
expiration TIMESTAMPTZ NOT NULL DEFAULT (NOW() + 10 'minute'),
|
expiration TIMESTAMPTZ NOT NULL DEFAULT (NOW() + '10 minutes'::interval),
|
||||||
|
|
||||||
PRIMARY KEY (state)
|
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;
|
||||||
@@ -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;
|
||||||
|
Before Width: | Height: | Size: 414 KiB After Width: | Height: | Size: 471 KiB |
@@ -9,6 +9,16 @@ entity "**accounts**" {
|
|||||||
*""user_id"": //text [FK]//
|
*""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**" {
|
entity "**shop_amazon**" {
|
||||||
+ ""account_id"": //integer [PK][FK]//
|
+ ""account_id"": //integer [PK][FK]//
|
||||||
+ ""shop_id"": //text [PK]//
|
+ ""shop_id"": //text [PK]//
|
||||||
@@ -17,6 +27,14 @@ entity "**shop_amazon**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_amazon_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -63,6 +81,14 @@ entity "**shop_big_cartel**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_big_cartel_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -109,6 +135,14 @@ entity "**shop_ebay**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_ebay_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -155,6 +189,14 @@ entity "**shop_ecwid**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_ecwid_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -201,6 +243,14 @@ entity "**shop_etsy**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_etsy_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -247,6 +297,14 @@ entity "**shop_shopify**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_shopify_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -293,6 +351,14 @@ entity "**shop_square_online**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_square_online_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -339,6 +405,14 @@ entity "**shop_squarespace**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_squarespace_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -385,6 +459,14 @@ entity "**shop_tiktok**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_tiktok_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -431,6 +513,14 @@ entity "**shop_walmart_marketplace**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_walmart_marketplace_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -477,6 +567,14 @@ entity "**shop_wix**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_wix_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -523,6 +621,14 @@ entity "**shop_woo_commerce**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_woo_commerce_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -569,6 +675,14 @@ entity "**shop_zoho**" {
|
|||||||
*""name"": //text //
|
*""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**" {
|
entity "**shop_zoho_listings**" {
|
||||||
+ ""shop_id"": //text [PK][FK]//
|
+ ""shop_id"": //text [PK][FK]//
|
||||||
+ ""listing_id"": //text [PK]//
|
+ ""listing_id"": //text [PK]//
|
||||||
@@ -654,6 +768,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_amazon**" }-- "**public.oauth_users**"
|
"**shop_amazon**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_amazon_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_amazon_listings**" }-- "**accounts**"
|
"**shop_amazon_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_amazon_listings**" ||-|| "**shop_amazon**"
|
"**shop_amazon_listings**" ||-|| "**shop_amazon**"
|
||||||
@@ -684,6 +800,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_big_cartel**" }-- "**public.oauth_users**"
|
"**shop_big_cartel**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_big_cartel_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_big_cartel_listings**" }-- "**accounts**"
|
"**shop_big_cartel_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_big_cartel_listings**" ||-|| "**shop_big_cartel**"
|
"**shop_big_cartel_listings**" ||-|| "**shop_big_cartel**"
|
||||||
@@ -714,6 +832,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_ebay**" }-- "**public.oauth_users**"
|
"**shop_ebay**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_ebay_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_ebay_listings**" }-- "**accounts**"
|
"**shop_ebay_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_ebay_listings**" ||-|| "**shop_ebay**"
|
"**shop_ebay_listings**" ||-|| "**shop_ebay**"
|
||||||
@@ -744,6 +864,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_ecwid**" }-- "**public.oauth_users**"
|
"**shop_ecwid**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_ecwid_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_ecwid_listings**" }-- "**accounts**"
|
"**shop_ecwid_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_ecwid_listings**" ||-|| "**shop_ecwid**"
|
"**shop_ecwid_listings**" ||-|| "**shop_ecwid**"
|
||||||
@@ -774,6 +896,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_etsy**" }-- "**public.oauth_users**"
|
"**shop_etsy**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_etsy_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_etsy_listings**" }-- "**accounts**"
|
"**shop_etsy_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_etsy_listings**" ||-|| "**shop_etsy**"
|
"**shop_etsy_listings**" ||-|| "**shop_etsy**"
|
||||||
@@ -804,6 +928,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_shopify**" }-- "**public.oauth_users**"
|
"**shop_shopify**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_shopify_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_shopify_listings**" }-- "**accounts**"
|
"**shop_shopify_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_shopify_listings**" ||-|| "**shop_shopify**"
|
"**shop_shopify_listings**" ||-|| "**shop_shopify**"
|
||||||
@@ -834,6 +960,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_square_online**" }-- "**public.oauth_users**"
|
"**shop_square_online**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_square_online_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_square_online_listings**" }-- "**accounts**"
|
"**shop_square_online_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_square_online_listings**" ||-|| "**shop_square_online**"
|
"**shop_square_online_listings**" ||-|| "**shop_square_online**"
|
||||||
@@ -864,6 +992,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_squarespace**" }-- "**public.oauth_users**"
|
"**shop_squarespace**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_squarespace_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_squarespace_listings**" }-- "**accounts**"
|
"**shop_squarespace_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_squarespace_listings**" ||-|| "**shop_squarespace**"
|
"**shop_squarespace_listings**" ||-|| "**shop_squarespace**"
|
||||||
@@ -894,6 +1024,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_tiktok**" }-- "**public.oauth_users**"
|
"**shop_tiktok**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_tiktok_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_tiktok_listings**" }-- "**accounts**"
|
"**shop_tiktok_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_tiktok_listings**" ||-|| "**shop_tiktok**"
|
"**shop_tiktok_listings**" ||-|| "**shop_tiktok**"
|
||||||
@@ -924,6 +1056,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_walmart_marketplace**" }-- "**public.oauth_users**"
|
"**shop_walmart_marketplace**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_walmart_marketplace_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_walmart_marketplace_listings**" }-- "**accounts**"
|
"**shop_walmart_marketplace_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_walmart_marketplace_listings**" ||-|| "**shop_walmart_marketplace**"
|
"**shop_walmart_marketplace_listings**" ||-|| "**shop_walmart_marketplace**"
|
||||||
@@ -954,6 +1088,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_wix**" }-- "**public.oauth_users**"
|
"**shop_wix**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_wix_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_wix_listings**" }-- "**accounts**"
|
"**shop_wix_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_wix_listings**" ||-|| "**shop_wix**"
|
"**shop_wix_listings**" ||-|| "**shop_wix**"
|
||||||
@@ -984,6 +1120,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_woo_commerce**" }-- "**public.oauth_users**"
|
"**shop_woo_commerce**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_woo_commerce_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_woo_commerce_listings**" }-- "**accounts**"
|
"**shop_woo_commerce_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_woo_commerce_listings**" ||-|| "**shop_woo_commerce**"
|
"**shop_woo_commerce_listings**" ||-|| "**shop_woo_commerce**"
|
||||||
@@ -1014,6 +1152,8 @@ entity "**sync_groups**" {
|
|||||||
|
|
||||||
"**shop_zoho**" }-- "**public.oauth_users**"
|
"**shop_zoho**" }-- "**public.oauth_users**"
|
||||||
|
|
||||||
|
"**shop_zoho_events**" ||-|| "**raw_shop_events**"
|
||||||
|
|
||||||
"**shop_zoho_listings**" }-- "**accounts**"
|
"**shop_zoho_listings**" }-- "**accounts**"
|
||||||
|
|
||||||
"**shop_zoho_listings**" ||-|| "**shop_zoho**"
|
"**shop_zoho_listings**" ||-|| "**shop_zoho**"
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 59 KiB After Width: | Height: | Size: 61 KiB |
@@ -51,6 +51,12 @@ entity "**etsy_users**" {
|
|||||||
*""shop_id"": //integer //
|
*""shop_id"": //integer //
|
||||||
}
|
}
|
||||||
|
|
||||||
|
entity "**mock_mode**" {
|
||||||
|
+ ""account_id"": //integer [PK][FK]//
|
||||||
|
--
|
||||||
|
*""mock_mode"": //boolean //
|
||||||
|
}
|
||||||
|
|
||||||
entity "**oauth_login_states**" {
|
entity "**oauth_login_states**" {
|
||||||
+ ""state"": //bytea [PK]//
|
+ ""state"": //bytea [PK]//
|
||||||
--
|
--
|
||||||
@@ -149,6 +155,8 @@ entity "**wix_store_events**" {
|
|||||||
|
|
||||||
"**etsy_users**" }-- "**accounts**"
|
"**etsy_users**" }-- "**accounts**"
|
||||||
|
|
||||||
|
"**mock_mode**" ||-|| "**accounts**"
|
||||||
|
|
||||||
"**oauth_tokens**" }-- "**oauth_users**"
|
"**oauth_tokens**" }-- "**oauth_users**"
|
||||||
|
|
||||||
"**sync_group_listing_drafts**" }-- "**accounts**"
|
"**sync_group_listing_drafts**" }-- "**accounts**"
|
||||||
|
|||||||
@@ -5,11 +5,13 @@ package accounts
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"slices"
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
@@ -225,6 +227,44 @@ func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account,
|
|||||||
}, nil
|
}, 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) {
|
func (db *Store) GetUserAndAccountByAccessToken(ctx context.Context, accessToken string) (OAuthUser, *Account, error) {
|
||||||
rows, err := db.db.Query(
|
rows, err := db.db.Query(
|
||||||
ctx,
|
ctx,
|
||||||
@@ -1428,8 +1468,6 @@ func (db *Store) SetListingInListingInMockSyncGroupBeingEdited(ctx context.Conte
|
|||||||
default:
|
default:
|
||||||
return fmt.Errorf("unexpected number of rows affected: %d", n)
|
return fmt.Errorf("unexpected number of rows affected: %d", n)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return consts.ErrNotFound
|
return consts.ErrNotFound
|
||||||
@@ -1461,6 +1499,290 @@ func (db *Store) DeleteListingInListingInMockSyncGroupBeingEdited(ctx context.Co
|
|||||||
return nil
|
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
|
// additional context
|
||||||
|
|
||||||
func (db *Store) GetAccountPointerByUserID(ctx context.Context, userID string) (*Account, error) {
|
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 {
|
func (db *Store) beginTxn(ctx context.Context, cb func(pgx.Tx) error) error {
|
||||||
return pgx.BeginFunc(ctx, db.db, cb)
|
return pgx.BeginFunc(ctx, db.db, cb)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func deref[T any](ptr *T) T {
|
||||||
|
if ptr != nil {
|
||||||
|
return *ptr
|
||||||
|
}
|
||||||
|
var zero T
|
||||||
|
return zero
|
||||||
|
}
|
||||||
|
|||||||
@@ -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()")
|
||||||
|
}
|
||||||
@@ -105,6 +105,22 @@ func (v_ctx *StoreWithContext) DeleteListingInListingInMockSyncGroupBeingEdited(
|
|||||||
return v_ctx.Store.DeleteListingInListingInMockSyncGroupBeingEdited(v_ctx.ctx, acctID, orderIndex)
|
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) {
|
func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
|
||||||
return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID)
|
return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1235,11 +1235,3 @@ func (db *Store) listMockSyncGroupListings(ctx context.Context, tx pgx.Tx, acctI
|
|||||||
|
|
||||||
return listings, nil
|
return listings, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func deref[T any](ptr *T) T {
|
|
||||||
if ptr == nil {
|
|
||||||
var zero T
|
|
||||||
return zero
|
|
||||||
}
|
|
||||||
return *ptr
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,377 @@
|
|||||||
|
package amazon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"ruben/inventory2/domains/accounts"
|
||||||
|
"ruben/inventory2/domains/raw_events"
|
||||||
|
"ruben/inventory2/logging"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Mocks struct {
|
||||||
|
log *logging.Logger
|
||||||
|
db *pgxpool.Pool
|
||||||
|
listener MockEventListener
|
||||||
|
|
||||||
|
// notifyRetryAfter is how long an event can sit "notified" (handed
|
||||||
|
// to the listener) without an ack before the dispatcher notifies
|
||||||
|
// it again. Deliberately a field, not a stored notify_again_at
|
||||||
|
// column, so the cadence can vary (or be tuned in tests) without
|
||||||
|
// touching any row.
|
||||||
|
notifyRetryAfter time.Duration
|
||||||
|
|
||||||
|
// pollInterval is the fallback cadence ProcessEvents' main loop
|
||||||
|
// checks for unprocessed/retry-due events on its own, independent
|
||||||
|
// of Postgres NOTIFY - a safety net for events that end up in
|
||||||
|
// mock.shop_amazon_events without ever going through the
|
||||||
|
// mock.raw_shop_events insert+trigger path that fires NOTIFY.
|
||||||
|
pollInterval time.Duration
|
||||||
|
|
||||||
|
ready chan struct{}
|
||||||
|
readyOnce sync.Once
|
||||||
|
}
|
||||||
|
|
||||||
|
// MockEventListener is handed events as they arrive. Notify should
|
||||||
|
// return promptly - any real work (including calling out to another
|
||||||
|
// system) can happen after it returns - but it must eventually call
|
||||||
|
// ack exactly once, when the event has been fully handled. Until ack
|
||||||
|
// is called, the dispatcher considers the event merely "notified"
|
||||||
|
// and will call Notify again after notifyRetryAfter, so ack may be
|
||||||
|
// called more than once total across retries; only the first call
|
||||||
|
// has any effect; further calls are safe (see (*Mocks).ack).
|
||||||
|
MockEventListener interface {
|
||||||
|
Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
eventChannelName = "mock_shop_amazon_event_inserted"
|
||||||
|
|
||||||
|
defaultNotifyRetryAfter = 30 * time.Second
|
||||||
|
defaultPollInterval = time.Minute
|
||||||
|
|
||||||
|
// backoff for reconnecting the LISTEN connection after it fails for a
|
||||||
|
// reason other than shutdown (e.g. a dropped connection, a Postgres
|
||||||
|
// restart). Doubles on each consecutive failure, capped, and resets
|
||||||
|
// once a reconnect actually succeeds.
|
||||||
|
initialListenReconnectBackoff = 1 * time.Second
|
||||||
|
maxListenReconnectBackoff = 30 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewMocks(log *logging.Logger, db *pgxpool.Pool) *Mocks {
|
||||||
|
return &Mocks{
|
||||||
|
log: log,
|
||||||
|
db: db,
|
||||||
|
notifyRetryAfter: defaultNotifyRetryAfter,
|
||||||
|
pollInterval: defaultPollInterval,
|
||||||
|
ready: make(chan struct{}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mocks) SetListener(l MockEventListener) *Mocks {
|
||||||
|
m.listener = l
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithNotifyRetryAfter overrides how long an event can sit "notified"
|
||||||
|
// without an ack before being notified again. Mainly useful for tests that
|
||||||
|
// don't want to wait out the default.
|
||||||
|
func (m *Mocks) WithNotifyRetryAfter(d time.Duration) *Mocks {
|
||||||
|
m.notifyRetryAfter = d
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// WithPollInterval overrides how often ProcessEvents checks for
|
||||||
|
// unprocessed/retry-due events on its own, independent of NOTIFY. Mainly
|
||||||
|
// useful for tests that don't want to wait out the default.
|
||||||
|
func (m *Mocks) WithPollInterval(d time.Duration) *Mocks {
|
||||||
|
m.pollInterval = d
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ready returns a channel that's closed once ProcessEvents has registered
|
||||||
|
// its Postgres LISTEN and is actively watching for notifications. Callers
|
||||||
|
// that need to know the reactive path is live - tests in particular -
|
||||||
|
// should wait on this instead of guessing with a sleep.
|
||||||
|
func (m *Mocks) Ready() <-chan struct{} {
|
||||||
|
return m.ready
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mocks) ProcessEvents(ctx context.Context) error {
|
||||||
|
notifCh, errCh := m.listenForNotifications(ctx)
|
||||||
|
backoff := initialListenReconnectBackoff
|
||||||
|
|
||||||
|
for {
|
||||||
|
m.log.Debug("processing events")
|
||||||
|
if err := m.processUnprocessedEvents(ctx); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("error occurred while processing events: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
|
||||||
|
case err := <-errCh:
|
||||||
|
var stop bool
|
||||||
|
if notifCh, errCh, backoff, stop = m.reconnectOrStop(ctx, err, backoff); stop {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
case _, ok := <-notifCh:
|
||||||
|
if !ok {
|
||||||
|
var stop bool
|
||||||
|
if notifCh, errCh, backoff, stop = m.reconnectOrStop(ctx, <-errCh, backoff); stop {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
m.log.Debug("woke up: notification received")
|
||||||
|
|
||||||
|
case <-time.After(m.pollInterval):
|
||||||
|
m.log.Debug("woke up: poll interval elapsed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// reconnectOrStop handles a LISTEN-connection failure. A nil listenErr (or
|
||||||
|
// ctx already being done) means this is an ordinary shutdown, not a
|
||||||
|
// failure - stop is true and the caller should return. Otherwise it logs a
|
||||||
|
// warning, waits out backoff, and re-establishes LISTEN: on success the
|
||||||
|
// backoff resets to its initial value for next time; on immediate failure
|
||||||
|
// (e.g. the pool itself is unreachable) it doubles, capped, so repeated
|
||||||
|
// failures back off rather than hot-looping.
|
||||||
|
func (m *Mocks) reconnectOrStop(
|
||||||
|
ctx context.Context,
|
||||||
|
listenErr error,
|
||||||
|
backoff time.Duration,
|
||||||
|
) (notifCh <-chan struct{}, errCh <-chan error, nextBackoff time.Duration, stop bool) {
|
||||||
|
if listenErr == nil || ctx.Err() != nil {
|
||||||
|
return nil, nil, backoff, true
|
||||||
|
}
|
||||||
|
|
||||||
|
m.log.Warn("lost connection while listening for notifications; reconnecting", "error", listenErr, "retry_in", backoff)
|
||||||
|
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil, nil, backoff, true
|
||||||
|
case <-time.After(backoff):
|
||||||
|
}
|
||||||
|
|
||||||
|
notifCh, errCh = m.listenForNotifications(ctx)
|
||||||
|
if notifCh != nil {
|
||||||
|
return notifCh, errCh, initialListenReconnectBackoff, false
|
||||||
|
}
|
||||||
|
|
||||||
|
nextBackoff = backoff * 2
|
||||||
|
if nextBackoff > maxListenReconnectBackoff {
|
||||||
|
nextBackoff = maxListenReconnectBackoff
|
||||||
|
}
|
||||||
|
return notifCh, errCh, nextBackoff, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-chan error) {
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
|
||||||
|
pc, err := m.db.Acquire(ctx)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- fmt.Errorf("failed to acquire a connection: %w", err)
|
||||||
|
return nil, errCh
|
||||||
|
}
|
||||||
|
|
||||||
|
conn := pc.Conn()
|
||||||
|
|
||||||
|
if _, err := conn.Exec(ctx, fmt.Sprintf("LISTEN %s", eventChannelName)); err != nil {
|
||||||
|
errCh <- fmt.Errorf("failed to start listening for notifications: %w", err)
|
||||||
|
return nil, errCh
|
||||||
|
}
|
||||||
|
|
||||||
|
m.readyOnce.Do(func() { close(m.ready) })
|
||||||
|
|
||||||
|
ch := make(chan struct{})
|
||||||
|
|
||||||
|
go func() (err error) {
|
||||||
|
defer func() {
|
||||||
|
pc.Release()
|
||||||
|
if err != nil {
|
||||||
|
errCh <- err
|
||||||
|
}
|
||||||
|
close(ch)
|
||||||
|
close(errCh)
|
||||||
|
}()
|
||||||
|
|
||||||
|
for {
|
||||||
|
if _, err := conn.WaitForNotification(ctx); err != nil {
|
||||||
|
if errors.Is(err, context.Canceled) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("error occurred while waiting for the next notification: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
select {
|
||||||
|
case ch <- struct{}{}:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
return ch, errCh
|
||||||
|
}
|
||||||
|
|
||||||
|
// processUnprocessedEvents finds every event that's either brand new or
|
||||||
|
// has been "notified" for longer than notifyRetryAfter without an ack, and
|
||||||
|
// (re)dispatches each to the listener. It never blocks on the listener:
|
||||||
|
// notified_at is committed first, and only then - after the transaction
|
||||||
|
// that recorded it has actually committed - is the listener told, from a
|
||||||
|
// goroutine that isn't tied to this function's lifetime.
|
||||||
|
func (m *Mocks) processUnprocessedEvents(ctx context.Context) error {
|
||||||
|
for done := false; !done; {
|
||||||
|
var toDispatch []raw_events.Event
|
||||||
|
|
||||||
|
err := pgx.BeginFunc(ctx, m.db, func(tx pgx.Tx) error {
|
||||||
|
rows, err := tx.Query(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
shop_id,
|
||||||
|
event_id,
|
||||||
|
event_timestamp
|
||||||
|
FROM
|
||||||
|
mock.shop_amazon_events
|
||||||
|
WHERE
|
||||||
|
processed_at IS NULL
|
||||||
|
AND (notified_at IS NULL OR notified_at < @retry_after)
|
||||||
|
ORDER BY
|
||||||
|
shop_id, event_timestamp
|
||||||
|
LIMIT
|
||||||
|
100
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"retry_after": time.Now().Add(-m.notifyRetryAfter),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to to perform query: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
evts, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
|
||||||
|
Shop_id string
|
||||||
|
Event_id string
|
||||||
|
Event_timestamp time.Time
|
||||||
|
}])
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to scan rows: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range evts {
|
||||||
|
ev := raw_events.Event{
|
||||||
|
Platform: string(accounts.Amazon),
|
||||||
|
StoreID: e.Shop_id,
|
||||||
|
EventID: e.Event_id,
|
||||||
|
EventTimestamp: e.Event_timestamp,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := markNotified(ctx, tx, ev); err != nil {
|
||||||
|
return fmt.Errorf("failed to mark event notified: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
toDispatch = append(toDispatch, ev)
|
||||||
|
}
|
||||||
|
|
||||||
|
if done = len(evts) == 0; !done {
|
||||||
|
m.log.Infof("notified listener for %d events", len(evts))
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, e := range toDispatch {
|
||||||
|
m.dispatch(ctx, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// dispatch hands e to the configured listener without blocking. The
|
||||||
|
// listener may call ack synchronously (e.g. inline once its own work is
|
||||||
|
// done) or from elsewhere entirely, arbitrarily later - ack does its own
|
||||||
|
// independent, idempotent write, so it's never at risk of racing (or being
|
||||||
|
// silently lost to) this call's context being cancelled.
|
||||||
|
func (m *Mocks) dispatch(ctx context.Context, e raw_events.Event) {
|
||||||
|
if m.listener == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
if err := m.listener.Notify(ctx, e, func(ackCtx context.Context) error {
|
||||||
|
return ack(ackCtx, m.db, e)
|
||||||
|
}); err != nil {
|
||||||
|
m.log.Errorf("error incurred by event listener: %v", err)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
func markNotified(ctx context.Context, tx pgx.Tx, e raw_events.Event) error {
|
||||||
|
_, err := tx.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
UPDATE
|
||||||
|
mock.shop_amazon_events
|
||||||
|
SET
|
||||||
|
notified_at = NOW()
|
||||||
|
WHERE
|
||||||
|
shop_id = @shop_id
|
||||||
|
AND event_id = @event_id
|
||||||
|
AND event_timestamp = @event_timestamp
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"shop_id": e.StoreID,
|
||||||
|
"event_id": e.EventID,
|
||||||
|
"event_timestamp": e.EventTimestamp,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// ack records that e has been fully handled. It's idempotent - calling it
|
||||||
|
// more than once (e.g. because a retried notification also eventually acks)
|
||||||
|
// just leaves processed_at at whenever it was first set.
|
||||||
|
func ack(ctx context.Context, db *pgxpool.Pool, e raw_events.Event) error {
|
||||||
|
_, err := db.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
UPDATE
|
||||||
|
mock.shop_amazon_events
|
||||||
|
SET
|
||||||
|
processed_at = NOW()
|
||||||
|
WHERE
|
||||||
|
shop_id = @shop_id
|
||||||
|
AND event_id = @event_id
|
||||||
|
AND event_timestamp = @event_timestamp
|
||||||
|
AND processed_at IS NULL
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"shop_id": e.StoreID,
|
||||||
|
"event_id": e.EventID,
|
||||||
|
"event_timestamp": e.EventTimestamp,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to perform query: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,459 @@
|
|||||||
|
package amazon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"ruben/inventory2/domains/raw_events"
|
||||||
|
"ruben/inventory2/internal/testdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notifySpy is a MockEventListener that records every event it's notified
|
||||||
|
// about, along with the ack callback it was given. By default it acks
|
||||||
|
// immediately (autoAck=true), matching a well-behaved listener; tests
|
||||||
|
// exercising the retry path set autoAck=false to simulate a listener that
|
||||||
|
// received the notification but hasn't finished yet, and call ackAt
|
||||||
|
// explicitly once it "finishes".
|
||||||
|
type notifySpy struct {
|
||||||
|
mu sync.Mutex
|
||||||
|
events []raw_events.Event
|
||||||
|
acks []func(context.Context) error
|
||||||
|
completedCount int // Notify calls that have returned, including any auto-ack
|
||||||
|
autoAck bool
|
||||||
|
}
|
||||||
|
|
||||||
|
func newNotifySpy() *notifySpy {
|
||||||
|
return ¬ifySpy{autoAck: true}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notifySpy) Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error {
|
||||||
|
s.mu.Lock()
|
||||||
|
s.events = append(s.events, e)
|
||||||
|
s.acks = append(s.acks, ack)
|
||||||
|
autoAck := s.autoAck
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
var ackErr error
|
||||||
|
if autoAck {
|
||||||
|
ackErr = ack(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
s.mu.Lock()
|
||||||
|
s.completedCount++
|
||||||
|
s.mu.Unlock()
|
||||||
|
|
||||||
|
return ackErr
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notifySpy) eventsSnapshot() []raw_events.Event {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return append([]raw_events.Event(nil), s.events...)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *notifySpy) setAutoAck(v bool) {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
s.autoAck = v
|
||||||
|
}
|
||||||
|
|
||||||
|
// ackAt manually invokes the i-th recorded ack callback (0-indexed, in
|
||||||
|
// Notify call order), simulating the listener finally finishing work it
|
||||||
|
// had earlier only been notified about.
|
||||||
|
func (s *notifySpy) ackAt(t *testing.T, i int) {
|
||||||
|
t.Helper()
|
||||||
|
s.mu.Lock()
|
||||||
|
ack := s.acks[i]
|
||||||
|
s.mu.Unlock()
|
||||||
|
require.NoError(t, ack(context.Background()), "ack()")
|
||||||
|
}
|
||||||
|
|
||||||
|
// completedCountSnapshot is safe to call from another goroutine (e.g. from
|
||||||
|
// inside require.Eventually's condition), unlike helpers that call t.Fatal.
|
||||||
|
func (s *notifySpy) completedCountSnapshot() int {
|
||||||
|
s.mu.Lock()
|
||||||
|
defer s.mu.Unlock()
|
||||||
|
return s.completedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitForCount blocks until at least n Notify calls have fully completed
|
||||||
|
// (including any auto-ack), cumulatively across the test - safe to call
|
||||||
|
// more than once with increasing n.
|
||||||
|
func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
return s.completedCountSnapshot() >= n
|
||||||
|
}, timeout, 5*time.Millisecond, "timed out waiting for %d total completed notifications (got %d)", n, s.completedCountSnapshot())
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertRawAmazonEvent inserts directly into mock.raw_shop_events, the same
|
||||||
|
// entry point real mock sale/refund/inventory simulations use. A DB trigger
|
||||||
|
// (mock.process_raw_amazon_event, see migrations 000026/000028) copies the
|
||||||
|
// row into mock.shop_amazon_events and fires pg_notify on
|
||||||
|
// mock_shop_amazon_event_inserted - so this one insert exercises the exact
|
||||||
|
// same path production traffic does, instead of faking the downstream
|
||||||
|
// table directly.
|
||||||
|
func insertRawAmazonEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := pool.Exec(context.Background(), `
|
||||||
|
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||||
|
VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb)
|
||||||
|
`, shopID, eventID)
|
||||||
|
require.NoError(t, err, "insert raw amazon event")
|
||||||
|
}
|
||||||
|
|
||||||
|
// insertRawAmazonEvents bulk-inserts n events for shopID in one statement,
|
||||||
|
// each with a distinct event_id/event_timestamp.
|
||||||
|
func insertRawAmazonEvents(t *testing.T, pool *pgxpool.Pool, shopID string, n int) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := pool.Exec(context.Background(), `
|
||||||
|
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||||
|
SELECT 'amazon', $1, NOW() + (s || ' milliseconds')::interval, 'evt-' || s, '{}'::jsonb
|
||||||
|
FROM generate_series(1, $2) AS s
|
||||||
|
`, shopID, n)
|
||||||
|
require.NoError(t, err, "insert %d raw amazon events", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// queryIsProcessed has no *testing.T dependency, so it's safe to call from
|
||||||
|
// require.Eventually's condition function (which testify runs on a
|
||||||
|
// separate goroutine - t.Fatal/require must only be called from the main
|
||||||
|
// test goroutine). isProcessed wraps it for direct, main-goroutine use.
|
||||||
|
func queryIsProcessed(ctx context.Context, pool *pgxpool.Pool, shopID, eventID string) (bool, error) {
|
||||||
|
var processed bool
|
||||||
|
err := pool.QueryRow(ctx, `
|
||||||
|
SELECT processed_at IS NOT NULL
|
||||||
|
FROM mock.shop_amazon_events
|
||||||
|
WHERE shop_id = $1 AND event_id = $2
|
||||||
|
`, shopID, eventID).Scan(&processed)
|
||||||
|
return processed, err
|
||||||
|
}
|
||||||
|
|
||||||
|
func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||||
|
t.Helper()
|
||||||
|
processed, err := queryIsProcessed(context.Background(), pool, shopID, eventID)
|
||||||
|
require.NoError(t, err, "check processed state")
|
||||||
|
return processed
|
||||||
|
}
|
||||||
|
|
||||||
|
// isNotified reports whether the event has been notified (at least once)
|
||||||
|
// but not yet acked/processed.
|
||||||
|
func isNotified(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
|
||||||
|
t.Helper()
|
||||||
|
var notified bool
|
||||||
|
err := pool.QueryRow(context.Background(), `
|
||||||
|
SELECT notified_at IS NOT NULL AND processed_at IS NULL
|
||||||
|
FROM mock.shop_amazon_events
|
||||||
|
WHERE shop_id = $1 AND event_id = $2
|
||||||
|
`, shopID, eventID).Scan(¬ified)
|
||||||
|
require.NoError(t, err, "check notified state")
|
||||||
|
return notified
|
||||||
|
}
|
||||||
|
|
||||||
|
func countUnprocessed(t *testing.T, pool *pgxpool.Pool, shopID string) int {
|
||||||
|
t.Helper()
|
||||||
|
var n int
|
||||||
|
err := pool.QueryRow(context.Background(), `
|
||||||
|
SELECT count(*) FROM mock.shop_amazon_events WHERE shop_id = $1 AND processed_at IS NULL
|
||||||
|
`, shopID).Scan(&n)
|
||||||
|
require.NoError(t, err, "count unprocessed events")
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
|
// cleanupShop registers deletion of every row this test's shopID may have
|
||||||
|
// produced, in FK-safe order (shop_amazon_events references raw_shop_events).
|
||||||
|
func cleanupShop(t *testing.T, pool *pgxpool.Pool, shopID string) {
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
pool.Exec(ctx, `DELETE FROM mock.shop_amazon_events WHERE shop_id = $1`, shopID)
|
||||||
|
pool.Exec(ctx, `DELETE FROM mock.raw_shop_events WHERE platform = 'amazon' AND shop_id = $1`, shopID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// terminateListenConnection finds the backend holding this package's LISTEN
|
||||||
|
// registration (identified by its last query text, which Postgres keeps
|
||||||
|
// showing while the connection sits idle waiting for notifications) and
|
||||||
|
// forcibly kills it - the same failure mode a dropped connection or a
|
||||||
|
// Postgres restart produces, so tests can exercise real reconnect behavior
|
||||||
|
// instead of a simulated one.
|
||||||
|
func terminateListenConnection(t *testing.T, pool *pgxpool.Pool) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
var pid int
|
||||||
|
err := pool.QueryRow(ctx, `
|
||||||
|
SELECT pid FROM pg_stat_activity
|
||||||
|
WHERE query = 'LISTEN ' || $1
|
||||||
|
ORDER BY backend_start DESC
|
||||||
|
LIMIT 1
|
||||||
|
`, eventChannelName).Scan(&pid)
|
||||||
|
require.NoError(t, err, "find the LISTEN connection's backend pid")
|
||||||
|
|
||||||
|
_, err = pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid)
|
||||||
|
require.NoError(t, err, "terminate backend %d", pid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitReady blocks until m signals it's actively listening, failing the
|
||||||
|
// test if that doesn't happen within timeout.
|
||||||
|
func waitReady(t *testing.T, m *Mocks, timeout time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case <-m.Ready():
|
||||||
|
case <-time.After(timeout):
|
||||||
|
require.Fail(t, "ProcessEvents() did not become ready (LISTEN registered)", "within %v", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// waitStopped blocks until ProcessEvents returns on errCh, asserting it
|
||||||
|
// returns a nil error, failing the test if that doesn't happen within
|
||||||
|
// timeout.
|
||||||
|
func waitStopped(t *testing.T, errCh <-chan error, timeout time.Duration) {
|
||||||
|
t.Helper()
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
require.NoError(t, err, "ProcessEvents() after context cancellation")
|
||||||
|
case <-time.After(timeout):
|
||||||
|
require.Fail(t, "ProcessEvents() did not return", "within %v of context cancellation", timeout)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
spy := newNotifySpy()
|
||||||
|
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
shopID := "test-shop-" + uuid.NewString()
|
||||||
|
cleanupShop(t, pool, shopID)
|
||||||
|
|
||||||
|
const n = 150 // exceeds the 100-row LIMIT per batch inside processUnprocessedEvents
|
||||||
|
insertRawAmazonEvents(t, pool, shopID, n)
|
||||||
|
|
||||||
|
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
|
||||||
|
|
||||||
|
spy.waitForCount(t, n, 5*time.Second)
|
||||||
|
|
||||||
|
assert.Zero(t, countUnprocessed(t, pool, shopID),
|
||||||
|
"countUnprocessed() should be 0 (all %d events should be processed across multiple 100-row batches)", n)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
m := NewMocks(testdb.Logger(), pool) // no SetListener call
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
shopID := "test-shop-" + uuid.NewString()
|
||||||
|
cleanupShop(t, pool, shopID)
|
||||||
|
|
||||||
|
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||||
|
|
||||||
|
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
|
||||||
|
|
||||||
|
// with no listener, nothing ever acks - the event is expected to stay
|
||||||
|
// "notified" forever, not silently marked processed.
|
||||||
|
assert.False(t, isProcessed(t, pool, shopID, "evt-1"),
|
||||||
|
"event was marked processed despite no listener being configured to ack it")
|
||||||
|
assert.True(t, isNotified(t, pool, shopID, "evt-1"),
|
||||||
|
"event should be in the notified state after being handed off with no listener to ack it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessUnprocessedEvents_RetriesUnackedNotification is the core of
|
||||||
|
// insight #2's fix: a listener that receives a notification but never acks
|
||||||
|
// gets notified again after notifyRetryAfter, and once it does ack (however
|
||||||
|
// late), the event settles into processed and stops being retried.
|
||||||
|
func TestProcessUnprocessedEvents_RetriesUnackedNotification(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
spy := newNotifySpy()
|
||||||
|
spy.setAutoAck(false)
|
||||||
|
m := NewMocks(testdb.Logger(), pool).
|
||||||
|
SetListener(spy).
|
||||||
|
WithNotifyRetryAfter(50 * time.Millisecond)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
shopID := "test-shop-" + uuid.NewString()
|
||||||
|
cleanupShop(t, pool, shopID)
|
||||||
|
|
||||||
|
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||||
|
|
||||||
|
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (1st pass)")
|
||||||
|
spy.waitForCount(t, 1, 2*time.Second)
|
||||||
|
|
||||||
|
require.False(t, isProcessed(t, pool, shopID, "evt-1"), "event was marked processed despite the listener never acking")
|
||||||
|
require.True(t, isNotified(t, pool, shopID, "evt-1"), "event should be in the notified state after the first dispatch")
|
||||||
|
|
||||||
|
// still within notifyRetryAfter: shouldn't be re-notified yet.
|
||||||
|
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (immediate 2nd pass)")
|
||||||
|
require.Len(t, spy.eventsSnapshot(), 1, "listener should not have been notified again before notifyRetryAfter elapsed")
|
||||||
|
|
||||||
|
time.Sleep(60 * time.Millisecond) // past notifyRetryAfter
|
||||||
|
|
||||||
|
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (3rd pass, after retry window)")
|
||||||
|
spy.waitForCount(t, 2, 2*time.Second)
|
||||||
|
|
||||||
|
// the listener "finishes" the first notification late, via the ack it
|
||||||
|
// was originally handed - not a fresh one from the retry.
|
||||||
|
spy.ackAt(t, 0)
|
||||||
|
|
||||||
|
require.True(t, isProcessed(t, pool, shopID, "evt-1"), "event should be processed once any recorded ack for it is called")
|
||||||
|
|
||||||
|
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (4th pass, after ack)")
|
||||||
|
assert.Len(t, spy.eventsSnapshot(), 2, "listener should not be notified again after being acked")
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessEvents_PollFallbackPicksUpRetryDueEvents proves the poll
|
||||||
|
// branch of ProcessEvents' select actually causes reprocessing, decoupled
|
||||||
|
// from NOTIFY entirely: after the one real insert (which does fire NOTIFY,
|
||||||
|
// same as any other test here), nothing ever triggers another notification
|
||||||
|
// for the rest of the test. The event becomes retry-due almost immediately
|
||||||
|
// (notifyRetryAfter is tiny), so the *only* way it can be dispatched a
|
||||||
|
// second time is the poll timer in the select firing on its own.
|
||||||
|
func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
spy := newNotifySpy()
|
||||||
|
spy.setAutoAck(false)
|
||||||
|
m := NewMocks(testdb.Logger(), pool).
|
||||||
|
SetListener(spy).
|
||||||
|
WithNotifyRetryAfter(30 * time.Millisecond).
|
||||||
|
WithPollInterval(60 * time.Millisecond)
|
||||||
|
|
||||||
|
shopID := "test-shop-" + uuid.NewString()
|
||||||
|
cleanupShop(t, pool, shopID)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- m.ProcessEvents(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitReady(t, m, 5*time.Second)
|
||||||
|
|
||||||
|
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||||
|
|
||||||
|
// first dispatch, via the real NOTIFY.
|
||||||
|
spy.waitForCount(t, 1, 2*time.Second)
|
||||||
|
|
||||||
|
// second dispatch: nothing will notify again from here on, so this can
|
||||||
|
// only come from the poll branch of the select waking the loop up on
|
||||||
|
// its own and finding the event retry-due.
|
||||||
|
spy.waitForCount(t, 2, 3*time.Second)
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
waitStopped(t, errCh, 5*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessEvents_ReactsToNotification drives the actual long-running
|
||||||
|
// loop: LISTEN registration, a real Postgres NOTIFY fired by the DB trigger
|
||||||
|
// on insert, WaitForNotification waking the loop, and the listener callback
|
||||||
|
// - the full stateful path, not just the deterministic batch-processing
|
||||||
|
// core covered above.
|
||||||
|
func TestProcessEvents_ReactsToNotification(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
spy := newNotifySpy()
|
||||||
|
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
|
||||||
|
|
||||||
|
shopID := "test-shop-" + uuid.NewString()
|
||||||
|
cleanupShop(t, pool, shopID)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- m.ProcessEvents(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitReady(t, m, 5*time.Second)
|
||||||
|
|
||||||
|
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
|
||||||
|
return processed
|
||||||
|
}, 5*time.Second, 20*time.Millisecond,
|
||||||
|
"event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire "+
|
||||||
|
"(the 1-minute poll fallback would eventually catch it, but this test intentionally doesn't wait that long)")
|
||||||
|
|
||||||
|
spy.waitForCount(t, 1, 2*time.Second)
|
||||||
|
got := spy.eventsSnapshot()[0]
|
||||||
|
assert.Equal(t, shopID, got.StoreID, "listener notified with unexpected StoreID")
|
||||||
|
assert.Equal(t, "evt-1", got.EventID, "listener notified with unexpected EventID")
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
waitStopped(t, errCh, 5*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in
|
||||||
|
// isolation, without depending on NOTIFY timing at all - a fast, low-flake
|
||||||
|
// guard against shutdown regressions (hangs, goroutine leaks) independent
|
||||||
|
// of whether the reactive path above is working.
|
||||||
|
func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
m := NewMocks(testdb.Logger(), pool)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- m.ProcessEvents(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitReady(t, m, 5*time.Second)
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
waitStopped(t, errCh, 5*time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's
|
||||||
|
// fix: forcibly kills the real backend connection ProcessEvents is
|
||||||
|
// LISTEN-ing on (the same failure mode a dropped connection or a Postgres
|
||||||
|
// restart produces) and confirms it reconnects and keeps working on its
|
||||||
|
// own, rather than the error propagating out of ProcessEvents entirely.
|
||||||
|
func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
spy := newNotifySpy()
|
||||||
|
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
|
||||||
|
|
||||||
|
shopID := "test-shop-" + uuid.NewString()
|
||||||
|
cleanupShop(t, pool, shopID)
|
||||||
|
|
||||||
|
ctx, cancel := context.WithCancel(context.Background())
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
errCh <- m.ProcessEvents(ctx)
|
||||||
|
}()
|
||||||
|
|
||||||
|
waitReady(t, m, 5*time.Second)
|
||||||
|
|
||||||
|
terminateListenConnection(t, pool)
|
||||||
|
|
||||||
|
// ProcessEvents should still be running, just reconnecting (initial
|
||||||
|
// backoff is 1s) - it must not have returned because of this.
|
||||||
|
select {
|
||||||
|
case err := <-errCh:
|
||||||
|
require.Fail(t, "ProcessEvents() returned after its LISTEN connection was killed, want it to reconnect and keep running",
|
||||||
|
"err = %v", err)
|
||||||
|
case <-time.After(2 * time.Second):
|
||||||
|
}
|
||||||
|
|
||||||
|
insertRawAmazonEvent(t, pool, shopID, "evt-1")
|
||||||
|
|
||||||
|
require.Eventually(t, func() bool {
|
||||||
|
processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
|
||||||
|
return processed
|
||||||
|
}, 5*time.Second, 20*time.Millisecond,
|
||||||
|
"event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - "+
|
||||||
|
"reconnection did not restore the reactive path")
|
||||||
|
|
||||||
|
cancel()
|
||||||
|
waitStopped(t, errCh, 5*time.Second)
|
||||||
|
}
|
||||||
@@ -17,26 +17,11 @@ import (
|
|||||||
"ruben/inventory2/logging"
|
"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 (
|
type (
|
||||||
// Authenticator is used to authenticate our users.
|
// Authenticator is used to authenticate our users.
|
||||||
Authenticator struct {
|
Authenticator struct {
|
||||||
log *logging.Logger
|
log *logging.Logger
|
||||||
|
domain string
|
||||||
*oidc.Provider
|
*oidc.Provider
|
||||||
oauth2.Config
|
oauth2.Config
|
||||||
db *pgxpool.Pool
|
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(
|
func New(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
db *pgxpool.Pool,
|
db *pgxpool.Pool,
|
||||||
logger *logging.Logger,
|
logger *logging.Logger,
|
||||||
|
domain, clientID, clientSecret, callbackURL string,
|
||||||
) (*Authenticator, error) {
|
) (*Authenticator, error) {
|
||||||
provider, err := oidc.NewProvider(
|
provider, err := oidc.NewProvider(
|
||||||
ctx,
|
ctx,
|
||||||
"https://"+AUTH0_DOMAIN+"/",
|
"https://"+domain+"/",
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -76,11 +65,12 @@ func New(
|
|||||||
|
|
||||||
return &Authenticator{
|
return &Authenticator{
|
||||||
log: logger,
|
log: logger,
|
||||||
|
domain: domain,
|
||||||
Provider: provider,
|
Provider: provider,
|
||||||
Config: oauth2.Config{
|
Config: oauth2.Config{
|
||||||
ClientID: AUTH0_CLIENT_ID,
|
ClientID: clientID,
|
||||||
ClientSecret: AUTH0_CLIENT_SECRET,
|
ClientSecret: clientSecret,
|
||||||
RedirectURL: AUTH0_CALLBACK_URL,
|
RedirectURL: callbackURL,
|
||||||
Endpoint: provider.Endpoint(),
|
Endpoint: provider.Endpoint(),
|
||||||
Scopes: []string{oidc.ScopeOpenID, "profile"},
|
Scopes: []string{oidc.ScopeOpenID, "profile"},
|
||||||
},
|
},
|
||||||
@@ -88,6 +78,18 @@ func New(
|
|||||||
}, nil
|
}, 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 {
|
func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error {
|
||||||
for {
|
for {
|
||||||
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil {
|
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 {
|
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
|
||||||
return &url.URL{
|
return &url.URL{
|
||||||
Scheme: "https",
|
Scheme: "https",
|
||||||
Host: AUTH0_DOMAIN,
|
Host: a.domain,
|
||||||
Path: "/v2/logout",
|
Path: "/v2/logout",
|
||||||
RawQuery: url.Values{
|
RawQuery: url.Values{
|
||||||
"returnTo": {
|
"returnTo": {
|
||||||
@@ -253,7 +255,7 @@ func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
|
|||||||
Host: requestHost,
|
Host: requestHost,
|
||||||
}).String(),
|
}).String(),
|
||||||
},
|
},
|
||||||
"client_id": {AUTH0_CLIENT_ID},
|
"client_id": {a.Config.ClientID},
|
||||||
}.Encode(),
|
}.Encode(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -267,7 +269,7 @@ func (a *Authenticator) RefreshAccessToken(
|
|||||||
err error,
|
err error,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, accessToken)
|
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, oldAccessToken)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
|
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
package authentication
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/rand"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DevLogin mints a local session for userID without going through the real
|
||||||
|
// Auth0 OAuth flow. It writes a real oauth_users/oauth_tokens row, so every
|
||||||
|
// other code path (identity lookup, account linking/creation, cookie
|
||||||
|
// handling) treats it exactly like a normal login.
|
||||||
|
//
|
||||||
|
// Only ever call this from a route gated on an explicit dev-mode flag -
|
||||||
|
// never wire it up unconditionally, since it lets the caller authenticate
|
||||||
|
// as any user_id with no credentials.
|
||||||
|
func (a *Authenticator) DevLogin(ctx context.Context, userID, name string) (accessToken string, expiration time.Time, err error) {
|
||||||
|
tokenBytes := make([]byte, 32)
|
||||||
|
if _, err := rand.Read(tokenBytes); err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("failed to generate access token: %w", err)
|
||||||
|
}
|
||||||
|
accessToken = "dev_" + hex.EncodeToString(tokenBytes)
|
||||||
|
|
||||||
|
// Set far in the future so the near-expiry refresh path in
|
||||||
|
// server/auth never tries to refresh a token that has no real
|
||||||
|
// Auth0-side refresh token behind it.
|
||||||
|
expiration = time.Now().Add(365 * 24 * time.Hour)
|
||||||
|
|
||||||
|
if _, err = a.db.Exec(
|
||||||
|
ctx,
|
||||||
|
`
|
||||||
|
WITH ensured_user AS (
|
||||||
|
INSERT INTO oauth_users (user_id)
|
||||||
|
VALUES (@user_id)
|
||||||
|
ON CONFLICT DO NOTHING
|
||||||
|
)
|
||||||
|
INSERT INTO oauth_tokens (
|
||||||
|
access_token,
|
||||||
|
token_type,
|
||||||
|
refresh_token,
|
||||||
|
expiry,
|
||||||
|
|
||||||
|
id_token_issuer,
|
||||||
|
id_token_audience,
|
||||||
|
id_token_subject,
|
||||||
|
id_token_expiry,
|
||||||
|
id_token_issued_at,
|
||||||
|
id_token_nonce,
|
||||||
|
id_token_access_token_hash,
|
||||||
|
|
||||||
|
id_token_custom_claims_family_name,
|
||||||
|
id_token_custom_claims_given_name,
|
||||||
|
id_token_custom_claims_name,
|
||||||
|
id_token_custom_claims_nickname,
|
||||||
|
id_token_custom_claims_picture,
|
||||||
|
id_token_custom_claims_updated_at
|
||||||
|
)
|
||||||
|
VALUES (
|
||||||
|
@access_token,
|
||||||
|
'dev',
|
||||||
|
'',
|
||||||
|
@expiry,
|
||||||
|
|
||||||
|
'dev-auth-bypass',
|
||||||
|
@audience,
|
||||||
|
@user_id,
|
||||||
|
@expiry,
|
||||||
|
NOW(),
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
|
||||||
|
@name,
|
||||||
|
@name,
|
||||||
|
@name,
|
||||||
|
@name,
|
||||||
|
'',
|
||||||
|
NOW()
|
||||||
|
)
|
||||||
|
`,
|
||||||
|
pgx.NamedArgs{
|
||||||
|
"access_token": accessToken,
|
||||||
|
"expiry": expiration,
|
||||||
|
"user_id": userID,
|
||||||
|
"name": name,
|
||||||
|
"audience": pgtype.FlatArray[string]{"dev"},
|
||||||
|
},
|
||||||
|
); err != nil {
|
||||||
|
return "", time.Time{}, fmt.Errorf("failed to save dev session: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return accessToken, expiration, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
package authentication
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"ruben/inventory2/consts"
|
||||||
|
"ruben/inventory2/internal/testdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDevLogin(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
auth := &Authenticator{db: pool}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID := testdb.NewUserID(t)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
|
||||||
|
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
|
||||||
|
})
|
||||||
|
|
||||||
|
accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User")
|
||||||
|
require.NoError(t, err, "DevLogin()")
|
||||||
|
require.NotEmpty(t, accessToken, "DevLogin() returned an empty access token")
|
||||||
|
assert.True(t, expiration.After(time.Now().Add(30*24*time.Hour)),
|
||||||
|
"DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration)
|
||||||
|
|
||||||
|
claims, err := auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
|
||||||
|
require.NoError(t, err, "GetAccessTokenClaimsAndExpiration()")
|
||||||
|
assert.Equal(t, "Test User", claims.Name, "claims.Name")
|
||||||
|
// Postgres timestamptz has microsecond precision, so the round-tripped
|
||||||
|
// value loses the sub-microsecond portion of Go's nanosecond clock.
|
||||||
|
assert.WithinDuration(t, expiration, claims.Expiration, time.Millisecond, "claims.Expiration")
|
||||||
|
|
||||||
|
_, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken)
|
||||||
|
require.NoError(t, err, "getRefreshTokenForAccessToken()")
|
||||||
|
assert.Equal(t, "dev", tokenType, "tokenType")
|
||||||
|
}
|
||||||
|
|
||||||
|
// DevLogin should be safe to call more than once for the same user_id -
|
||||||
|
// e.g. testing multiple times as the same dev identity - since oauth_users
|
||||||
|
// is keyed on user_id but each call mints its own oauth_tokens row.
|
||||||
|
func TestDevLogin_SameUserIDTwice(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
auth := &Authenticator{db: pool}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID := testdb.NewUserID(t)
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
|
||||||
|
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
|
||||||
|
})
|
||||||
|
|
||||||
|
token1, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||||
|
require.NoError(t, err, "first DevLogin()")
|
||||||
|
|
||||||
|
token2, _, err := auth.DevLogin(ctx, userID, "Test User")
|
||||||
|
require.NoError(t, err, "second DevLogin()")
|
||||||
|
|
||||||
|
assert.NotEqual(t, token1, token2, "DevLogin() returned the same access token twice")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
auth := &Authenticator{db: pool}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
_, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t))
|
||||||
|
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccessTokenClaimsAndExpiration()")
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
package raw_events_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"ruben/inventory2/domains/raw_events"
|
||||||
|
"ruben/inventory2/internal/testdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSaveAndLoadEventsForStore(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
store := raw_events.NewStore(testdb.Logger(), pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
platform := "test-platform"
|
||||||
|
storeID := "test-store-" + uuid.NewString()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Exec(context.Background(), "DELETE FROM raw_store_events WHERE platform = $1 AND store_id = $2", platform, storeID)
|
||||||
|
})
|
||||||
|
|
||||||
|
older := raw_events.Event{
|
||||||
|
Platform: platform,
|
||||||
|
StoreID: storeID,
|
||||||
|
EventID: "evt-1",
|
||||||
|
EventTimestamp: time.Now().Add(-time.Hour).UTC(),
|
||||||
|
Payload: json.RawMessage(`{"n":1}`),
|
||||||
|
}
|
||||||
|
newer := raw_events.Event{
|
||||||
|
Platform: platform,
|
||||||
|
StoreID: storeID,
|
||||||
|
EventID: "evt-2",
|
||||||
|
EventTimestamp: time.Now().UTC(),
|
||||||
|
Payload: json.RawMessage(`{"n":2}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
require.NoError(t, store.Save(ctx, &older), "Save() older event")
|
||||||
|
require.NoError(t, store.Save(ctx, &newer), "Save() newer event")
|
||||||
|
|
||||||
|
got, err := store.LoadEventsForStore(ctx, platform, storeID)
|
||||||
|
require.NoError(t, err, "LoadEventsForStore()")
|
||||||
|
require.Len(t, got, 2, "LoadEventsForStore()")
|
||||||
|
|
||||||
|
// ordered event_timestamp DESC - newest first.
|
||||||
|
assert.Equal(t, "evt-2", got[0].EventID, "LoadEventsForStore()[0]")
|
||||||
|
assert.Equal(t, "evt-1", got[1].EventID, "LoadEventsForStore()[1]")
|
||||||
|
|
||||||
|
var payload struct{ N int }
|
||||||
|
require.NoError(t, json.Unmarshal(got[0].Payload, &payload), "unmarshal LoadEventsForStore()[0].Payload")
|
||||||
|
assert.Equal(t, 2, payload.N, "LoadEventsForStore()[0].Payload n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadEventsForStore_NoEvents(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
store := raw_events.NewStore(testdb.Logger(), pool)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString())
|
||||||
|
require.NoError(t, err, "LoadEventsForStore()")
|
||||||
|
assert.Empty(t, got, "LoadEventsForStore()")
|
||||||
|
}
|
||||||
@@ -0,0 +1,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
|
||||||
|
}
|
||||||
@@ -0,0 +1,129 @@
|
|||||||
|
package reports_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"ruben/inventory2/consts"
|
||||||
|
"ruben/inventory2/domains/accounts"
|
||||||
|
"ruben/inventory2/domains/reports"
|
||||||
|
"ruben/inventory2/internal/testdb"
|
||||||
|
)
|
||||||
|
|
||||||
|
// setupEtsyMockShop creates a fresh account and an Etsy mock shop for it,
|
||||||
|
// and registers cleanup for every row it creates, in FK-safe order
|
||||||
|
// (shop_etsy[_events] -> mock.accounts -> accounts; oauth_users cleanup is
|
||||||
|
// handled by testdb.SeedOAuthUser itself).
|
||||||
|
//
|
||||||
|
// Etsy (not Amazon) is deliberately used here: domains/amazon's tests
|
||||||
|
// process every unprocessed row in mock.shop_amazon_events system-wide
|
||||||
|
// (that's correct production behavior for a background worker, not a
|
||||||
|
// bug), so any test that writes Amazon-platform mock events risks being
|
||||||
|
// picked up by domains/amazon's tests when the two packages' test
|
||||||
|
// binaries run concurrently (go test ./... does this by default). Using
|
||||||
|
// a different platform here keeps this package's fixtures completely off
|
||||||
|
// domains/amazon's tables and NOTIFY channel.
|
||||||
|
func setupEtsyMockShop(t *testing.T, pool *pgxpool.Pool, acctStore *accounts.Store) (acctID int64, shopID string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID := testdb.NewUserID(t)
|
||||||
|
testdb.SeedOAuthUser(t, pool, userID)
|
||||||
|
|
||||||
|
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
|
||||||
|
require.NoError(t, err, "CreateAccount()")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||||
|
})
|
||||||
|
|
||||||
|
id, err := acctStore.CreateMockShop(ctx, acct.AccountID, accounts.Etsy, "Test Shop")
|
||||||
|
require.NoError(t, err, "CreateMockShop()")
|
||||||
|
shopID = id.String()
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
ctx := context.Background()
|
||||||
|
pool.Exec(ctx, "DELETE FROM mock.shop_etsy_events WHERE shop_id = $1", shopID)
|
||||||
|
pool.Exec(ctx, "DELETE FROM mock.raw_shop_events WHERE shop_id = $1", shopID)
|
||||||
|
pool.Exec(ctx, "DELETE FROM mock.shop_etsy WHERE account_id = $1", acct.AccountID)
|
||||||
|
pool.Exec(ctx, "DELETE FROM mock.accounts WHERE account_id = $1", acct.AccountID)
|
||||||
|
})
|
||||||
|
|
||||||
|
return acct.AccountID, shopID
|
||||||
|
}
|
||||||
|
|
||||||
|
func insertRawShopEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string, ts time.Time, payload string) {
|
||||||
|
t.Helper()
|
||||||
|
_, err := pool.Exec(context.Background(), `
|
||||||
|
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
|
||||||
|
VALUES ($1, $2, $3, $4, $5::jsonb)
|
||||||
|
`, string(accounts.Etsy), shopID, ts, eventID, payload)
|
||||||
|
require.NoError(t, err, "insert raw shop event")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRawShopEvents(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||||
|
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||||
|
|
||||||
|
older := time.Now().Add(-time.Hour).UTC()
|
||||||
|
newer := time.Now().UTC()
|
||||||
|
insertRawShopEvent(t, pool, shopID, "evt-1", older, `{"n":1}`)
|
||||||
|
insertRawShopEvent(t, pool, shopID, "evt-2", newer, `{"n":2}`)
|
||||||
|
|
||||||
|
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Etsy, shopID)
|
||||||
|
require.NoError(t, err, "GetRawShopEvents()")
|
||||||
|
require.Len(t, got, 2, "GetRawShopEvents()")
|
||||||
|
|
||||||
|
// ordered event_timestamp DESC, event_id ASC - newest first.
|
||||||
|
assert.Equal(t, "evt-2", got[0].EventID, "GetRawShopEvents()[0]")
|
||||||
|
assert.Equal(t, "evt-1", got[1].EventID, "GetRawShopEvents()[1]")
|
||||||
|
|
||||||
|
assert.Equal(t, accounts.Etsy, got[0].Platform, "got[0].Platform")
|
||||||
|
assert.Equal(t, shopID, got[0].ShopID, "got[0].ShopID")
|
||||||
|
|
||||||
|
var payload struct{ N int }
|
||||||
|
require.NoError(t, json.Unmarshal(got[0].RawPayload, &payload), "unmarshal got[0].RawPayload")
|
||||||
|
assert.Equal(t, 2, payload.N, "got[0].RawPayload n")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRawShopEvents_NoEvents(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||||
|
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
|
||||||
|
|
||||||
|
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Etsy, shopID)
|
||||||
|
require.NoError(t, err, "GetRawShopEvents()")
|
||||||
|
assert.Empty(t, got, "GetRawShopEvents()")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetRawShopEvents_UnknownShop(t *testing.T) {
|
||||||
|
pool := testdb.Pool(t)
|
||||||
|
acctStore := accounts.NewStore(testdb.Logger(), pool)
|
||||||
|
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
userID := testdb.NewUserID(t)
|
||||||
|
testdb.SeedOAuthUser(t, pool, userID)
|
||||||
|
|
||||||
|
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
|
||||||
|
require.NoError(t, err, "CreateAccount()")
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
|
||||||
|
})
|
||||||
|
|
||||||
|
_, err = reportsStore.GetRawShopEvents(ctx, acct.AccountID, accounts.Etsy, "no-such-shop-"+uuid.NewString())
|
||||||
|
require.ErrorIs(t, err, consts.ErrNotFound, "GetRawShopEvents()")
|
||||||
|
}
|
||||||
@@ -0,0 +1,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",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 36 KiB |
|
After Width: | Height: | Size: 9.3 KiB |
|
After Width: | Height: | Size: 507 B |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 368 B |
|
After Width: | Height: | Size: 756 B |
|
After Width: | Height: | Size: 15 KiB |
@@ -0,0 +1 @@
|
|||||||
|
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
|
||||||
|
After Width: | Height: | Size: 2.3 KiB |
|
After Width: | Height: | Size: 385 B |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 539 B |
|
After Width: | Height: | Size: 4.9 KiB |
|
After Width: | Height: | Size: 675 B |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -11,8 +11,10 @@ require (
|
|||||||
github.com/coreos/go-oidc/v3 v3.17.0
|
github.com/coreos/go-oidc/v3 v3.17.0
|
||||||
github.com/gin-gonic/gin v1.11.0
|
github.com/gin-gonic/gin v1.11.0
|
||||||
github.com/google/uuid v1.6.0
|
github.com/google/uuid v1.6.0
|
||||||
|
github.com/joho/godotenv v1.5.1
|
||||||
github.com/lmittmann/tint v1.1.2
|
github.com/lmittmann/tint v1.1.2
|
||||||
github.com/oapi-codegen/runtime 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
|
golang.org/x/oauth2 v0.34.0
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -26,14 +28,12 @@ require (
|
|||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.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/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/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
|
||||||
github.com/getkin/kin-openapi v0.133.0 // indirect
|
github.com/getkin/kin-openapi v0.133.0 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.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-jose/go-jose/v4 v4.1.3 // indirect
|
||||||
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
github.com/go-openapi/jsonpointer v0.21.0 // indirect
|
||||||
github.com/go-openapi/swag v0.23.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/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/goccy/go-yaml v1.19.2 // 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/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // 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/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
github.com/perimeterx/marshmallow v1.1.5 // indirect
|
||||||
github.com/pkg/errors v0.9.1 // 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/qpack v0.6.0 // indirect
|
||||||
github.com/quic-go/quic-go v0.59.0 // indirect
|
github.com/quic-go/quic-go v0.59.0 // indirect
|
||||||
github.com/speakeasy-api/jsonpath v0.6.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/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
golang.org/x/text v0.33.0 // indirect
|
||||||
golang.org/x/tools v0.41.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
|
google.golang.org/protobuf v1.36.11 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
|
|||||||
@@ -16,29 +16,15 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
|
|||||||
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
|
github.com/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 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
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 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
|
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 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
|
||||||
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
|
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/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/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/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 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
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 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
|
||||||
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
|
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=
|
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.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 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
|
||||||
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
|
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 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
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 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
|
||||||
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
|
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 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
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 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
|
||||||
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
|
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 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
|
||||||
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
|
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=
|
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/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 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
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 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
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-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 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
|
||||||
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
|
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 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
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.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
|
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.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.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.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.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.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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
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/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 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
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=
|
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/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 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
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 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
|
||||||
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
|
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 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
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 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
|
||||||
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
|
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 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
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/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 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
|
||||||
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
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.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
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.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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
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 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
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=
|
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.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 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
|
||||||
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
|
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 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
|
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.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
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.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.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.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.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.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.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 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
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 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
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 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
|
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 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
|
||||||
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
|
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.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 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
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=
|
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 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
|
||||||
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
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-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-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-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 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
|
||||||
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
|
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.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 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
|
||||||
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
|
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=
|
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-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-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-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-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-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 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
|
||||||
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
|
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 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
|
||||||
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
|
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-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-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-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 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
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=
|
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-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-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-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.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 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
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-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.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.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.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.6/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.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 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
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-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-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.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 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
|
||||||
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
|
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-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-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-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/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-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-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
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.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-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.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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
|
||||||
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
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=
|
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.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 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
// Package testdb provides shared fixtures for integration tests that need a
|
||||||
|
// real Postgres connection. It targets TEST_DATABASE_URL, which `make test`
|
||||||
|
// points at a dedicated inventory_2_test database by default; override it
|
||||||
|
// (e.g. `make test TEST_DATABASE_URL=...`) to run the same tests against
|
||||||
|
// another database, such as the real dev one.
|
||||||
|
package testdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"log/slog"
|
||||||
|
"os"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
|
|
||||||
|
"ruben/inventory2/logging"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Pool connects to TEST_DATABASE_URL. If it isn't set, the test is skipped
|
||||||
|
// rather than failed, so `go test ./...` works without Postgres running.
|
||||||
|
func Pool(t *testing.T) *pgxpool.Pool {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dsn := os.Getenv("TEST_DATABASE_URL")
|
||||||
|
if dsn == "" {
|
||||||
|
t.Skip("TEST_DATABASE_URL not set; skipping integration test (see .env.example, or run via `make test`)")
|
||||||
|
}
|
||||||
|
|
||||||
|
pool, err := pgxpool.New(context.Background(), dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to connect to test database: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(pool.Close)
|
||||||
|
|
||||||
|
if err := pool.Ping(context.Background()); err != nil {
|
||||||
|
t.Fatalf("failed to reach test database at TEST_DATABASE_URL: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logger returns a logger that discards output, for stores that require one
|
||||||
|
// but whose logging isn't under test.
|
||||||
|
func Logger() *logging.Logger {
|
||||||
|
return logging.New(slog.NewTextHandler(io.Discard, nil))
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewUserID returns a unique user_id for a test to use, so parallel test
|
||||||
|
// runs (including against a shared database) never collide.
|
||||||
|
func NewUserID(t *testing.T) string {
|
||||||
|
t.Helper()
|
||||||
|
return "test-" + t.Name() + "-" + uuid.NewString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedOAuthUser inserts a bare oauth_users row so accounts/etc. FKs that
|
||||||
|
// reference it are satisfiable, and registers cleanup. Use SeedOAuthSession
|
||||||
|
// instead if the test also needs a valid access token.
|
||||||
|
func SeedOAuthUser(t *testing.T, pool *pgxpool.Pool, userID string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO oauth_users (user_id) VALUES ($1)`, userID); err != nil {
|
||||||
|
t.Fatalf("failed to seed oauth_users row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, err := pool.Exec(context.Background(), `DELETE FROM oauth_users WHERE user_id = $1`, userID); err != nil {
|
||||||
|
t.Errorf("cleanup: failed to delete oauth_users row: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SeedOAuthSession inserts an oauth_users row plus a matching oauth_tokens
|
||||||
|
// row with a freshly generated access token, mirroring the shape a real (or
|
||||||
|
// authentication.Authenticator.DevLogin) session leaves behind. Registers
|
||||||
|
// cleanup for both rows, in dependency order.
|
||||||
|
func SeedOAuthSession(t *testing.T, pool *pgxpool.Pool, userID string) (accessToken string) {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
SeedOAuthUser(t, pool, userID)
|
||||||
|
|
||||||
|
accessToken = "test-token-" + uuid.NewString()
|
||||||
|
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO oauth_tokens (
|
||||||
|
access_token, token_type, refresh_token, expiry,
|
||||||
|
id_token_issuer, id_token_audience, id_token_subject,
|
||||||
|
id_token_expiry, id_token_issued_at, id_token_nonce, id_token_access_token_hash,
|
||||||
|
id_token_custom_claims_family_name, id_token_custom_claims_given_name,
|
||||||
|
id_token_custom_claims_name, id_token_custom_claims_nickname,
|
||||||
|
id_token_custom_claims_picture, id_token_custom_claims_updated_at
|
||||||
|
) VALUES (
|
||||||
|
$1, 'test', '', NOW() + INTERVAL '1 hour',
|
||||||
|
'test', '{test}', $2,
|
||||||
|
NOW() + INTERVAL '1 hour', NOW(), '', '',
|
||||||
|
'Test', 'User',
|
||||||
|
'Test User', 'testuser',
|
||||||
|
'', NOW()
|
||||||
|
)
|
||||||
|
`, accessToken, userID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("failed to seed oauth_tokens row: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if _, err := pool.Exec(context.Background(), `DELETE FROM oauth_tokens WHERE access_token = $1`, accessToken); err != nil {
|
||||||
|
t.Errorf("cleanup: failed to delete oauth_tokens row: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return accessToken
|
||||||
|
}
|
||||||
@@ -21,17 +21,16 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
"github.com/lmittmann/tint"
|
"github.com/lmittmann/tint"
|
||||||
|
|
||||||
|
"ruben/inventory2/config"
|
||||||
"ruben/inventory2/domains/accounts"
|
"ruben/inventory2/domains/accounts"
|
||||||
|
"ruben/inventory2/domains/amazon"
|
||||||
"ruben/inventory2/domains/authentication"
|
"ruben/inventory2/domains/authentication"
|
||||||
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
||||||
"ruben/inventory2/domains/raw_events"
|
"ruben/inventory2/domains/raw_events"
|
||||||
|
"ruben/inventory2/domains/reports"
|
||||||
"ruben/inventory2/logging"
|
"ruben/inventory2/logging"
|
||||||
"ruben/inventory2/server"
|
"ruben/inventory2/server"
|
||||||
)
|
"ruben/inventory2/server/sse"
|
||||||
|
|
||||||
const (
|
|
||||||
etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5"
|
|
||||||
etsyAPISharedSecret = "jaaw0tyizf"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -65,25 +64,66 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
|||||||
ctx, shutdown := context.WithCancel(ctx)
|
ctx, shutdown := context.WithCancel(ctx)
|
||||||
defer shutdown()
|
defer shutdown()
|
||||||
|
|
||||||
|
// load configuration
|
||||||
|
|
||||||
|
cfg, err := config.Load()
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to load configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
// connect to the database
|
// connect to the database
|
||||||
|
|
||||||
connPool, err := newPool(ctx)
|
connPool, err := newPool(ctx, cfg.DatabaseURL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("failed to initialize database connection pool: %w", err)
|
return fmt.Errorf("failed to initialize database connection pool: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// start background processes
|
var auth *authentication.Authenticator
|
||||||
|
if cfg.DevAuthEnabled {
|
||||||
auth, err := authentication.New(ctx, connPool, logger.WithGroup("authenticator"))
|
auth = authentication.NewDev(connPool, logger.WithGroup("authenticator"))
|
||||||
if err != nil {
|
} else {
|
||||||
return fmt.Errorf("failed to construct authenticator: %w", err)
|
auth, err = authentication.New(
|
||||||
|
ctx,
|
||||||
|
connPool,
|
||||||
|
logger.WithGroup("authenticator"),
|
||||||
|
cfg.Auth0Domain,
|
||||||
|
cfg.Auth0ClientID,
|
||||||
|
cfg.Auth0ClientSecret,
|
||||||
|
cfg.Auth0CallbackURL,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to construct authenticator: %w", err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
authErrCh := runAuthProcesses(ctx, auth)
|
accts := accounts.NewStore(logger.WithGroup("accounts"), connPool)
|
||||||
|
|
||||||
// start http server
|
// 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
|
// wait for interrupt signal or unrecoverable failure, then shutdown
|
||||||
|
|
||||||
@@ -92,8 +132,9 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
|||||||
|
|
||||||
var (
|
var (
|
||||||
alreadyShutdown struct {
|
alreadyShutdown struct {
|
||||||
server bool
|
server bool
|
||||||
authProcesses bool
|
authProcesses bool
|
||||||
|
eventProcessing bool
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
select {
|
select {
|
||||||
@@ -106,6 +147,12 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
logger.Error("auth processes encountered error", "error", err)
|
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:
|
case err := <-srvErrCh:
|
||||||
alreadyShutdown.server = true
|
alreadyShutdown.server = true
|
||||||
logger.Error("server shutdown unexpectedly")
|
logger.Error("server shutdown unexpectedly")
|
||||||
@@ -127,6 +174,13 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
|
|||||||
logger.Info("auth processes shut down")
|
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 !alreadyShutdown.server {
|
||||||
if err := <-srvErrCh; err != nil {
|
if err := <-srvErrCh; err != nil {
|
||||||
errs = append(errs, fmt.Errorf("server experienced an error: %w", err))
|
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
|
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(
|
r := server.NewRouter(
|
||||||
logger.WithGroup("server"),
|
logger.WithGroup("server"),
|
||||||
"./",
|
"./",
|
||||||
raw_events.NewStore(logger.WithGroup("raw-event-store"), connPool),
|
raw_events.NewStore(logger.WithGroup("raw-event-store"), connPool),
|
||||||
accounts.NewStore(logger, connPool),
|
accts,
|
||||||
|
reports.NewStore(logger, connPool, accts),
|
||||||
etsy_platform.NewPlatform(
|
etsy_platform.NewPlatform(
|
||||||
logger,
|
logger,
|
||||||
func(acctID int64) string {
|
func(acctID int64) string {
|
||||||
return fmt.Sprintf("/oauth/account/%d/auth_code", acctID)
|
return fmt.Sprintf("/oauth/account/%d/auth_code", acctID)
|
||||||
},
|
},
|
||||||
etsyAPIKeystring,
|
cfg.EtsyAPIKeystring,
|
||||||
etsyAPISharedSecret,
|
cfg.EtsyAPISharedSecret,
|
||||||
connPool,
|
connPool,
|
||||||
),
|
),
|
||||||
auth,
|
auth,
|
||||||
|
cfg.DevAuthEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
srv := &http.Server{
|
srv := &http.Server{
|
||||||
Addr: ":8082", // local
|
Addr: fmt.Sprintf(":%d", cfg.Port), // local
|
||||||
Handler: r,
|
Handler: r,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,7 +258,7 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
|
|||||||
defer cancel()
|
defer cancel()
|
||||||
defer close(alreadyShutdownCh)
|
defer close(alreadyShutdownCh)
|
||||||
|
|
||||||
logger.Info("server running on 8082...")
|
logger.Infof("server running on %d...", cfg.Port)
|
||||||
if err := srv.ListenAndServe(); err != nil {
|
if err := srv.ListenAndServe(); err != nil {
|
||||||
if !errors.Is(err, http.ErrServerClosed) {
|
if !errors.Is(err, http.ErrServerClosed) {
|
||||||
runningErrCh <- fmt.Errorf("server experienced error: %w", err)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
(function(){
|
||||||
|
const defaultDebounceTimeInMs = 500;
|
||||||
|
|
||||||
|
let isReady = false
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
isReady = true
|
||||||
|
})
|
||||||
|
function onReady(fn) {
|
||||||
|
if (isReady || document.readyState === 'complete') {
|
||||||
|
fn()
|
||||||
|
} else {
|
||||||
|
document.addEventListener('DOMContentLoaded', fn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
htmx.defineExtension("resize", {
|
||||||
|
init: (api) => {
|
||||||
|
function hasResizeTrigger(elt) {
|
||||||
|
for (spec of api.getTriggerSpecs(elt)) {
|
||||||
|
if (spec.trigger === 'resize') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDebounceTimeInMs(elt) {
|
||||||
|
const amountAttr = elt.attributes['hx-resize-debounce'];
|
||||||
|
if (!amountAttr) {
|
||||||
|
return defaultDebounceTimeInMs
|
||||||
|
}
|
||||||
|
const amountStr = amountAttr.value
|
||||||
|
if (!amountStr) {
|
||||||
|
return defaultDebounceTimeInMs
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return parseInt(amountStr);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('invalid debounce time on trigger:', trigger);
|
||||||
|
console.error(err);
|
||||||
|
return defaultDebounceTimeInMs;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextID = 1;
|
||||||
|
let timeoutIDs = {};
|
||||||
|
let observers = {};
|
||||||
|
function processNode(elt) {
|
||||||
|
if (!hasResizeTrigger(elt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = elt['hx-resize-internal-data'];
|
||||||
|
if (!data) {
|
||||||
|
data = {
|
||||||
|
id: nextID,
|
||||||
|
};
|
||||||
|
nextID += 1;
|
||||||
|
|
||||||
|
elt['hx-resize-internal-data'] = data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obs = new ResizeObserver(() => {
|
||||||
|
if (timeoutIDs[data.id]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const debounceTime = getDebounceTimeInMs(elt);
|
||||||
|
timeoutIDs[data.id] = setTimeout(() => {
|
||||||
|
elt.dispatchEvent(new Event('resize'));
|
||||||
|
delete timeoutIDs[data.id];
|
||||||
|
}, debounceTime);
|
||||||
|
});
|
||||||
|
|
||||||
|
obs.observe(elt);
|
||||||
|
|
||||||
|
observers[data.id] = obs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanupNode(elt) {
|
||||||
|
if (!hasResizeTrigger(elt)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let data = elt['hx-resize-internal-data'];
|
||||||
|
if (!data) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const obs = observers[data.id];
|
||||||
|
if (obs) {
|
||||||
|
obs.disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
delete observers[data.id];
|
||||||
|
delete timeoutIDs[data.id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// process nodes
|
||||||
|
htmx.on('htmx:beforeProcessNode', evt => processNode(evt.target));
|
||||||
|
htmx.on('htmx:beforeCleanupElement', evt => cleanupNode(evt.target));
|
||||||
|
onReady(function() {
|
||||||
|
document.querySelectorAll('[hx-trigger]').forEach(processNode);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})()
|
||||||
@@ -3,36 +3,43 @@ package accounts
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"slices"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
|
|
||||||
"ruben/inventory2/consts"
|
"ruben/inventory2/consts"
|
||||||
"ruben/inventory2/domains/accounts"
|
"ruben/inventory2/domains/accounts"
|
||||||
|
"ruben/inventory2/domains/reports"
|
||||||
"ruben/inventory2/logging"
|
"ruben/inventory2/logging"
|
||||||
"ruben/inventory2/server/auth"
|
"ruben/inventory2/server/auth"
|
||||||
"ruben/inventory2/server/param"
|
"ruben/inventory2/server/param"
|
||||||
"ruben/inventory2/server/response"
|
"ruben/inventory2/server/response"
|
||||||
"ruben/inventory2/server/sse"
|
"ruben/inventory2/server/sse"
|
||||||
|
"ruben/inventory2/server/ui/charts"
|
||||||
)
|
)
|
||||||
|
|
||||||
type accountSubrouter struct {
|
type accountSubrouter struct {
|
||||||
log *logging.Logger
|
log *logging.Logger
|
||||||
accts *accounts.Store
|
accts *accounts.Store
|
||||||
pub *sse.UpdateNotificationPublisher
|
reports *reports.Store
|
||||||
|
pub *sse.UpdateNotificationPublisher
|
||||||
}
|
}
|
||||||
|
|
||||||
func Routes(
|
func Routes(
|
||||||
r *gin.RouterGroup,
|
r *gin.RouterGroup,
|
||||||
logger *logging.Logger,
|
logger *logging.Logger,
|
||||||
accts *accounts.Store,
|
accts *accounts.Store,
|
||||||
|
reports *reports.Store,
|
||||||
pub *sse.UpdateNotificationPublisher,
|
pub *sse.UpdateNotificationPublisher,
|
||||||
) {
|
) {
|
||||||
as := &accountSubrouter{
|
as := &accountSubrouter{
|
||||||
log: logger,
|
log: logger,
|
||||||
accts: accts,
|
accts: accts,
|
||||||
pub: pub,
|
reports: reports,
|
||||||
|
pub: pub,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.POST("", response.Handler(as.createAccount))
|
r.POST("", response.Handler(as.createAccount))
|
||||||
@@ -46,6 +53,9 @@ func Routes(
|
|||||||
mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing))
|
mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing))
|
||||||
mockShops.DELETE("/listings/:listing-id", response.Handler(as.deleteMockListing))
|
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 := r.Group("/:acctID/inventory/sync-groups", pub.Publish("/:acctID/inventory/sync-groups"))
|
||||||
syncGroups.POST("", response.Handler(as.saveNewSyncGroup))
|
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/shop", response.Handler(as.setShopInListingInMockSyncGroupBeingEdited))
|
||||||
mockSyncGroupBeingEdited.PUT("/listings/:orderIndex/listing", response.Handler(as.setListingInListingInMockSyncGroupBeingEdited))
|
mockSyncGroupBeingEdited.PUT("/listings/:orderIndex/listing", response.Handler(as.setListingInListingInMockSyncGroupBeingEdited))
|
||||||
mockSyncGroupBeingEdited.DELETE("/listings/:orderIndex", response.Handler(as.deleteListingInListingInMockSyncGroupBeingEdited))
|
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 /
|
// POST /
|
||||||
@@ -413,6 +431,135 @@ func (s *accountSubrouter) deleteMockListing(c *gin.Context) (response.Response,
|
|||||||
return response.StatusNoContent(), nil
|
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
|
// POST /:acctID/inventory/sync-groups/mock/draft/listings
|
||||||
func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
|
||||||
r := c.Request
|
r := c.Request
|
||||||
@@ -736,6 +883,150 @@ func (s *accountSubrouter) deleteListingInListingInMockSyncGroupBeingEdited(c *g
|
|||||||
return response.StatusOK(), nil
|
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 {
|
func lowerSnakeCase(s accounts.Platform) string {
|
||||||
return strings.ToLower(strings.Join(strings.Split(string(s), " "), "_"))
|
return strings.ToLower(strings.Join(strings.Split(string(s), " "), "_"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"ruben/inventory2/domains/accounts"
|
"ruben/inventory2/domains/accounts"
|
||||||
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
||||||
"ruben/inventory2/domains/raw_events"
|
"ruben/inventory2/domains/raw_events"
|
||||||
|
"ruben/inventory2/domains/reports"
|
||||||
"ruben/inventory2/logging"
|
"ruben/inventory2/logging"
|
||||||
accounts_api "ruben/inventory2/server/api/accounts"
|
accounts_api "ruben/inventory2/server/api/accounts"
|
||||||
auth_api "ruben/inventory2/server/api/auth"
|
auth_api "ruben/inventory2/server/api/auth"
|
||||||
@@ -22,14 +23,17 @@ func Routes(
|
|||||||
auth *auth.Service,
|
auth *auth.Service,
|
||||||
sq *sse.Queue,
|
sq *sse.Queue,
|
||||||
accts *accounts.Store,
|
accts *accounts.Store,
|
||||||
|
reps *reports.Store,
|
||||||
unp *sse.UpdateNotificationPublisher,
|
unp *sse.UpdateNotificationPublisher,
|
||||||
rawEvents *raw_events.Store,
|
rawEvents *raw_events.Store,
|
||||||
etsy *etsy_platform.Platform,
|
etsy *etsy_platform.Platform,
|
||||||
|
devAuthEnabled bool,
|
||||||
) {
|
) {
|
||||||
auth_api.Routes(
|
auth_api.Routes(
|
||||||
r.Group("/auth"),
|
r.Group("/auth"),
|
||||||
logger.WithGroup("/auth"),
|
logger.WithGroup("/auth"),
|
||||||
auth.GetAuthenticator(),
|
auth.GetAuthenticator(),
|
||||||
|
devAuthEnabled,
|
||||||
)
|
)
|
||||||
sse_api.Routes(
|
sse_api.Routes(
|
||||||
r.Group("/events", auth.Authenticate()),
|
r.Group("/events", auth.Authenticate()),
|
||||||
@@ -40,6 +44,7 @@ func Routes(
|
|||||||
r.Group("/accounts", auth.Authenticate()),
|
r.Group("/accounts", auth.Authenticate()),
|
||||||
logger.WithGroup("/accounts"),
|
logger.WithGroup("/accounts"),
|
||||||
accts,
|
accts,
|
||||||
|
reps,
|
||||||
unp.Group("/accounts"),
|
unp.Group("/accounts"),
|
||||||
)
|
)
|
||||||
webhooks.Routes(
|
webhooks.Routes(
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ func Routes(
|
|||||||
r *gin.RouterGroup,
|
r *gin.RouterGroup,
|
||||||
logger *logging.Logger,
|
logger *logging.Logger,
|
||||||
auth *authentication.Authenticator,
|
auth *authentication.Authenticator,
|
||||||
|
devAuthEnabled bool,
|
||||||
) {
|
) {
|
||||||
ls := &loginSubrouter{
|
ls := &loginSubrouter{
|
||||||
log: logger,
|
log: logger,
|
||||||
@@ -30,6 +31,12 @@ func Routes(
|
|||||||
r.GET("/login", response.Handler(ls.loginPage))
|
r.GET("/login", response.Handler(ls.loginPage))
|
||||||
r.GET("/login/callback", response.Handler(ls.loginCallback))
|
r.GET("/login/callback", response.Handler(ls.loginCallback))
|
||||||
r.GET("/logout", response.Handler(ls.logoutPage))
|
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) {
|
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
|
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) {
|
func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
|
||||||
r := c.Request
|
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()).
|
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
|
||||||
Cookie(cookies.Expired("access_token")), nil
|
Cookie(cookies.Expired("access_token")), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *loginSubrouter) devLogoutPage(c *gin.Context) (response.Response, error) {
|
||||||
|
r := c.Request
|
||||||
|
|
||||||
|
host := r.Header.Get("X-Forwarded-Host")
|
||||||
|
if host == "" {
|
||||||
|
host = r.Host
|
||||||
|
}
|
||||||
|
|
||||||
|
if ck, err := r.Cookie("access_token"); err == nil && ck != nil {
|
||||||
|
if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil {
|
||||||
|
s.log.Error("failed to delete auth token", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.TemporaryRedirect("/ui").
|
||||||
|
Cookie(cookies.Expired("access_token")), nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ func Int64(dst *int64) encoding.TextUnmarshaler {
|
|||||||
return (*int64Text)(dst)
|
return (*int64Text)(dst)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func Float64(dst *float64) encoding.TextUnmarshaler {
|
||||||
|
return (*float64Text)(dst)
|
||||||
|
}
|
||||||
|
|
||||||
func Bool(dst *bool) encoding.TextUnmarshaler {
|
func Bool(dst *bool) encoding.TextUnmarshaler {
|
||||||
return (*boolText)(dst)
|
return (*boolText)(dst)
|
||||||
}
|
}
|
||||||
@@ -33,6 +37,7 @@ type (
|
|||||||
rawText string
|
rawText string
|
||||||
intText int
|
intText int
|
||||||
int64Text int64
|
int64Text int64
|
||||||
|
float64Text float64
|
||||||
boolText bool
|
boolText bool
|
||||||
platformText accounts.Platform
|
platformText accounts.Platform
|
||||||
)
|
)
|
||||||
@@ -60,6 +65,15 @@ func (n *int64Text) UnmarshalText(text []byte) error {
|
|||||||
return nil
|
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 {
|
func (b *boolText) UnmarshalText(text []byte) error {
|
||||||
v, err := strconv.ParseBool(string(text))
|
v, err := strconv.ParseBool(string(text))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -65,7 +65,9 @@ func (s Spec) Unmarshal(c *gin.Context) error {
|
|||||||
for k, dst := range s.form {
|
for k, dst := range s.form {
|
||||||
v, ok := c.GetPostForm(k)
|
v, ok := c.GetPostForm(k)
|
||||||
if !ok || v == "" {
|
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 {
|
if err := dst.UnmarshalText([]byte(v)); err != nil {
|
||||||
return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k)
|
return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k)
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ func (b bodyRes) HTML(body []byte) Response {
|
|||||||
return HTML(body).wrap(b)
|
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 {
|
func (b bodyRes) JSON(body any) Response {
|
||||||
return JSON(body).wrap(b)
|
return JSON(body).wrap(b)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -47,6 +47,10 @@ func (c cookieRes) HTML(body []byte) Response {
|
|||||||
return HTML(body).wrap(c)
|
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 {
|
func (c cookieRes) JSON(body any) Response {
|
||||||
return JSON(body).wrap(c)
|
return JSON(body).wrap(c)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ func (h headerRes) HTML(body []byte) Response {
|
|||||||
return HTML(body).wrap(h)
|
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 {
|
func (h headerRes) JSON(body any) Response {
|
||||||
return JSON(body).wrap(h)
|
return JSON(body).wrap(h)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,8 +11,9 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
htmlRes struct {
|
htmlRes struct {
|
||||||
body []byte
|
body []byte
|
||||||
res Response
|
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 {
|
func (h htmlRes) String() string {
|
||||||
if h.res != nil {
|
if h.res != nil {
|
||||||
return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res)
|
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 {
|
func (h htmlRes) HTML(body []byte) Response {
|
||||||
h.body = body
|
h.body = body
|
||||||
|
h.reader = nil
|
||||||
|
return h
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h htmlRes) HTMLReader(body io.Reader) Response {
|
||||||
|
h.body = nil
|
||||||
|
h.reader = body
|
||||||
return h
|
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) {
|
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 {
|
func (h htmlRes) getCookies() []http.Cookie {
|
||||||
|
|||||||
@@ -49,6 +49,10 @@ func (j jsonRes) HTML(body []byte) Response {
|
|||||||
return HTML(body).wrap(j)
|
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 {
|
func (j jsonRes) JSON(body any) Response {
|
||||||
j.body = body
|
j.body = body
|
||||||
return j
|
return j
|
||||||
|
|||||||
@@ -88,6 +88,10 @@ func (r redirectRes) HTML(body []byte) Response {
|
|||||||
return HTML(body).wrap(r)
|
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 {
|
func (r redirectRes) JSON(body any) Response {
|
||||||
return JSON(body).wrap(r)
|
return JSON(body).wrap(r)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,7 @@ type (
|
|||||||
Redirect(code redirect.Code, to string) Response
|
Redirect(code redirect.Code, to string) Response
|
||||||
Body(io.ReadCloser) Response
|
Body(io.ReadCloser) Response
|
||||||
HTML([]byte) Response
|
HTML([]byte) Response
|
||||||
|
HTMLReader(io.Reader) Response
|
||||||
JSON(any) Response
|
JSON(any) Response
|
||||||
Cookie(http.Cookie) Response
|
Cookie(http.Cookie) Response
|
||||||
|
|
||||||
|
|||||||
@@ -64,6 +64,10 @@ func (s statusRes) HTML(body []byte) Response {
|
|||||||
return HTML(body).wrap(s)
|
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 {
|
func (s statusRes) JSON(body any) Response {
|
||||||
return JSON(body).wrap(s)
|
return JSON(body).wrap(s)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import (
|
|||||||
"ruben/inventory2/domains/authentication"
|
"ruben/inventory2/domains/authentication"
|
||||||
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
||||||
"ruben/inventory2/domains/raw_events"
|
"ruben/inventory2/domains/raw_events"
|
||||||
|
"ruben/inventory2/domains/reports"
|
||||||
"ruben/inventory2/logging"
|
"ruben/inventory2/logging"
|
||||||
"ruben/inventory2/server/api"
|
"ruben/inventory2/server/api"
|
||||||
"ruben/inventory2/server/auth"
|
"ruben/inventory2/server/auth"
|
||||||
@@ -30,8 +31,10 @@ func NewRouter(
|
|||||||
contentDir string,
|
contentDir string,
|
||||||
rawEvents *raw_events.Store,
|
rawEvents *raw_events.Store,
|
||||||
accts *accounts.Store,
|
accts *accounts.Store,
|
||||||
|
reps *reports.Store,
|
||||||
etsy *etsy_platform.Platform,
|
etsy *etsy_platform.Platform,
|
||||||
authr *authentication.Authenticator,
|
authr *authentication.Authenticator,
|
||||||
|
devAuthEnabled bool,
|
||||||
) *Router {
|
) *Router {
|
||||||
authM := auth.NewService(
|
authM := auth.NewService(
|
||||||
logger.WithGroup("auth-middleware"),
|
logger.WithGroup("auth-middleware"),
|
||||||
@@ -59,8 +62,10 @@ func NewRouter(
|
|||||||
"/ui",
|
"/ui",
|
||||||
rawEvents,
|
rawEvents,
|
||||||
accts,
|
accts,
|
||||||
|
reps,
|
||||||
etsy,
|
etsy,
|
||||||
authM.Authenticate(),
|
authM.Authenticate(),
|
||||||
|
devAuthEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
// non-html content: scripts, styles, images, etc
|
// non-html content: scripts, styles, images, etc
|
||||||
@@ -92,9 +97,11 @@ func NewRouter(
|
|||||||
authM,
|
authM,
|
||||||
sq,
|
sq,
|
||||||
accts,
|
accts,
|
||||||
|
reps,
|
||||||
unp,
|
unp,
|
||||||
rawEvents,
|
rawEvents,
|
||||||
etsy,
|
etsy,
|
||||||
|
devAuthEnabled,
|
||||||
)
|
)
|
||||||
|
|
||||||
return &Router{
|
return &Router{
|
||||||
@@ -107,6 +114,10 @@ func (r *Router) RunSSE(ctx context.Context) error {
|
|||||||
return r.sse.Start(ctx)
|
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 {
|
func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc {
|
||||||
scfs := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
|
scfs := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
|
||||||
return func(c *gin.Context) {
|
return func(c *gin.Context) {
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"ruben/inventory2/domains/raw_events"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
DBEventPublisher struct {
|
||||||
|
queue sender
|
||||||
|
getAccountID func(context.Context, raw_events.Event) (int64, error)
|
||||||
|
getEventType func(acctID int64, e raw_events.Event) (string, error)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (q *Queue) NewDBEventPublisher(
|
||||||
|
getAccountID func(context.Context, raw_events.Event) (int64, error),
|
||||||
|
getEventType func(acctID int64, e raw_events.Event) (string, error),
|
||||||
|
) *DBEventPublisher {
|
||||||
|
return &DBEventPublisher{
|
||||||
|
queue: q,
|
||||||
|
getAccountID: getAccountID,
|
||||||
|
getEventType: getEventType,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Notify publishes e to the SSE queue. The publish itself is quick and
|
||||||
|
// synchronous, so it acks inline once it succeeds - there's no separate
|
||||||
|
// async completion to wait for here.
|
||||||
|
func (p *DBEventPublisher) Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error {
|
||||||
|
acctID, err := p.getAccountID(ctx, e)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to get account id: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
et, err := p.getEventType(acctID, e)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to compute event type: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := p.queue.Send(ctx, StandardEvent(acctID, et)); err != nil {
|
||||||
|
return fmt.Errorf("failed to send sse event to listener: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ack(ctx); err != nil {
|
||||||
|
return fmt.Errorf("failed to ack event: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -22,11 +22,6 @@ type (
|
|||||||
trimBasePath string
|
trimBasePath string
|
||||||
basePathPattern string
|
basePathPattern string
|
||||||
}
|
}
|
||||||
|
|
||||||
// sender is satisfied by *Queue
|
|
||||||
sender interface {
|
|
||||||
Send(ctx context.Context, e Event) error
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func (q *Queue) NewUpdateNotificationPublisher(
|
func (q *Queue) NewUpdateNotificationPublisher(
|
||||||
@@ -110,11 +105,7 @@ func (p *UpdateNotificationPublisher) Publish(pathPattern string) gin.HandlerFun
|
|||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := p.queue.Send(ctx, Event{
|
if err := p.queue.Send(ctx, StandardEvent(acctID, e)); err != nil {
|
||||||
AccountID: acctID,
|
|
||||||
Type: e,
|
|
||||||
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, e)),
|
|
||||||
}); err != nil {
|
|
||||||
p.log.Errorf("failed to send sse event to listener: %v", err)
|
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()
|
wg.Wait()
|
||||||
|
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func getPathSegments(p string) []string {
|
func getPathSegments(p string) []string {
|
||||||
|
|||||||
@@ -124,6 +124,14 @@ func (q *Queue) Send(ctx context.Context, e Event) error {
|
|||||||
return nil
|
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) {
|
func (e *Event) Write(w http.ResponseWriter) {
|
||||||
fmt.Fprintf(
|
fmt.Fprintf(
|
||||||
w,
|
w,
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
package sse
|
||||||
|
|
||||||
|
import "context"
|
||||||
|
|
||||||
|
type (
|
||||||
|
// sender is satisfied by *Queue
|
||||||
|
sender interface {
|
||||||
|
Send(ctx context.Context, e Event) error
|
||||||
|
}
|
||||||
|
)
|
||||||
@@ -0,0 +1,159 @@
|
|||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"ruben/inventory2/server/ui/svg"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// TODO: rename to BarChart
|
||||||
|
Bar struct {
|
||||||
|
values []BarValue
|
||||||
|
classes barChartClasses
|
||||||
|
}
|
||||||
|
|
||||||
|
BarValue struct {
|
||||||
|
Label string
|
||||||
|
Value float64
|
||||||
|
}
|
||||||
|
|
||||||
|
barChartClasses struct {
|
||||||
|
svg []string
|
||||||
|
bar []string
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewBar(vs ...BarValue) *Bar {
|
||||||
|
return &Bar{
|
||||||
|
values: vs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Bar) WithSVGClass(classes ...string) *Bar {
|
||||||
|
c.classes.svg = append(c.classes.svg, classes...)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Bar) WithBarClass(classes ...string) *Bar {
|
||||||
|
c.classes.bar = append(c.classes.bar, classes...)
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Bar) SVG() svg.MarshalerReader {
|
||||||
|
const (
|
||||||
|
minX = 0
|
||||||
|
minY = 0
|
||||||
|
maxX = 100
|
||||||
|
maxY = 100
|
||||||
|
rangeX = maxX - minX
|
||||||
|
rangeY = maxY - minY
|
||||||
|
)
|
||||||
|
|
||||||
|
s := svg.NewSVG().
|
||||||
|
Attr(
|
||||||
|
svg.Style{
|
||||||
|
"width": "100%",
|
||||||
|
"height": "auto",
|
||||||
|
"padding": "1em",
|
||||||
|
"border-width": "2px",
|
||||||
|
},
|
||||||
|
svg.ViewBox{
|
||||||
|
X: minX,
|
||||||
|
Y: minY,
|
||||||
|
Width: maxX - minX,
|
||||||
|
Height: maxY - minY,
|
||||||
|
},
|
||||||
|
svg.Class(
|
||||||
|
strings.Join(append(
|
||||||
|
[]string{"rounded-lg", "border-border"},
|
||||||
|
c.classes.bar...,
|
||||||
|
), " "),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if len(c.classes.svg) != 0 {
|
||||||
|
s = s.Attr(svg.Class(strings.Join(c.classes.svg, " ")))
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(c.values) > 0 {
|
||||||
|
minV := slices.MinFunc(c.values, func(a, b BarValue) int {
|
||||||
|
if a.Value < b.Value {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if a.Value > b.Value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
maxV := slices.MaxFunc(c.values, func(a, b BarValue) int {
|
||||||
|
if a.Value < b.Value {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if a.Value > b.Value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
})
|
||||||
|
rangeV := maxV.Value - minV.Value
|
||||||
|
|
||||||
|
spacePerBar := float64(rangeX) / float64(len(c.values))
|
||||||
|
barPadding := 0.025 * spacePerBar
|
||||||
|
barWidth := spacePerBar - (2 * barPadding)
|
||||||
|
fontSize := barWidth / 2
|
||||||
|
|
||||||
|
for i, v := range c.values {
|
||||||
|
scaledV := (v.Value - minV.Value) * rangeY / rangeV
|
||||||
|
barX := float64(i)*spacePerBar + barPadding
|
||||||
|
s = s.Child(
|
||||||
|
new(svg.Rect).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(barX).AsX(),
|
||||||
|
svg.NewLength(maxY-scaledV).AsY(),
|
||||||
|
svg.NewLength(barWidth).AsWidth(),
|
||||||
|
svg.NewLength(scaledV).AsHeight(),
|
||||||
|
svg.Class(
|
||||||
|
strings.Join(append(
|
||||||
|
[]string{"fill-accent"},
|
||||||
|
c.classes.bar...,
|
||||||
|
), " "),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
svg.NewText(fmt.Sprint(v.Value)).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(barX+(barWidth/2)).AsX(),
|
||||||
|
svg.NewLength(maxY-scaledV+fontSize).AsY(),
|
||||||
|
svg.Fill("var(--accent-foreground)"),
|
||||||
|
svg.TextAnchorMiddle,
|
||||||
|
// svg.NewLength(fontSize).AsFontSize(),
|
||||||
|
svg.Style{
|
||||||
|
"font-size": fmt.Sprintf("%vpx", fontSize),
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if v.Label != "" {
|
||||||
|
x := barX + (barWidth / 2)
|
||||||
|
y := maxY - fontSize
|
||||||
|
s = s.Child(
|
||||||
|
svg.NewText(v.Label).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(x).AsX(),
|
||||||
|
svg.NewLength(y).AsY(),
|
||||||
|
svg.Fill("var(--accent-foreground)"),
|
||||||
|
//svg.NewLength(fontSize).AsFontSize(),
|
||||||
|
svg.Style{
|
||||||
|
"font-size": fmt.Sprintf("%vpx", fontSize),
|
||||||
|
},
|
||||||
|
svg.DominantBaselineCentral,
|
||||||
|
svg.TransformRotate(-90).About(x, y),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s
|
||||||
|
}
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ruben/inventory2/server/ui/svg"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
LineChart struct {
|
||||||
|
width float64
|
||||||
|
height float64
|
||||||
|
foreground string
|
||||||
|
background string
|
||||||
|
values []LineValue
|
||||||
|
max *float64
|
||||||
|
min *float64
|
||||||
|
}
|
||||||
|
LineValue struct {
|
||||||
|
Label string
|
||||||
|
Value float64
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewLineChart(width, height float64, vs ...LineValue) *LineChart {
|
||||||
|
return &LineChart{
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
values: vs,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LineChart) Max(n float64) *LineChart {
|
||||||
|
c.max = &n
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LineChart) Min(n float64) *LineChart {
|
||||||
|
c.min = &n
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LineChart) Foreground(fg string) *LineChart {
|
||||||
|
c.foreground = fg
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LineChart) Background(bg string) *LineChart {
|
||||||
|
c.background = bg
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *LineChart) SVG() svg.MarshalerReader {
|
||||||
|
var (
|
||||||
|
minVal float64
|
||||||
|
maxVal float64
|
||||||
|
)
|
||||||
|
|
||||||
|
fg := "black"
|
||||||
|
if c.foreground != "" {
|
||||||
|
fg = c.foreground
|
||||||
|
}
|
||||||
|
bg := c.background
|
||||||
|
|
||||||
|
if c.min != nil {
|
||||||
|
minVal = *c.min
|
||||||
|
} else if len(c.values) > 0 {
|
||||||
|
minVal = slices.MinFunc(c.values, compareLineValues).Value
|
||||||
|
}
|
||||||
|
|
||||||
|
if c.max != nil {
|
||||||
|
maxVal = *c.max
|
||||||
|
} else if len(c.values) > 0 {
|
||||||
|
maxVal = slices.MaxFunc(c.values, compareLineValues).Value
|
||||||
|
}
|
||||||
|
|
||||||
|
svgBg := bg
|
||||||
|
if svgBg == "" {
|
||||||
|
svgBg = "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
s := svg.NewSVG().
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(c.width).
|
||||||
|
AsWidth(),
|
||||||
|
svg.NewLength(c.height).
|
||||||
|
AsHeight(),
|
||||||
|
svg.Style{
|
||||||
|
// "width": "100%",
|
||||||
|
// "height": "100%",
|
||||||
|
"background": svgBg,
|
||||||
|
},
|
||||||
|
/*
|
||||||
|
svg.ViewBox{
|
||||||
|
X: 0,
|
||||||
|
Y: minVal,
|
||||||
|
Width: float64(len(c.values) - 1),
|
||||||
|
Height: maxVal - minVal,
|
||||||
|
},
|
||||||
|
*/
|
||||||
|
svg.ViewBox{
|
||||||
|
X: 0,
|
||||||
|
Y: 0,
|
||||||
|
Width: c.width,
|
||||||
|
Height: c.height,
|
||||||
|
},
|
||||||
|
svg.PreserveAspectRatio{},
|
||||||
|
)
|
||||||
|
|
||||||
|
g := new(svg.G).
|
||||||
|
Attr(
|
||||||
|
// getChartOrientationTransform(c.height),
|
||||||
|
svg.Transform{
|
||||||
|
// scale down to provide padding
|
||||||
|
svg.TransformTranslate{
|
||||||
|
X: 0.05 * c.width,
|
||||||
|
Y: 0.05 * c.height,
|
||||||
|
},
|
||||||
|
svg.TransformScale{
|
||||||
|
X: 0.9,
|
||||||
|
Y: 0.9,
|
||||||
|
},
|
||||||
|
|
||||||
|
// flip
|
||||||
|
svg.TransformScale{
|
||||||
|
X: 1,
|
||||||
|
Y: -1,
|
||||||
|
},
|
||||||
|
svg.TransformTranslate{
|
||||||
|
Y: -c.height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// lines
|
||||||
|
|
||||||
|
pts := make(svg.Points, len(c.values))
|
||||||
|
for i, p := range c.values {
|
||||||
|
pts[i] = svg.Point{
|
||||||
|
X: (c.width / float64(len(c.values)-1)) * float64(i),
|
||||||
|
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
g = g.Child(
|
||||||
|
new(svg.Polyline).Attr(
|
||||||
|
pts,
|
||||||
|
svg.NewLength(2).
|
||||||
|
Unit(svg.Px).
|
||||||
|
AsStrokeWidth(),
|
||||||
|
svg.VectorEffectNonScalingStroke,
|
||||||
|
svg.Stroke(fg),
|
||||||
|
svg.Fill("none"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
// points
|
||||||
|
|
||||||
|
if len(c.values) > 0 {
|
||||||
|
r := 1.0 / float64(len(c.values))
|
||||||
|
rx := r
|
||||||
|
ry := r
|
||||||
|
|
||||||
|
for i, p := range c.values {
|
||||||
|
// dot on the line graph
|
||||||
|
|
||||||
|
x := pts[i].X
|
||||||
|
y := pts[i].Y
|
||||||
|
|
||||||
|
ell := new(svg.Ellipse).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(x).
|
||||||
|
AsCX(),
|
||||||
|
svg.NewLength(y).
|
||||||
|
AsCY(),
|
||||||
|
svg.Percentage(rx).
|
||||||
|
AsRX(),
|
||||||
|
svg.Percentage(ry).
|
||||||
|
AsRY(),
|
||||||
|
svg.Stroke(fg),
|
||||||
|
svg.NewLength(2).
|
||||||
|
Unit(svg.Px).
|
||||||
|
AsStrokeWidth(),
|
||||||
|
svg.VectorEffectNonScalingStroke,
|
||||||
|
)
|
||||||
|
|
||||||
|
if bg != "" {
|
||||||
|
ell = ell.Attr(
|
||||||
|
svg.Fill(bg),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
ell = ell.Attr(
|
||||||
|
svg.Fill(fg),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
g = g.Child(
|
||||||
|
ell,
|
||||||
|
|
||||||
|
// label
|
||||||
|
upsideDownCenteredText(p.Label, x, y).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(c.height*0.05).
|
||||||
|
Unit(svg.Px).
|
||||||
|
AsFontSize(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return s.Child(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareLineValues(a, b LineValue) int {
|
||||||
|
if a.Value < b.Value {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if a.Value > b.Value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsideDownCenteredText(txt string, x, y float64) *svg.Text {
|
||||||
|
return svg.NewText(txt).
|
||||||
|
Attr(
|
||||||
|
svg.TextAnchorMiddle,
|
||||||
|
svg.DominantBaselineMiddle,
|
||||||
|
|
||||||
|
svg.NewLength(x).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(y).
|
||||||
|
AsY(),
|
||||||
|
|
||||||
|
upsideDownTransform(x, y),
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<html><body><svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100" style="background: pink" viewBox="0 0 100 100" preserveAspectRatio="none"><g transform="translate(5 5) scale(0.9 0.9) scale(1 -1) translate(0 -100)"><polyline points="0,2.5 20,5 40,10 60,20 80,40 100,80" stroke-width="2px" vector-effect="non-scaling-stroke" stroke="purple" fill="none"/><ellipse cx="0" cy="2.5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="0" y="2.5" transform="translate(0 2.5) scale(1 -1) translate(-0 -2.5)" font-size="5px"></text><ellipse cx="20" cy="5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="20" y="5" transform="translate(20 5) scale(1 -1) translate(-20 -5)" font-size="5px"></text><ellipse cx="40" cy="10" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="40" y="10" transform="translate(40 10) scale(1 -1) translate(-40 -10)" font-size="5px"></text><ellipse cx="60" cy="20" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="60" y="20" transform="translate(60 20) scale(1 -1) translate(-60 -20)" font-size="5px"></text><ellipse cx="80" cy="40" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="80" y="40" transform="translate(80 40) scale(1 -1) translate(-80 -40)" font-size="5px"></text><ellipse cx="100" cy="80" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="100" y="80" transform="translate(100 80) scale(1 -1) translate(-100 -80)" font-size="5px"></text></g></svg></body></html>
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
func ExampleLineChart() {
|
||||||
|
c := NewLineChart(100, 100,
|
||||||
|
LineValue{
|
||||||
|
Value: 1,
|
||||||
|
},
|
||||||
|
LineValue{
|
||||||
|
Value: 2,
|
||||||
|
},
|
||||||
|
LineValue{
|
||||||
|
Value: 4,
|
||||||
|
},
|
||||||
|
LineValue{
|
||||||
|
Value: 8,
|
||||||
|
},
|
||||||
|
LineValue{
|
||||||
|
Value: 16,
|
||||||
|
},
|
||||||
|
LineValue{
|
||||||
|
Value: 32,
|
||||||
|
},
|
||||||
|
).
|
||||||
|
Min(0).
|
||||||
|
Max(40).
|
||||||
|
Foreground("purple").
|
||||||
|
Background("pink").
|
||||||
|
SVG().
|
||||||
|
GetMarkup()
|
||||||
|
|
||||||
|
output := []byte(`<html><body>` + c + `</body></html>`)
|
||||||
|
os.WriteFile("./line_chart.html", []byte(output), 0666)
|
||||||
|
fmt.Println(c)
|
||||||
|
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100" style="background: pink" viewBox="0 0 100 100" preserveAspectRatio="none"><g transform="translate(5 5) scale(0.9 0.9) scale(1 -1) translate(0 -100)"><polyline points="0,2.5 20,5 40,10 60,20 80,40 100,80" stroke-width="2px" vector-effect="non-scaling-stroke" stroke="purple" fill="none"/><ellipse cx="0" cy="2.5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="0" y="2.5" transform="translate(0 2.5) scale(1 -1) translate(-0 -2.5)" font-size="5px"></text><ellipse cx="20" cy="5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="20" y="5" transform="translate(20 5) scale(1 -1) translate(-20 -5)" font-size="5px"></text><ellipse cx="40" cy="10" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="40" y="10" transform="translate(40 10) scale(1 -1) translate(-40 -10)" font-size="5px"></text><ellipse cx="60" cy="20" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="60" y="20" transform="translate(60 20) scale(1 -1) translate(-60 -20)" font-size="5px"></text><ellipse cx="80" cy="40" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="80" y="40" transform="translate(80 40) scale(1 -1) translate(-80 -40)" font-size="5px"></text><ellipse cx="100" cy="80" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="100" y="80" transform="translate(100 80) scale(1 -1) translate(-100 -80)" font-size="5px"></text></g></svg>
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
package charts
|
||||||
|
|
||||||
|
import "ruben/inventory2/server/ui/svg"
|
||||||
|
|
||||||
|
func getChartOrientationTransform(height float64) svg.Transform {
|
||||||
|
return svg.Transform{
|
||||||
|
svg.TransformScale{
|
||||||
|
X: 1,
|
||||||
|
Y: -1,
|
||||||
|
},
|
||||||
|
svg.TransformTranslate{
|
||||||
|
Y: -height,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func upsideDownTransform(x, y float64) svg.Transform {
|
||||||
|
return svg.Transform{
|
||||||
|
svg.TransformTranslate{
|
||||||
|
X: x,
|
||||||
|
Y: y,
|
||||||
|
},
|
||||||
|
svg.TransformScale{
|
||||||
|
X: 1,
|
||||||
|
Y: -1,
|
||||||
|
},
|
||||||
|
svg.TransformTranslate{
|
||||||
|
X: -x,
|
||||||
|
Y: -y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 7.1 KiB |
@@ -0,0 +1,425 @@
|
|||||||
|
package charts
|
||||||
|
|
||||||
|
import (
|
||||||
|
"ruben/inventory2/server/ui/svg"
|
||||||
|
"slices"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
TimeLineChart struct {
|
||||||
|
width float64
|
||||||
|
height float64
|
||||||
|
foreground string
|
||||||
|
background string
|
||||||
|
values []TimeLineValue
|
||||||
|
max *float64
|
||||||
|
min *float64
|
||||||
|
}
|
||||||
|
TimeLineValue struct {
|
||||||
|
Label string
|
||||||
|
Time time.Time
|
||||||
|
Value float64
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func NewTimeLineChart(width, height float64, vs ...TimeLineValue) *TimeLineChart {
|
||||||
|
return &TimeLineChart{
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
values: validValues(vs),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func validValues(vs []TimeLineValue) []TimeLineValue {
|
||||||
|
values := make([]TimeLineValue, 0, len(vs))
|
||||||
|
for _, v := range vs {
|
||||||
|
if !v.Time.IsZero() {
|
||||||
|
values = append(values, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return values
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) Max(n float64) *TimeLineChart {
|
||||||
|
c.max = &n
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) Min(n float64) *TimeLineChart {
|
||||||
|
c.min = &n
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) Foreground(fg string) *TimeLineChart {
|
||||||
|
c.foreground = fg
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) Background(bg string) *TimeLineChart {
|
||||||
|
c.background = bg
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) SVG() svg.MarshalerReader {
|
||||||
|
return svg.NewSVG().
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(c.width).
|
||||||
|
AsWidth(),
|
||||||
|
svg.NewLength(c.height).
|
||||||
|
AsHeight(),
|
||||||
|
svg.Style{
|
||||||
|
"background": c.getOuterSVGBackground(),
|
||||||
|
},
|
||||||
|
svg.ViewBox{
|
||||||
|
X: 0,
|
||||||
|
Y: 0,
|
||||||
|
Width: c.width,
|
||||||
|
Height: c.height,
|
||||||
|
},
|
||||||
|
svg.PreserveAspectRatio{},
|
||||||
|
).
|
||||||
|
Child(
|
||||||
|
new(svg.G).
|
||||||
|
Attr(
|
||||||
|
svg.Transform{
|
||||||
|
// scale down to provide padding
|
||||||
|
svg.TransformTranslate{
|
||||||
|
X: 0.05 * c.width,
|
||||||
|
Y: 0.05 * c.height,
|
||||||
|
},
|
||||||
|
svg.TransformScale{
|
||||||
|
X: 0.9,
|
||||||
|
Y: 0.85, // a little more padding on the bottom
|
||||||
|
},
|
||||||
|
|
||||||
|
// flip
|
||||||
|
svg.TransformScale{
|
||||||
|
X: 1,
|
||||||
|
Y: -1,
|
||||||
|
},
|
||||||
|
svg.TransformTranslate{
|
||||||
|
Y: -c.height,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).
|
||||||
|
Child(c.getChildren()...),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) getOuterSVGBackground() string {
|
||||||
|
if c.background != "" {
|
||||||
|
return c.background
|
||||||
|
}
|
||||||
|
return "auto"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) getChildren() []svg.GChildren {
|
||||||
|
pts := c.getPoints()
|
||||||
|
|
||||||
|
return append(
|
||||||
|
append(
|
||||||
|
[]svg.GChildren{
|
||||||
|
new(svg.Polyline).Attr(
|
||||||
|
pts,
|
||||||
|
svg.NewLength(2).
|
||||||
|
Unit(svg.Px).
|
||||||
|
AsStrokeWidth(),
|
||||||
|
svg.VectorEffectNonScalingStroke,
|
||||||
|
svg.Stroke(c.getStroke()),
|
||||||
|
svg.Fill("none"),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
c.buildDots(pts)...,
|
||||||
|
),
|
||||||
|
c.buildLabels(pts)...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) getPoints() svg.Points {
|
||||||
|
minVal, _ := c.getMinVal()
|
||||||
|
maxVal, _ := c.getMaxVal()
|
||||||
|
|
||||||
|
minTime, maxTime := c.getMinAndMaxUnixTimes()
|
||||||
|
|
||||||
|
pts := make(svg.Points, len(c.values))
|
||||||
|
for i, p := range c.values {
|
||||||
|
pts[i] = svg.Point{
|
||||||
|
X: c.width * float64(p.Time.UnixNano()-minTime) / float64(maxTime-minTime),
|
||||||
|
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return pts
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) getMinVal() (float64, bool) {
|
||||||
|
if c.min != nil {
|
||||||
|
return *c.min, true
|
||||||
|
}
|
||||||
|
if values := c.values; len(values) > 0 {
|
||||||
|
return slices.MinFunc(values, compareTimeLineValues).Value, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) getMaxVal() (float64, bool) {
|
||||||
|
if c.max != nil {
|
||||||
|
return *c.max, true
|
||||||
|
}
|
||||||
|
if len(c.values) > 0 {
|
||||||
|
return slices.MaxFunc(c.values, compareTimeLineValues).Value, true
|
||||||
|
}
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareTimeLineValues(a, b TimeLineValue) int {
|
||||||
|
if a.Value < b.Value {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
if a.Value > b.Value {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) getMinAndMaxUnixTimes() (minTime, maxTime int64) {
|
||||||
|
if len(c.values) > 0 {
|
||||||
|
return slices.MinFunc(c.values, compareTimeLineTimeValues).Time.UnixNano(),
|
||||||
|
slices.MaxFunc(c.values, compareTimeLineTimeValues).Time.UnixNano()
|
||||||
|
}
|
||||||
|
return 0, 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareTimeLineTimeValues(a, b TimeLineValue) int {
|
||||||
|
at := a.Time
|
||||||
|
bt := b.Time
|
||||||
|
an := at.UnixNano()
|
||||||
|
bn := bt.UnixNano()
|
||||||
|
if at.IsZero() {
|
||||||
|
an = 0
|
||||||
|
}
|
||||||
|
if bt.IsZero() {
|
||||||
|
bn = 0
|
||||||
|
}
|
||||||
|
return int(an - bn)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) buildDots(pts svg.Points) []svg.GChildren {
|
||||||
|
if len(c.values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chn := make([]svg.GChildren, len(c.values))
|
||||||
|
for i, p := range pts {
|
||||||
|
chn[i] = c.buildDot(p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) buildDot(p svg.Point) *svg.Circle {
|
||||||
|
return new(svg.Circle).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(p.X).
|
||||||
|
AsCX(),
|
||||||
|
svg.NewLength(p.Y).
|
||||||
|
AsCY(),
|
||||||
|
svg.NewLength(0.01*min(c.width, c.height)).
|
||||||
|
AsR(),
|
||||||
|
svg.Stroke(c.getStroke()),
|
||||||
|
svg.NewLength(2).
|
||||||
|
Unit(svg.Px).
|
||||||
|
AsStrokeWidth(),
|
||||||
|
svg.VectorEffectNonScalingStroke,
|
||||||
|
c.getDotFill(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) getDotFill() svg.Fill {
|
||||||
|
if c.background != "" {
|
||||||
|
return svg.Fill(c.background)
|
||||||
|
} else {
|
||||||
|
return svg.Fill(c.getStroke())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) buildLabels(pts svg.Points) []svg.GChildren {
|
||||||
|
if len(c.values) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chn := make([]svg.GChildren, len(c.values))
|
||||||
|
for i, p := range pts {
|
||||||
|
chn[i] = c.buildLabel(c.values[i], p)
|
||||||
|
}
|
||||||
|
|
||||||
|
return chn
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) buildLabel(v TimeLineValue, p svg.Point) *svg.Element[svg.SVGTag, svg.SVGAttribute, svg.SVGChildren] {
|
||||||
|
return svg.NewSVG().
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(p.X).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(p.Y).
|
||||||
|
AsY(),
|
||||||
|
svg.NewLength(24).
|
||||||
|
Unit(svg.Px).
|
||||||
|
AsWidth(),
|
||||||
|
svg.NewLength(24).
|
||||||
|
Unit(svg.Px).
|
||||||
|
AsHeight(),
|
||||||
|
svg.Class("group"),
|
||||||
|
svg.Style{
|
||||||
|
"overflow": "visible",
|
||||||
|
"fill": "var(--accent-foreground)",
|
||||||
|
},
|
||||||
|
svg.ViewBox{
|
||||||
|
X: 0,
|
||||||
|
Y: 0,
|
||||||
|
Width: 1,
|
||||||
|
Height: 1,
|
||||||
|
},
|
||||||
|
svg.PreserveAspectRatio{
|
||||||
|
Align: &svg.AlignValue{
|
||||||
|
X: svg.AlignMid,
|
||||||
|
Y: svg.AlignMid,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
).
|
||||||
|
Child(
|
||||||
|
c.buildAlwaysDisplayedLabelText(v, p),
|
||||||
|
new(svg.Rect).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(0.1).
|
||||||
|
AsStrokeWidth(),
|
||||||
|
svg.Fill("var(--muted)"),
|
||||||
|
svg.Stroke("var(--accent-foreground)"),
|
||||||
|
svg.NewLength(2.5).
|
||||||
|
AsHeight(),
|
||||||
|
svg.NewLength(-5).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(-3).
|
||||||
|
AsY(),
|
||||||
|
svg.NewLength(10).
|
||||||
|
AsWidth(),
|
||||||
|
svg.Class("not-group-hover:hidden"),
|
||||||
|
),
|
||||||
|
c.buildHoverLabelText(v, p),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) buildAlwaysDisplayedLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
||||||
|
return c.buildLabelText(v, p)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) buildHoverLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
||||||
|
labelText := c.buildLabelText(v, p).
|
||||||
|
Attr(
|
||||||
|
svg.Class("not-group-hover:hidden"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if v.Time.IsZero() {
|
||||||
|
return labelText
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelText.Child(
|
||||||
|
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
|
||||||
|
Attr(
|
||||||
|
svg.TextAnchorMiddle,
|
||||||
|
svg.DominantBaselineMiddle,
|
||||||
|
svg.Style{
|
||||||
|
"font-size": "1px",
|
||||||
|
},
|
||||||
|
svg.Class("not-group-hover:hidden"),
|
||||||
|
|
||||||
|
svg.NewLength(0).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(2.5).
|
||||||
|
AsY(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
||||||
|
return new(svg.G).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(0).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(0).
|
||||||
|
AsY(),
|
||||||
|
upsideDownTransform(0, 0),
|
||||||
|
).
|
||||||
|
Child(
|
||||||
|
svg.NewText(v.Label).
|
||||||
|
Attr(
|
||||||
|
svg.TextAnchorMiddle,
|
||||||
|
svg.DominantBaselineMiddle,
|
||||||
|
svg.Style{
|
||||||
|
"font-size": "1px",
|
||||||
|
},
|
||||||
|
|
||||||
|
svg.NewLength(0).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(1.25).
|
||||||
|
AsY(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
|
||||||
|
labelText := new(svg.G).
|
||||||
|
Attr(
|
||||||
|
svg.NewLength(0).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(0).
|
||||||
|
AsY(),
|
||||||
|
upsideDownTransform(0, 0),
|
||||||
|
).
|
||||||
|
Child(
|
||||||
|
svg.NewText(v.Label).
|
||||||
|
Attr(
|
||||||
|
svg.TextAnchorMiddle,
|
||||||
|
svg.DominantBaselineMiddle,
|
||||||
|
svg.Style{
|
||||||
|
"font-size": "1px",
|
||||||
|
},
|
||||||
|
|
||||||
|
svg.NewLength(0).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(1.25).
|
||||||
|
AsY(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
if v.Time.IsZero() {
|
||||||
|
return labelText
|
||||||
|
}
|
||||||
|
|
||||||
|
return labelText.Child(
|
||||||
|
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
|
||||||
|
Attr(
|
||||||
|
svg.TextAnchorMiddle,
|
||||||
|
svg.DominantBaselineMiddle,
|
||||||
|
svg.Style{
|
||||||
|
"font-size": "1px",
|
||||||
|
},
|
||||||
|
svg.Class("not-group-hover:hidden"),
|
||||||
|
|
||||||
|
svg.NewLength(0).
|
||||||
|
AsX(),
|
||||||
|
svg.NewLength(2.5).
|
||||||
|
AsY(),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
*/
|
||||||
|
|
||||||
|
func (c *TimeLineChart) getStroke() string {
|
||||||
|
if c.foreground != "" {
|
||||||
|
return c.foreground
|
||||||
|
}
|
||||||
|
return "black"
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ import (
|
|||||||
"ruben/inventory2/domains/accounts"
|
"ruben/inventory2/domains/accounts"
|
||||||
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
etsy_platform "ruben/inventory2/domains/platforms/etsy"
|
||||||
"ruben/inventory2/domains/raw_events"
|
"ruben/inventory2/domains/raw_events"
|
||||||
|
"ruben/inventory2/domains/reports"
|
||||||
"ruben/inventory2/logging"
|
"ruben/inventory2/logging"
|
||||||
"ruben/inventory2/server/auth"
|
"ruben/inventory2/server/auth"
|
||||||
"ruben/inventory2/server/response"
|
"ruben/inventory2/server/response"
|
||||||
@@ -23,12 +24,14 @@ import (
|
|||||||
|
|
||||||
type (
|
type (
|
||||||
webpageRouter struct {
|
webpageRouter struct {
|
||||||
log *logging.Logger
|
log *logging.Logger
|
||||||
uiPath string
|
uiPath string
|
||||||
templater *templater.Templater
|
templater *templater.Templater
|
||||||
rawEvents *raw_events.Store
|
rawEvents *raw_events.Store
|
||||||
accts *accounts.Store
|
accts *accounts.Store
|
||||||
etsy *etsy_platform.Platform
|
reports *reports.Store
|
||||||
|
etsy *etsy_platform.Platform
|
||||||
|
devAuthEnabled bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrTemplateNotFound is returned if the reason the template failed to compile
|
// ErrTemplateNotFound is returned if the reason the template failed to compile
|
||||||
@@ -40,12 +43,14 @@ type (
|
|||||||
|
|
||||||
func Routes(
|
func Routes(
|
||||||
logger *logging.Logger,
|
logger *logging.Logger,
|
||||||
r gin.IRoutes,
|
r gin.IRouter,
|
||||||
uiPath string,
|
uiPath string,
|
||||||
rawEvents *raw_events.Store,
|
rawEvents *raw_events.Store,
|
||||||
accts *accounts.Store,
|
accts *accounts.Store,
|
||||||
|
reps *reports.Store,
|
||||||
etsy *etsy_platform.Platform,
|
etsy *etsy_platform.Platform,
|
||||||
authenticate gin.HandlerFunc,
|
authenticate gin.HandlerFunc,
|
||||||
|
devAuthEnabled bool,
|
||||||
) {
|
) {
|
||||||
|
|
||||||
s := &webpageRouter{
|
s := &webpageRouter{
|
||||||
@@ -98,6 +103,39 @@ func Routes(
|
|||||||
"multInt": func(a, b int) int {
|
"multInt": func(a, b int) int {
|
||||||
return a * b
|
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
|
// html
|
||||||
"rawHTML": func(s string) template.HTML {
|
"rawHTML": func(s string) template.HTML {
|
||||||
@@ -141,9 +179,11 @@ func Routes(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
rawEvents: rawEvents,
|
rawEvents: rawEvents,
|
||||||
accts: accts,
|
accts: accts,
|
||||||
etsy: etsy,
|
reports: reps,
|
||||||
|
etsy: etsy,
|
||||||
|
devAuthEnabled: devAuthEnabled,
|
||||||
}
|
}
|
||||||
|
|
||||||
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate))
|
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
|
r := c.Request
|
||||||
ctx := r.Context()
|
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{
|
args := []any{
|
||||||
"Request",
|
"Request",
|
||||||
r,
|
r,
|
||||||
|
|
||||||
|
// dev mode
|
||||||
|
"DevAuthEnabled",
|
||||||
|
s.devAuthEnabled,
|
||||||
|
|
||||||
// add services and data here
|
// add services and data here
|
||||||
"RawEvents",
|
"RawEvents",
|
||||||
s.rawEvents.WithContext(ctx),
|
s.rawEvents.WithContext(ctx),
|
||||||
@@ -181,12 +234,16 @@ func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error)
|
|||||||
newURLCalculator(r.URL),
|
newURLCalculator(r.URL),
|
||||||
"Accounts",
|
"Accounts",
|
||||||
s.accts.WithContext(ctx),
|
s.accts.WithContext(ctx),
|
||||||
|
"Reports",
|
||||||
|
s.reports.WithContext(ctx),
|
||||||
"Etsy",
|
"Etsy",
|
||||||
s.etsy.WithContext(ctx),
|
s.etsy.WithContext(ctx),
|
||||||
|
"MockMode",
|
||||||
|
mockMode,
|
||||||
|
|
||||||
// auth tooling
|
// auth tooling
|
||||||
"Identity",
|
"Identity",
|
||||||
auth.GetIdentity(ctx),
|
id,
|
||||||
"Auth",
|
"Auth",
|
||||||
newTemplateAuthenticator(r),
|
newTemplateAuthenticator(r),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
// Attribute is the minimum method set of of an svg attribute.
|
||||||
|
Attribute interface {
|
||||||
|
// PrintKey must return the svg attribute key
|
||||||
|
PrintKey() string
|
||||||
|
// PrintVAlue must return the svg attribute value string, if applicable
|
||||||
|
PrintValue() (string, bool)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
func PrintAttribute(a Attribute) string {
|
||||||
|
v, ok := a.PrintValue()
|
||||||
|
if !ok {
|
||||||
|
return a.PrintKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
return fmt.Sprintf("%s=%s", a.PrintKey(), v)
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
type (
|
||||||
|
Circle = VoidElement[CircleTag, CircleAttribute]
|
||||||
|
|
||||||
|
CircleAttribute interface {
|
||||||
|
Attribute
|
||||||
|
IsCircleAttribute()
|
||||||
|
}
|
||||||
|
|
||||||
|
CircleTag struct{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (CircleTag) PrintTag() string {
|
||||||
|
return "circle"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CX) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CXP) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CY) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CYP) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (R) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (RP) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (PathLength) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Fill) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (Stroke) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (StrokeWidth) IsCircleAttribute() {
|
||||||
|
}
|
||||||
|
|
||||||
|
func (VectorEffect) IsCircleAttribute() {
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"html"
|
||||||
|
)
|
||||||
|
|
||||||
|
type (
|
||||||
|
Class string
|
||||||
|
)
|
||||||
|
|
||||||
|
func (Class) PrintKey() string {
|
||||||
|
return "class"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c Class) PrintValue() (string, bool) {
|
||||||
|
return fmt.Sprintf(`"%s"`, html.EscapeString(string(c))), true
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
type (
|
||||||
|
CX struct {
|
||||||
|
*LengthAttr[CXTag]
|
||||||
|
}
|
||||||
|
CXP struct {
|
||||||
|
PercentageAttr[CXTag]
|
||||||
|
}
|
||||||
|
|
||||||
|
CXTag struct{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Length) AsCX() CX {
|
||||||
|
return CX{LengthAttr: (*LengthAttr[CXTag])(l)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Percentage) AsCX() CXP {
|
||||||
|
return CXP{PercentageAttr: (PercentageAttr[CXTag])(p)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CXTag) PrintTag() string {
|
||||||
|
return "cx"
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
package svg
|
||||||
|
|
||||||
|
type (
|
||||||
|
CY struct {
|
||||||
|
*LengthAttr[CYTag]
|
||||||
|
}
|
||||||
|
CYP struct {
|
||||||
|
PercentageAttr[CYTag]
|
||||||
|
}
|
||||||
|
|
||||||
|
CYTag struct{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func (l *Length) AsCY() CY {
|
||||||
|
return CY{LengthAttr: (*LengthAttr[CYTag])(l)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p Percentage) AsCY() CYP {
|
||||||
|
return CYP{PercentageAttr: (PercentageAttr[CYTag])(p)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (CYTag) PrintTag() string {
|
||||||
|
return "cy"
|
||||||
|
}
|
||||||