44 Commits
Author SHA1 Message Date
angel d2b1d1e8cc gitea-claude workflow test: add penpot tools
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 13s
2026-08-21 15:43:23 -06:00
angel ccd9cd9c94 gitea-claude workflow fix: allow assignee of @claude to trigger action
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 18s
2026-08-21 02:55:52 -06:00
angel f310eeba9b gitea-claude workflow fix: explicitly set model
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 21s
2026-08-21 02:30:32 -06:00
angel f956aee811 gitea-claude workflow fix: set version to @gitea
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 19s
2026-08-21 02:19:44 -06:00
angel 2146dd3f46 gitea-claude workflow fix: tweak version
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 8s
Tests / Go tests (push) Successful in 16s
2026-08-21 01:51:46 -06:00
angel 393cda8785 gitea-claude workflow fix: missing version
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 14s
2026-08-21 01:49:56 -06:00
angel 4fbf5c6b53 new gitea-claude workflow
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 19s
2026-08-21 01:44:11 -06:00
angel cdd2202839 docs: document self-hosted Gitea and its MCP tooling for agents
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 13s
Make sure AI agents know this project is hosted on a self-hosted Gitea
instance rather than GitHub, and that mcp__angel__* MCP tools (with no
Projects API) are available for interacting with it.
2026-08-20 23:59:48 -06:00
angelandClaude Sonnet 5 ee97ae20f8 docs: make README's "Where things live" index clickable links
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 21s
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:40:09 -06:00
angelandClaude Sonnet 5 b9dd4fc0e5 docs: split README into distributed docs, fix stale/broken content
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 16s
README had drifted: an unresolved merge-conflict marker left over from a
past edit, a directory-structure diagram that no longer matches the repo
(/internal/site, /internal/domains/platforms/tiktok - actual layout is
server/, domains/, etc per AGENTS.md), and a duplicated dev-workflow blurb
that AGENTS.md already documents more accurately.

Splits the roadmap checklist out to ROADMAP.md and the Etsy API compliance
checklist to domains/platforms/etsy/COMPLIANCE.md (next to the code it
governs, where someone touching that integration will actually look for
it) instead of burying both in one large README. README itself becomes a
short front door with a "Where things live" index up top, since scattering
docs across files only helps if there's an obvious map to them.

Moves the CQRS/event-sourcing architecture note into AGENTS.md's
Architecture section (with a new domains/raw_events bullet) rather than
leaving it as prose in README, since AGENTS.md is the maintained
engineering reference and that's where a reader would already be looking
for how the domains are structured.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 17:34:58 -06:00
angel ed1c8363fd linked research document to README
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
Tests / Go tests (push) Successful in 14s
2026-08-20 17:24:23 -06:00
angel c2f27b3b98 Make README.md badge a link.
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 21s
2026-08-20 17:17:24 -06:00
angelandClaude Sonnet 5 8b3e923c56 gitignore: ignore all .env.* files, not just .env
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 14s
.env.dev (used for local dev config, see AGENTS.md's DEV_AUTH_ENABLED
setup) was showing up as untracked instead of being ignored like .env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:33:51 -06:00
angelandClaude Sonnet 5 308f16f6cc docs: add platform market research and weighted integration priority ranking
Research to inform which mock platform (Amazon, Shopify, etc.) is worth
turning into a real integration next, after Etsy. Adds GMV/seller-count
market-size data for every platform in domains/accounts/platform.go, a
weighted scoring model (GMV 45%, API/inventory-webhook completeness 30%,
growth 15%, integration cost 10%), and the resulting ranked order.

Also fills in Etsy's own row, sourced directly from
domains/platforms/etsy/generated_client rather than external docs since
it's the one platform actually live in this repo: Etsy Open API v3 has no
webhook/push mechanism at all, and only the OAuth connection flow is
built so far - polling-based order/inventory sync doesn't exist yet. Etsy
is included as a reference score (3.85/10) against the same rubric used
for the unbuilt platforms, mainly as a sanity check on the model rather
than a claim that building it first was wrong.

Flags that the existing capability table's BigCommerce row doesn't match
an actual platform in this codebase (Tiktok does, and is now researched
instead), and that Tiktok Shop's API details are unconfirmed pending a
dedicated research pass.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-20 16:23:47 -06:00
angelandClaude Sonnet 5 ede7555d43 auth: split dev-mode auth constructor and wire up dev-login/logout UI
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 13s
Squeamish about New()'s empty-domain-string sentinel for "dev mode, skip
OIDC discovery" - split into New (always makes a real OIDC discovery
call, all params required) and NewDev (no ctx/domain/credentials at all,
since none are used). main.go now branches on cfg.DevAuthEnabled to pick
the right constructor instead of main.go/config.go coordinating on when
it's safe to pass empty strings.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XEDaCB7C2NEBgyvqEtZuxY
2026-08-20 00:35:22 -06:00
angel 1899d76def store api research 2026-08-20 00:35:22 -06:00
angel 6a473b2ea0 Claude-assisted improvements (untested)
- dev auth flow (side-step OAuth)
- db event processing integration tests
- dev scripts (eg Makefile)
- db / test db migration setup scripts.
2026-08-20 00:35:22 -06:00
angel 50adc8e2de prototyped svg reports
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
Tests / Go tests (push) Failing after 20s
2026-08-20 00:35:13 -06:00
angel 4748eda41e amazon events published as server side event 2026-08-20 00:35:13 -06:00
angel 16ecf2dc66 mock amazon event processing stubbed 2026-08-20 00:35:13 -06:00
angel f5a3d2594d bad logs
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 4s
Tests / Go tests (push) Successful in 17s
2026-08-20 00:35:05 -06:00
angel 987386ea87 protoype: list raw events on mock mode reports page 2026-08-20 00:35:05 -06:00
angel df1b93261f mock events saved to db 2026-08-20 00:35:05 -06:00
angel 4386ede68e mock events db schema 2026-08-20 00:35:05 -06:00
angel c115ab8774 regenerated diagrams 2026-08-20 00:35:05 -06:00
angel b6c652bbf9 fixed mock mode button; shortened simulation tabs 2026-08-20 00:35:05 -06:00
angel 6945669e51 MockMode: made global in templates; increase bort of navbar in mock mode 2026-08-20 00:35:05 -06:00
angel ef7bd051fe stubbed simulations page content: set inventory 2026-08-20 00:35:05 -06:00
angel 1330d6efe2 stubbed simulations page content: refund 2026-08-20 00:35:05 -06:00
angel 10e0261fd2 stubbed simulations page content: sale 2026-08-20 00:35:05 -06:00
angel d4348b3ba9 add tests workflow badge to readme
Gitea Actions Demo / Explore-Gitea-Actions (push) Successful in 5s
Tests / Go tests (push) Successful in 21s
2026-08-20 00:34:54 -06:00
181 changed files with 11159 additions and 356 deletions
+60
View File
@@ -0,0 +1,60 @@
name: Claude Assistant for Gitea
on:
# Trigger on issue comments (works on both issues and pull requests in Gitea)
issue_comment:
types: [created]
# Trigger on issues being opened or assigned
issues:
types: [opened, assigned]
# Note: pull_request_review_comment has limited support in Gitea
# Use issue_comment instead which covers PR comments
jobs:
claude-assistant:
# Basic trigger detection - check for @claude in comments or issue body
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || github.event.action == 'assigned'))
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
# Note: Gitea Actions may not require id-token: write for basic functionality
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Run Claude Assistant
uses: markwylde/claude-code-gitea-action@gitea
with:
allowed_tools: |
penpot-self-hosted__execute_code
penpot-self-hosted__high_level_overview
penpot-self-hosted__penpot_api_info
penpot-self-hosted__export_shape
penpot_self_hosted__execute_code
penpot_self_hosted__high_level_overview
penpot_self_hosted__penpot_api_info
penpot_self_hosted__export_shape
gitea_api_url: "https://gitea.inventory-plus-plus.com/api/v1"
gitea_token: ${{ secrets.GITEA_TOKEN }} # Use standard workflow token
# anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
# Prefer claude_code_oauth_token over anthropic_api_key (cheaper!)
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
timeout_minutes: "60"
trigger_phrase: "@claude"
assignee_trigger: "@claude"
# Needed due to bug: https://github.com/anthropics/claude-code-action/issues/1416
model: "claude-sonnet-5"
# Optional: Customize for Gitea environment
custom_instructions: |
You are working in a Gitea environment. Be aware that:
- Some GitHub Actions features may behave differently
- Focus on core functionality and avoid advanced GitHub-specific features
- Use standard git operations when possible
env:
GITEA_SERVER_URL: "https://gitea.inventory-plus-plus.com"
+4
View File
@@ -2,3 +2,7 @@
*.swp
node_modules
.env
.env.*
.env.example
+10
View File
@@ -0,0 +1,10 @@
{
"mcpServers": {
"penpot-self-hosted": {
"description": "Set here, even though set globally, so it is also available for the gitea-claude action bot",
"type": "http",
"url": "https://penpot.inventory-plus-plus.com/mcp/stream?userToken=${PENPOT_MCP_USER_TOKEN}"
}
}
}
+282
View File
@@ -0,0 +1,282 @@
# AGENTS.md
Go backend for a multi-platform ecommerce inventory-sync tool. Etsy is the
one real, live platform integration; everything else (Amazon, BigCartel,
Ebay, Ecwid, Shopify, SquareOnline, Squarespace, Tiktok, WalmartMarketplace,
Wix, WooCommerce, Zoho) exists only as a **mock simulation layer** used for
development, demos, and testing the sync/reporting logic without needing
real store credentials. See `README.md` for the product-level roadmap.
## Platform integration priority
When picking the next mock platform to turn into a real integration (after
Etsy), use this weighted ranking of the 12 not-yet-live mock platforms. It
scores each on GMV/market opportunity (45%), API/inventory-sync completeness
(30%), growth trajectory (15%), and integration cost (10%) - see
`STORE_API_RESEARCH.md`'s "Weighted priority ranking" section for the full
scoring table, per-criterion reasoning, and the market-size/API research
it's built on.
1. **Shopify** (9.15/10) - cleanest/most complete API (webhooks for both
order and inventory events, modern GraphQL), still-strong GMV growth
(+29-35% YoY), and a proven self-serve distribution channel (the Shopify
App Store, where inventory-sync apps are an established category) that
no other platform here has an equivalent of.
2. **Amazon** (8.85/10) - largest raw dollar opportunity (~$575B in 2025
third-party GMV), and inventory-sync pain (suppressed listings, FBA
stranded-inventory fees) is one of the sharpest problems this tool could
solve there. `domains/amazon` already has the most real infrastructure
of any mock platform (the `ProcessEvents` LISTEN/NOTIFY loop, SSE
wiring), so a real SP-API integration reuses that instead of starting
from zero. Scores lower than Shopify mainly because SP-API auth (LWA +
AWS SigV4) and rate limiting are the most complex of any platform here,
and seller growth has gone flat.
3. **Tiktok Shop** (7.15/10) - hypergrowth (global GMV nearly doubled in
2025, projected to double again in 2026) is what earns it this spot, but
its API details are the least confirmed of anything researched -
`partner.tiktokshop.com` didn't yield readable docs during this pass.
Needs a dedicated research pass to confirm exact webhook/endpoint names
before treating this ranking as actionable.
4. **Walmart Marketplace** (6.35/10) - smaller in absolute GMV than the top
three, but growing ~50% YoY and a natural second-marketplace target for
sellers already on Amazon; full order+inventory webhook coverage.
5. **Wix** (6.35/10) - tied with Walmart on score but behind it on the
growth tiebreaker; full inventory-webhook coverage and an easy API, just
a smaller/less certain GMV number.
6. **WooCommerce** (5.95/10) - large store count (~4-6M) but no
platform-wide GMV figure exists (self-hosted plugin, no central ledger),
and no dedicated inventory webhook (relies on a `product.updated` proxy).
7. **Square Online** (5.90/10) - full inventory-webhook coverage and
well-documented APIs (shared with the rest of Square's product line),
but its ecommerce-specific GMV can't be isolated from Square's much
larger in-person POS business.
8. **Ebay** (5.75/10) - most sellers of any platform here (18.3M) but the
lowest GMV-per-seller by far, plus no dedicated inventory-change webhook.
9. **Squarespace** (4.75/10) - smaller GMV, no inventory webhook.
10. **Zoho** (3.85/10) - tiny confirmed store count (~2,196 globally).
11. **Ecwid** (3.20/10) - shrinking (-20% YoY store count).
12. **Big Cartel** (1.50/10) - last, and not close: no inventory API or
webhook at all is a structural dealbreaker for this tool's core use
case, independent of its (also declining, -41% YoY) market size.
(BigCommerce, which appears in the API-capability table in
`STORE_API_RESEARCH.md`, is excluded here - it isn't one of this codebase's
actual mock platforms; that table predates the platform list settling on
Tiktok instead.)
Etsy isn't in the ranking above (it's already built), but scores 3.85/10 as
a reference point if run through the same rubric - mid-pack-and-declining
GMV, and the *only* platform researched with zero webhook/push support of
any kind (order or inventory - confirmed directly against
`domains/platforms/etsy/generated_client`, not external docs). That's not a
retroactive case against having built Etsy - it was presumably chosen for
reasons this rubric doesn't score (an existing relationship, an easier path
to developer credentials) - but it's a reminder that the lack of any
webhook system makes real order/inventory sync a polling loop, which is
exactly the kind of integration cost this rubric undercounts once a
platform is more than superficially wired up. Note also that the OAuth
connection flow is the only part of Etsy that's actually built so far
(`domains/platforms/etsy/etsy.go`) - polling-based receipt/inventory sync
against the live API doesn't exist yet.
## Setup
1. Postgres running locally, with an `app_client` role/database matching
`.env.example`'s `DATABASE_URL`.
2. `cp .env.example .env` and fill in real values (Auth0, Etsy). For local
dev-only work you don't need real Auth0/Etsy secrets if `DEV_AUTH_ENABLED`
is `true` (see Auth below) - `changeme` placeholders are fine.
3. Create the dedicated test database once: `createdb -O app_client
inventory_2_test`. `make test` migrates it automatically after that.
## Commands
- `make dev` (default: bare `make`) - applies pending migrations, starts the
Tailwind watcher in the background, runs the server in the foreground.
Ctrl-C stops both.
- `make run` - just `go run .`, no migrations or CSS.
- `make test` - runs the full test suite against the dedicated
`inventory_2_test` database (migrates it first). Safe to run anytime;
never touches real data.
- `make test-against-dev-db` - same suite, pointed at the real dev database
instead. Useful for checking behavior against real data; tests clean up
after themselves via `t.Cleanup`, but it's still touching your dev DB.
- `make migrate-up` / `migrate-down` / `migrate-version` - wrap the
`migrate` CLI using `DATABASE_URL` from `.env`, so the DSN is never
hand-typed. `migrate-test-*` variants target `TEST_DATABASE_URL`.
- `go build ./...`, `go vet ./...`, `gofmt -l .` - standard, expected clean
before considering work done.
- `go test ./... -race` - the test suite is safe to run this way (see
Testing below for what made that true).
## Architecture
- `domains/` - business logic, one package per bounded concern
(`accounts`, `authentication`, `raw_events`, `reports`, `amazon`,
`platforms/etsy`). Each domain owns its own DB access; there's no shared
ORM/repository layer. Looking to loosely follow CQRS: writes go through
domain `Store` methods, reads are mostly separate query methods on the
same `Store`.
- `domains/raw_events` - the [event-sourcing](https://martinfowler.com/eaaDev/EventSourcing.html)
store: `Save` appends an `Event`, `LoadEventsForStore` replays a
platform+store's series. Other domains (`reports`,
`domains/accounts/mocks.go`) read from views/queries that project over
this event series rather than mutating their own standalone state. See
also the [CQRS](https://martinfowler.com/bliki/CQRS.html) note above.
- `domains/accounts/mocks.go` - the mock-platform simulation layer:
`CreateMockShop`, `CreateMockListing`, `SaveNewMockSale` /
`SaveNewMockRefund` / `SaveNewMockInventoryReset`, etc. These are the
*real* entry points production code (and the simulate-sale/refund/
inventory UI) uses - prefer them over hand-rolled SQL when writing tests
or new features that need mock shop/listing/event data.
- `domains/amazon/mock.go` - the one platform with a live *background
processor* on top of the mock layer: a Postgres `LISTEN`/`NOTIFY`-driven
loop (`(*Mocks).ProcessEvents`) that reacts to newly inserted mock events
and dispatches them to a `MockEventListener` (wired to Server-Sent Events
in `main.go`, so the UI updates live). No other platform has this yet;
if adding one, read this file's history/comments first - it went through
several correctness passes (reconnect-on-failure, ack-based retry,
configurable poll fallback) worth not re-discovering from scratch.
- `server/` - HTTP layer (Gin). `server/api` is the JSON/HTMX API,
`server/ui` renders HTML pages, `server/auth` is session/auth middleware,
`server/sse` is the Server-Sent Events plumbing.
- `config/config.go` - all runtime configuration comes from environment
variables (loaded from `.env` via `godotenv`), never hardcoded. If you
add a new required external dependency (a new secret, a new service
URL), add it here, not as a literal in the code that uses it.
- `internal/testdb` - shared test fixtures: `Pool(t)` (connects via
`TEST_DATABASE_URL`, skips the test if unset), `Logger()`, `NewUserID(t)`,
`SeedOAuthUser`/`SeedOAuthSession`.
## Database & migrations
- Migrations live in `database_migrations/`, run via `golang-migrate`
(`migrate` CLI). Every migration needs a paired `.up.sql`/`.down.sql`.
- **Migration files can drift from the live schema, and drift can mean
"unrunnable," not just "stale docs."** This has happened at least twice:
an interval-literal syntax error that failed on any fresh database, and a
column that existed in the live dev DB but not in what the migration
files would produce (fixed by adding a migration that reconciles the two,
then `migrate force <version>` on the DB that already matched by hand).
When something about the schema seems off, verify against the *live* DB
(`psql \d`, `pg_get_viewdef(...)`) before trusting the migration files,
and *especially* before assuming a fresh database would come up the same
way the long-running dev one does.
- The mock-platform schema (`mock.*`) follows a consistent per-platform
pattern: `mock.shop_<platform>`, `mock.shop_<platform>_listings`, and for
Amazon specifically `mock.shop_<platform>_events` (the background
processor's queue). Some platforms also have a recursive
`mock.shop_<platform>_listing_counts` view (built on
`mock.shop_<platform>_listing_event_sequence`) that computes a running
inventory count from `mock.raw_shop_events` - it's real per-platform
logic living in SQL, not a passthrough, and worth reading with
`pg_get_viewdef(..., true)` rather than assuming it matches another
platform's shape.
- **Platform-string casing is inconsistent and matters.** Most
`accounts.Platform` constants are lowercase/snake_case (`"amazon"`,
`"big_cartel"`, ...) matching what SQL triggers/views expect, but
`Etsy`/`Tiktok`/`Wix` are capitalized Go-side (`"Etsy"`, not `"etsy"`).
Some SQL objects for those platforms expect the capitalized form (e.g.
the Etsy listing-counts view filters on `'Etsy'`), others expect
lowercase (e.g. the Etsy `raw_shop_events` trigger checks `'etsy'`).
Don't assume; check the actual SQL (`pg_get_viewdef`, trigger
definitions) for the specific object you're relying on before writing a
fixture or a query.
- The `mock_shop_<platform>_event_inserted` `NOTIFY` channels are global,
not scoped by shop/account. Any insert into `raw_shop_events` for that
platform notifies *every* currently-listening connection for that
platform, regardless of which test or code path caused it. See Testing
below for how this bit two test packages at once.
## Testing
- Tests are real integration tests against a real Postgres database
(`internal/testdb.Pool(t)`), not mocks/stubs of the DB layer - this
codebase is mostly thin `Store` methods wrapping SQL, so testing the Go
code in isolation would miss most of the actual risk.
- Assertions use `github.com/stretchr/testify`'s `assert`/`require`
throughout - `require` for anything the rest of the test depends on
(halts immediately), `assert` for independent checks. Use
`require.Eventually` for "poll until true or timeout" patterns instead of
hand-rolling one.
- **`require.Eventually`'s condition function runs on a separate
goroutine.** Never call `require.*`/`t.Fatal*` (which invoke
`t.FailNow()`) from inside one - Go's testing package requires `FailNow`
to only be called from the test's own goroutine; calling it from a
spawned one doesn't panic cleanly, it just silently reports the wrong
thing. If a helper used inside an `Eventually` closure needs to check an
error, give it a `*testing.T`-free variant that returns `(value, error)`
and let the closure decide what "not yet satisfied" means.
- Every test cleans up its own rows via `t.Cleanup`, in FK-safe order
(children before parents). Use unique, randomly-generated IDs
(`testdb.NewUserID(t)`, `uuid.NewString()`) so tests are safe to run
concurrently with themselves and with real dev data under
`test-against-dev-db`.
- **Pick a mock platform deliberately when writing new fixtures that touch
`mock.raw_shop_events`.** `domains/amazon`'s tests process *every*
unprocessed event system-wide (that's correct behavior for its
background worker, not a bug) and react to the global Amazon NOTIFY
channel - any other package's tests that insert Amazon-platform mock
events risk being picked up by `domains/amazon`'s tests when `go test
./...` runs both packages' binaries concurrently (Go's default).
`domains/reports`, for example, deliberately uses Etsy as its example
platform for exactly this reason. If you're not testing
`domains/amazon` itself, don't use Amazon as your fixture platform.
- Never disable/re-enable a shared DB trigger to simulate a "notify never
fires" scenario in a test, even temporarily - it's unsafe to do against
a database anything else might be using concurrently (including a real
`make dev` session someone has running). If you need to prove a
timing/fallback path works, find a way to construct the scenario through
normal writes instead (see `domains/amazon/mock_test.go`'s poll-fallback
test for an example: it reuses the retry mechanism to guarantee a second
dispatch can only come from the poll timer, without touching any
trigger).
- Killing a real Postgres backend connection (`pg_terminate_backend`,
found via `pg_stat_activity`) is a legitimate, safe way to test
reconnect/failure-recovery logic when properly scoped - see
`domains/amazon/mock_test.go`'s `terminateListenConnection`. Be aware its
PID lookup matches on query text globally, not "this test's connection
specifically" - check for other live processes (e.g. a running `make
dev`) before running a test like this against a shared database.
## Auth
- Real auth is Auth0-backed OIDC (`domains/authentication`). For local
dev, set `DEV_AUTH_ENABLED=true` and hit `GET
/api/auth/dev-login?user_id=whoever` to mint a real session (writes
directly to `oauth_users`/`oauth_tokens`, same shape a real login
produces) without any Auth0 round-trip. Different `user_id` values let
you test multiple identities/accounts side by side. Never enable this
outside local development.
## Gitea
- This project is hosted on a self-hosted **Gitea** instance at
`gitea.inventory-plus-plus.com` (repo:
`angel/inventory-plus-plus`), not GitHub - the `origin` remote points at
it over SSH. Issues, pull requests, wiki, and CI (Gitea Actions - see the
Tests badge at the top of `README.md`) all live there rather than on
GitHub, even though the tooling/workflow (Actions YAML, PR-based review)
looks GitHub-shaped.
- A Gitea MCP server is available in agent sessions (tools prefixed
`mcp__angel__...` - e.g. `issue_read`/`issue_write`,
`pull_request_read`/`pull_request_write`,
`list_pull_requests`/`list_issues`, `list_branches`, `wiki_read`/
`wiki_write`) for reading/managing issues, PRs, branches, releases, and
the wiki without shelling out to `git`/`gh`. There is no GitHub CLI
(`gh`) equivalent here - use these MCP tools or `git` directly instead.
- The Gitea MCP server has **no Projects API** - it cannot read or modify
Gitea Project boards. Don't attempt to automate Project-board changes
(e.g. moving an issue between columns) through it; that has to be done
manually in the Gitea UI.
## Commit conventions
- Commit messages: imperative mood subject line, no period, body explains
*why* (what problem existed, what the fix changes) rather than
re-describing the diff. One logical change per commit - e.g. a bugfix
found while doing unrelated work gets its own commit, not folded into
the original task's.
- Work summaries: see `work-summaries/` for dated records of past
sessions' changes and reasoning - useful context before touching an area
someone else (human or agent) recently worked on.
+1
View File
@@ -0,0 +1 @@
@AGENTS.md
+55
View File
@@ -0,0 +1,55 @@
.DEFAULT_GOAL := dev
-include .env
export
MIGRATE := migrate -path database_migrations -database "$(DATABASE_URL)"
MIGRATE_TEST := migrate -path database_migrations -database "$(TEST_DATABASE_URL)"
.PHONY: dev migrate-up migrate-down migrate-version tailwind run \
test test-against-dev-db migrate-test-up migrate-test-down migrate-test-version
# applies pending migrations, starts the tailwind watcher in the background,
# then runs the server in the foreground. Ctrl-C stops both.
dev: migrate-up
@./tailwind.sh & \
TW_PID=$$!; \
trap "kill $$TW_PID 2>/dev/null" EXIT INT TERM; \
go run .
migrate-up:
$(MIGRATE) up
migrate-down:
$(MIGRATE) down 1
migrate-version:
$(MIGRATE) version
tailwind:
./tailwind.sh
run:
go run .
# runs the integration test suite against TEST_DATABASE_URL (the dedicated
# inventory_2_test database by default). Applies pending migrations there
# first.
test: migrate-test-up
go test ./...
# same as `test`, but points TEST_DATABASE_URL at the real dev database for
# this run instead of the dedicated test database - useful for checking
# behavior against real data. Leaves the dev DB in whatever state the tests'
# own cleanup produces; it's still your dev data, treat it accordingly.
test-against-dev-db: TEST_DATABASE_URL := $(DATABASE_URL)
test-against-dev-db: test
migrate-test-up:
$(MIGRATE_TEST) up
migrate-test-down:
$(MIGRATE_TEST) down 1
migrate-test-version:
$(MIGRATE_TEST) version
+63 -94
View File
@@ -1,99 +1,79 @@
# V2 - Attempt 2
## Inventory++ [![Tests Passing?](https://gitea.inventory-plus-plus.com/angel/inventory-plus-plus/actions/workflows/tests.yaml/badge.svg?branch=master)](https://gitea.inventory-plus-plus.com/angel/inventory-plus-plus/actions?workflow=&scoped_workflow_source_repo_id=0&actor=0&status=0&branch=master)
WIP!
This is an application intended to ease the process of managing multiple online stores.
The benefits provided by this application will be to automatically manage shared inventory between stores,
reducing the amount of time needed to synchronize inventory between stores.
# Roadmap
## Where things live
- [ ] Etsy (WIP)
- [ ] GET ETSY AUTH (WIP)
- [x] move auth state stuff to database (out of cache)
- [x] only generate a sign up link IF they click the link on the accounts page
- [ ] get api key approved
- [ ] automatically clean up access tokens and state when expired
- [ ] access tokens
- [ ] state
- [?] Get new access token using refresh token flow
- [ ] make a FK between the etsy_store_events table and etsy_users table (store_id columns don't match types)
- [ ] Auth0
- [ ] get off dev api key?
- [x] Get new access token using refresh token flow
- [ ] test
- [x] when token is expired, redirect them to the login page, then redirect them back to where they were heading to.
- [ ] Social connections login
- [x] automatically clean up access tokens and state when expired
- [x] access tokens
- [x] state
- [ ] Complete this design document?
- [ ] Complete defining this roadmap checklist
- [ ] Website displaying an audit of store events
- [ ] Start with just a list of events for a given store (use a static test store)
- [ ] ...
- [ ] Dark mode
- [ ] don't let a listing be in multiple sync groups
- [ ] Next stores on the list (at least hypothetically)
- Shopify
- WooCommerce
- BigCommerce
- Wix
- Squarespace
- Square Online
- Zoho
- Ecwid
- Big Cartel
Bigger marketplaces:
- Amazon
- Walmart Marketplace
- Ebay
Documentation is split up rather than kept in one big doc - start here:
## Nice to haves
- **[`AGENTS.md`](./AGENTS.md)** - the engineering reference: setup, `make` commands, architecture, database/migration gotchas, testing conventions, auth, and the researched platform-integration priority ranking (which platform to build next, and why).
- **[`ROADMAP.md`](./ROADMAP.md)** - in-progress and planned work, as a checklist.
- **[`research/platforms.md`](./research/platforms.md)** - the underlying API-capability and market-size research the platform-priority ranking is based on.
- **[`domains/platforms/etsy/COMPLIANCE.md`](./domains/platforms/etsy/COMPLIANCE.md)** - Etsy API usage obligations to keep satisfied when touching the Etsy integration.
- **[`diagrams/`](./diagrams)** - architecture and schema diagrams, linked from the Diagrams section below.
- [ ] Drop in a good logger
- [ ] log all errors caught by the http server
## Constraints
- [ ] Etsy
- [ ] API Licensed Uses and Restrictions:
- [ ] Link directly back to the product information and/or image Content on Etsy, where the Application utilizes product information and/or images.
- [ ] Provide a prominently displayed email address on Your Application for third parties to contact You with any questions or issues. You shall respond to such inquiries in a timely manner.
- [ ] Use commercially reasonable efforts to provide a terms of service and privacy policy in a visible location on your Application.
- [ ] Display item Content or product information and/or images which is more than six (6) hours older than such information is on the Website, and other Etsy Content cannot be more than twenty-four (24) hours older than such Content on the Website.
- [ ] Use the API in a manner that exceeds reasonable request volume or constitutes excessive or abusive usage. Users are allocated by default, 10,000 calls per day.
- [ ] You shall not use or alter any text, logos, Etsy's Trademarks, Etsy's signature colors, Etsy's layout, or a confusingly similar layout to Etsy's layout in such a way which may suggest endorsement or affiliation by Etsy.
- [ ] Any use of the Etsy logo or Etsy's Trademarks must be used in its entirety and must not be altered or used in a misleading way.
- [ ] You shall not use a mark which is confusingly similar to Etsy's Trademarks.
- [ ] Any use of the Etsy logo or Etsy's Trademarks in Your Application shall be less prominent than the logo or mark that primarily describes the Application and Your use of the Etsy logo shall not imply any endorsement or affiliation by Etsy.
- [ ] You may publicize, issue press or blog releases of Your Application only if You state that it was created using the Etsy API and that You in no way imply that Your Application is endorsed or certified by Etsy.
- [ ] You must place or display the following notice prominently on Your Application:
"The term 'Etsy' is a trademark of Etsy, Inc. This application uses the Etsy API but is not endorsed or certified by Etsy, Inc."
- [ ] Immediately report any security deficiencies You discover to Etsy by emailing developer@etsy.com.
This project is hosted on a self-hosted [Gitea](https://gitea.inventory-plus-plus.com/angel/inventory-plus-plus) instance (not GitHub) - issues, pull requests, CI (the Tests badge above), and the wiki all live there. See `AGENTS.md`'s Gitea section for details, including the Gitea MCP server tools available to AI agents.
Looking to follow the [CQRS](https://martinfowler.com/bliki/CQRS.html) pattern.
## Deploying
The database will follow the [event sourcing](https://martinfowler.com/eaaDev/EventSourcing.html) database pattern.
All events (or commands) will be stored in a respective event series, and all database reads will be from views that are projections, reductions, aggregations of those event series.
The application runs locally, from this directory.
It is deployed simply by running either from the root of the project,
```
make run
```
or
```
go run .
```
See `AGENTS.md` for the full setup/`make` command reference (migrations, tests, dev auth, etc).
## Application/directory structure
- /internal
- /site: website
- /webhooks: webhooks for platform events
- /domains: packages for each domain
- /store_events: storing and events
- /platforms: ecommerce platform domains
- /tiktok: interface with tiktok
- ... etc
- ... etc
## Technology
![Application structure](./diagrams/application_structure.svg)
### Languages
## Website hierarchy
#### Server side
Golang, Go Templates
- /site
#### Front end
HTML, CSS, Javascript
### Databases
Postgres, in docker
#### Management
Using [migrate](https://github.com/golang-migrate/migrate) to manage build out the database schema, and to run
migrations.
### Tooling
#### Server side
- github.com/angelbeltran/templater: for wiring up template directories for serving over the web and improving
the task of composing template together.
- go templates: generating html declaratively from the server.
- github.com/jackc/pgx/v5: for postgres db interfacing
#### Front end
- htmx: for strong hypermedia support
- hyperscript: for minimal, inline scripting, with strong integration with htmx
- tailwind: for styling the front end, using tried and testing styling paradigms, conventions, and templates.
# Architectural and Software Diagrams
## Application structure
See `AGENTS.md`'s Architecture section for the current, maintained breakdown of packages/directories.
![Application structure](./diagrams/application_structure.svg)
## Database schemas
**public**
@@ -105,39 +85,28 @@ All events (or commands) will be stored in a respective event series, and all da
## Events
`domains/raw_events` is the event-sourcing store behind this - see `AGENTS.md`'s Architecture section.
### Event Sourcing Architecture
![Event sourcing architecture](./diagrams/event_sourcing.svg)
### Event Structure
![Event](./diagrams/event.svg)
### Store Event Database Tables
![Event database tables](./diagrams/event_tables.svg)
## Platform: Etsy
### Signing up
**TODO: need an account page that can create accounts ahead of time - force users to create an account first!**
![Event database tables](./diagrams/etsy/obtaining_access_token.svg)
***TODO: create a page that will take billing information and include it in this process***
### Getting a new refresh token
***TODO***
### Models
![Models](./diagrams/etsy/models.svg)
See `ROADMAP.md` for what's still unbuilt in the Etsy flow (an account-creation page ahead of OAuth, billing info collection, refresh-token handling), and `domains/platforms/etsy/COMPLIANCE.md` for API usage obligations.
## All Diagrams
+38
View File
@@ -0,0 +1,38 @@
# Roadmap
- [ ] Etsy (WIP)
- [ ] GET ETSY AUTH (WIP)
- [x] move auth state stuff to database (out of cache)
- [x] only generate a sign up link IF they click the link on the accounts page
- [ ] get api key approved
- [ ] automatically clean up access tokens and state when expired
- [ ] access tokens
- [ ] state
- [ ] account-creation page ahead of OAuth (force users to create an account before connecting a store)
- [ ] collect billing information as part of that account-creation flow
- [?] Get new access token using refresh token flow
- [ ] make a FK between the etsy_store_events table and etsy_users table (store_id columns don't match types)
- [ ] build polling-based order/inventory sync against the live Etsy API - only the OAuth connection flow is implemented so far (see `research/platforms.md`'s Etsy notes: API v3 has no webhook/push system, so this has to be a poll loop)
- see `domains/platforms/etsy/COMPLIANCE.md` for Etsy API usage obligations to keep satisfied along the way
- [ ] Auth0
- [ ] get off dev api key?
- [x] Get new access token using refresh token flow
- [ ] test
- [x] when token is expired, redirect them to the login page, then redirect them back to where they were heading to.
- [ ] Social connections login
- [x] automatically clean up access tokens and state when expired
- [x] access tokens
- [x] state
- [ ] Complete this design document?
- [ ] Complete defining this roadmap checklist
- [ ] Website displaying an audit of store events
- [ ] Start with just a list of events for a given store (use a static test store)
- [ ] ...
- [ ] Dark mode
- [ ] don't let a listing be in multiple sync groups
- [ ] Next platform to build out beyond Etsy - see `AGENTS.md`'s "Platform integration priority" section for the researched, weighted ranking (currently: Shopify, then Amazon, then Tiktok Shop) instead of picking from the raw platform list
## Nice to haves
- [ ] Drop in a good logger
- [ ] log all errors caught by the http server
+128
View File
@@ -0,0 +1,128 @@
// Package config loads application configuration from environment variables,
// optionally populated from a .env file in the working directory.
package config
import (
"flag"
"fmt"
"os"
"strconv"
"github.com/joho/godotenv"
)
type Config struct {
DatabaseURL string
Auth0Domain string
Auth0ClientID string
Auth0ClientSecret string
Auth0CallbackURL string
EtsyAPIKeystring string
EtsyAPISharedSecret string
// DevAuthEnabled, when true, exposes a route that mints a local
// login session for any user_id without going through Auth0. Must
// never be true outside local development.
DevAuthEnabled bool
Port int
}
var envFlagPtr = flag.String("env", "", "")
func init() {
flag.Parse()
}
// Load reads a .env file, if present, into the process environment, then
// reads the required configuration values from the environment.
func Load() (Config, error) {
dev, err := checkEnvFlag()
if err != nil {
return Config{}, err
}
if dev {
if err := godotenv.Load(".env.dev"); err != nil && !os.IsNotExist(err) {
return Config{}, fmt.Errorf("failed to load .env.dev file: %w", err)
}
} else {
if err := godotenv.Load(); err != nil && !os.IsNotExist(err) {
return Config{}, fmt.Errorf("failed to load .env file: %w", err)
}
}
var (
cfg Config
missing []string
)
required := func(key string) string {
v := os.Getenv(key)
if v == "" {
missing = append(missing, key)
}
return v
}
cfg.DatabaseURL = required("DATABASE_URL")
// DEV_AUTH_ENABLED must be known before the Auth0 vars are read below,
// since it decides whether those are required at all.
if raw := os.Getenv("DEV_AUTH_ENABLED"); raw != "" {
devAuthEnabled, err := strconv.ParseBool(raw)
if err != nil {
return Config{}, fmt.Errorf("invalid DEV_AUTH_ENABLED value %q: %w", raw, err)
}
cfg.DevAuthEnabled = devAuthEnabled
}
// With DEV_AUTH_ENABLED, real Auth0 login/logout never runs (dev-login
// mints sessions locally instead), so these are unused and optional.
if cfg.DevAuthEnabled {
cfg.Auth0Domain = os.Getenv("AUTH0_DOMAIN")
cfg.Auth0ClientID = os.Getenv("AUTH0_CLIENT_ID")
cfg.Auth0ClientSecret = os.Getenv("AUTH0_CLIENT_SECRET")
cfg.Auth0CallbackURL = os.Getenv("AUTH0_CALLBACK_URL")
} else {
cfg.Auth0Domain = required("AUTH0_DOMAIN")
cfg.Auth0ClientID = required("AUTH0_CLIENT_ID")
cfg.Auth0ClientSecret = required("AUTH0_CLIENT_SECRET")
cfg.Auth0CallbackURL = required("AUTH0_CALLBACK_URL")
}
cfg.EtsyAPIKeystring = required("ETSY_API_KEYSTRING")
cfg.EtsyAPISharedSecret = required("ETSY_API_SHARED_SECRET")
portStr := required("PORT")
if len(missing) > 0 {
return Config{}, fmt.Errorf(
"missing required environment variables: %v (see .env.example)",
missing,
)
}
if port, err := strconv.Atoi(portStr); err != nil {
return Config{}, fmt.Errorf("invalid port number: %w", err)
} else {
cfg.Port = port
}
return cfg, nil
}
func checkEnvFlag() (dev bool, err error) {
switch env := *envFlagPtr; env {
case "dev":
return true, nil
case "prd":
return false, nil
case "":
return false, fmt.Errorf("no env flag specified: must be one of dev, prd")
default:
return false, fmt.Errorf("invalid env flag specified: must be one of dev, prd: %q", env)
}
}
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
)
func newPool(ctx context.Context) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, "postgres://app_client:app_password@localhost:5432/inventory_2?sslmode=disable")
func newPool(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
pool, err := pgxpool.New(ctx, databaseURL)
if err != nil {
return nil, fmt.Errorf("failed to create database client: %w", err)
}
@@ -1,6 +1,6 @@
CREATE TABLE oauth_login_states (
state BYTEA NOT NULL,
expiration TIMESTAMPTZ NOT NULL DEFAULT (NOW() + 10 'minute'),
expiration TIMESTAMPTZ NOT NULL DEFAULT (NOW() + '10 minutes'::interval),
PRIMARY KEY (state)
);
@@ -0,0 +1,63 @@
BEGIN;
--- event processing
DROP TRIGGER amazon_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_amazon_event;
DROP TRIGGER big_cartel_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_big_cartel_event;
DROP TRIGGER ebay_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_ebay_event;
DROP TRIGGER ecwid_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_ecwid_event;
DROP TRIGGER etsy_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_etsy_event;
DROP TRIGGER shopify_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_shopify_event;
DROP TRIGGER square_online_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_square_online_event;
DROP TRIGGER squarespace_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_squarespace_event;
DROP TRIGGER tiktok_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_tiktok_event;
DROP TRIGGER walmart_marketplace_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_walmart_marketplace_event;
DROP TRIGGER wix_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_wix_event;
DROP TRIGGER woo_commerce_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_woo_commerce_event;
DROP TRIGGER zoho_store_events ON mock.raw_shop_events;
DROP FUNCTION mock.process_raw_zoho_event;
-- event tables
DROP TABLE mock.shop_amazon_events;
DROP TABLE mock.shop_big_cartel_events;
DROP TABLE mock.shop_ebay_events;
DROP TABLE mock.shop_ecwid_events;
DROP TABLE mock.shop_etsy_events;
DROP TABLE mock.shop_shopify_events;
DROP TABLE mock.shop_square_online_events;
DROP TABLE mock.shop_squarespace_events;
DROP TABLE mock.shop_tiktok_events;
DROP TABLE mock.shop_walmart_marketplace_events;
DROP TABLE mock.shop_wix_events;
DROP TABLE mock.shop_woo_commerce_events;
DROP TABLE mock.shop_zoho_events;
DROP TABLE mock.raw_shop_events;
COMMIT;
@@ -0,0 +1,464 @@
BEGIN;
-- raw shop events
CREATE TABLE mock.raw_shop_events (
platform TEXT NOT NULL,
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
raw_payload JSONB NOT NULL,
PRIMARY KEY (platform, shop_id, event_id, event_timestamp)
);
-- processed event tables
CREATE TABLE mock.shop_amazon_events (
platform TEXT NOT NULL DEFAULT 'amazon' CHECK (platform = 'amazon'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_big_cartel_events (
platform TEXT NOT NULL DEFAULT 'big_cartel' CHECK (platform = 'big_cartel'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_ebay_events (
platform TEXT NOT NULL DEFAULT 'ebay' CHECK (platform = 'ebay'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_ecwid_events (
platform TEXT NOT NULL DEFAULT 'ecwid' CHECK (platform = 'ecwid'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_etsy_events (
platform TEXT NOT NULL DEFAULT 'etsy' CHECK (platform = 'etsy'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_shopify_events (
platform TEXT NOT NULL DEFAULT 'shopify' CHECK (platform = 'shopify'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_square_online_events (
platform TEXT NOT NULL DEFAULT 'square_online' CHECK (platform = 'square_online'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_squarespace_events (
platform TEXT NOT NULL DEFAULT 'squarespace' CHECK (platform = 'squarespace'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_tiktok_events (
platform TEXT NOT NULL DEFAULT 'tiktok' CHECK (platform = 'tiktok'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_walmart_marketplace_events (
platform TEXT NOT NULL DEFAULT 'walmart_marketplace' CHECK (platform = 'walmart_marketplace'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_wix_events (
platform TEXT NOT NULL DEFAULT 'wix' CHECK (platform = 'wix'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_woo_commerce_events (
platform TEXT NOT NULL DEFAULT 'woo_commerce' CHECK (platform = 'woo_commerce'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
CREATE TABLE mock.shop_zoho_events (
platform TEXT NOT NULL DEFAULT 'zoho' CHECK (platform = 'zoho'),
shop_id TEXT NOT NULL,
event_timestamp TIMESTAMPTZ NOT NULL,
event_id TEXT NOT NULL,
PRIMARY KEY (shop_id, event_id, event_timestamp),
FOREIGN KEY (platform, shop_id, event_id, event_timestamp) REFERENCES mock.raw_shop_events
);
--- event processing
CREATE OR REPLACE FUNCTION mock.process_raw_amazon_event() RETURNS TRIGGER AS $process_raw_amazon_event$
BEGIN
INSERT INTO mock.shop_amazon_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_amazon_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER amazon_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'amazon')
EXECUTE FUNCTION mock.process_raw_amazon_event();
CREATE OR REPLACE FUNCTION mock.process_raw_big_cartel_event() RETURNS TRIGGER AS $process_raw_big_cartel_event$
BEGIN
INSERT INTO mock.shop_big_cartel_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_big_cartel_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER big_cartel_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'big_cartel')
EXECUTE FUNCTION mock.process_raw_big_cartel_event();
CREATE OR REPLACE FUNCTION mock.process_raw_ebay_event() RETURNS TRIGGER AS $process_raw_ebay_event$
BEGIN
INSERT INTO mock.shop_ebay_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_ebay_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER ebay_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'ebay')
EXECUTE FUNCTION mock.process_raw_ebay_event();
CREATE OR REPLACE FUNCTION mock.process_raw_ecwid_event() RETURNS TRIGGER AS $process_raw_ecwid_event$
BEGIN
INSERT INTO mock.shop_ecwid_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_ecwid_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER ecwid_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'ecwid')
EXECUTE FUNCTION mock.process_raw_ecwid_event();
CREATE OR REPLACE FUNCTION mock.process_raw_etsy_event() RETURNS TRIGGER AS $process_raw_etsy_event$
BEGIN
INSERT INTO mock.shop_etsy_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_etsy_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER etsy_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'etsy')
EXECUTE FUNCTION mock.process_raw_etsy_event();
CREATE OR REPLACE FUNCTION mock.process_raw_shopify_event() RETURNS TRIGGER AS $process_raw_shopify_event$
BEGIN
INSERT INTO mock.shop_shopify_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_shopify_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER shopify_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'shopify')
EXECUTE FUNCTION mock.process_raw_shopify_event();
CREATE OR REPLACE FUNCTION mock.process_raw_square_online_event() RETURNS TRIGGER AS $process_raw_square_online_event$
BEGIN
INSERT INTO mock.shop_square_online_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_square_online_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER square_online_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'square_online')
EXECUTE FUNCTION mock.process_raw_square_online_event();
CREATE OR REPLACE FUNCTION mock.process_raw_squarespace_event() RETURNS TRIGGER AS $process_raw_squarespace_event$
BEGIN
INSERT INTO mock.shop_squarespace_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_squarespace_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER squarespace_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'squarespace')
EXECUTE FUNCTION mock.process_raw_squarespace_event();
CREATE OR REPLACE FUNCTION mock.process_raw_tiktok_event() RETURNS TRIGGER AS $process_raw_tiktok_event$
BEGIN
INSERT INTO mock.shop_tiktok_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_tiktok_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER tiktok_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'tiktok')
EXECUTE FUNCTION mock.process_raw_tiktok_event();
CREATE OR REPLACE FUNCTION mock.process_raw_walmart_marketplace_event() RETURNS TRIGGER AS $process_raw_walmart_marketplace_event$
BEGIN
INSERT INTO mock.shop_walmart_marketplace_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_walmart_marketplace_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER walmart_marketplace_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'walmart_marketplace')
EXECUTE FUNCTION mock.process_raw_walmart_marketplace_event();
CREATE OR REPLACE FUNCTION mock.process_raw_wix_event() RETURNS TRIGGER AS $process_raw_wix_event$
BEGIN
INSERT INTO mock.shop_wix_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_wix_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER wix_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'wix')
EXECUTE FUNCTION mock.process_raw_wix_event();
CREATE OR REPLACE FUNCTION mock.process_raw_woo_commerce_event() RETURNS TRIGGER AS $process_raw_woo_commerce_event$
BEGIN
INSERT INTO mock.shop_woo_commerce_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_woo_commerce_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER woo_commerce_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'woo_commerce')
EXECUTE FUNCTION mock.process_raw_woo_commerce_event();
CREATE OR REPLACE FUNCTION mock.process_raw_zoho_event() RETURNS TRIGGER AS $process_raw_zoho_event$
BEGIN
INSERT INTO mock.shop_zoho_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
RETURN NULL;
END;
$process_raw_zoho_event$ LANGUAGE plpgsql;
CREATE OR REPLACE TRIGGER zoho_store_events
AFTER INSERT ON mock.raw_shop_events
FOR EACH ROW
WHEN (NEW.platform = 'zoho')
EXECUTE FUNCTION mock.process_raw_zoho_event();
COMMIT;
@@ -0,0 +1,9 @@
BEGIN;
DROP TABLE mock.sale_form_shop_selection;
DROP TABLE mock.refund_form_shop_selection;
DROP TABLE mock.count_form_shop_selection;
COMMIT;
@@ -0,0 +1,23 @@
BEGIN;
CREATE TABLE mock.sale_form_shop_selection (
account_id INTEGER PRIMARY KEY,
platform PLATFORM NOT NULL,
shop_id TEXT NOT NULL
);
CREATE TABLE mock.refund_form_shop_selection (
account_id INTEGER PRIMARY KEY,
platform PLATFORM NOT NULL,
shop_id TEXT NOT NULL
);
CREATE TABLE mock.count_form_shop_selection (
account_id INTEGER PRIMARY KEY,
platform PLATFORM NOT NULL,
shop_id TEXT NOT NULL
);
COMMIT;
@@ -0,0 +1,58 @@
BEGIN;
ALTER TABLE mock.shop_amazon_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_amazon_events_by_timestamp;
ALTER TABLE mock.shop_big_cartel_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_big_cartel_events_by_timestamp;
ALTER TABLE mock.shop_ebay_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_ebay_events_by_timestamp;
ALTER TABLE mock.shop_ecwid_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_ecwid_events_by_timestamp;
ALTER TABLE mock.shop_etsy_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_etsy_events_by_timestamp;
ALTER TABLE mock.shop_shopify_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_shopify_events_by_timestamp;
ALTER TABLE mock.shop_square_online_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_square_online_events_by_timestamp;
ALTER TABLE mock.shop_squarespace_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_squarespace_events_by_timestamp;
ALTER TABLE mock.shop_tiktok_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_tiktok_events_by_timestamp;
ALTER TABLE mock.shop_walmart_marketplace_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_walmart_marketplace_events_by_timestamp;
ALTER TABLE mock.shop_wix_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_wix_events_by_timestamp;
ALTER TABLE mock.shop_woo_commerce_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_woo_commerce_events_by_timestamp;
ALTER TABLE mock.shop_zoho_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX mock.shop_zoho_events_by_timestamp;
COMMIT;
@@ -0,0 +1,345 @@
BEGIN;
ALTER TABLE mock.shop_amazon_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_amazon_events_by_timestamp ON mock.shop_amazon_events (shop_id, event_timestamp);
CREATE INDEX shop_amazon_events_by_timestamp_unprocessed ON mock.shop_amazon_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_big_cartel_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_big_cartel_events_by_timestamp ON mock.shop_big_cartel_events (shop_id, event_timestamp);
CREATE INDEX shop_big_cartel_events_by_timestamp_unprocessed ON mock.shop_big_cartel_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_ebay_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_ebay_events_by_timestamp ON mock.shop_ebay_events (shop_id, event_timestamp);
CREATE INDEX shop_ebay_events_by_timestamp_unprocessed ON mock.shop_ebay_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_ecwid_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_ecwid_events_by_timestamp ON mock.shop_ecwid_events (shop_id, event_timestamp);
CREATE INDEX shop_ecwid_events_by_timestamp_unprocessed ON mock.shop_ecwid_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_etsy_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_etsy_events_by_timestamp ON mock.shop_etsy_events (shop_id, event_timestamp);
CREATE INDEX shop_etsy_events_by_timestamp_unprocessed ON mock.shop_etsy_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_shopify_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_shopify_events_by_timestamp ON mock.shop_shopify_events (shop_id, event_timestamp);
CREATE INDEX shop_shopify_events_by_timestamp_unprocessed ON mock.shop_shopify_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_square_online_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_square_online_events_by_timestamp ON mock.shop_square_online_events (shop_id, event_timestamp);
CREATE INDEX shop_square_online_events_by_timestamp_unprocessed ON mock.shop_square_online_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_squarespace_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_squarespace_events_by_timestamp ON mock.shop_squarespace_events (shop_id, event_timestamp);
CREATE INDEX shop_squarespace_events_by_timestamp_unprocessed ON mock.shop_squarespace_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_tiktok_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_tiktok_events_by_timestamp ON mock.shop_tiktok_events (shop_id, event_timestamp);
CREATE INDEX shop_tiktok_events_by_timestamp_unprocessed ON mock.shop_tiktok_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_walmart_marketplace_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_walmart_marketplace_events_by_timestamp ON mock.shop_walmart_marketplace_events (shop_id, event_timestamp);
CREATE INDEX shop_walmart_marketplace_events_by_timestamp_unprocessed ON mock.shop_walmart_marketplace_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_wix_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_wix_events_by_timestamp ON mock.shop_wix_events (shop_id, event_timestamp);
CREATE INDEX shop_wix_events_by_timestamp_unprocessed ON mock.shop_wix_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_woo_commerce_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_woo_commerce_events_by_timestamp ON mock.shop_woo_commerce_events (shop_id, event_timestamp);
CREATE INDEX shop_woo_commerce_events_by_timestamp_unprocessed ON mock.shop_woo_commerce_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_zoho_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
CREATE INDEX shop_zoho_events_by_timestamp ON mock.shop_zoho_events (shop_id, event_timestamp);
CREATE INDEX shop_zoho_events_by_timestamp_unprocessed ON mock.shop_zoho_events (shop_id, event_timestamp) WHERE NOT processed;
--- event processing
CREATE OR REPLACE FUNCTION mock.process_raw_amazon_event() RETURNS TRIGGER AS $process_raw_amazon_event$
BEGIN
INSERT INTO mock.shop_amazon_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_amazon_event_inserted', null);
RETURN NULL;
END;
$process_raw_amazon_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_big_cartel_event() RETURNS TRIGGER AS $process_raw_big_cartel_event$
BEGIN
INSERT INTO mock.shop_big_cartel_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_big_cartel_event_inserted', null);
RETURN NULL;
END;
$process_raw_big_cartel_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_ebay_event() RETURNS TRIGGER AS $process_raw_ebay_event$
BEGIN
INSERT INTO mock.shop_ebay_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_ebay_event_inserted', null);
RETURN NULL;
END;
$process_raw_ebay_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_ecwid_event() RETURNS TRIGGER AS $process_raw_ecwid_event$
BEGIN
INSERT INTO mock.shop_ecwid_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_ecwid_event_inserted', null);
RETURN NULL;
END;
$process_raw_ecwid_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_etsy_event() RETURNS TRIGGER AS $process_raw_etsy_event$
BEGIN
INSERT INTO mock.shop_etsy_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_etsy_event_inserted', null);
RETURN NULL;
END;
$process_raw_etsy_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_shopify_event() RETURNS TRIGGER AS $process_raw_shopify_event$
BEGIN
INSERT INTO mock.shop_shopify_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_shopify_event_inserted', null);
RETURN NULL;
END;
$process_raw_shopify_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_square_online_event() RETURNS TRIGGER AS $process_raw_square_online_event$
BEGIN
INSERT INTO mock.shop_square_online_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_square_online_event_inserted', null);
RETURN NULL;
END;
$process_raw_square_online_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_squarespace_event() RETURNS TRIGGER AS $process_raw_squarespace_event$
BEGIN
INSERT INTO mock.shop_squarespace_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_squarespace_event_inserted', null);
RETURN NULL;
END;
$process_raw_squarespace_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_tiktok_event() RETURNS TRIGGER AS $process_raw_tiktok_event$
BEGIN
INSERT INTO mock.shop_tiktok_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_tiktok_event_inserted', null);
RETURN NULL;
END;
$process_raw_tiktok_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_walmart_marketplace_event() RETURNS TRIGGER AS $process_raw_walmart_marketplace_event$
BEGIN
INSERT INTO mock.shop_walmart_marketplace_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_walmart_marketplace_event_inserted', null);
RETURN NULL;
END;
$process_raw_walmart_marketplace_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_wix_event() RETURNS TRIGGER AS $process_raw_wix_event$
BEGIN
INSERT INTO mock.shop_wix_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_wix_event_inserted', null);
RETURN NULL;
END;
$process_raw_wix_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_woo_commerce_event() RETURNS TRIGGER AS $process_raw_woo_commerce_event$
BEGIN
INSERT INTO mock.shop_woo_commerce_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_woo_commerce_event_inserted', null);
RETURN NULL;
END;
$process_raw_woo_commerce_event$ LANGUAGE plpgsql;
CREATE OR REPLACE FUNCTION mock.process_raw_zoho_event() RETURNS TRIGGER AS $process_raw_zoho_event$
BEGIN
INSERT INTO mock.shop_zoho_events (
platform,
shop_id,
event_timestamp,
event_id
)
SELECT
NEW.platform,
NEW.shop_id,
NEW.event_timestamp,
NEW.event_id;
PERFORM pg_notify('mock_shop_zoho_event_inserted', null);
RETURN NULL;
END;
$process_raw_zoho_event$ LANGUAGE plpgsql;
COMMIT;
@@ -0,0 +1,32 @@
BEGIN;
DROP VIEW mock.shop_amazon_listing_counts;
DROP VIEW mock.shop_amazon_listing_event_sequence;
DROP VIEW mock.shop_big_cartel_listing_counts;
DROP VIEW mock.shop_big_cartel_listing_event_sequence;
DROP VIEW mock.shop_ebay_listing_counts;
DROP VIEW mock.shop_ebay_listing_event_sequence;
DROP VIEW mock.shop_ecwid_listing_counts;
DROP VIEW mock.shop_ecwid_listing_event_sequence;
DROP VIEW mock.shop_etsy_listing_counts;
DROP VIEW mock.shop_etsy_listing_event_sequence;
DROP VIEW mock.shop_shopify_listing_counts;
DROP VIEW mock.shop_shopify_listing_event_sequence;
DROP VIEW mock.shop_square_online_listing_counts;
DROP VIEW mock.shop_square_online_listing_event_sequence;
DROP VIEW mock.shop_squarespace_listing_counts;
DROP VIEW mock.shop_squarespace_listing_event_sequence;
DROP VIEW mock.shop_tiktok_listing_counts;
DROP VIEW mock.shop_tiktok_listing_event_sequence;
DROP VIEW mock.shop_walmart_marketplace_listing_counts;
DROP VIEW mock.shop_walmart_marketplace_listing_event_sequence;
DROP VIEW mock.shop_wix_listing_counts;
DROP VIEW mock.shop_wix_listing_event_sequence;
DROP VIEW mock.shop_woo_commerce_listing_counts;
DROP VIEW mock.shop_woo_commerce_listing_event_sequence;
DROP VIEW mock.shop_zoho_listing_counts;
DROP VIEW mock.shop_zoho_listing_event_sequence;
COMMIT;
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,14 @@
BEGIN;
ALTER TABLE oauth_tokens
ALTER COLUMN id_token_custom_claims_updated_at
TYPE TEXT
USING id_token_custom_claims_updated_at::TEXT;
ALTER TABLE oauth_tokens
ADD COLUMN claims JSONB NOT NULL DEFAULT '{}'::jsonb;
ALTER TABLE oauth_tokens
ALTER COLUMN claims DROP DEFAULT;
COMMIT;
@@ -0,0 +1,16 @@
BEGIN;
-- Reconciles oauth_tokens with the schema the application code actually
-- expects. Neither of these two changes was ever committed as a migration
-- despite being applied by hand at some point - see the "claims" column
-- below, which no application code reads or writes.
ALTER TABLE oauth_tokens
DROP COLUMN claims;
ALTER TABLE oauth_tokens
ALTER COLUMN id_token_custom_claims_updated_at
TYPE TIMESTAMPTZ
USING id_token_custom_claims_updated_at::TIMESTAMPTZ;
COMMIT;
@@ -0,0 +1,18 @@
BEGIN;
ALTER TABLE mock.shop_amazon_events
ADD COLUMN processed BOOLEAN DEFAULT FALSE,
ADD COLUMN processed_successfully BOOLEAN DEFAULT FALSE;
UPDATE mock.shop_amazon_events
SET processed = (processed_at IS NOT NULL),
processed_successfully = (processed_at IS NOT NULL);
DROP INDEX IF EXISTS mock.shop_amazon_events_by_timestamp_unprocessed;
CREATE INDEX shop_amazon_events_by_timestamp_unprocessed ON mock.shop_amazon_events (shop_id, event_timestamp) WHERE NOT processed;
ALTER TABLE mock.shop_amazon_events
DROP COLUMN notified_at,
DROP COLUMN processed_at;
COMMIT;
@@ -0,0 +1,29 @@
BEGIN;
-- Replaces the boolean processed/processed_successfully pair with a
-- three-state model driven by two nullable timestamps:
-- unprocessed: notified_at IS NULL
-- notified: notified_at IS NOT NULL AND processed_at IS NULL
-- processed: processed_at IS NOT NULL
-- The dispatcher sets notified_at each time it hands an event to the
-- listener (initially, and again on retry if the listener never acks).
-- Only the listener's ack, via its own timestamped write, sets
-- processed_at - it's a separate write specifically so it never races the
-- transaction that recorded notified_at.
ALTER TABLE mock.shop_amazon_events
ADD COLUMN notified_at TIMESTAMPTZ,
ADD COLUMN processed_at TIMESTAMPTZ;
UPDATE mock.shop_amazon_events
SET processed_at = NOW()
WHERE processed;
ALTER TABLE mock.shop_amazon_events
DROP COLUMN processed,
DROP COLUMN processed_successfully;
DROP INDEX IF EXISTS mock.shop_amazon_events_by_timestamp_unprocessed;
CREATE INDEX shop_amazon_events_by_timestamp_unprocessed ON mock.shop_amazon_events (shop_id, event_timestamp) WHERE processed_at IS NULL;
COMMIT;
File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 414 KiB

After

Width:  |  Height:  |  Size: 471 KiB

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

Before

Width:  |  Height:  |  Size: 59 KiB

After

Width:  |  Height:  |  Size: 61 KiB

+8
View File
@@ -51,6 +51,12 @@ entity "**etsy_users**" {
*""shop_id"": //integer //
}
entity "**mock_mode**" {
+ ""account_id"": //integer [PK][FK]//
--
*""mock_mode"": //boolean //
}
entity "**oauth_login_states**" {
+ ""state"": //bytea [PK]//
--
@@ -149,6 +155,8 @@ entity "**wix_store_events**" {
"**etsy_users**" }-- "**accounts**"
"**mock_mode**" ||-|| "**accounts**"
"**oauth_tokens**" }-- "**oauth_users**"
"**sync_group_listing_drafts**" }-- "**accounts**"
+332 -2
View File
@@ -5,11 +5,13 @@ package accounts
import (
"context"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
@@ -225,6 +227,44 @@ func (db *Store) GetAccountByEmail(ctx context.Context, email string) (Account,
}, nil
}
func (db *Store) GetAccountIDByMockPlatformAndShopID(ctx context.Context, platform Platform, shopID string) (int64, error) {
info, ok := getMockShopSchemaInfo(platform)
if !ok {
return 0, fmt.Errorf("unrecognized platform: %w", consts.ErrNotFound)
}
rows, err := db.db.Query(
ctx,
fmt.Sprintf(
`
SELECT
account_id
FROM
%s
WHERE
shop_id = @shop_id
`,
info.shopTable,
),
pgx.NamedArgs{
"shop_id": shopID,
},
)
if err != nil {
return 0, fmt.Errorf("failed to perform query: %w", err)
}
acctID, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int64])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return 0, consts.ErrNotFound
}
return 0, fmt.Errorf("failed to scan rows: %w", err)
}
return acctID, nil
}
func (db *Store) GetUserAndAccountByAccessToken(ctx context.Context, accessToken string) (OAuthUser, *Account, error) {
rows, err := db.db.Query(
ctx,
@@ -1428,8 +1468,6 @@ func (db *Store) SetListingInListingInMockSyncGroupBeingEdited(ctx context.Conte
default:
return fmt.Errorf("unexpected number of rows affected: %d", n)
}
return nil
}
return consts.ErrNotFound
@@ -1461,6 +1499,290 @@ func (db *Store) DeleteListingInListingInMockSyncGroupBeingEdited(ctx context.Co
return nil
}
// if err := s.accts.SetShopInMockSaleForm(c, acctID, platform, shopID); err != nil {
func (db *Store) SetShopInMockSaleForm(ctx context.Context, acctID int64, platform Platform, shopID string) error {
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.sale_form_shop_selection (
account_id,
platform,
shop_id
)
VALUES (
@account_id,
@platform,
@shop_id
)
ON CONFLICT (account_id) DO UPDATE
SET platform = EXCLUDED.platform,
shop_id = EXCLUDED.shop_id
`,
pgx.NamedArgs{
"account_id": acctID,
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (db *Store) SetShopInMockRefundForm(ctx context.Context, acctID int64, platform Platform, shopID string) error {
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.refund_form_shop_selection (
account_id,
platform,
shop_id
)
VALUES (
@account_id,
@platform,
@shop_id
)
ON CONFLICT (account_id) DO UPDATE
SET platform = EXCLUDED.platform,
shop_id = EXCLUDED.shop_id
`,
pgx.NamedArgs{
"account_id": acctID,
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
func (db *Store) SetShopInMockCountForm(ctx context.Context, acctID int64, platform Platform, shopID string) error {
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.count_form_shop_selection (
account_id,
platform,
shop_id
)
VALUES (
@account_id,
@platform,
@shop_id
)
ON CONFLICT (account_id) DO UPDATE
SET platform = EXCLUDED.platform,
shop_id = EXCLUDED.shop_id
`,
pgx.NamedArgs{
"account_id": acctID,
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
type MockEventFormValues struct {
SaleShopID string
SalePlatform Platform
RefundShopID string
RefundPlatform Platform
CountShopID string
CountPlatform Platform
}
func (db *Store) GetMockEventFormValues(ctx context.Context, acctID int64) (*MockEventFormValues, error) {
rows, err := db.db.Query(
ctx,
`
SELECT
sf.platform AS sale_platform,
sf.shop_id AS sale_shop_id,
rf.platform AS refund_platform,
rf.shop_id AS refund_shop_id,
cf.platform AS count_platform,
cf.shop_id AS count_shop_id
FROM
mock.sale_form_shop_selection AS sf
FULL OUTER JOIN
mock.refund_form_shop_selection AS rf
USING
(account_id)
FULL OUTER JOIN
mock.count_form_shop_selection AS cf
USING
(account_id)
WHERE
sf.account_id = @account_id
`,
pgx.NamedArgs{
"account_id": acctID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
v, err := pgx.CollectExactlyOneRow(rows, pgx.RowToStructByNameLax[struct {
Sale_shop_id pgtype.Text
Sale_platform *Platform
Refund_shop_id pgtype.Text
Refund_platform *Platform
Count_shop_id pgtype.Text
Count_platform *Platform
}])
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
return &MockEventFormValues{}, nil
}
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
return &MockEventFormValues{
SaleShopID: v.Sale_shop_id.String,
SalePlatform: deref(v.Sale_platform),
RefundShopID: v.Refund_shop_id.String,
RefundPlatform: deref(v.Refund_platform),
CountShopID: v.Count_shop_id.String,
CountPlatform: deref(v.Count_platform),
}, nil
}
func (db *Store) SaveNewMockSale(ctx context.Context, acctID int64, platform Platform, shopID, listingID string, count int) (uuid.UUID, error) {
eventID := uuid.New()
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.raw_shop_events (
platform,
shop_id,
event_timestamp,
event_id,
raw_payload
)
VALUES (
@platform,
@shop_id,
NOW(),
@event_id,
@raw_payload
)
`,
pgx.NamedArgs{
"platform": platform,
"shop_id": shopID,
"event_id": eventID,
"raw_payload": json.RawMessage(fmt.Sprintf(`{
"type": "sale",
"listingID": %q,
"count": %d
}`, listingID, count)),
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query: %w", err)
}
return eventID, nil
}
func (db *Store) SaveNewMockRefund(ctx context.Context, acctID int64, platform Platform, shopID, listingID string, count int) (uuid.UUID, error) {
eventID := uuid.New()
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.raw_shop_events (
platform,
shop_id,
event_timestamp,
event_id,
raw_payload
)
VALUES (
@platform,
@shop_id,
NOW(),
@event_id,
@raw_payload
)
`,
pgx.NamedArgs{
"platform": platform,
"shop_id": shopID,
"event_id": eventID,
"raw_payload": json.RawMessage(fmt.Sprintf(`{
"type": "refund",
"listingID": %q,
"count": %d
}`, listingID, count)),
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query: %w", err)
}
return eventID, nil
}
func (db *Store) SaveNewMockInventoryReset(ctx context.Context, acctID int64, platform Platform, shopID, listingID string, count int) (uuid.UUID, error) {
eventID := uuid.New()
_, err := db.db.Exec(
ctx,
`
INSERT INTO
mock.raw_shop_events (
platform,
shop_id,
event_timestamp,
event_id,
raw_payload
)
VALUES (
@platform,
@shop_id,
NOW(),
@event_id,
@raw_payload
)
`,
pgx.NamedArgs{
"platform": platform,
"shop_id": shopID,
"event_id": eventID,
"raw_payload": json.RawMessage(fmt.Sprintf(`{
"type": "inventory-reset",
"listingID": %q,
"count": %d
}`, listingID, count)),
},
)
if err != nil {
return uuid.Nil, fmt.Errorf("failed to perform query: %w", err)
}
return eventID, nil
}
// additional context
func (db *Store) GetAccountPointerByUserID(ctx context.Context, userID string) (*Account, error) {
@@ -1481,3 +1803,11 @@ func (db *Store) beginReadonlyTxn(ctx context.Context, cb func(pgx.Tx) error) er
func (db *Store) beginTxn(ctx context.Context, cb func(pgx.Tx) error) error {
return pgx.BeginFunc(ctx, db.db, cb)
}
func deref[T any](ptr *T) T {
if ptr != nil {
return *ptr
}
var zero T
return zero
}
+103
View File
@@ -0,0 +1,103 @@
package accounts_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/internal/testdb"
)
func TestCreateAccount(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
userID := testdb.NewUserID(t)
testdb.SeedOAuthUser(t, pool, userID)
email := userID + "@example.com"
acct, err := store.CreateAccount(ctx, userID, email)
require.NoError(t, err, "CreateAccount()")
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
})
assert.NotZero(t, acct.AccountID, "CreateAccount() returned a zero AccountID")
assert.Equal(t, userID, acct.UserID, "CreateAccount() UserID")
assert.Equal(t, email, acct.Email, "CreateAccount() Email")
got, err := store.GetAccount(ctx, acct.AccountID)
require.NoError(t, err, "GetAccount()")
assert.Equal(t, acct, got, "GetAccount()")
}
func TestCreateAccount_DuplicateUserIsConflict(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
userID := testdb.NewUserID(t)
testdb.SeedOAuthUser(t, pool, userID)
acct, err := store.CreateAccount(ctx, userID, userID+"@example.com")
require.NoError(t, err, "first CreateAccount()")
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
})
_, err = store.CreateAccount(ctx, userID, userID+"-other@example.com")
require.ErrorIs(t, err, consts.ErrConflict, "second CreateAccount()")
}
func TestGetAccount_NotFound(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
_, err := store.GetAccount(ctx, -1)
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccount()")
}
func TestGetUserAndAccountByAccessToken(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
userID := testdb.NewUserID(t)
accessToken := testdb.SeedOAuthSession(t, pool, userID)
// before an account exists: user resolves, account does not.
user, acct, err := store.GetUserAndAccountByAccessToken(ctx, accessToken)
require.NoError(t, err, "GetUserAndAccountByAccessToken() before account creation")
assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID")
assert.Nil(t, acct, "GetUserAndAccountByAccessToken() Account should be nil before an account is created")
created, err := store.CreateAccount(ctx, userID, userID+"@example.com")
require.NoError(t, err, "CreateAccount()")
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", created.AccountID)
})
// after an account exists: both resolve.
user, acct, err = store.GetUserAndAccountByAccessToken(ctx, accessToken)
require.NoError(t, err, "GetUserAndAccountByAccessToken() after account creation")
assert.Equal(t, userID, user.UserID, "GetUserAndAccountByAccessToken() UserID")
if assert.NotNil(t, acct, "GetUserAndAccountByAccessToken() Account should be set after an account is created") {
assert.Equal(t, created.AccountID, acct.AccountID, "GetUserAndAccountByAccessToken() Account.AccountID")
}
}
func TestGetUserAndAccountByAccessToken_UnknownToken(t *testing.T) {
pool := testdb.Pool(t)
store := accounts.NewStore(testdb.Logger(), pool)
ctx := context.Background()
_, _, err := store.GetUserAndAccountByAccessToken(ctx, "no-such-token-"+testdb.NewUserID(t))
require.ErrorIs(t, err, consts.ErrNotFound, "GetUserAndAccountByAccessToken()")
}
+16
View File
@@ -105,6 +105,22 @@ func (v_ctx *StoreWithContext) DeleteListingInListingInMockSyncGroupBeingEdited(
return v_ctx.Store.DeleteListingInListingInMockSyncGroupBeingEdited(v_ctx.ctx, acctID, orderIndex)
}
func (v_ctx *StoreWithContext) SetShopInMockSaleForm(acctID int64, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInMockSaleForm(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) SetShopInMockRefundForm(acctID int64, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInMockRefundForm(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) SetShopInMockCountForm(acctID int64, platform Platform, shopID string) error {
return v_ctx.Store.SetShopInMockCountForm(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) GetMockEventFormValues(acctID int64) (*MockEventFormValues, error) {
return v_ctx.Store.GetMockEventFormValues(v_ctx.ctx, acctID)
}
func (v_ctx *StoreWithContext) GetAccountPointerByUserID(userID string) (*Account, error) {
return v_ctx.Store.GetAccountPointerByUserID(v_ctx.ctx, userID)
}
-8
View File
@@ -1235,11 +1235,3 @@ func (db *Store) listMockSyncGroupListings(ctx context.Context, tx pgx.Tx, acctI
return listings, nil
}
func deref[T any](ptr *T) T {
if ptr == nil {
var zero T
return zero
}
return *ptr
}
+377
View File
@@ -0,0 +1,377 @@
package amazon
import (
"context"
"errors"
"fmt"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/logging"
"sync"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
type (
Mocks struct {
log *logging.Logger
db *pgxpool.Pool
listener MockEventListener
// notifyRetryAfter is how long an event can sit "notified" (handed
// to the listener) without an ack before the dispatcher notifies
// it again. Deliberately a field, not a stored notify_again_at
// column, so the cadence can vary (or be tuned in tests) without
// touching any row.
notifyRetryAfter time.Duration
// pollInterval is the fallback cadence ProcessEvents' main loop
// checks for unprocessed/retry-due events on its own, independent
// of Postgres NOTIFY - a safety net for events that end up in
// mock.shop_amazon_events without ever going through the
// mock.raw_shop_events insert+trigger path that fires NOTIFY.
pollInterval time.Duration
ready chan struct{}
readyOnce sync.Once
}
// MockEventListener is handed events as they arrive. Notify should
// return promptly - any real work (including calling out to another
// system) can happen after it returns - but it must eventually call
// ack exactly once, when the event has been fully handled. Until ack
// is called, the dispatcher considers the event merely "notified"
// and will call Notify again after notifyRetryAfter, so ack may be
// called more than once total across retries; only the first call
// has any effect; further calls are safe (see (*Mocks).ack).
MockEventListener interface {
Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error
}
)
const (
eventChannelName = "mock_shop_amazon_event_inserted"
defaultNotifyRetryAfter = 30 * time.Second
defaultPollInterval = time.Minute
// backoff for reconnecting the LISTEN connection after it fails for a
// reason other than shutdown (e.g. a dropped connection, a Postgres
// restart). Doubles on each consecutive failure, capped, and resets
// once a reconnect actually succeeds.
initialListenReconnectBackoff = 1 * time.Second
maxListenReconnectBackoff = 30 * time.Second
)
func NewMocks(log *logging.Logger, db *pgxpool.Pool) *Mocks {
return &Mocks{
log: log,
db: db,
notifyRetryAfter: defaultNotifyRetryAfter,
pollInterval: defaultPollInterval,
ready: make(chan struct{}),
}
}
func (m *Mocks) SetListener(l MockEventListener) *Mocks {
m.listener = l
return m
}
// WithNotifyRetryAfter overrides how long an event can sit "notified"
// without an ack before being notified again. Mainly useful for tests that
// don't want to wait out the default.
func (m *Mocks) WithNotifyRetryAfter(d time.Duration) *Mocks {
m.notifyRetryAfter = d
return m
}
// WithPollInterval overrides how often ProcessEvents checks for
// unprocessed/retry-due events on its own, independent of NOTIFY. Mainly
// useful for tests that don't want to wait out the default.
func (m *Mocks) WithPollInterval(d time.Duration) *Mocks {
m.pollInterval = d
return m
}
// Ready returns a channel that's closed once ProcessEvents has registered
// its Postgres LISTEN and is actively watching for notifications. Callers
// that need to know the reactive path is live - tests in particular -
// should wait on this instead of guessing with a sleep.
func (m *Mocks) Ready() <-chan struct{} {
return m.ready
}
func (m *Mocks) ProcessEvents(ctx context.Context) error {
notifCh, errCh := m.listenForNotifications(ctx)
backoff := initialListenReconnectBackoff
for {
m.log.Debug("processing events")
if err := m.processUnprocessedEvents(ctx); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return fmt.Errorf("error occurred while processing events: %w", err)
}
select {
case <-ctx.Done():
return nil
case err := <-errCh:
var stop bool
if notifCh, errCh, backoff, stop = m.reconnectOrStop(ctx, err, backoff); stop {
return nil
}
case _, ok := <-notifCh:
if !ok {
var stop bool
if notifCh, errCh, backoff, stop = m.reconnectOrStop(ctx, <-errCh, backoff); stop {
return nil
}
continue
}
m.log.Debug("woke up: notification received")
case <-time.After(m.pollInterval):
m.log.Debug("woke up: poll interval elapsed")
}
}
}
// reconnectOrStop handles a LISTEN-connection failure. A nil listenErr (or
// ctx already being done) means this is an ordinary shutdown, not a
// failure - stop is true and the caller should return. Otherwise it logs a
// warning, waits out backoff, and re-establishes LISTEN: on success the
// backoff resets to its initial value for next time; on immediate failure
// (e.g. the pool itself is unreachable) it doubles, capped, so repeated
// failures back off rather than hot-looping.
func (m *Mocks) reconnectOrStop(
ctx context.Context,
listenErr error,
backoff time.Duration,
) (notifCh <-chan struct{}, errCh <-chan error, nextBackoff time.Duration, stop bool) {
if listenErr == nil || ctx.Err() != nil {
return nil, nil, backoff, true
}
m.log.Warn("lost connection while listening for notifications; reconnecting", "error", listenErr, "retry_in", backoff)
select {
case <-ctx.Done():
return nil, nil, backoff, true
case <-time.After(backoff):
}
notifCh, errCh = m.listenForNotifications(ctx)
if notifCh != nil {
return notifCh, errCh, initialListenReconnectBackoff, false
}
nextBackoff = backoff * 2
if nextBackoff > maxListenReconnectBackoff {
nextBackoff = maxListenReconnectBackoff
}
return notifCh, errCh, nextBackoff, false
}
func (m *Mocks) listenForNotifications(ctx context.Context) (<-chan struct{}, <-chan error) {
errCh := make(chan error, 1)
pc, err := m.db.Acquire(ctx)
if err != nil {
errCh <- fmt.Errorf("failed to acquire a connection: %w", err)
return nil, errCh
}
conn := pc.Conn()
if _, err := conn.Exec(ctx, fmt.Sprintf("LISTEN %s", eventChannelName)); err != nil {
errCh <- fmt.Errorf("failed to start listening for notifications: %w", err)
return nil, errCh
}
m.readyOnce.Do(func() { close(m.ready) })
ch := make(chan struct{})
go func() (err error) {
defer func() {
pc.Release()
if err != nil {
errCh <- err
}
close(ch)
close(errCh)
}()
for {
if _, err := conn.WaitForNotification(ctx); err != nil {
if errors.Is(err, context.Canceled) {
return nil
}
return fmt.Errorf("error occurred while waiting for the next notification: %w", err)
}
select {
case ch <- struct{}{}:
case <-ctx.Done():
return nil
}
}
}()
return ch, errCh
}
// processUnprocessedEvents finds every event that's either brand new or
// has been "notified" for longer than notifyRetryAfter without an ack, and
// (re)dispatches each to the listener. It never blocks on the listener:
// notified_at is committed first, and only then - after the transaction
// that recorded it has actually committed - is the listener told, from a
// goroutine that isn't tied to this function's lifetime.
func (m *Mocks) processUnprocessedEvents(ctx context.Context) error {
for done := false; !done; {
var toDispatch []raw_events.Event
err := pgx.BeginFunc(ctx, m.db, func(tx pgx.Tx) error {
rows, err := tx.Query(
ctx,
`
SELECT
shop_id,
event_id,
event_timestamp
FROM
mock.shop_amazon_events
WHERE
processed_at IS NULL
AND (notified_at IS NULL OR notified_at < @retry_after)
ORDER BY
shop_id, event_timestamp
LIMIT
100
`,
pgx.NamedArgs{
"retry_after": time.Now().Add(-m.notifyRetryAfter),
},
)
if err != nil {
return fmt.Errorf("failed to to perform query: %w", err)
}
evts, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Shop_id string
Event_id string
Event_timestamp time.Time
}])
if err != nil {
return fmt.Errorf("failed to scan rows: %w", err)
}
for _, e := range evts {
ev := raw_events.Event{
Platform: string(accounts.Amazon),
StoreID: e.Shop_id,
EventID: e.Event_id,
EventTimestamp: e.Event_timestamp,
}
if err := markNotified(ctx, tx, ev); err != nil {
return fmt.Errorf("failed to mark event notified: %w", err)
}
toDispatch = append(toDispatch, ev)
}
if done = len(evts) == 0; !done {
m.log.Infof("notified listener for %d events", len(evts))
}
return nil
})
if err != nil {
return err
}
for _, e := range toDispatch {
m.dispatch(ctx, e)
}
}
return nil
}
// dispatch hands e to the configured listener without blocking. The
// listener may call ack synchronously (e.g. inline once its own work is
// done) or from elsewhere entirely, arbitrarily later - ack does its own
// independent, idempotent write, so it's never at risk of racing (or being
// silently lost to) this call's context being cancelled.
func (m *Mocks) dispatch(ctx context.Context, e raw_events.Event) {
if m.listener == nil {
return
}
go func() {
if err := m.listener.Notify(ctx, e, func(ackCtx context.Context) error {
return ack(ackCtx, m.db, e)
}); err != nil {
m.log.Errorf("error incurred by event listener: %v", err)
}
}()
}
func markNotified(ctx context.Context, tx pgx.Tx, e raw_events.Event) error {
_, err := tx.Exec(
ctx,
`
UPDATE
mock.shop_amazon_events
SET
notified_at = NOW()
WHERE
shop_id = @shop_id
AND event_id = @event_id
AND event_timestamp = @event_timestamp
`,
pgx.NamedArgs{
"shop_id": e.StoreID,
"event_id": e.EventID,
"event_timestamp": e.EventTimestamp,
},
)
return err
}
// ack records that e has been fully handled. It's idempotent - calling it
// more than once (e.g. because a retried notification also eventually acks)
// just leaves processed_at at whenever it was first set.
func ack(ctx context.Context, db *pgxpool.Pool, e raw_events.Event) error {
_, err := db.Exec(
ctx,
`
UPDATE
mock.shop_amazon_events
SET
processed_at = NOW()
WHERE
shop_id = @shop_id
AND event_id = @event_id
AND event_timestamp = @event_timestamp
AND processed_at IS NULL
`,
pgx.NamedArgs{
"shop_id": e.StoreID,
"event_id": e.EventID,
"event_timestamp": e.EventTimestamp,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
return nil
}
+459
View File
@@ -0,0 +1,459 @@
package amazon
import (
"context"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/internal/testdb"
)
// notifySpy is a MockEventListener that records every event it's notified
// about, along with the ack callback it was given. By default it acks
// immediately (autoAck=true), matching a well-behaved listener; tests
// exercising the retry path set autoAck=false to simulate a listener that
// received the notification but hasn't finished yet, and call ackAt
// explicitly once it "finishes".
type notifySpy struct {
mu sync.Mutex
events []raw_events.Event
acks []func(context.Context) error
completedCount int // Notify calls that have returned, including any auto-ack
autoAck bool
}
func newNotifySpy() *notifySpy {
return &notifySpy{autoAck: true}
}
func (s *notifySpy) Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error {
s.mu.Lock()
s.events = append(s.events, e)
s.acks = append(s.acks, ack)
autoAck := s.autoAck
s.mu.Unlock()
var ackErr error
if autoAck {
ackErr = ack(ctx)
}
s.mu.Lock()
s.completedCount++
s.mu.Unlock()
return ackErr
}
func (s *notifySpy) eventsSnapshot() []raw_events.Event {
s.mu.Lock()
defer s.mu.Unlock()
return append([]raw_events.Event(nil), s.events...)
}
func (s *notifySpy) setAutoAck(v bool) {
s.mu.Lock()
defer s.mu.Unlock()
s.autoAck = v
}
// ackAt manually invokes the i-th recorded ack callback (0-indexed, in
// Notify call order), simulating the listener finally finishing work it
// had earlier only been notified about.
func (s *notifySpy) ackAt(t *testing.T, i int) {
t.Helper()
s.mu.Lock()
ack := s.acks[i]
s.mu.Unlock()
require.NoError(t, ack(context.Background()), "ack()")
}
// completedCountSnapshot is safe to call from another goroutine (e.g. from
// inside require.Eventually's condition), unlike helpers that call t.Fatal.
func (s *notifySpy) completedCountSnapshot() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.completedCount
}
// waitForCount blocks until at least n Notify calls have fully completed
// (including any auto-ack), cumulatively across the test - safe to call
// more than once with increasing n.
func (s *notifySpy) waitForCount(t *testing.T, n int, timeout time.Duration) {
t.Helper()
require.Eventually(t, func() bool {
return s.completedCountSnapshot() >= n
}, timeout, 5*time.Millisecond, "timed out waiting for %d total completed notifications (got %d)", n, s.completedCountSnapshot())
}
// insertRawAmazonEvent inserts directly into mock.raw_shop_events, the same
// entry point real mock sale/refund/inventory simulations use. A DB trigger
// (mock.process_raw_amazon_event, see migrations 000026/000028) copies the
// row into mock.shop_amazon_events and fires pg_notify on
// mock_shop_amazon_event_inserted - so this one insert exercises the exact
// same path production traffic does, instead of faking the downstream
// table directly.
func insertRawAmazonEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) {
t.Helper()
_, err := pool.Exec(context.Background(), `
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
VALUES ('amazon', $1, NOW(), $2, '{}'::jsonb)
`, shopID, eventID)
require.NoError(t, err, "insert raw amazon event")
}
// insertRawAmazonEvents bulk-inserts n events for shopID in one statement,
// each with a distinct event_id/event_timestamp.
func insertRawAmazonEvents(t *testing.T, pool *pgxpool.Pool, shopID string, n int) {
t.Helper()
_, err := pool.Exec(context.Background(), `
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
SELECT 'amazon', $1, NOW() + (s || ' milliseconds')::interval, 'evt-' || s, '{}'::jsonb
FROM generate_series(1, $2) AS s
`, shopID, n)
require.NoError(t, err, "insert %d raw amazon events", n)
}
// queryIsProcessed has no *testing.T dependency, so it's safe to call from
// require.Eventually's condition function (which testify runs on a
// separate goroutine - t.Fatal/require must only be called from the main
// test goroutine). isProcessed wraps it for direct, main-goroutine use.
func queryIsProcessed(ctx context.Context, pool *pgxpool.Pool, shopID, eventID string) (bool, error) {
var processed bool
err := pool.QueryRow(ctx, `
SELECT processed_at IS NOT NULL
FROM mock.shop_amazon_events
WHERE shop_id = $1 AND event_id = $2
`, shopID, eventID).Scan(&processed)
return processed, err
}
func isProcessed(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
t.Helper()
processed, err := queryIsProcessed(context.Background(), pool, shopID, eventID)
require.NoError(t, err, "check processed state")
return processed
}
// isNotified reports whether the event has been notified (at least once)
// but not yet acked/processed.
func isNotified(t *testing.T, pool *pgxpool.Pool, shopID, eventID string) bool {
t.Helper()
var notified bool
err := pool.QueryRow(context.Background(), `
SELECT notified_at IS NOT NULL AND processed_at IS NULL
FROM mock.shop_amazon_events
WHERE shop_id = $1 AND event_id = $2
`, shopID, eventID).Scan(&notified)
require.NoError(t, err, "check notified state")
return notified
}
func countUnprocessed(t *testing.T, pool *pgxpool.Pool, shopID string) int {
t.Helper()
var n int
err := pool.QueryRow(context.Background(), `
SELECT count(*) FROM mock.shop_amazon_events WHERE shop_id = $1 AND processed_at IS NULL
`, shopID).Scan(&n)
require.NoError(t, err, "count unprocessed events")
return n
}
// cleanupShop registers deletion of every row this test's shopID may have
// produced, in FK-safe order (shop_amazon_events references raw_shop_events).
func cleanupShop(t *testing.T, pool *pgxpool.Pool, shopID string) {
t.Cleanup(func() {
ctx := context.Background()
pool.Exec(ctx, `DELETE FROM mock.shop_amazon_events WHERE shop_id = $1`, shopID)
pool.Exec(ctx, `DELETE FROM mock.raw_shop_events WHERE platform = 'amazon' AND shop_id = $1`, shopID)
})
}
// terminateListenConnection finds the backend holding this package's LISTEN
// registration (identified by its last query text, which Postgres keeps
// showing while the connection sits idle waiting for notifications) and
// forcibly kills it - the same failure mode a dropped connection or a
// Postgres restart produces, so tests can exercise real reconnect behavior
// instead of a simulated one.
func terminateListenConnection(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
ctx := context.Background()
var pid int
err := pool.QueryRow(ctx, `
SELECT pid FROM pg_stat_activity
WHERE query = 'LISTEN ' || $1
ORDER BY backend_start DESC
LIMIT 1
`, eventChannelName).Scan(&pid)
require.NoError(t, err, "find the LISTEN connection's backend pid")
_, err = pool.Exec(ctx, `SELECT pg_terminate_backend($1)`, pid)
require.NoError(t, err, "terminate backend %d", pid)
}
// waitReady blocks until m signals it's actively listening, failing the
// test if that doesn't happen within timeout.
func waitReady(t *testing.T, m *Mocks, timeout time.Duration) {
t.Helper()
select {
case <-m.Ready():
case <-time.After(timeout):
require.Fail(t, "ProcessEvents() did not become ready (LISTEN registered)", "within %v", timeout)
}
}
// waitStopped blocks until ProcessEvents returns on errCh, asserting it
// returns a nil error, failing the test if that doesn't happen within
// timeout.
func waitStopped(t *testing.T, errCh <-chan error, timeout time.Duration) {
t.Helper()
select {
case err := <-errCh:
require.NoError(t, err, "ProcessEvents() after context cancellation")
case <-time.After(timeout):
require.Fail(t, "ProcessEvents() did not return", "within %v of context cancellation", timeout)
}
}
func TestProcessUnprocessedEvents_ProcessesAllEventsAcrossBatches(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
ctx := context.Background()
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
const n = 150 // exceeds the 100-row LIMIT per batch inside processUnprocessedEvents
insertRawAmazonEvents(t, pool, shopID, n)
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
spy.waitForCount(t, n, 5*time.Second)
assert.Zero(t, countUnprocessed(t, pool, shopID),
"countUnprocessed() should be 0 (all %d events should be processed across multiple 100-row batches)", n)
}
func TestProcessUnprocessedEvents_NoListenerConfigured(t *testing.T) {
pool := testdb.Pool(t)
m := NewMocks(testdb.Logger(), pool) // no SetListener call
ctx := context.Background()
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
insertRawAmazonEvent(t, pool, shopID, "evt-1")
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents()")
// with no listener, nothing ever acks - the event is expected to stay
// "notified" forever, not silently marked processed.
assert.False(t, isProcessed(t, pool, shopID, "evt-1"),
"event was marked processed despite no listener being configured to ack it")
assert.True(t, isNotified(t, pool, shopID, "evt-1"),
"event should be in the notified state after being handed off with no listener to ack it")
}
// TestProcessUnprocessedEvents_RetriesUnackedNotification is the core of
// insight #2's fix: a listener that receives a notification but never acks
// gets notified again after notifyRetryAfter, and once it does ack (however
// late), the event settles into processed and stops being retried.
func TestProcessUnprocessedEvents_RetriesUnackedNotification(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
spy.setAutoAck(false)
m := NewMocks(testdb.Logger(), pool).
SetListener(spy).
WithNotifyRetryAfter(50 * time.Millisecond)
ctx := context.Background()
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
insertRawAmazonEvent(t, pool, shopID, "evt-1")
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (1st pass)")
spy.waitForCount(t, 1, 2*time.Second)
require.False(t, isProcessed(t, pool, shopID, "evt-1"), "event was marked processed despite the listener never acking")
require.True(t, isNotified(t, pool, shopID, "evt-1"), "event should be in the notified state after the first dispatch")
// still within notifyRetryAfter: shouldn't be re-notified yet.
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (immediate 2nd pass)")
require.Len(t, spy.eventsSnapshot(), 1, "listener should not have been notified again before notifyRetryAfter elapsed")
time.Sleep(60 * time.Millisecond) // past notifyRetryAfter
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (3rd pass, after retry window)")
spy.waitForCount(t, 2, 2*time.Second)
// the listener "finishes" the first notification late, via the ack it
// was originally handed - not a fresh one from the retry.
spy.ackAt(t, 0)
require.True(t, isProcessed(t, pool, shopID, "evt-1"), "event should be processed once any recorded ack for it is called")
require.NoError(t, m.processUnprocessedEvents(ctx), "processUnprocessedEvents() (4th pass, after ack)")
assert.Len(t, spy.eventsSnapshot(), 2, "listener should not be notified again after being acked")
}
// TestProcessEvents_PollFallbackPicksUpRetryDueEvents proves the poll
// branch of ProcessEvents' select actually causes reprocessing, decoupled
// from NOTIFY entirely: after the one real insert (which does fire NOTIFY,
// same as any other test here), nothing ever triggers another notification
// for the rest of the test. The event becomes retry-due almost immediately
// (notifyRetryAfter is tiny), so the *only* way it can be dispatched a
// second time is the poll timer in the select firing on its own.
func TestProcessEvents_PollFallbackPicksUpRetryDueEvents(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
spy.setAutoAck(false)
m := NewMocks(testdb.Logger(), pool).
SetListener(spy).
WithNotifyRetryAfter(30 * time.Millisecond).
WithPollInterval(60 * time.Millisecond)
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- m.ProcessEvents(ctx)
}()
waitReady(t, m, 5*time.Second)
insertRawAmazonEvent(t, pool, shopID, "evt-1")
// first dispatch, via the real NOTIFY.
spy.waitForCount(t, 1, 2*time.Second)
// second dispatch: nothing will notify again from here on, so this can
// only come from the poll branch of the select waking the loop up on
// its own and finding the event retry-due.
spy.waitForCount(t, 2, 3*time.Second)
cancel()
waitStopped(t, errCh, 5*time.Second)
}
// TestProcessEvents_ReactsToNotification drives the actual long-running
// loop: LISTEN registration, a real Postgres NOTIFY fired by the DB trigger
// on insert, WaitForNotification waking the loop, and the listener callback
// - the full stateful path, not just the deterministic batch-processing
// core covered above.
func TestProcessEvents_ReactsToNotification(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- m.ProcessEvents(ctx)
}()
waitReady(t, m, 5*time.Second)
insertRawAmazonEvent(t, pool, shopID, "evt-1")
require.Eventually(t, func() bool {
processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
return processed
}, 5*time.Second, 20*time.Millisecond,
"event was not processed within 5s of insertion - the reactive LISTEN/NOTIFY wake-up did not fire "+
"(the 1-minute poll fallback would eventually catch it, but this test intentionally doesn't wait that long)")
spy.waitForCount(t, 1, 2*time.Second)
got := spy.eventsSnapshot()[0]
assert.Equal(t, shopID, got.StoreID, "listener notified with unexpected StoreID")
assert.Equal(t, "evt-1", got.EventID, "listener notified with unexpected EventID")
cancel()
waitStopped(t, errCh, 5*time.Second)
}
// TestProcessEvents_ShutsDownOnContextCancel checks the lifecycle in
// isolation, without depending on NOTIFY timing at all - a fast, low-flake
// guard against shutdown regressions (hangs, goroutine leaks) independent
// of whether the reactive path above is working.
func TestProcessEvents_ShutsDownOnContextCancel(t *testing.T) {
pool := testdb.Pool(t)
m := NewMocks(testdb.Logger(), pool)
ctx, cancel := context.WithCancel(context.Background())
errCh := make(chan error, 1)
go func() {
errCh <- m.ProcessEvents(ctx)
}()
waitReady(t, m, 5*time.Second)
cancel()
waitStopped(t, errCh, 5*time.Second)
}
// TestProcessEvents_ReconnectsAfterListenConnectionDrops is insight #4's
// fix: forcibly kills the real backend connection ProcessEvents is
// LISTEN-ing on (the same failure mode a dropped connection or a Postgres
// restart produces) and confirms it reconnects and keeps working on its
// own, rather than the error propagating out of ProcessEvents entirely.
func TestProcessEvents_ReconnectsAfterListenConnectionDrops(t *testing.T) {
pool := testdb.Pool(t)
spy := newNotifySpy()
m := NewMocks(testdb.Logger(), pool).SetListener(spy)
shopID := "test-shop-" + uuid.NewString()
cleanupShop(t, pool, shopID)
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
errCh := make(chan error, 1)
go func() {
errCh <- m.ProcessEvents(ctx)
}()
waitReady(t, m, 5*time.Second)
terminateListenConnection(t, pool)
// ProcessEvents should still be running, just reconnecting (initial
// backoff is 1s) - it must not have returned because of this.
select {
case err := <-errCh:
require.Fail(t, "ProcessEvents() returned after its LISTEN connection was killed, want it to reconnect and keep running",
"err = %v", err)
case <-time.After(2 * time.Second):
}
insertRawAmazonEvent(t, pool, shopID, "evt-1")
require.Eventually(t, func() bool {
processed, _ := queryIsProcessed(context.Background(), pool, shopID, "evt-1")
return processed
}, 5*time.Second, 20*time.Millisecond,
"event was not processed within 5s of insertion after the LISTEN connection was forcibly dropped - "+
"reconnection did not restore the reactive path")
cancel()
waitStopped(t, errCh, 5*time.Second)
}
+27 -25
View File
@@ -17,26 +17,11 @@ import (
"ruben/inventory2/logging"
)
// TODO: move these to a config?
const (
// The URL of our Auth0 Tenant Domain.
// If you're using a Custom Domain, be sure to set this to that value instead.
AUTH0_DOMAIN = "dev-uq3gqy5bdnwxmr6d.us.auth0.com"
// Our Auth0 application"s Client ID.
AUTH0_CLIENT_ID = "JEjrXTQ9fxlTLgp9RgTIACpUk8a2lqNT"
// Our Auth0 application"s Client Secret.
AUTH0_CLIENT_SECRET = "83U-iWdVaNnwk9XDzteo_2VMyOq_l1siKYqg1_2E7jCzgL8MnkaxlysPMcPMGlxA"
// The Callback URL of our application.
AUTH0_CALLBACK_URL = "https://inventory-plus-plus.com/api/auth/login/callback"
)
type (
// Authenticator is used to authenticate our users.
Authenticator struct {
log *logging.Logger
log *logging.Logger
domain string
*oidc.Provider
oauth2.Config
db *pgxpool.Pool
@@ -60,15 +45,19 @@ type (
}
)
// New instantiates the *Authenticator.
// New instantiates an *Authenticator backed by a real Auth0 tenant: it makes
// an OIDC discovery call against domain, so login/callback/logout are fully
// functional. Use NewDev instead when DEV_AUTH_ENABLED is set and no real
// Auth0 app is configured.
func New(
ctx context.Context,
db *pgxpool.Pool,
logger *logging.Logger,
domain, clientID, clientSecret, callbackURL string,
) (*Authenticator, error) {
provider, err := oidc.NewProvider(
ctx,
"https://"+AUTH0_DOMAIN+"/",
"https://"+domain+"/",
)
if err != nil {
return nil, err
@@ -76,11 +65,12 @@ func New(
return &Authenticator{
log: logger,
domain: domain,
Provider: provider,
Config: oauth2.Config{
ClientID: AUTH0_CLIENT_ID,
ClientSecret: AUTH0_CLIENT_SECRET,
RedirectURL: AUTH0_CALLBACK_URL,
ClientID: clientID,
ClientSecret: clientSecret,
RedirectURL: callbackURL,
Endpoint: provider.Endpoint(),
Scopes: []string{oidc.ScopeOpenID, "profile"},
},
@@ -88,6 +78,18 @@ func New(
}, nil
}
// NewDev instantiates an *Authenticator with no real Auth0 tenant behind it:
// no OIDC discovery call is made, and Provider/Config are left zero-valued.
// Only DevLogin is safe to call on the result - Exchange, VerifyIDToken, and
// GetLogoutURL all assume a real Auth0 setup and will misbehave. Only use
// this from a route gated on an explicit dev-mode flag.
func NewDev(db *pgxpool.Pool, logger *logging.Logger) *Authenticator {
return &Authenticator{
log: logger,
db: db,
}
}
func (a *Authenticator) RunBackgroundCleanup(ctx context.Context) error {
for {
if _, err := a.db.Exec(ctx, "DELETE FROM oauth_tokens WHERE expiry < NOW()"); err != nil {
@@ -244,7 +246,7 @@ func (a *Authenticator) VerifyIDToken(ctx context.Context, token *oauth2.Token)
func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
return &url.URL{
Scheme: "https",
Host: AUTH0_DOMAIN,
Host: a.domain,
Path: "/v2/logout",
RawQuery: url.Values{
"returnTo": {
@@ -253,7 +255,7 @@ func (a *Authenticator) GetLogoutURL(requestHost string) *url.URL {
Host: requestHost,
}).String(),
},
"client_id": {AUTH0_CLIENT_ID},
"client_id": {a.Config.ClientID},
}.Encode(),
}
}
@@ -267,7 +269,7 @@ func (a *Authenticator) RefreshAccessToken(
err error,
) {
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, accessToken)
refreshToken, tokenType, err := a.getRefreshTokenForAccessToken(ctx, oldAccessToken)
if err != nil {
return "", time.Time{}, fmt.Errorf("failed to load refresh token: %w", err)
}
+97
View File
@@ -0,0 +1,97 @@
package authentication
import (
"context"
"crypto/rand"
"encoding/hex"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
)
// DevLogin mints a local session for userID without going through the real
// Auth0 OAuth flow. It writes a real oauth_users/oauth_tokens row, so every
// other code path (identity lookup, account linking/creation, cookie
// handling) treats it exactly like a normal login.
//
// Only ever call this from a route gated on an explicit dev-mode flag -
// never wire it up unconditionally, since it lets the caller authenticate
// as any user_id with no credentials.
func (a *Authenticator) DevLogin(ctx context.Context, userID, name string) (accessToken string, expiration time.Time, err error) {
tokenBytes := make([]byte, 32)
if _, err := rand.Read(tokenBytes); err != nil {
return "", time.Time{}, fmt.Errorf("failed to generate access token: %w", err)
}
accessToken = "dev_" + hex.EncodeToString(tokenBytes)
// Set far in the future so the near-expiry refresh path in
// server/auth never tries to refresh a token that has no real
// Auth0-side refresh token behind it.
expiration = time.Now().Add(365 * 24 * time.Hour)
if _, err = a.db.Exec(
ctx,
`
WITH ensured_user AS (
INSERT INTO oauth_users (user_id)
VALUES (@user_id)
ON CONFLICT DO NOTHING
)
INSERT INTO oauth_tokens (
access_token,
token_type,
refresh_token,
expiry,
id_token_issuer,
id_token_audience,
id_token_subject,
id_token_expiry,
id_token_issued_at,
id_token_nonce,
id_token_access_token_hash,
id_token_custom_claims_family_name,
id_token_custom_claims_given_name,
id_token_custom_claims_name,
id_token_custom_claims_nickname,
id_token_custom_claims_picture,
id_token_custom_claims_updated_at
)
VALUES (
@access_token,
'dev',
'',
@expiry,
'dev-auth-bypass',
@audience,
@user_id,
@expiry,
NOW(),
'',
'',
@name,
@name,
@name,
@name,
'',
NOW()
)
`,
pgx.NamedArgs{
"access_token": accessToken,
"expiry": expiration,
"user_id": userID,
"name": name,
"audience": pgtype.FlatArray[string]{"dev"},
},
); err != nil {
return "", time.Time{}, fmt.Errorf("failed to save dev session: %w", err)
}
return accessToken, expiration, nil
}
+74
View File
@@ -0,0 +1,74 @@
package authentication
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts"
"ruben/inventory2/internal/testdb"
)
func TestDevLogin(t *testing.T) {
pool := testdb.Pool(t)
auth := &Authenticator{db: pool}
ctx := context.Background()
userID := testdb.NewUserID(t)
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
})
accessToken, expiration, err := auth.DevLogin(ctx, userID, "Test User")
require.NoError(t, err, "DevLogin()")
require.NotEmpty(t, accessToken, "DevLogin() returned an empty access token")
assert.True(t, expiration.After(time.Now().Add(30*24*time.Hour)),
"DevLogin() expiration = %v, want something far enough out to avoid the near-expiry refresh path", expiration)
claims, err := auth.GetAccessTokenClaimsAndExpiration(ctx, accessToken)
require.NoError(t, err, "GetAccessTokenClaimsAndExpiration()")
assert.Equal(t, "Test User", claims.Name, "claims.Name")
// Postgres timestamptz has microsecond precision, so the round-tripped
// value loses the sub-microsecond portion of Go's nanosecond clock.
assert.WithinDuration(t, expiration, claims.Expiration, time.Millisecond, "claims.Expiration")
_, tokenType, err := auth.getRefreshTokenForAccessToken(ctx, accessToken)
require.NoError(t, err, "getRefreshTokenForAccessToken()")
assert.Equal(t, "dev", tokenType, "tokenType")
}
// DevLogin should be safe to call more than once for the same user_id -
// e.g. testing multiple times as the same dev identity - since oauth_users
// is keyed on user_id but each call mints its own oauth_tokens row.
func TestDevLogin_SameUserIDTwice(t *testing.T) {
pool := testdb.Pool(t)
auth := &Authenticator{db: pool}
ctx := context.Background()
userID := testdb.NewUserID(t)
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM oauth_tokens WHERE id_token_subject = $1", userID)
pool.Exec(context.Background(), "DELETE FROM oauth_users WHERE user_id = $1", userID)
})
token1, _, err := auth.DevLogin(ctx, userID, "Test User")
require.NoError(t, err, "first DevLogin()")
token2, _, err := auth.DevLogin(ctx, userID, "Test User")
require.NoError(t, err, "second DevLogin()")
assert.NotEqual(t, token1, token2, "DevLogin() returned the same access token twice")
}
func TestGetAccessTokenClaimsAndExpiration_UnknownToken(t *testing.T) {
pool := testdb.Pool(t)
auth := &Authenticator{db: pool}
ctx := context.Background()
_, err := auth.GetAccessTokenClaimsAndExpiration(ctx, "no-such-token-"+testdb.NewUserID(t))
require.ErrorIs(t, err, consts.ErrNotFound, "GetAccessTokenClaimsAndExpiration()")
}
+19
View File
@@ -0,0 +1,19 @@
# Etsy API compliance checklist
Obligations from Etsy's API Licensed Uses and Restrictions. Anything
touching `domains/platforms/etsy` or the Etsy-facing UI should keep these
satisfied before shipping.
- [ ] Link directly back to the product information and/or image Content on Etsy, where the Application utilizes product information and/or images.
- [ ] Provide a prominently displayed email address on Your Application for third parties to contact You with any questions or issues. You shall respond to such inquiries in a timely manner.
- [ ] Use commercially reasonable efforts to provide a terms of service and privacy policy in a visible location on your Application.
- [ ] Display item Content or product information and/or images which is more than six (6) hours older than such information is on the Website, and other Etsy Content cannot be more than twenty-four (24) hours older than such Content on the Website.
- [ ] Use the API in a manner that exceeds reasonable request volume or constitutes excessive or abusive usage. Users are allocated by default, 10,000 calls per day.
- [ ] You shall not use or alter any text, logos, Etsy's Trademarks, Etsy's signature colors, Etsy's layout, or a confusingly similar layout to Etsy's layout in such a way which may suggest endorsement or affiliation by Etsy.
- [ ] Any use of the Etsy logo or Etsy's Trademarks must be used in its entirety and must not be altered or used in a misleading way.
- [ ] You shall not use a mark which is confusingly similar to Etsy's Trademarks.
- [ ] Any use of the Etsy logo or Etsy's Trademarks in Your Application shall be less prominent than the logo or mark that primarily describes the Application and Your use of the Etsy logo shall not imply any endorsement or affiliation by Etsy.
- [ ] You may publicize, issue press or blog releases of Your Application only if You state that it was created using the Etsy API and that You in no way imply that Your Application is endorsed or certified by Etsy.
- [ ] You must place or display the following notice prominently on Your Application:
"The term 'Etsy' is a trademark of Etsy, Inc. This application uses the Etsy API but is not endorsed or certified by Etsy, Inc."
- [ ] Immediately report any security deficiencies You discover to Etsy by emailing developer@etsy.com.
+68
View File
@@ -0,0 +1,68 @@
package raw_events_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/internal/testdb"
)
func TestSaveAndLoadEventsForStore(t *testing.T) {
pool := testdb.Pool(t)
store := raw_events.NewStore(testdb.Logger(), pool)
ctx := context.Background()
platform := "test-platform"
storeID := "test-store-" + uuid.NewString()
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM raw_store_events WHERE platform = $1 AND store_id = $2", platform, storeID)
})
older := raw_events.Event{
Platform: platform,
StoreID: storeID,
EventID: "evt-1",
EventTimestamp: time.Now().Add(-time.Hour).UTC(),
Payload: json.RawMessage(`{"n":1}`),
}
newer := raw_events.Event{
Platform: platform,
StoreID: storeID,
EventID: "evt-2",
EventTimestamp: time.Now().UTC(),
Payload: json.RawMessage(`{"n":2}`),
}
require.NoError(t, store.Save(ctx, &older), "Save() older event")
require.NoError(t, store.Save(ctx, &newer), "Save() newer event")
got, err := store.LoadEventsForStore(ctx, platform, storeID)
require.NoError(t, err, "LoadEventsForStore()")
require.Len(t, got, 2, "LoadEventsForStore()")
// ordered event_timestamp DESC - newest first.
assert.Equal(t, "evt-2", got[0].EventID, "LoadEventsForStore()[0]")
assert.Equal(t, "evt-1", got[1].EventID, "LoadEventsForStore()[1]")
var payload struct{ N int }
require.NoError(t, json.Unmarshal(got[0].Payload, &payload), "unmarshal LoadEventsForStore()[0].Payload")
assert.Equal(t, 2, payload.N, "LoadEventsForStore()[0].Payload n")
}
func TestLoadEventsForStore_NoEvents(t *testing.T) {
pool := testdb.Pool(t)
store := raw_events.NewStore(testdb.Logger(), pool)
ctx := context.Background()
got, err := store.LoadEventsForStore(ctx, "test-platform", "no-such-store-"+uuid.NewString())
require.NoError(t, err, "LoadEventsForStore()")
assert.Empty(t, got, "LoadEventsForStore()")
}
+80
View File
@@ -0,0 +1,80 @@
package reports
import (
"context"
"encoding/json"
"errors"
"fmt"
"time"
"github.com/jackc/pgx/v5"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
)
type (
RawShopEvent struct {
Platform accounts.Platform
ShopID string
EventTimestamp time.Time
EventID string
RawPayload json.RawMessage
}
)
func (db *Store) GetRawShopEvents(ctx context.Context, acctID int64, platform accounts.Platform, shopID string) ([]RawShopEvent, error) {
if _, err := db.accts.GetMockShop(ctx, acctID, platform, shopID); err != nil {
if errors.Is(err, consts.ErrNotFound) {
return nil, fmt.Errorf("shop not found: %w", err)
}
return nil, fmt.Errorf("failed to look up shop: %w", err)
}
rows, err := db.db.Query(
ctx,
`
SELECT
event_timestamp,
event_id,
raw_payload
FROM
mock.raw_shop_events
WHERE
platform = @platform
AND shop_id = @shop_id
ORDER BY
event_timestamp DESC,
event_id ASC
`,
pgx.NamedArgs{
"platform": platform,
"shop_id": shopID,
},
)
if err != nil {
return nil, fmt.Errorf("failed to perform query: %w", err)
}
vs, err := pgx.CollectRows(rows, pgx.RowToStructByNameLax[struct {
Event_timestamp time.Time
Event_id string
Raw_payload json.RawMessage
}])
if err != nil {
return nil, fmt.Errorf("failed to scan rows: %w", err)
}
evts := make([]RawShopEvent, len(vs))
for i, v := range vs {
evts[i] = RawShopEvent{
Platform: platform,
ShopID: shopID,
EventTimestamp: v.Event_timestamp,
EventID: v.Event_id,
RawPayload: v.Raw_payload,
}
}
return evts, nil
}
+129
View File
@@ -0,0 +1,129 @@
package reports_test
import (
"context"
"encoding/json"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/reports"
"ruben/inventory2/internal/testdb"
)
// setupEtsyMockShop creates a fresh account and an Etsy mock shop for it,
// and registers cleanup for every row it creates, in FK-safe order
// (shop_etsy[_events] -> mock.accounts -> accounts; oauth_users cleanup is
// handled by testdb.SeedOAuthUser itself).
//
// Etsy (not Amazon) is deliberately used here: domains/amazon's tests
// process every unprocessed row in mock.shop_amazon_events system-wide
// (that's correct production behavior for a background worker, not a
// bug), so any test that writes Amazon-platform mock events risks being
// picked up by domains/amazon's tests when the two packages' test
// binaries run concurrently (go test ./... does this by default). Using
// a different platform here keeps this package's fixtures completely off
// domains/amazon's tables and NOTIFY channel.
func setupEtsyMockShop(t *testing.T, pool *pgxpool.Pool, acctStore *accounts.Store) (acctID int64, shopID string) {
t.Helper()
ctx := context.Background()
userID := testdb.NewUserID(t)
testdb.SeedOAuthUser(t, pool, userID)
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
require.NoError(t, err, "CreateAccount()")
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
})
id, err := acctStore.CreateMockShop(ctx, acct.AccountID, accounts.Etsy, "Test Shop")
require.NoError(t, err, "CreateMockShop()")
shopID = id.String()
t.Cleanup(func() {
ctx := context.Background()
pool.Exec(ctx, "DELETE FROM mock.shop_etsy_events WHERE shop_id = $1", shopID)
pool.Exec(ctx, "DELETE FROM mock.raw_shop_events WHERE shop_id = $1", shopID)
pool.Exec(ctx, "DELETE FROM mock.shop_etsy WHERE account_id = $1", acct.AccountID)
pool.Exec(ctx, "DELETE FROM mock.accounts WHERE account_id = $1", acct.AccountID)
})
return acct.AccountID, shopID
}
func insertRawShopEvent(t *testing.T, pool *pgxpool.Pool, shopID, eventID string, ts time.Time, payload string) {
t.Helper()
_, err := pool.Exec(context.Background(), `
INSERT INTO mock.raw_shop_events (platform, shop_id, event_timestamp, event_id, raw_payload)
VALUES ($1, $2, $3, $4, $5::jsonb)
`, string(accounts.Etsy), shopID, ts, eventID, payload)
require.NoError(t, err, "insert raw shop event")
}
func TestGetRawShopEvents(t *testing.T) {
pool := testdb.Pool(t)
acctStore := accounts.NewStore(testdb.Logger(), pool)
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
ctx := context.Background()
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
older := time.Now().Add(-time.Hour).UTC()
newer := time.Now().UTC()
insertRawShopEvent(t, pool, shopID, "evt-1", older, `{"n":1}`)
insertRawShopEvent(t, pool, shopID, "evt-2", newer, `{"n":2}`)
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Etsy, shopID)
require.NoError(t, err, "GetRawShopEvents()")
require.Len(t, got, 2, "GetRawShopEvents()")
// ordered event_timestamp DESC, event_id ASC - newest first.
assert.Equal(t, "evt-2", got[0].EventID, "GetRawShopEvents()[0]")
assert.Equal(t, "evt-1", got[1].EventID, "GetRawShopEvents()[1]")
assert.Equal(t, accounts.Etsy, got[0].Platform, "got[0].Platform")
assert.Equal(t, shopID, got[0].ShopID, "got[0].ShopID")
var payload struct{ N int }
require.NoError(t, json.Unmarshal(got[0].RawPayload, &payload), "unmarshal got[0].RawPayload")
assert.Equal(t, 2, payload.N, "got[0].RawPayload n")
}
func TestGetRawShopEvents_NoEvents(t *testing.T) {
pool := testdb.Pool(t)
acctStore := accounts.NewStore(testdb.Logger(), pool)
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
ctx := context.Background()
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
got, err := reportsStore.GetRawShopEvents(ctx, acctID, accounts.Etsy, shopID)
require.NoError(t, err, "GetRawShopEvents()")
assert.Empty(t, got, "GetRawShopEvents()")
}
func TestGetRawShopEvents_UnknownShop(t *testing.T) {
pool := testdb.Pool(t)
acctStore := accounts.NewStore(testdb.Logger(), pool)
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
ctx := context.Background()
userID := testdb.NewUserID(t)
testdb.SeedOAuthUser(t, pool, userID)
acct, err := acctStore.CreateAccount(ctx, userID, userID+"@example.com")
require.NoError(t, err, "CreateAccount()")
t.Cleanup(func() {
pool.Exec(context.Background(), "DELETE FROM accounts WHERE account_id = $1", acct.AccountID)
})
_, err = reportsStore.GetRawShopEvents(ctx, acct.AccountID, accounts.Etsy, "no-such-shop-"+uuid.NewString())
require.ErrorIs(t, err, consts.ErrNotFound, "GetRawShopEvents()")
}
+259
View File
@@ -0,0 +1,259 @@
package reports
import (
"context"
"fmt"
"slices"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/logging"
)
//go:generate concurry -s Store
type (
Store struct {
log *logging.Logger
db *pgxpool.Pool
accts *accounts.Store
}
)
func NewStore(logger *logging.Logger, db *pgxpool.Pool, accts *accounts.Store) *Store {
return &Store{
log: logger,
db: db,
accts: accts,
}
}
func (db *Store) WithContext(ctx context.Context) *StoreWithContext {
return NewStoreWithContext(ctx, db)
}
// TODO: prefix all these names with 'mock' since they're all mock stuff
type (
ListingCountsOverTimeReport struct {
AccountID int64
Platform accounts.Platform
ShopID string
ListingID string
Counts []ListingCountAtTime
}
ListingCountAtTime struct {
EventTimestamp *time.Time
Count int64
}
)
func (r ListingCountsOverTimeReport) MaxCount() ListingCountAtTime {
if len(r.Counts) == 0 {
return ListingCountAtTime{}
}
return slices.MaxFunc(r.Counts, func(a, b ListingCountAtTime) int {
return int(a.Count - b.Count)
})
}
func (r ListingCountsOverTimeReport) MinCount() ListingCountAtTime {
if len(r.Counts) == 0 {
return ListingCountAtTime{}
}
return slices.MinFunc(r.Counts, func(a, b ListingCountAtTime) int {
return int(a.Count - b.Count)
})
}
func (db *Store) GetListingCountsReport(ctx context.Context, acctID int64, platform accounts.Platform, shopID, listingID string) (*ListingCountsOverTimeReport, error) {
counts, err := db.GetListingCountsOverTime(ctx, acctID, platform, shopID, listingID)
if err != nil {
return nil, err
}
return &ListingCountsOverTimeReport{
AccountID: acctID,
Platform: platform,
ShopID: shopID,
ListingID: listingID,
Counts: counts,
}, nil
}
func (db *Store) GetListingCountsOverTime(ctx context.Context, acctID int64, platform accounts.Platform, shopID, listingID string) ([]ListingCountAtTime, error) {
info, ok := getMockShopSchemaInfo(platform)
if !ok {
return nil, consts.ErrNotFound
}
var counts []ListingCountAtTime
err := pgx.BeginTxFunc(ctx, db.db, pgx.TxOptions{
AccessMode: pgx.ReadOnly,
}, func(tx pgx.Tx) error {
rows, err := tx.Query(
ctx,
fmt.Sprintf(
`
SELECT
1
FROM
%s
WHERE
shop_id = @shop_id
AND listing_id = @listing_id
`,
info.listingsTable,
),
pgx.NamedArgs{
"shop_id": shopID,
"listing_id": listingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query to check the existing of the listing: %w", err)
}
if _, err := pgx.CollectExactlyOneRow(rows, pgx.RowTo[int]); err != nil {
return fmt.Errorf("listing not found: %w", consts.ErrNotFound)
}
rows, err = tx.Query(
ctx,
fmt.Sprintf(
`
SELECT
event_timestamp AS eventTimestamp,
"count"
FROM
%s
WHERE
shop_id = @shop_id
AND listing_id = @listing_id
`,
info.listingCountsView,
),
pgx.NamedArgs{
"shop_id": shopID,
"listing_id": listingID,
},
)
if err != nil {
return fmt.Errorf("failed to perform query: %w", err)
}
if counts, err = pgx.CollectRows(rows, pgx.RowToStructByNameLax[ListingCountAtTime]); err != nil {
return fmt.Errorf("failed to scan rows: %w", err)
}
return nil
})
return counts, err
}
type mockShopSchemaInfo struct {
platform accounts.Platform
shopTable string
listingsTable string
listingCountsView string
}
func getMockShopSchemaInfo(platform accounts.Platform) (mockShopSchemaInfo, bool) {
for _, in := range getAllMockShopSchemaInfos() {
if in.platform == platform {
return in, true
}
}
return mockShopSchemaInfo{}, false
}
func getAllMockShopSchemaInfos() []mockShopSchemaInfo {
return []mockShopSchemaInfo{
{
platform: accounts.Amazon,
shopTable: "mock.shop_amazon",
listingsTable: "mock.shop_amazon_listings",
listingCountsView: "mock.shop_amazon_listing_counts",
},
{
platform: accounts.BigCartel,
shopTable: "mock.shop_big_cartel",
listingsTable: "mock.shop_big_cartel_listings",
listingCountsView: "mock.shop_big_cartel_listing_counts",
},
{
platform: accounts.Ebay,
shopTable: "mock.shop_ebay",
listingsTable: "mock.shop_ebay_listings",
listingCountsView: "mock.shop_ebay_listing_counts",
},
{
platform: accounts.Ecwid,
shopTable: "mock.shop_ecwid",
listingsTable: "mock.shop_ecwid_listings",
listingCountsView: "mock.shop_ecwid_listing_counts",
},
{
platform: accounts.Etsy,
shopTable: "mock.shop_etsy",
listingsTable: "mock.shop_etsy_listings",
listingCountsView: "mock.shop_etsy_listing_counts",
},
{
platform: accounts.Shopify,
shopTable: "mock.shop_shopify",
listingsTable: "mock.shop_shopify_listings",
listingCountsView: "mock.shop_shopify_listing_counts",
},
{
platform: accounts.SquareOnline,
shopTable: "mock.shop_square_online",
listingsTable: "mock.shop_square_online_listings",
listingCountsView: "mock.shop_square_online_listing_counts",
},
{
platform: accounts.Squarespace,
shopTable: "mock.shop_squarespace",
listingsTable: "mock.shop_squarespace_listings",
listingCountsView: "mock.shop_squarespace_listing_counts",
},
{
platform: accounts.Tiktok,
shopTable: "mock.shop_tiktok",
listingsTable: "mock.shop_tiktok_listings",
listingCountsView: "mock.shop_tiktok_listing_counts",
},
{
platform: accounts.WalmartMarketplace,
shopTable: "mock.shop_walmart_marketplace",
listingsTable: "mock.shop_walmart_marketplace_listings",
listingCountsView: "mock.shop_walmart_marketplace_listing_counts",
},
{
platform: accounts.Wix,
shopTable: "mock.shop_wix",
listingsTable: "mock.shop_wix_listings",
listingCountsView: "mock.shop_wix_listing_counts",
},
{
platform: accounts.WooCommerce,
shopTable: "mock.shop_woo_commerce",
listingsTable: "mock.shop_woo_commerce_listings",
listingCountsView: "mock.shop_woo_commerce_listing_counts",
},
{
platform: accounts.Zoho,
shopTable: "mock.shop_zoho",
listingsTable: "mock.shop_zoho_listings",
listingCountsView: "mock.shop_zoho_listing_counts",
},
}
}
+128
View File
@@ -0,0 +1,128 @@
package reports_test
import (
"context"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/reports"
"ruben/inventory2/internal/testdb"
)
// createEtsyListing creates a listing with the given base count in shopID
// and registers its cleanup. Must be called after setupEtsyMockShop, so
// cleanup order (LIFO) deletes the listing before the shop it belongs to.
func createEtsyListing(t *testing.T, acctStore *accounts.Store, acctID int64, shopID string, baseCount int64) (listingID string) {
t.Helper()
ctx := context.Background()
pool := testdb.Pool(t)
listing, err := acctStore.CreateMockListing(ctx, accounts.MockListing{
AccountShopListingIDs: accounts.AccountShopListingIDs{
AccountShopIDs: accounts.AccountShopIDs{
AccountIDs: accounts.AccountIDs{AccountID: acctID},
Platform: accounts.Etsy,
ShopID: shopID,
},
},
SKU: "test-sku",
Name: "Test Listing",
Description: "a listing created for a test",
Count: baseCount,
})
require.NoError(t, err, "CreateMockListing()")
t.Cleanup(func() {
pool.Exec(context.Background(), `
DELETE FROM mock.shop_etsy_listings WHERE shop_id = $1 AND listing_id = $2
`, shopID, listing.ListingID)
})
return listing.ListingID
}
// TestGetListingCountsOverTime exercises the actual running-count logic,
// which lives in a recursive SQL view (mock.shop_etsy_listing_counts,
// built on mock.shop_etsy_listing_event_sequence) rather than in Go: it
// starts from the listing's base count and walks mock.raw_shop_events in
// order, applying each as a delta (sale/refund) or an absolute reset
// (inventory-reset). Uses the real SaveNewMock* methods to write those
// events, the same entry points the simulate-sale/refund/inventory UI
// uses, rather than hand-rolling the JSON payload shape.
func TestGetListingCountsOverTime(t *testing.T) {
pool := testdb.Pool(t)
acctStore := accounts.NewStore(testdb.Logger(), pool)
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
ctx := context.Background()
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
listingID := createEtsyListing(t, acctStore, acctID, shopID, 100)
_, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Etsy, shopID, listingID, 10)
require.NoError(t, err, "SaveNewMockSale()")
_, err = acctStore.SaveNewMockRefund(ctx, acctID, accounts.Etsy, shopID, listingID, 5)
require.NoError(t, err, "SaveNewMockRefund()")
_, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Etsy, shopID, listingID, 50)
require.NoError(t, err, "SaveNewMockInventoryReset()")
got, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Etsy, shopID, listingID)
require.NoError(t, err, "GetListingCountsOverTime()")
wantCounts := []int64{100, 110, 105, 50}
require.Len(t, got, len(wantCounts), "GetListingCountsOverTime()")
gotCounts := make([]int64, len(got))
for i, row := range got {
gotCounts[i] = row.Count
}
assert.Equal(t, wantCounts, gotCounts, "GetListingCountsOverTime() counts, full sequence: %+v", got)
assert.Nil(t, got[0].EventTimestamp, "got[0].EventTimestamp should be nil (the base count row)")
for i := 1; i < len(got); i++ {
assert.NotNil(t, got[i].EventTimestamp, "got[%d].EventTimestamp should be set (only the base row should be nil)", i)
}
}
func TestGetListingCountsOverTime_UnknownListing(t *testing.T) {
pool := testdb.Pool(t)
acctStore := accounts.NewStore(testdb.Logger(), pool)
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
ctx := context.Background()
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
_, err := reportsStore.GetListingCountsOverTime(ctx, acctID, accounts.Etsy, shopID, "no-such-listing")
require.ErrorIs(t, err, consts.ErrNotFound, "GetListingCountsOverTime()")
}
func TestGetListingCountsReport(t *testing.T) {
pool := testdb.Pool(t)
acctStore := accounts.NewStore(testdb.Logger(), pool)
reportsStore := reports.NewStore(testdb.Logger(), pool, acctStore)
ctx := context.Background()
acctID, shopID := setupEtsyMockShop(t, pool, acctStore)
listingID := createEtsyListing(t, acctStore, acctID, shopID, 100)
_, err := acctStore.SaveNewMockSale(ctx, acctID, accounts.Etsy, shopID, listingID, 10)
require.NoError(t, err, "SaveNewMockSale()")
_, err = acctStore.SaveNewMockInventoryReset(ctx, acctID, accounts.Etsy, shopID, listingID, 20)
require.NoError(t, err, "SaveNewMockInventoryReset()")
report, err := reportsStore.GetListingCountsReport(ctx, acctID, accounts.Etsy, shopID, listingID)
require.NoError(t, err, "GetListingCountsReport()")
assert.Equal(t, acctID, report.AccountID, "report.AccountID")
assert.Equal(t, accounts.Etsy, report.Platform, "report.Platform")
assert.Equal(t, shopID, report.ShopID, "report.ShopID")
assert.Equal(t, listingID, report.ListingID, "report.ListingID")
// counts over the sequence: 100 (base) -> 110 (sale +10) -> 20 (reset)
assert.Equal(t, int64(110), report.MaxCount().Count, "MaxCount().Count")
assert.Equal(t, int64(20), report.MinCount().Count, "MinCount().Count")
}
+33
View File
@@ -0,0 +1,33 @@
// Code generated by concurry DO NOT EDIT.
// https://github.com/angelbeltran/concurry
// concurry
package reports
import (
"context"
"ruben/inventory2/domains/accounts"
)
type StoreWithContext struct {
ctx context.Context
*Store
}
func NewStoreWithContext(ctx context.Context, v *Store) *StoreWithContext {
return &StoreWithContext{
ctx: ctx,
Store: v,
}
}
func (v_ctx *StoreWithContext) GetRawShopEvents(acctID int64, platform accounts.Platform, shopID string) ([]RawShopEvent, error) {
return v_ctx.Store.GetRawShopEvents(v_ctx.ctx, acctID, platform, shopID)
}
func (v_ctx *StoreWithContext) GetListingCountsReport(acctID int64, platform accounts.Platform, shopID string, listingID string) (*ListingCountsOverTimeReport, error) {
return v_ctx.Store.GetListingCountsReport(v_ctx.ctx, acctID, platform, shopID, listingID)
}
func (v_ctx *StoreWithContext) GetListingCountsOverTime(acctID int64, platform accounts.Platform, shopID string, listingID string) ([]ListingCountAtTime, error) {
return v_ctx.Store.GetListingCountsOverTime(v_ctx.ctx, acctID, platform, shopID, listingID)
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 507 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 368 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+1
View File
@@ -0,0 +1 @@
{"name":"","short_name":"","icons":[{"src":"/android-chrome-192x192.png","sizes":"192x192","type":"image/png"},{"src":"/android-chrome-512x512.png","sizes":"512x512","type":"image/png"}],"theme_color":"#ffffff","background_color":"#ffffff","display":"standalone"}
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 385 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 539 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 675 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

+4 -5
View File
@@ -11,8 +11,10 @@ require (
github.com/coreos/go-oidc/v3 v3.17.0
github.com/gin-gonic/gin v1.11.0
github.com/google/uuid v1.6.0
github.com/joho/godotenv v1.5.1
github.com/lmittmann/tint v1.1.2
github.com/oapi-codegen/runtime v1.1.2
github.com/stretchr/testify v1.11.1
golang.org/x/oauth2 v0.34.0
)
@@ -26,14 +28,12 @@ require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d // indirect
github.com/chenzhuoyu/iasm v0.9.1 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/getkin/kin-openapi v0.133.0 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-jose/go-jose/v3 v3.0.4 // indirect
github.com/go-jose/go-jose/v4 v4.1.3 // indirect
github.com/go-openapi/jsonpointer v0.21.0 // indirect
github.com/go-openapi/swag v0.23.0 // indirect
@@ -42,7 +42,6 @@ require (
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/golang/protobuf v1.5.4 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
@@ -63,6 +62,7 @@ require (
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/perimeterx/marshmallow v1.1.5 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/speakeasy-api/jsonpath v0.6.0 // indirect
@@ -82,7 +82,6 @@ require (
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/tools v0.41.0 // indirect
google.golang.org/appengine v1.6.8 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
+4 -94
View File
@@ -16,29 +16,15 @@ github.com/apapsch/go-jsonmerge/v2 v2.0.0/go.mod h1:lvDnEdqiQrp0O42VQGgmlKpxL1AP
github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w=
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.5.0/go.mod h1:ED5hyg4y6t3/9Ku1R6dU/4KyJ48DZ4jPhfY1O2AihPM=
github.com/bytedance/sonic v1.10.0-rc/go.mod h1:ElCzW+ufi8qKqNW0FY314xriJhyJhuoJ3gFZdAHF7NM=
github.com/bytedance/sonic v1.10.0-rc3 h1:uNSnscRapXTwUgTyOF0GVljYD08p9X/Lbr9MweSV3V0=
github.com/bytedance/sonic v1.10.0-rc3/go.mod h1:iZcSUejdk5aukTND/Eu/ivjQuEL0Cu9/rf50Hi0u/g4=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/chenzhuoyu/base64x v0.0.0-20211019084208-fb5309c8db06/go.mod h1:DH46F32mSOjUmXrMHnKwZdA8wcEefY7UVqBKYGjpdQY=
github.com/chenzhuoyu/base64x v0.0.0-20221115062448-fe3a3abad311/go.mod h1:b583jCggY9gE99b6G5LEC39OIiVsWj+R97kbl5odCEk=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d h1:77cEq6EriyTZ0g/qfRdp61a3Uu/AWrgIq2s0ClJV1g0=
github.com/chenzhuoyu/base64x v0.0.0-20230717121745-296ad89f973d/go.mod h1:8EPpVsBuRksnlj1mLy4AWzRNQYxauNi62uWcE3to6eA=
github.com/chenzhuoyu/iasm v0.9.0 h1:9fhXjVzq5hUy2gkhhgHl95zG2cEAhw9OSGs8toWWAwo=
github.com/chenzhuoyu/iasm v0.9.0/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chenzhuoyu/iasm v0.9.1 h1:tUHQJXo3NhBqw6s33wkGn9SP3bvrWLdlVIJ3hQBL7P0=
github.com/chenzhuoyu/iasm v0.9.1/go.mod h1:Xjy2NpN3h7aUqeqM+woSuuvxmIe6+DDsiNLIrkAmYog=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/coreos/go-oidc/v3 v3.8.0 h1:s3e30r6VEl3/M7DTSCEuImmrfu1/1WBgA0cXkdzkrAY=
github.com/coreos/go-oidc/v3 v3.8.0/go.mod h1:yQzSCqBnK3e6Fs5l+f5i0F8Kwf0zpH9bPEsbY00KanM=
github.com/coreos/go-oidc/v3 v3.17.0 h1:hWBGaQfbi0iVviX4ibC7bk8OKT5qNr4klBaCHVNvehc=
github.com/coreos/go-oidc/v3 v3.17.0/go.mod h1:wqPbKFrVnE90vty060SB40FCJ8fTHTxSwyXJqZH+sI8=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
@@ -51,24 +37,14 @@ github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5ql
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/gabriel-vasile/mimetype v1.4.2 h1:w5qFW6JKBz9Y393Y4q372O9A7cUSequkh1Q7OhCmWKU=
github.com/gabriel-vasile/mimetype v1.4.2/go.mod h1:zApsH/mKG4w07erKIaJPFiX0Tsq9BFQgN3qGY5GnNgA=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/getkin/kin-openapi v0.133.0 h1:pJdmNohVIJ97r4AUFtEXRXwESr8b0bD721u/Tz6k8PQ=
github.com/getkin/kin-openapi v0.133.0/go.mod h1:boAciF6cXk5FhPqe/NQeBTeenbjqU4LhWBf09ILVvWE=
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.9.1 h1:4idEAncQnU5cB7BeOkPtxjfCSye0AAm1R0RVIqJ+Jmg=
github.com/gin-gonic/gin v1.9.1/go.mod h1:hPrL7YrpYKXt5YId3A/Tnip5kqbEAP+KLuI3SUcPTeU=
github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk=
github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls=
github.com/go-jose/go-jose/v3 v3.0.0 h1:s6rrhirfEP/CGIoc6p+PZAeogN2SxKav6Wp7+dyMWVo=
github.com/go-jose/go-jose/v3 v3.0.0/go.mod h1:RNkWWRld676jZEYoV3+XK8L2ZnNSvIsxFMht0mSX+u8=
github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY=
github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ=
github.com/go-jose/go-jose/v4 v4.1.3 h1:CVLmWDhDVRa6Mi/IgCgaopNosCaHz7zrMeF9MlZRkrs=
github.com/go-jose/go-jose/v4 v4.1.3/go.mod h1:x4oUasVrzR7071A4TnHLGSPpNOm2a21K9Kf04k1rs08=
github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ=
@@ -81,15 +57,11 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.14.1 h1:9c50NUPC30zyuKprjL3vNZ0m5oG+jU0zvx4AqHGnv4k=
github.com/go-playground/validator/v10 v10.14.1/go.mod h1:9iXMNT7sEkjXb0I+enO7QXmzG6QCsPWY4zveKFVRSyU=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM=
github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
@@ -103,22 +75,14 @@ github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvq
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg=
github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE=
github.com/google/uuid v1.5.0 h1:1p67kYwdtXjb0gL0BPiP1Av9wiZPo5A8z2cWkTZ+eyU=
github.com/google/uuid v1.5.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
@@ -129,23 +93,19 @@ github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsI
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/pgx/v5 v5.8.0 h1:TYPDoleBBme0xGSAX3/+NujXXtpZn9HBONkQC7IEZSo=
github.com/jackc/pgx/v5 v5.8.0/go.mod h1:QVeDInX2m9VyzvNeiCJVjCkNFqzsNb43204HshNSZKw=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/juju/gnuflag v0.0.0-20171113085948-2ce1bb71843d/go.mod h1:2PavIy+JPciBPrBUjwbNvtwB6RQlve+hkpll6QSNmOE=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.5 h1:0E5MSMDEoAulmXNFquVs//DdoomxaoTY1kUhbc/qbZg=
github.com/klauspost/cpuid/v2 v2.2.5/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -153,8 +113,6 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.2.4 h1:XlAE/cm/ms7TE/VMVoduSpNBoyc2dOxHs5MZSwAN63Q=
github.com/leodido/go-urn v1.2.4/go.mod h1:7ZrI8mTSeBSHl/UaRyKQW1qZeMgak41ANeCNaVckg+4=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw=
@@ -195,8 +153,6 @@ github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1y
github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY=
github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw=
github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro=
github.com/pelletier/go-toml/v2 v2.0.9 h1:uH2qQXheeefCCkuBBSLi7jCiSmj3VRh2+Goq2N7Xxu0=
github.com/pelletier/go-toml/v2 v2.0.9/go.mod h1:tJU2Z3ZkXwnxa4DPO899bsyIoywizdUvyaeZurnPPDc=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s=
@@ -230,20 +186,15 @@ github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.11 h1:BMaWp1Bb6fHwEtbplGBGJ498wD+LKlNSl25MjdZY4dU=
github.com/ugorji/go/codec v1.2.11/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk=
@@ -253,28 +204,17 @@ github.com/woodsbury/decimal128 v1.3.0/go.mod h1:C5UTmyTjW3JftjUFzOVhC20BEQa2a4Z
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4 h1:0sw0nJM544SpsihWx1bkXdYLQDlzRflMgFJQ4Yih9ts=
github.com/yosssi/gohtml v0.0.0-20201013000340-ee4748c638f4/go.mod h1:+ccdNT0xMY1dtc5XBxumbYfOUhmduiGudqaDgD2rVRE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.4.0 h1:A8WCeEWhLwPBKNbFi5Wv5UTCBx5zzubnXDlMOFAzFMc=
golang.org/x/arch v0.4.0/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
golang.org/x/arch v0.23.0 h1:lKF64A2jF6Zd8L0knGltUnegD62JMFBiCPBmQpToHhg=
golang.org/x/arch v0.23.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190911031432-227b76d455e7/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
golang.org/x/crypto v0.47.0 h1:V6e3FRj+n4dbpw86FJ8Fv7XVOql7TEwpHapKoMJ/GO8=
golang.org/x/crypto v0.47.0/go.mod h1:ff3Y9VzzKbwSSEzWqJsJVBnWmRwRSHt/6Op5n9bQc4A=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.31.0 h1:HaW9xtz0+kOcWKwli0ZXy79Ix+UW/vOfmWI5QVd2tgI=
golang.org/x/mod v0.31.0/go.mod h1:43JraMp9cGx1Rx3AqioxrbrhNsLl2l/iNAvuBkrezpg=
golang.org/x/mod v0.32.0 h1:9F4d3PHLljb6x//jOyokMv3eX+YDeepZSEo3mFJy93c=
golang.org/x/mod v0.32.0/go.mod h1:SgipZ/3h2Ci89DlEtEXWUk/HteuRin+HHhN+WbNhguU=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
@@ -282,23 +222,15 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk=
golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.49.0 h1:eeHFmOGUTtaaPSGNmjBKpbng9MulQsJURQUAfUwY++o=
golang.org/x/net v0.49.0/go.mod h1:/ysNB2EvaqvesRkuLAyjI1ycPZlQHM3q01F02UY/MV8=
golang.org/x/oauth2 v0.15.0 h1:s8pnnxNVzjWyrvYdFUQq5llS1PX2zhPXmccZv99h7uQ=
golang.org/x/oauth2 v0.15.0/go.mod h1:q48ptWNTY5XWf+JNten23lcvHpLJ0ZSxF5ttTHKVCAM=
golang.org/x/oauth2 v0.34.0 h1:hqK/t4AKgbqWkdkcAeI8XLmbK+4m4G5YeQRrmiotGlw=
golang.org/x/oauth2 v0.34.0/go.mod h1:lzm5WQJQwKZ3nwavOZ3IS5Aulzxi68dUSgRHujetwEA=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
@@ -315,44 +247,26 @@ golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.40.0 h1:yLkxfA+Qnul4cs9QA3KnlFu0lVmd8JJfoq+E41uSutA=
golang.org/x/tools v0.40.0/go.mod h1:Ik/tzLRlbscWpqqMRjyWYDisX8bG13FrdXp3o4Sr9lc=
golang.org/x/tools v0.41.0 h1:a9b8iMweWG+S0OBnlU36rzLp20z1Rp10w+IY2czHTQc=
golang.org/x/tools v0.41.0/go.mod h1:XSY6eDqxVNiYgezAVqqCeihT4j1U2CCsqvH3WhQpnlg=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/appengine v1.6.8 h1:IhEN5q69dyKagZPYMSdIjS2HqprW324FRQZJcGqPAsM=
google.golang.org/appengine v1.6.8/go.mod h1:1jJ3jBArFh5pcgW8gCtRJnepW8FzD1V44FJffLiz/Ds=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@@ -361,8 +275,6 @@ google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzi
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.31.0 h1:g0LDEJHgrBl9N9r17Ru3sqWhkIx2NB67okBHPwC7hs8=
google.golang.org/protobuf v1.31.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -382,5 +294,3 @@ gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
+115
View File
@@ -0,0 +1,115 @@
// Package testdb provides shared fixtures for integration tests that need a
// real Postgres connection. It targets TEST_DATABASE_URL, which `make test`
// points at a dedicated inventory_2_test database by default; override it
// (e.g. `make test TEST_DATABASE_URL=...`) to run the same tests against
// another database, such as the real dev one.
package testdb
import (
"context"
"io"
"log/slog"
"os"
"testing"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"ruben/inventory2/logging"
)
// Pool connects to TEST_DATABASE_URL. If it isn't set, the test is skipped
// rather than failed, so `go test ./...` works without Postgres running.
func Pool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_DATABASE_URL")
if dsn == "" {
t.Skip("TEST_DATABASE_URL not set; skipping integration test (see .env.example, or run via `make test`)")
}
pool, err := pgxpool.New(context.Background(), dsn)
if err != nil {
t.Fatalf("failed to connect to test database: %v", err)
}
t.Cleanup(pool.Close)
if err := pool.Ping(context.Background()); err != nil {
t.Fatalf("failed to reach test database at TEST_DATABASE_URL: %v", err)
}
return pool
}
// Logger returns a logger that discards output, for stores that require one
// but whose logging isn't under test.
func Logger() *logging.Logger {
return logging.New(slog.NewTextHandler(io.Discard, nil))
}
// NewUserID returns a unique user_id for a test to use, so parallel test
// runs (including against a shared database) never collide.
func NewUserID(t *testing.T) string {
t.Helper()
return "test-" + t.Name() + "-" + uuid.NewString()
}
// SeedOAuthUser inserts a bare oauth_users row so accounts/etc. FKs that
// reference it are satisfiable, and registers cleanup. Use SeedOAuthSession
// instead if the test also needs a valid access token.
func SeedOAuthUser(t *testing.T, pool *pgxpool.Pool, userID string) {
t.Helper()
ctx := context.Background()
if _, err := pool.Exec(ctx, `INSERT INTO oauth_users (user_id) VALUES ($1)`, userID); err != nil {
t.Fatalf("failed to seed oauth_users row: %v", err)
}
t.Cleanup(func() {
if _, err := pool.Exec(context.Background(), `DELETE FROM oauth_users WHERE user_id = $1`, userID); err != nil {
t.Errorf("cleanup: failed to delete oauth_users row: %v", err)
}
})
}
// SeedOAuthSession inserts an oauth_users row plus a matching oauth_tokens
// row with a freshly generated access token, mirroring the shape a real (or
// authentication.Authenticator.DevLogin) session leaves behind. Registers
// cleanup for both rows, in dependency order.
func SeedOAuthSession(t *testing.T, pool *pgxpool.Pool, userID string) (accessToken string) {
t.Helper()
ctx := context.Background()
SeedOAuthUser(t, pool, userID)
accessToken = "test-token-" + uuid.NewString()
_, err := pool.Exec(ctx, `
INSERT INTO oauth_tokens (
access_token, token_type, refresh_token, expiry,
id_token_issuer, id_token_audience, id_token_subject,
id_token_expiry, id_token_issued_at, id_token_nonce, id_token_access_token_hash,
id_token_custom_claims_family_name, id_token_custom_claims_given_name,
id_token_custom_claims_name, id_token_custom_claims_nickname,
id_token_custom_claims_picture, id_token_custom_claims_updated_at
) VALUES (
$1, 'test', '', NOW() + INTERVAL '1 hour',
'test', '{test}', $2,
NOW() + INTERVAL '1 hour', NOW(), '', '',
'Test', 'User',
'Test User', 'testuser',
'', NOW()
)
`, accessToken, userID)
if err != nil {
t.Fatalf("failed to seed oauth_tokens row: %v", err)
}
t.Cleanup(func() {
if _, err := pool.Exec(context.Background(), `DELETE FROM oauth_tokens WHERE access_token = $1`, accessToken); err != nil {
t.Errorf("cleanup: failed to delete oauth_tokens row: %v", err)
}
})
return accessToken
}
+98 -22
View File
@@ -21,17 +21,16 @@ import (
"github.com/jackc/pgx/v5/pgxpool"
"github.com/lmittmann/tint"
"ruben/inventory2/config"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/amazon"
"ruben/inventory2/domains/authentication"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/domains/reports"
"ruben/inventory2/logging"
"ruben/inventory2/server"
)
const (
etsyAPIKeystring = "38ncokqh0jih5jshfk8iv4n5"
etsyAPISharedSecret = "jaaw0tyizf"
"ruben/inventory2/server/sse"
)
func main() {
@@ -65,25 +64,66 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
ctx, shutdown := context.WithCancel(ctx)
defer shutdown()
// load configuration
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("failed to load configuration: %w", err)
}
// connect to the database
connPool, err := newPool(ctx)
connPool, err := newPool(ctx, cfg.DatabaseURL)
if err != nil {
return fmt.Errorf("failed to initialize database connection pool: %w", err)
}
// start background processes
auth, err := authentication.New(ctx, connPool, logger.WithGroup("authenticator"))
if err != nil {
return fmt.Errorf("failed to construct authenticator: %w", err)
var auth *authentication.Authenticator
if cfg.DevAuthEnabled {
auth = authentication.NewDev(connPool, logger.WithGroup("authenticator"))
} else {
auth, err = authentication.New(
ctx,
connPool,
logger.WithGroup("authenticator"),
cfg.Auth0Domain,
cfg.Auth0ClientID,
cfg.Auth0ClientSecret,
cfg.Auth0CallbackURL,
)
if err != nil {
return fmt.Errorf("failed to construct authenticator: %w", err)
}
}
authErrCh := runAuthProcesses(ctx, auth)
accts := accounts.NewStore(logger.WithGroup("accounts"), connPool)
// start http server
srvErrCh := runServer(ctx, logger, connPool, auth)
sseQueue, srvErrCh := runServer(ctx, logger, connPool, auth, accts, cfg)
// start background processes
authErrCh := runAuthProcesses(ctx, auth)
eventErrCh := runEventProcessing(
ctx,
amazon.NewMocks(logger.WithGroup("amazon"), connPool).
SetListener(sseQueue.NewDBEventPublisher(
func(ctx context.Context, e raw_events.Event) (acctID int64, err error) {
p, err := accounts.NewPlatform(e.Platform)
if err != nil {
return 0, err
}
return accts.GetAccountIDByMockPlatformAndShopID(ctx, p, e.StoreID)
},
func(acctID int64, e raw_events.Event) (eventType string, err error) {
// TODO: fine tune the event type later (don't want a referesh on EVERY event)
return fmt.Sprintf("accounts_%d_simulations", acctID), nil
},
)),
)
// wait for interrupt signal or unrecoverable failure, then shutdown
@@ -92,8 +132,9 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
var (
alreadyShutdown struct {
server bool
authProcesses bool
server bool
authProcesses bool
eventProcessing bool
}
)
select {
@@ -106,6 +147,12 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
if err != nil {
logger.Error("auth processes encountered error", "error", err)
}
case err := <-eventErrCh:
alreadyShutdown.eventProcessing = true
logger.Error("event processing shutdown unexpectedly")
if err != nil {
logger.Error("event processing encountered error", "error", err)
}
case err := <-srvErrCh:
alreadyShutdown.server = true
logger.Error("server shutdown unexpectedly")
@@ -127,6 +174,13 @@ func runApp(ctx context.Context, logger *logging.Logger) error {
logger.Info("auth processes shut down")
}
if !alreadyShutdown.eventProcessing {
if err := <-authErrCh; err != nil {
errs = append(errs, fmt.Errorf("event processing experienced an error: %w", err))
}
logger.Info("event processing shut down")
}
if !alreadyShutdown.server {
if err := <-srvErrCh; err != nil {
errs = append(errs, fmt.Errorf("server experienced an error: %w", err))
@@ -150,26 +204,48 @@ func runAuthProcesses(ctx context.Context, auth *authentication.Authenticator) <
return errCh
}
func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Pool, auth *authentication.Authenticator) <-chan error {
func runEventProcessing(ctx context.Context, amz *amazon.Mocks) <-chan error {
errCh := make(chan error, 1)
go func() {
defer close(errCh)
if err := amz.ProcessEvents(ctx); err != nil {
errCh <- err
}
}()
return errCh
}
func runServer(
ctx context.Context,
logger *logging.Logger,
connPool *pgxpool.Pool,
auth *authentication.Authenticator,
accts *accounts.Store,
cfg config.Config,
) (*sse.Queue, <-chan error) {
r := server.NewRouter(
logger.WithGroup("server"),
"./",
raw_events.NewStore(logger.WithGroup("raw-event-store"), connPool),
accounts.NewStore(logger, connPool),
accts,
reports.NewStore(logger, connPool, accts),
etsy_platform.NewPlatform(
logger,
func(acctID int64) string {
return fmt.Sprintf("/oauth/account/%d/auth_code", acctID)
},
etsyAPIKeystring,
etsyAPISharedSecret,
cfg.EtsyAPIKeystring,
cfg.EtsyAPISharedSecret,
connPool,
),
auth,
cfg.DevAuthEnabled,
)
srv := &http.Server{
Addr: ":8082", // local
Addr: fmt.Sprintf(":%d", cfg.Port), // local
Handler: r,
}
@@ -182,7 +258,7 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
defer cancel()
defer close(alreadyShutdownCh)
logger.Info("server running on 8082...")
logger.Infof("server running on %d...", cfg.Port)
if err := srv.ListenAndServe(); err != nil {
if !errors.Is(err, http.ErrServerClosed) {
runningErrCh <- fmt.Errorf("server experienced error: %w", err)
@@ -231,5 +307,5 @@ func runServer(ctx context.Context, logger *logging.Logger, connPool *pgxpool.Po
}
}()
return errCh
return r.GetSSEQueue(), errCh
}
+209
View File
@@ -0,0 +1,209 @@
# Platform Research
https://docs.google.com/spreadsheets/d/1xWfXn-wbiHTBeqyhnq0b46_sgLGyiYc_5N5mXKYD1R8/edit?gid=0#gid=0
| Platform | Order Event: placed | Order Event: changed | Products List | Products Look up | Inventory Look up | Inventory Update | Inventory Event: change |
|----------|----------|----------|----------|----------|----------|----------|----------|
| **Etsy (live)** | N/A — no webhook/push system in API v3 at all; poll `GET /v3/application/shops/{shop_id}/receipts` (filter `min_created`) | poll same endpoint filtered by `min_last_modified` | `GET /v3/application/shops/{shop_id}/listings` | `GET /v3/application/listings/{listing_id}` | `GET /v3/application/listings/{listing_id}/inventory` | `PUT /v3/application/listings/{listing_id}/inventory` | N/A — no inventory-change webhook; poll the inventory endpoint |
| Shopify | orders/create | orders/cancelled, orders/delete, orders/paid, etc | /queries/products | /queries/product | /queries/product | /mutations/inventorySetQuantities | inventory_levels/update |
| WooCommerce | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /wp-json/wc/v3/products` | `GET /wp-json/wc/v3/products/<id>` | `stock_quantity` field on product resource | `PUT /wp-json/wc/v3/products/<id>` (stock_quantity), or `/products/batch` | `product.updated` (no dedicated inventory webhook) |
| BigCommerce | webhook `store/order/created` | webhook `store/order/updated`, `store/order/statusUpdated` | `GET /v3/catalog/products` | `GET /v3/catalog/products/{product_id}` | `GET /v3/inventory/items` | `PUT /v3/inventory/adjustments/absolute` (also `/relative`) | `store/product/inventory/updated` |
| Wix | webhook `wix.ecom.v1.order.created` | webhook `wix.ecom.v1.order.updated` (also `.canceled`) | Query Products (Catalog V3) | Get Product | Query Inventory Items | Update Inventory Variants | `wix.stores.catalog.v3.inventory_item.updated` |
| Squarespace | webhook `order.create` | webhook `order.update` (FULFILLED, REFUNDED, CANCELED, MARKED_PENDING, EMAIL_UPDATED) | `GET /v2/commerce/products` | `GET /v2/commerce/products/{productIdCsvs}` | `GET /1.0/commerce/inventory/{variantIdCsvs}` | `POST /1.0/commerce/inventory/adjustments` | N/A (no inventory webhook topic) |
| Square Online | webhook `order.created` | webhook `order.updated`, `order.fulfillment.updated` | `GET /v2/catalog/list` | `GET /v2/catalog/object/{object_id}` | `POST /v2/inventory/counts/batch-retrieve` | `POST /v2/inventory/changes/batch-create` (BatchChangeInventory) | webhook `inventory.count.updated` |
| Zoho | webhook `salesorder.created` (Zoho Commerce) | webhook `salesorder.confirmed, .cancelled, .declined, .shipped, .delivered` | `GET /store/api/v1/products` | `GET /store/api/v1/products/{product_id}` | `GET /store/api/v1/variants` (`stock_on_hand`, `actual_available_stock`) | `POST /store/api/v1/inventoryadjustments` | N/A (no inventory/stock webhook event) |
| Ecwid | webhook `order.created` | webhook `order.updated`, `order.deleted` | `GET /api/v3/{storeId}/products` | `GET /api/v3/{storeId}/products/{productId}` | `GET /api/v3/{storeId}/products/{productId}` (`quantity`/`unlimited`) | `PUT /api/v3/{storeId}/products/{productId}/inventory` (`quantityDelta`) | `product.updated` webhook |
| Big Cartel | webhook `order.create` (app-approved) | webhook `order.update` (app-approved) | `GET /v1/accounts/{account_id}/products` | `GET /v1/accounts/{account_id}/products/{id}` | N/A — no dedicated inventory field/endpoint | N/A — no inventory update endpoint | N/A — no inventory-specific webhook |
| Amazon | `ORDER_CHANGE` notification (SP-API) | `ORDER_CHANGE` notification (same type, status delta) | `searchCatalogItems` (GET `/catalog/2022-04-01/items`) | `getCatalogItem` (GET `/catalog/2022-04-01/items/{asin}`) | `getInventorySummaries` (FBA Inventory API, GET `/fba/inventory/v1/summaries`) | `patchListingsItem` (PATCH `/listings/2021-08-01/items/{sellerId}/{sku}`) | `FBA_INVENTORY_AVAILABILITY_CHANGES` notification |
| Walmart Marketplace | PO created event (webhook) | Order intent to cancel / PO line auto-cancelled event (webhook); status flow Created→Acknowledged→Shipped→Delivered/Cancelled | `GET /v3/items` (getAllItems) | `GET /v3/items/{id}` (getAnItem) | `GET /v3/inventory?sku={sku}` | `PUT /v3/inventory` (also bulk via `POST /v3/feeds`) | Inventory OOS event (webhook) |
| Ebay | `FixedPriceTransaction` / `ItemSold` (Platform Notifications, legacy Trading API) | `ItemMarkedShipped` notification; also `getOrders` filtered by `lastmodifieddate` (Fulfillment API) | `GET /sell/inventory/v1/inventory_item` (getInventoryItems) | `GET /sell/inventory/v1/inventory_item/{sku}` (getInventoryItem) | `GET /sell/inventory/v1/inventory_item/{sku}` (availability.shipToLocationAvailability) | `POST /sell/inventory/v1/bulk_update_price_quantity` (bulkUpdatePriceQuantity) | N/A — no dedicated inventory-change topic found |
| Platform | Docs | Webhooks | API |
|----------|----------|----------|----------|
| Etsy (live) | https://developer.etsy.com/documentation/ | N/A — no webhook/push mechanism exists in Etsy Open API v3 | https://developer.etsy.com/documentation/reference (confirmed directly against `domains/platforms/etsy/generated_client` in this repo, not external docs) |
| Shopify | https://shopify.dev/docs/api | https://shopify.dev/docs/api/webhooks/latest?reference=toml | https://shopify.dev/docs/api/admin-graphql/latest |
| WooCommerce | https://developer.woocommerce.com/docs/apis/rest-api/ | https://developer.woocommerce.com/docs/apis/rest-api/v2/webhooks/ | https://developer.woocommerce.com/docs/apis/rest-api/v3/products/ |
| BigCommerce | https://developer.bigcommerce.com/docs | https://developer.bigcommerce.com/docs/integrations/webhooks/overview | https://developer.bigcommerce.com/docs/rest-catalog/products |
| Wix | https://dev.wix.com/docs | https://dev.wix.com/docs/build-apps/develop-your-app/api-integrations/events-and-webhooks/about-webhooks | https://dev.wix.com/docs/api-reference |
| Squarespace | https://developers.squarespace.com/commerce-apis/overview | https://developers.squarespace.com/commerce-apis/webhooksubscriptions-overview | https://developers.squarespace.com/commerce-apis/overview |
| Square Online | https://developer.squareup.com/docs | https://developer.squareup.com/docs/webhooks/overview | https://developer.squareup.com/reference/square |
| Zoho | https://www.zoho.com/commerce/api/introduction.html | https://www.zoho.com/commerce/api/webhooks.html | https://www.zoho.com/commerce/api/apis-list.html |
| Ecwid | https://docs.ecwid.com/ | https://docs.ecwid.com/webhook-automations | https://api-docs.ecwid.com/reference |
| Big Cartel | https://developers.bigcartel.com/ | https://developers.bigcartel.com/api/v1 (webhooks section, no standalone page) | https://developers.bigcartel.com/api/v1 |
| Amazon | https://developer-docs.amazon.com/sp-api/docs/welcome | https://developer-docs.amazon.com/sp-api/docs/notifications-api-v1-use-case-guide | https://developer-docs.amazon.com/sp-api/reference |
| Walmart Marketplace | https://developer.walmart.com/ | https://developer.walmart.com/doc/us/mp/us-mp-notifications/ | https://developer.walmart.com/us-marketplace/docs/inventory-api-overview |
| Ebay | https://developer.ebay.com/develop | https://developer.ebay.com/api-docs/commerce/notification/overview.html | https://developer.ebay.com/api-docs/sell/inventory/overview.html |
### Notes / caveats from research
- **Square Online**: no separate API — orders, catalog, and inventory are handled by Square's core Seller APIs (developer.squareup.com), the same ones used across all Square products. Unrelated to Squarespace despite the name.
- **Zoho**: "Zoho Commerce" (commerce.zoho.com) is the storefront product comparable to Shopify/Squarespace and owns the order/product/webhook APIs listed above. Zoho Inventory is a separate warehouse/stock-management app with its own API but no documented webhook support.
- **Big Cartel**: no true inventory API — only an `inventory_enabled` flag and `quantity_gte`/`quantity_lte` filters on products. No endpoint to set stock and no inventory-change webhook. Webhook access is gated per-app approval; exact topic names are inferred from integration examples since Big Cartel has no canonical published list.
- **Amazon SP-API**: no separate "placed" vs "changed" order topics — both flow through a single `ORDER_CHANGE` notification, differentiated by payload content.
- **WooCommerce / Ecwid**: neither has a dedicated inventory-change webhook; stock changes surface via the general `product.updated` event instead.
- **Etsy**: the one row in the first table sourced from this repo's actual code rather than external docs (`domains/platforms/etsy/generated_client`, generated from Etsy's own OpenAPI spec) - so it's the most reliable row here, not the least. Etsy Open API v3 has no webhook/push system whatsoever for anything, order or inventory alike; every other platform in the table has at least *some* real-time push. The live integration in this repo currently only implements the OAuth connection flow (`GenerateConnectionURLForNewAccount`/`HandleNewAuthCode` in `domains/platforms/etsy/etsy.go`) - polling-based order/inventory sync against `GetShopReceipts`/`GetListingInventory` isn't built yet.
- **Ebay**: order-event names are less certain — developer.ebay.com pages repeatedly failed to load during research, so those values come from documented Platform Notifications event types found via search rather than a directly confirmed doc page.
- **TikTok Shop**: not in the capability table above - `partner.tiktokshop.com/docv2` is JS-rendered and didn't return usable content via fetch, so exact webhook event names and endpoint paths aren't confirmed (unlike every other row in that table, which comes from readable docs). What is confirmed via secondary sources: TikTok Shop's Partner API has webhooks covering order, product, and inventory changes, and a Product/Inventory API for listing and stock management. Treat as directionally real but needing its own dedicated research pass - with confirmed endpoint names - before implementation.
- **BigCommerce vs Tiktok**: the capability table above includes BigCommerce, but BigCommerce is not one of this codebase's actual mock platforms (see `AGENTS.md`'s platform list - `domains/accounts/platform.go`'s `allPlatforms` has `Tiktok`, not BigCommerce). BigCommerce's row is left in place since the research itself may still be useful, but it's excluded from the priority ranking below; Tiktok is included despite the weaker sourcing noted above, since it's a platform that actually exists in this codebase.
## Market-size research (2026-08-20)
Pulled to sanity-check which platforms are most worth integrating first, on
top of the API-completeness comparison above. See `AGENTS.md`'s "Platform
integration priority" section for the resulting ranking and reasoning.
Figures are mid-2026 estimates for FY2025 (or most recent trailing period)
unless noted. Anything not sourced from an SEC filing is a third-party
estimate (StoreLeads, DemandSage, Marketplace Pulse, etc.) and should be
treated as directional, not precise - methodology varies a lot between
sources, especially for "number of stores/sellers."
| Platform | Active sellers/stores | Most recent GMV | Source confidence |
|---|---|---|---|
| Amazon (3P) | ~1.9M active sellers | ~$575B (3P GMV, 2025); total Amazon GMV >$800B | Moderate - earnings-adjacent estimates |
| Shopify | ~3-6.8M active stores (methodology varies) | $378.4B (FY2025, +29% YoY) | High - SEC filings |
| eBay | ~18.3M active sellers | $79.6B (FY2025, +7% YoY) | High - SEC filings |
| Etsy (already live) | 5.6M active sellers | $11.92B GMS (FY2025, -5.3%) | High - SEC filings |
| Squarespace Commerce | ~353K live ecommerce sites | ~$7.2B (2026 est.) | Low - marketing estimate |
| WooCommerce | ~4.2-6M active stores | ~$30-35B (2025 est.; average store is tiny, ~$7-8K/yr) | Low - third-party estimate, no central ledger |
| Walmart Marketplace | ~200-250K sellers, +50% YoY growth | ~$10B (rough external estimate; Walmart doesn't break this out cleanly) | Low |
| Wix eCommerce | ~760K-3M live stores | $4.1B-$12.4B (conflicting self-reported figures) | Low |
| Tiktok Shop | ~15M sellers globally, ~500K registered / ~216K active in the US | $64.3B global (2025, nearly 2x 2024); $15.1B US (+68% YoY) | Low - third-party estimate, hypergrowth market |
| BigCommerce *(not an actual mock platform here - see caveat above)* | ~37-42K *active* stores (shrinking, shifting to enterprise) | $34B+ | Moderate |
| Square Online | n/a (bundled into Square's overall $250B GPV, mostly in-person POS) | Can't isolate | N/A |
| Zoho Commerce | ~2,196 stores globally (285 US), +34% YoY US growth off a tiny base | Not disclosed; likely small given store count | Low |
| Ecwid | ~130-164K live stores, declining (-20% YoY) | Not disclosed | Low |
| Big Cartel | ~91-192K stores (estimates vary widely), declining sharply (-41% YoY in 2026 Q1) | Not disclosed; platform targets low-AOV indie sellers | Low |
Key takeaways:
- Amazon's 3P GMV ($575B) is ~1.5x Shopify's *total* GMV despite Shopify
having 2-3x more active stores - Amazon sellers skew toward larger,
more serious operations.
- eBay has by far the most sellers (18.3M) but the lowest GMV-per-seller of
any major platform here ($79.6B / 18.3M), suggesting a lot of integration
surface for comparatively thin per-seller value - compounded by its weak
inventory-webhook story (see table above).
- WooCommerce can't be sized financially at all - it's a WordPress plugin,
not a company with a ledger - so its case rests entirely on raw store
count, not proven revenue-per-integration.
- Walmart Marketplace is small in absolute terms but growing fast (+50%
YoY) and is a natural "second marketplace" for sellers already on Amazon.
## Weighted priority ranking (2026-08-20)
Scores each of the 12 not-yet-live mock platforms (everything in
`domains/accounts/platform.go`'s `allPlatforms` except Etsy) on four 0-10
criteria, weighted and summed to a single composite score. This is a
judgment call turned into numbers, not a precise formula - the weights and
per-platform scores below are my read of the research above; treat the
*ranking* as the useful output, not the second decimal place. BigCommerce is
excluded (not an actual platform in this codebase, see caveat above).
Etsy itself is scored too, as a **reference row only** - it's already the
live integration, so it's not competing for "what to build next," but
running it through the same rubric is a useful sanity check on the model.
**Criteria & weights:**
- **GMV / market opportunity (45%)** - bucketed from the GMV figures above
(>$500B=10, $300-500B=9, $50-100B=7, $25-50B=6, $8-15B=4, $5-8B=3,
undisclosed-and-small=1). This is weighted highest because "lucrative"
is fundamentally a dollar-opportunity question.
- **API/inventory-sync completeness (30%)** - from the capability table at
the top of this doc: full order-webhook + dedicated inventory-webhook
coverage scores highest, missing inventory webhook scores mid, no
inventory API at all (Big Cartel) scores near-zero. This directly gates
how good a product experience is even possible on that platform.
- **Growth trajectory (15%)** - YoY GMV/store growth; rewards fast-growing
platforms (Tiktok, Walmart) and penalizes shrinking ones (Ecwid, Big
Cartel) as a proxy for where future opportunity is heading.
- **Integration cost, inverted (10%)** - auth complexity and existing
codebase head start (Amazon's `domains/amazon` background-processor
infra) score higher; gated/undocumented webhook access (Big Cartel)
scores lowest. Weighted lowest since it affects timeline more than
whether the integration is worth doing at all.
**Scores:**
| Platform | GMV (45%) | API (30%) | Growth (15%) | Cost (10%) | Weighted total |
|---|---|---|---|---|---|
| Shopify | 9 | 10 | 8 | 9 | **9.15** |
| Amazon | 10 | 10 | 5 | 6 | **8.85** |
| Tiktok Shop | 7 | 7 | 10 | 4 | **7.15** |
| Walmart Marketplace | 4 | 9 | 9 | 5 | **6.35** |
| Wix | 4 | 10 | 5 | 8 | **6.35** |
| WooCommerce | 6 | 6 | 5 | 7 | **5.95** |
| Square Online | 3 | 10 | 5 | 8 | **5.90** |
| Ebay | 7 | 5 | 4 | 5 | **5.75** |
| Squarespace | 3 | 6 | 6 | 7 | **4.75** |
| *Etsy (reference, already live)* | *4* | *3* | *1* | *10* | *3.85* |
| Zoho | 1 | 6 | 6 | 7 | **3.85** |
| Ecwid | 1 | 6 | 1 | 8 | **3.20** |
| Big Cartel | 1 | 2 | 1 | 3 | **1.50** |
**Ranked priority order:**
1. **Shopify** — 9.15
2. **Amazon** — 8.85
3. **Tiktok Shop** — 7.15
4. **Walmart Marketplace** — 6.35
5. **Wix** — 6.35 (behind Walmart on the growth tiebreaker: 9 vs 5)
6. **WooCommerce** — 5.95
7. **Square Online** — 5.90
8. **Ebay** — 5.75
9. **Squarespace** — 4.75
10. **Etsy** — 3.85 (reference only - already live, not competing for "what to build next")
11. **Zoho** — 3.85
12. **Ecwid** — 3.20
13. **Big Cartel** — 1.50
Notable movement from the earlier qualitative pass: Shopify edges out Amazon
once integration cost and growth are counted, not just raw GMV - Amazon's
dollar opportunity is still bigger, but Shopify is cheaper to build, easier
to get first customers through (App Store), and still growing faster.
Tiktok Shop, unresearched until this pass, lands at #3 on the strength of
its growth rate alone - but see the sourcing caveat above before acting on
that; its API details need a dedicated research pass before it's actually
buildable. Big Cartel is unambiguously last: no inventory API is a
structural dealbreaker for this specific product, independent of its market
size.
**Etsy, run through the same rubric, scores 3.85 - tied with Zoho, below
every platform except Ecwid and Big Cartel.** Its GMV is mid-pack and
declining (-5.3% YoY), and - per the caveat above - it's the *only*
platform researched with literally no webhook/push mechanism for anything,
so it scores below every platform here except Big Cartel on API
completeness too. The only criterion where it dominates is integration cost
(10/10, since it's already built). This is a useful gut-check on the model,
not a claim that building Etsy first was a mistake - Etsy was presumably
chosen for reasons this rubric doesn't capture (an existing relationship, a
founder's market knowledge, being the most approachable API to get
developer credentials for), not for having the best growth/GMV/webhook
profile. Worth remembering when weighing this ranking against Etsy's actual
day-to-day integration cost, which - per the caveat above - has turned out
to be nontrivial in practice: with no webhooks at all, real order/inventory
sync has to be built as a polling loop, which is exactly the kind of
integration cost this rubric's "Cost" column doesn't capture once a
platform is more than superficially wired up.
Sources:
- [Shopify Statistics 2026: Market Share, $116B GMV, Employees](https://www.chargeflow.io/blog/shopify-statistics)
- [Shopify 2026: $378.4B GMV, Store and Seller Data](https://termsandconditionstemplate.com/shopify-statistics-2026)
- [Amazon Third-Party Sellers Generate $575 Billion in GMV](https://english.ebrun.com/20260719/688440.shtml)
- [Amazon GMV Surpassed $800 Billion in 2025 - Marketplace Pulse](https://www.marketplacepulse.com/articles/amazon-gmv-surpassed-800-billion-in-2025)
- [Top 1.6% of Sellers Drive 50% of Amazon's 3P GMV](https://www.marketplacepulse.com/articles/top-16-of-sellers-drive-50-of-amazons-3p-gmv)
- [WooCommerce Market Share 2026: 33.4% Global Stats](https://redstagfulfillment.com/what-is-woocommerces-market-share/)
- [BigCommerce Statistics 2026](https://www.chargeflow.io/blog/bigcommerce-statistics)
- [How many Walmart Marketplace sellers are there in 2025?](https://redstagfulfillment.com/how-many-walmart-marketplace-sellers/)
- [Walmart Marketplace Grows 50% in One Year - Marketplace Pulse](https://www.marketplacepulse.com/articles/walmart-marketplace-grows-50-in-one-year)
- [eBay Gross Merchandise Volume (GMV) 2018-2026 - Marketplace Pulse](https://www.marketplacepulse.com/stats/ebay-gross-merchandise-volume-gmv)
- [eBay Statistics (2026): Active Buyers, Listings, GMV, Revenue](https://expandedramblings.com/index.php/ebay-stats/)
- [The State of Wix in 2026](https://storeleads.app/reports/wix)
- [Squarespace Subscriber and Revenue Statistics for 2026](https://backlinko.com/squarespace-users)
- [Etsy, Inc. Reports Fourth Quarter and Full Year 2025 Results](https://investors.etsy.com/news-events/press-releases/detail/218/etsy-inc-reports-fourth-quarter-and-full-year-2025-results)
- [Block Statistics (2026): Sellers, GPV, Block Revenue](https://expandedramblings.com/index.php/square-statistics/)
+108
View File
@@ -0,0 +1,108 @@
(function(){
const defaultDebounceTimeInMs = 500;
let isReady = false
document.addEventListener('DOMContentLoaded', function() {
isReady = true
})
function onReady(fn) {
if (isReady || document.readyState === 'complete') {
fn()
} else {
document.addEventListener('DOMContentLoaded', fn)
}
}
htmx.defineExtension("resize", {
init: (api) => {
function hasResizeTrigger(elt) {
for (spec of api.getTriggerSpecs(elt)) {
if (spec.trigger === 'resize') {
return true;
}
}
return false;
}
function getDebounceTimeInMs(elt) {
const amountAttr = elt.attributes['hx-resize-debounce'];
if (!amountAttr) {
return defaultDebounceTimeInMs
}
const amountStr = amountAttr.value
if (!amountStr) {
return defaultDebounceTimeInMs
}
try {
return parseInt(amountStr);
} catch (err) {
console.warn('invalid debounce time on trigger:', trigger);
console.error(err);
return defaultDebounceTimeInMs;
}
}
let nextID = 1;
let timeoutIDs = {};
let observers = {};
function processNode(elt) {
if (!hasResizeTrigger(elt)) {
return;
}
let data = elt['hx-resize-internal-data'];
if (!data) {
data = {
id: nextID,
};
nextID += 1;
elt['hx-resize-internal-data'] = data;
}
const obs = new ResizeObserver(() => {
if (timeoutIDs[data.id]) {
return;
}
const debounceTime = getDebounceTimeInMs(elt);
timeoutIDs[data.id] = setTimeout(() => {
elt.dispatchEvent(new Event('resize'));
delete timeoutIDs[data.id];
}, debounceTime);
});
obs.observe(elt);
observers[data.id] = obs;
}
function cleanupNode(elt) {
if (!hasResizeTrigger(elt)) {
return;
}
let data = elt['hx-resize-internal-data'];
if (!data) {
return;
}
const obs = observers[data.id];
if (obs) {
obs.disconnect();
}
delete observers[data.id];
delete timeoutIDs[data.id];
}
// process nodes
htmx.on('htmx:beforeProcessNode', evt => processNode(evt.target));
htmx.on('htmx:beforeCleanupElement', evt => cleanupNode(evt.target));
onReady(function() {
document.querySelectorAll('[hx-trigger]').forEach(processNode);
})
}
});
})()
+297 -6
View File
@@ -3,36 +3,43 @@ package accounts
import (
"errors"
"fmt"
"slices"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"ruben/inventory2/consts"
"ruben/inventory2/domains/accounts"
"ruben/inventory2/domains/reports"
"ruben/inventory2/logging"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/param"
"ruben/inventory2/server/response"
"ruben/inventory2/server/sse"
"ruben/inventory2/server/ui/charts"
)
type accountSubrouter struct {
log *logging.Logger
accts *accounts.Store
pub *sse.UpdateNotificationPublisher
log *logging.Logger
accts *accounts.Store
reports *reports.Store
pub *sse.UpdateNotificationPublisher
}
func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
accts *accounts.Store,
reports *reports.Store,
pub *sse.UpdateNotificationPublisher,
) {
as := &accountSubrouter{
log: logger,
accts: accts,
pub: pub,
log: logger,
accts: accts,
reports: reports,
pub: pub,
}
r.POST("", response.Handler(as.createAccount))
@@ -46,6 +53,9 @@ func Routes(
mockShops.PUT("/listings/:listing-id", response.Handler(as.updateMockListing))
mockShops.DELETE("/listings/:listing-id", response.Handler(as.deleteMockListing))
mockListingCharts := r.Group("/:acctID/platforms/:platform/shops/mocks/:shop-id/listings/:listing-id/charts")
mockListingCharts.GET("/counts", response.Handler(as.getCountsChartForMockListing))
syncGroups := r.Group("/:acctID/inventory/sync-groups", pub.Publish("/:acctID/inventory/sync-groups"))
syncGroups.POST("", response.Handler(as.saveNewSyncGroup))
@@ -78,6 +88,14 @@ func Routes(
mockSyncGroupBeingEdited.PUT("/listings/:orderIndex/shop", response.Handler(as.setShopInListingInMockSyncGroupBeingEdited))
mockSyncGroupBeingEdited.PUT("/listings/:orderIndex/listing", response.Handler(as.setListingInListingInMockSyncGroupBeingEdited))
mockSyncGroupBeingEdited.DELETE("/listings/:orderIndex", response.Handler(as.deleteListingInListingInMockSyncGroupBeingEdited))
simulations := r.Group("/:acctID/simulations", pub.Publish("/:acctID/simulations"))
simulations.PUT("/sale/form/shop", response.Handler(as.setShopInMockSaleForm))
simulations.POST("/sale", response.Handler(as.postNewMockSale))
simulations.PUT("/refund/form/shop", response.Handler(as.setShopInMockRefundForm))
simulations.POST("/refund", response.Handler(as.postNewMockRefund))
simulations.PUT("/count/form/shop", response.Handler(as.setShopInMockCountForm))
simulations.PUT("/shops/:shopID/listings/:listingID/count", response.Handler(as.setMockListingCount))
}
// POST /
@@ -413,6 +431,135 @@ func (s *accountSubrouter) deleteMockListing(c *gin.Context) (response.Response,
return response.StatusNoContent(), nil
}
// TODO: accept dimensions
// GET /:acctID/platforms/:platform/shops/mocks/:shop-id/charts/counts
func (s *accountSubrouter) getCountsChartForMockListing(c *gin.Context) (response.Response, error) {
s.log.Debug("ENDPOINT HIT")
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
width float64
height float64
timezone string
)
err := param.Path("platform", param.Platform(&platform)).
Path("shop-id", param.Text(&shopID)).
Path("listing-id", param.Text(&listingID)).
Form("width", param.Float64(&width)).
Form("height", param.Float64(&height)).
Form("timezone", param.Text(&timezone)).
Unmarshal(c)
if err != nil {
return nil, err
}
if width <= 0 {
return nil, response.BadRequest().Msg("width must be positive")
}
if height <= 0 {
return nil, response.BadRequest().Msg("height must be positive")
}
report, err := s.reports.GetListingCountsReport(c, acctID, platform, shopID, listingID)
if err != nil {
return nil, fmt.Errorf("failed to get report: %w", err)
}
/*
bvs := make([]charts.BarValue, len(report.Counts))
for i, v := range report.Counts {
label := "start"
if v.EventTimestamp != nil {
label = v.EventTimestamp.Format(time.RFC822)
}
bvs[i] = charts.BarValue{
Label: label,
Value: float64(v.Count),
}
}
return response.StatusOK().
HTMLReader(charts.NewBar(bvs...).SVG().GetMarkupReader()), nil
*/
/*
lvs := make([]charts.LineValue, len(report.Counts))
for i, v := range report.Counts {
label := "initial"
if v.EventTimestamp != nil {
label = v.EventTimestamp.Format(time.RFC822)
}
lvs[i] = charts.LineValue{
Label: label,
Value: float64(v.Count),
}
}
*/
l, err := time.LoadLocation(timezone)
if err != nil {
s.log.Errorf("failed to parse time zone %s: %v; defaulting to UTC", timezone, err)
l = time.UTC
}
var times []time.Time
for _, v := range report.Counts {
if v.EventTimestamp == nil {
continue
}
times = append(times, *v.EventTimestamp)
}
var (
minTime time.Time
maxTime time.Time
initTime time.Time
)
if len(times) > 0 {
minTime = slices.MinFunc(times, func(a, b time.Time) int {
return int(a.UnixNano() - b.UnixNano())
}).In(l)
maxTime = slices.MaxFunc(times, func(a, b time.Time) int {
return int(a.UnixNano() - b.UnixNano())
}).In(l)
if len(times) > 1 {
avgIntervalPerUpdate := time.Duration((maxTime.UnixNano() - minTime.UnixNano())) / time.Duration(len(times)-1)
initTime = minTime.Add(-avgIntervalPerUpdate)
} else {
initTime = minTime.Add(-time.Minute)
}
}
lvs := make([]charts.TimeLineValue, len(report.Counts))
for i, v := range report.Counts {
ts := initTime
label := fmt.Sprint(v.Count)
if v.EventTimestamp != nil {
ts = v.EventTimestamp.In(l)
}
lvs[i] = charts.TimeLineValue{
Label: label,
Time: ts,
Value: float64(v.Count),
}
}
return response.StatusOK().
HTMLReader(
//charts.NewLineChart(width, height, lvs...).
charts.NewTimeLineChart(width, height, lvs...).
Foreground("var(--foreground)").
Background("var(--muted)").
SVG().
GetMarkupReader(),
), nil
}
// POST /:acctID/inventory/sync-groups/mock/draft/listings
func (s *accountSubrouter) createMockSyncGroupListingDraft(c *gin.Context) (response.Response, error) {
r := c.Request
@@ -736,6 +883,150 @@ func (s *accountSubrouter) deleteListingInListingInMockSyncGroupBeingEdited(c *g
return response.StatusOK(), nil
}
// PUT /:acctID/simulations/sale/form/shop
func (s *accountSubrouter) setShopInMockSaleForm(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Unmarshal(c); err != nil {
return nil, err
}
if err := s.accts.SetShopInMockSaleForm(c, acctID, platform, shopID); err != nil {
return nil, fmt.Errorf("failed to set shop in mock sale form: %w", err)
}
return response.StatusOK(), nil
}
// POST /:acctID/simulations/sale
func (s *accountSubrouter) postNewMockSale(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
count int
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Form("listing-id", param.Text(&listingID)).
Form("count", param.Int(&count)).
Unmarshal(c); err != nil {
return nil, err
}
if _, err := s.accts.SaveNewMockSale(c, acctID, platform, shopID, listingID, count); err != nil {
return nil, fmt.Errorf("failed to save new mock sale: %w", err)
}
return response.StatusCreated(), nil
}
// PUT /:acctID/simulations/refund/form/shop
func (s *accountSubrouter) setShopInMockRefundForm(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Unmarshal(c); err != nil {
return nil, err
}
if err := s.accts.SetShopInMockRefundForm(c, acctID, platform, shopID); err != nil {
return nil, fmt.Errorf("failed to set shop in mock refund form: %w", err)
}
return response.StatusOK(), nil
}
// POST /:acctID/simulations/refund
func (s *accountSubrouter) postNewMockRefund(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
count int
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Form("listing-id", param.Text(&listingID)).
Form("count", param.Int(&count)).
Unmarshal(c); err != nil {
return nil, err
}
if _, err := s.accts.SaveNewMockRefund(c, acctID, platform, shopID, listingID, count); err != nil {
return nil, fmt.Errorf("failed to save new mock refund: %w", err)
}
return response.StatusCreated(), nil
}
// PUT /:acctID/simulations/count/form/shop
func (s *accountSubrouter) setShopInMockCountForm(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
)
if err := param.Form("platform", param.Platform(&platform)).
Form("shop-id", param.Text(&shopID)).
Unmarshal(c); err != nil {
return nil, err
}
if err := s.accts.SetShopInMockCountForm(c, acctID, platform, shopID); err != nil {
return nil, fmt.Errorf("failed to set shop in mock count form: %w", err)
}
return response.StatusOK(), nil
}
// PUT /:acctID/simulations/shops/:shopID/listings/:listingID/count
func (s *accountSubrouter) setMockListingCount(c *gin.Context) (response.Response, error) {
acctID := auth.GetIdentity(c).Account.AccountID
var (
platform accounts.Platform
shopID string
listingID string
count int
)
if err := param.Form("platform", param.Platform(&platform)).
Path("shopID", param.Text(&shopID)).
Path("listingID", param.Text(&listingID)).
Form("count", param.Int(&count)).
Unmarshal(c); err != nil {
return nil, err
}
if _, err := s.accts.SaveNewMockInventoryReset(c, acctID, platform, shopID, listingID, count); err != nil {
return nil, fmt.Errorf("failed to save new mock inventory reset: %w", err)
}
return response.StatusOK(), nil
}
func lowerSnakeCase(s accounts.Platform) string {
return strings.ToLower(strings.Join(strings.Split(string(s), " "), "_"))
}
+5
View File
@@ -6,6 +6,7 @@ import (
"ruben/inventory2/domains/accounts"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/domains/reports"
"ruben/inventory2/logging"
accounts_api "ruben/inventory2/server/api/accounts"
auth_api "ruben/inventory2/server/api/auth"
@@ -22,14 +23,17 @@ func Routes(
auth *auth.Service,
sq *sse.Queue,
accts *accounts.Store,
reps *reports.Store,
unp *sse.UpdateNotificationPublisher,
rawEvents *raw_events.Store,
etsy *etsy_platform.Platform,
devAuthEnabled bool,
) {
auth_api.Routes(
r.Group("/auth"),
logger.WithGroup("/auth"),
auth.GetAuthenticator(),
devAuthEnabled,
)
sse_api.Routes(
r.Group("/events", auth.Authenticate()),
@@ -40,6 +44,7 @@ func Routes(
r.Group("/accounts", auth.Authenticate()),
logger.WithGroup("/accounts"),
accts,
reps,
unp.Group("/accounts"),
)
webhooks.Routes(
+62
View File
@@ -21,6 +21,7 @@ func Routes(
r *gin.RouterGroup,
logger *logging.Logger,
auth *authentication.Authenticator,
devAuthEnabled bool,
) {
ls := &loginSubrouter{
log: logger,
@@ -30,6 +31,12 @@ func Routes(
r.GET("/login", response.Handler(ls.loginPage))
r.GET("/login/callback", response.Handler(ls.loginCallback))
r.GET("/logout", response.Handler(ls.logoutPage))
if devAuthEnabled {
logger.Warn("DEV_AUTH_ENABLED is set: /api/auth/dev-login is live and lets any caller authenticate as any user_id with no credentials. Never enable this outside local development.")
r.GET("/dev-login", response.Handler(ls.devLoginPage))
r.GET("/dev-logout", response.Handler(ls.devLogoutPage))
}
}
func (s *loginSubrouter) loginPage(c *gin.Context) (response.Response, error) {
@@ -75,6 +82,43 @@ func (s *loginSubrouter) loginCallback(c *gin.Context) (response.Response, error
Cookie(cookies.AccessToken(accessToken, expiration)), nil
}
// devLoginPage mints a local session for a user_id, skipping the real Auth0
// OAuth round-trip. Only registered when devAuthEnabled is passed to Routes.
//
// Query params:
// - user_id: identity to log in as (default "dev-user"); use different
// values to test multiple accounts side by side.
// - name: display name for the identity (default derived from user_id).
// - target: where to redirect after login (default "/").
func (s *loginSubrouter) devLoginPage(c *gin.Context) (response.Response, error) {
r := c.Request
ctx := r.Context()
q := r.URL.Query()
userID := q.Get("user_id")
if userID == "" {
userID = "dev-user"
}
name := q.Get("name")
if name == "" {
name = "Dev User (" + userID + ")"
}
targetURI := q.Get("target")
if targetURI == "" {
targetURI = "/"
}
accessToken, expiration, err := s.auth.DevLogin(ctx, userID, name)
if err != nil {
return nil, response.Errorf("failed to create dev session: %w", err)
}
return response.TemporaryRedirect(targetURI).
Cookie(cookies.AccessToken(accessToken, expiration)), nil
}
func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
r := c.Request
@@ -92,3 +136,21 @@ func (s *loginSubrouter) logoutPage(c *gin.Context) (response.Response, error) {
return response.TemporaryRedirect(s.auth.GetLogoutURL(host).String()).
Cookie(cookies.Expired("access_token")), nil
}
func (s *loginSubrouter) devLogoutPage(c *gin.Context) (response.Response, error) {
r := c.Request
host := r.Header.Get("X-Forwarded-Host")
if host == "" {
host = r.Host
}
if ck, err := r.Cookie("access_token"); err == nil && ck != nil {
if err := s.auth.DeleteOAuthTokens(r.Context(), ck.Value); err != nil {
s.log.Error("failed to delete auth token", "error", err)
}
}
return response.TemporaryRedirect("/ui").
Cookie(cookies.Expired("access_token")), nil
}
+14
View File
@@ -21,6 +21,10 @@ func Int64(dst *int64) encoding.TextUnmarshaler {
return (*int64Text)(dst)
}
func Float64(dst *float64) encoding.TextUnmarshaler {
return (*float64Text)(dst)
}
func Bool(dst *bool) encoding.TextUnmarshaler {
return (*boolText)(dst)
}
@@ -33,6 +37,7 @@ type (
rawText string
intText int
int64Text int64
float64Text float64
boolText bool
platformText accounts.Platform
)
@@ -60,6 +65,15 @@ func (n *int64Text) UnmarshalText(text []byte) error {
return nil
}
func (n *float64Text) UnmarshalText(text []byte) error {
i, err := strconv.ParseFloat(string(text), 64)
if err != nil {
return err
}
*n = float64Text(i)
return nil
}
func (b *boolText) UnmarshalText(text []byte) error {
v, err := strconv.ParseBool(string(text))
if err != nil {
+3 -1
View File
@@ -65,7 +65,9 @@ func (s Spec) Unmarshal(c *gin.Context) error {
for k, dst := range s.form {
v, ok := c.GetPostForm(k)
if !ok || v == "" {
return response.BadRequest().Msgf("no %s provided", k)
if v, ok = c.GetQuery(k); !ok {
return response.BadRequest().Msgf("no %s provided", k)
}
}
if err := dst.UnmarshalText([]byte(v)); err != nil {
return response.BadRequest().Wrap(err).Msgf("invalid %s provided", k)
+4
View File
@@ -47,6 +47,10 @@ func (b bodyRes) HTML(body []byte) Response {
return HTML(body).wrap(b)
}
func (b bodyRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(b)
}
func (b bodyRes) JSON(body any) Response {
return JSON(body).wrap(b)
}
+4
View File
@@ -47,6 +47,10 @@ func (c cookieRes) HTML(body []byte) Response {
return HTML(body).wrap(c)
}
func (c cookieRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(c)
}
func (c cookieRes) JSON(body any) Response {
return JSON(body).wrap(c)
}
+4
View File
@@ -103,6 +103,10 @@ func (h headerRes) HTML(body []byte) Response {
return HTML(body).wrap(h)
}
func (h headerRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(h)
}
func (h headerRes) JSON(body any) Response {
return JSON(body).wrap(h)
}
+23 -3
View File
@@ -11,8 +11,9 @@ import (
type (
htmlRes struct {
body []byte
res Response
body []byte
reader io.Reader
res Response
}
)
@@ -24,6 +25,12 @@ func HTML(body []byte) Response {
}
}
func HTMLReader(body io.Reader) Response {
return htmlRes{
reader: body,
}
}
func (h htmlRes) String() string {
if h.res != nil {
return fmt.Sprintf(`{"body": %q, "nested": %s}`, string(h.body), h.res)
@@ -46,6 +53,13 @@ func (h htmlRes) Redirect(code redirect.Code, to string) Response {
func (h htmlRes) HTML(body []byte) Response {
h.body = body
h.reader = nil
return h
}
func (h htmlRes) HTMLReader(body io.Reader) Response {
h.body = nil
h.reader = body
return h
}
@@ -133,7 +147,13 @@ func (h htmlRes) GetRedirect() (code redirect.Code, to string, ok bool) {
}
func (h htmlRes) getBody() (body io.ReadCloser, ok bool, err error) {
return io.NopCloser(bytes.NewBuffer(h.body)), true, nil
var r io.Reader
if h.reader != nil {
r = h.reader
} else {
r = bytes.NewBuffer(h.body)
}
return io.NopCloser(r), true, nil
}
func (h htmlRes) getCookies() []http.Cookie {
+4
View File
@@ -49,6 +49,10 @@ func (j jsonRes) HTML(body []byte) Response {
return HTML(body).wrap(j)
}
func (j jsonRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(j)
}
func (j jsonRes) JSON(body any) Response {
j.body = body
return j
+4
View File
@@ -88,6 +88,10 @@ func (r redirectRes) HTML(body []byte) Response {
return HTML(body).wrap(r)
}
func (r redirectRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(r)
}
func (r redirectRes) JSON(body any) Response {
return JSON(body).wrap(r)
}
+1
View File
@@ -25,6 +25,7 @@ type (
Redirect(code redirect.Code, to string) Response
Body(io.ReadCloser) Response
HTML([]byte) Response
HTMLReader(io.Reader) Response
JSON(any) Response
Cookie(http.Cookie) Response
+4
View File
@@ -64,6 +64,10 @@ func (s statusRes) HTML(body []byte) Response {
return HTML(body).wrap(s)
}
func (s statusRes) HTMLReader(body io.Reader) Response {
return HTMLReader(body).wrap(s)
}
func (s statusRes) JSON(body any) Response {
return JSON(body).wrap(s)
}
+11
View File
@@ -12,6 +12,7 @@ import (
"ruben/inventory2/domains/authentication"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/domains/reports"
"ruben/inventory2/logging"
"ruben/inventory2/server/api"
"ruben/inventory2/server/auth"
@@ -30,8 +31,10 @@ func NewRouter(
contentDir string,
rawEvents *raw_events.Store,
accts *accounts.Store,
reps *reports.Store,
etsy *etsy_platform.Platform,
authr *authentication.Authenticator,
devAuthEnabled bool,
) *Router {
authM := auth.NewService(
logger.WithGroup("auth-middleware"),
@@ -59,8 +62,10 @@ func NewRouter(
"/ui",
rawEvents,
accts,
reps,
etsy,
authM.Authenticate(),
devAuthEnabled,
)
// non-html content: scripts, styles, images, etc
@@ -92,9 +97,11 @@ func NewRouter(
authM,
sq,
accts,
reps,
unp,
rawEvents,
etsy,
devAuthEnabled,
)
return &Router{
@@ -107,6 +114,10 @@ func (r *Router) RunSSE(ctx context.Context) error {
return r.sse.Start(ctx)
}
func (r *Router) GetSSEQueue() *sse.Queue {
return r.sse
}
func fileServer(urlPrefix, dir string, beforeServe func(c *gin.Context)) gin.HandlerFunc {
scfs := http.StripPrefix(urlPrefix, http.FileServer(http.Dir(dir)))
return func(c *gin.Context) {
+51
View File
@@ -0,0 +1,51 @@
package sse
import (
"context"
"fmt"
"ruben/inventory2/domains/raw_events"
)
type (
DBEventPublisher struct {
queue sender
getAccountID func(context.Context, raw_events.Event) (int64, error)
getEventType func(acctID int64, e raw_events.Event) (string, error)
}
)
func (q *Queue) NewDBEventPublisher(
getAccountID func(context.Context, raw_events.Event) (int64, error),
getEventType func(acctID int64, e raw_events.Event) (string, error),
) *DBEventPublisher {
return &DBEventPublisher{
queue: q,
getAccountID: getAccountID,
getEventType: getEventType,
}
}
// Notify publishes e to the SSE queue. The publish itself is quick and
// synchronous, so it acks inline once it succeeds - there's no separate
// async completion to wait for here.
func (p *DBEventPublisher) Notify(ctx context.Context, e raw_events.Event, ack func(context.Context) error) error {
acctID, err := p.getAccountID(ctx, e)
if err != nil {
return fmt.Errorf("failed to get account id: %w", err)
}
et, err := p.getEventType(acctID, e)
if err != nil {
return fmt.Errorf("failed to compute event type: %w", err)
}
if err := p.queue.Send(ctx, StandardEvent(acctID, et)); err != nil {
return fmt.Errorf("failed to send sse event to listener: %w", err)
}
if err := ack(ctx); err != nil {
return fmt.Errorf("failed to ack event: %w", err)
}
return nil
}
+1 -12
View File
@@ -22,11 +22,6 @@ type (
trimBasePath string
basePathPattern string
}
// sender is satisfied by *Queue
sender interface {
Send(ctx context.Context, e Event) error
}
)
func (q *Queue) NewUpdateNotificationPublisher(
@@ -110,11 +105,7 @@ func (p *UpdateNotificationPublisher) Publish(pathPattern string) gin.HandlerFun
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
if err := p.queue.Send(ctx, Event{
AccountID: acctID,
Type: e,
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, e)),
}); err != nil {
if err := p.queue.Send(ctx, StandardEvent(acctID, e)); err != nil {
p.log.Errorf("failed to send sse event to listener: %v", err)
}
}()
@@ -146,8 +137,6 @@ func (p *UpdateNotificationPublisher) Push(ctx context.Context, acctID int64, ev
wg.Wait()
return errors.Join(errs...)
return nil
}
func getPathSegments(p string) []string {
+8
View File
@@ -124,6 +124,14 @@ func (q *Queue) Send(ctx context.Context, e Event) error {
return nil
}
func StandardEvent(acctID int64, eventType string) Event {
return Event{
AccountID: acctID,
Type: eventType,
Data: []byte(fmt.Sprintf(`{"eventType": %q}`, eventType)),
}
}
func (e *Event) Write(w http.ResponseWriter) {
fmt.Fprintf(
w,
+10
View File
@@ -0,0 +1,10 @@
package sse
import "context"
type (
// sender is satisfied by *Queue
sender interface {
Send(ctx context.Context, e Event) error
}
)
+159
View File
@@ -0,0 +1,159 @@
package charts
import (
"fmt"
"slices"
"strings"
"ruben/inventory2/server/ui/svg"
)
type (
// TODO: rename to BarChart
Bar struct {
values []BarValue
classes barChartClasses
}
BarValue struct {
Label string
Value float64
}
barChartClasses struct {
svg []string
bar []string
}
)
func NewBar(vs ...BarValue) *Bar {
return &Bar{
values: vs,
}
}
func (c *Bar) WithSVGClass(classes ...string) *Bar {
c.classes.svg = append(c.classes.svg, classes...)
return c
}
func (c *Bar) WithBarClass(classes ...string) *Bar {
c.classes.bar = append(c.classes.bar, classes...)
return c
}
func (c *Bar) SVG() svg.MarshalerReader {
const (
minX = 0
minY = 0
maxX = 100
maxY = 100
rangeX = maxX - minX
rangeY = maxY - minY
)
s := svg.NewSVG().
Attr(
svg.Style{
"width": "100%",
"height": "auto",
"padding": "1em",
"border-width": "2px",
},
svg.ViewBox{
X: minX,
Y: minY,
Width: maxX - minX,
Height: maxY - minY,
},
svg.Class(
strings.Join(append(
[]string{"rounded-lg", "border-border"},
c.classes.bar...,
), " "),
),
)
if len(c.classes.svg) != 0 {
s = s.Attr(svg.Class(strings.Join(c.classes.svg, " ")))
}
if len(c.values) > 0 {
minV := slices.MinFunc(c.values, func(a, b BarValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
})
maxV := slices.MaxFunc(c.values, func(a, b BarValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
})
rangeV := maxV.Value - minV.Value
spacePerBar := float64(rangeX) / float64(len(c.values))
barPadding := 0.025 * spacePerBar
barWidth := spacePerBar - (2 * barPadding)
fontSize := barWidth / 2
for i, v := range c.values {
scaledV := (v.Value - minV.Value) * rangeY / rangeV
barX := float64(i)*spacePerBar + barPadding
s = s.Child(
new(svg.Rect).
Attr(
svg.NewLength(barX).AsX(),
svg.NewLength(maxY-scaledV).AsY(),
svg.NewLength(barWidth).AsWidth(),
svg.NewLength(scaledV).AsHeight(),
svg.Class(
strings.Join(append(
[]string{"fill-accent"},
c.classes.bar...,
), " "),
),
),
svg.NewText(fmt.Sprint(v.Value)).
Attr(
svg.NewLength(barX+(barWidth/2)).AsX(),
svg.NewLength(maxY-scaledV+fontSize).AsY(),
svg.Fill("var(--accent-foreground)"),
svg.TextAnchorMiddle,
// svg.NewLength(fontSize).AsFontSize(),
svg.Style{
"font-size": fmt.Sprintf("%vpx", fontSize),
},
),
)
if v.Label != "" {
x := barX + (barWidth / 2)
y := maxY - fontSize
s = s.Child(
svg.NewText(v.Label).
Attr(
svg.NewLength(x).AsX(),
svg.NewLength(y).AsY(),
svg.Fill("var(--accent-foreground)"),
//svg.NewLength(fontSize).AsFontSize(),
svg.Style{
"font-size": fmt.Sprintf("%vpx", fontSize),
},
svg.DominantBaselineCentral,
svg.TransformRotate(-90).About(x, y),
),
)
}
}
}
return s
}
File diff suppressed because one or more lines are too long
+236
View File
@@ -0,0 +1,236 @@
package charts
import (
"ruben/inventory2/server/ui/svg"
"slices"
)
type (
LineChart struct {
width float64
height float64
foreground string
background string
values []LineValue
max *float64
min *float64
}
LineValue struct {
Label string
Value float64
}
)
func NewLineChart(width, height float64, vs ...LineValue) *LineChart {
return &LineChart{
width: width,
height: height,
values: vs,
}
}
func (c *LineChart) Max(n float64) *LineChart {
c.max = &n
return c
}
func (c *LineChart) Min(n float64) *LineChart {
c.min = &n
return c
}
func (c *LineChart) Foreground(fg string) *LineChart {
c.foreground = fg
return c
}
func (c *LineChart) Background(bg string) *LineChart {
c.background = bg
return c
}
func (c *LineChart) SVG() svg.MarshalerReader {
var (
minVal float64
maxVal float64
)
fg := "black"
if c.foreground != "" {
fg = c.foreground
}
bg := c.background
if c.min != nil {
minVal = *c.min
} else if len(c.values) > 0 {
minVal = slices.MinFunc(c.values, compareLineValues).Value
}
if c.max != nil {
maxVal = *c.max
} else if len(c.values) > 0 {
maxVal = slices.MaxFunc(c.values, compareLineValues).Value
}
svgBg := bg
if svgBg == "" {
svgBg = "auto"
}
s := svg.NewSVG().
Attr(
svg.NewLength(c.width).
AsWidth(),
svg.NewLength(c.height).
AsHeight(),
svg.Style{
// "width": "100%",
// "height": "100%",
"background": svgBg,
},
/*
svg.ViewBox{
X: 0,
Y: minVal,
Width: float64(len(c.values) - 1),
Height: maxVal - minVal,
},
*/
svg.ViewBox{
X: 0,
Y: 0,
Width: c.width,
Height: c.height,
},
svg.PreserveAspectRatio{},
)
g := new(svg.G).
Attr(
// getChartOrientationTransform(c.height),
svg.Transform{
// scale down to provide padding
svg.TransformTranslate{
X: 0.05 * c.width,
Y: 0.05 * c.height,
},
svg.TransformScale{
X: 0.9,
Y: 0.9,
},
// flip
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
Y: -c.height,
},
},
)
// lines
pts := make(svg.Points, len(c.values))
for i, p := range c.values {
pts[i] = svg.Point{
X: (c.width / float64(len(c.values)-1)) * float64(i),
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
}
}
g = g.Child(
new(svg.Polyline).Attr(
pts,
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
svg.Stroke(fg),
svg.Fill("none"),
),
)
// points
if len(c.values) > 0 {
r := 1.0 / float64(len(c.values))
rx := r
ry := r
for i, p := range c.values {
// dot on the line graph
x := pts[i].X
y := pts[i].Y
ell := new(svg.Ellipse).
Attr(
svg.NewLength(x).
AsCX(),
svg.NewLength(y).
AsCY(),
svg.Percentage(rx).
AsRX(),
svg.Percentage(ry).
AsRY(),
svg.Stroke(fg),
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
)
if bg != "" {
ell = ell.Attr(
svg.Fill(bg),
)
} else {
ell = ell.Attr(
svg.Fill(fg),
)
}
g = g.Child(
ell,
// label
upsideDownCenteredText(p.Label, x, y).
Attr(
svg.NewLength(c.height*0.05).
Unit(svg.Px).
AsFontSize(),
),
)
}
}
return s.Child(g)
}
func compareLineValues(a, b LineValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
}
func upsideDownCenteredText(txt string, x, y float64) *svg.Text {
return svg.NewText(txt).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.NewLength(x).
AsX(),
svg.NewLength(y).
AsY(),
upsideDownTransform(x, y),
)
}
+1
View File
@@ -0,0 +1 @@
<html><body><svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100" style="background: pink" viewBox="0 0 100 100" preserveAspectRatio="none"><g transform="translate(5 5) scale(0.9 0.9) scale(1 -1) translate(0 -100)"><polyline points="0,2.5 20,5 40,10 60,20 80,40 100,80" stroke-width="2px" vector-effect="non-scaling-stroke" stroke="purple" fill="none"/><ellipse cx="0" cy="2.5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="0" y="2.5" transform="translate(0 2.5) scale(1 -1) translate(-0 -2.5)" font-size="5px"></text><ellipse cx="20" cy="5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="20" y="5" transform="translate(20 5) scale(1 -1) translate(-20 -5)" font-size="5px"></text><ellipse cx="40" cy="10" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="40" y="10" transform="translate(40 10) scale(1 -1) translate(-40 -10)" font-size="5px"></text><ellipse cx="60" cy="20" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="60" y="20" transform="translate(60 20) scale(1 -1) translate(-60 -20)" font-size="5px"></text><ellipse cx="80" cy="40" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="80" y="40" transform="translate(80 40) scale(1 -1) translate(-80 -40)" font-size="5px"></text><ellipse cx="100" cy="80" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="100" y="80" transform="translate(100 80) scale(1 -1) translate(-100 -80)" font-size="5px"></text></g></svg></body></html>
+40
View File
@@ -0,0 +1,40 @@
package charts
import (
"fmt"
"os"
)
func ExampleLineChart() {
c := NewLineChart(100, 100,
LineValue{
Value: 1,
},
LineValue{
Value: 2,
},
LineValue{
Value: 4,
},
LineValue{
Value: 8,
},
LineValue{
Value: 16,
},
LineValue{
Value: 32,
},
).
Min(0).
Max(40).
Foreground("purple").
Background("pink").
SVG().
GetMarkup()
output := []byte(`<html><body>` + c + `</body></html>`)
os.WriteFile("./line_chart.html", []byte(output), 0666)
fmt.Println(c)
// Output: <svg version="1.1" xmlns="http://www.w3.org/2000/svg" width="100" height="100" style="background: pink" viewBox="0 0 100 100" preserveAspectRatio="none"><g transform="translate(5 5) scale(0.9 0.9) scale(1 -1) translate(0 -100)"><polyline points="0,2.5 20,5 40,10 60,20 80,40 100,80" stroke-width="2px" vector-effect="non-scaling-stroke" stroke="purple" fill="none"/><ellipse cx="0" cy="2.5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="0" y="2.5" transform="translate(0 2.5) scale(1 -1) translate(-0 -2.5)" font-size="5px"></text><ellipse cx="20" cy="5" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="20" y="5" transform="translate(20 5) scale(1 -1) translate(-20 -5)" font-size="5px"></text><ellipse cx="40" cy="10" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="40" y="10" transform="translate(40 10) scale(1 -1) translate(-40 -10)" font-size="5px"></text><ellipse cx="60" cy="20" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="60" y="20" transform="translate(60 20) scale(1 -1) translate(-60 -20)" font-size="5px"></text><ellipse cx="80" cy="40" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="80" y="40" transform="translate(80 40) scale(1 -1) translate(-80 -40)" font-size="5px"></text><ellipse cx="100" cy="80" rx="0.16666666666666666%" ry="0.16666666666666666%" stroke="purple" stroke-width="2px" vector-effect="non-scaling-stroke" fill="pink"/><text text-anchor="middle" dominant-baseline="middle" x="100" y="80" transform="translate(100 80) scale(1 -1) translate(-100 -80)" font-size="5px"></text></g></svg>
}
+32
View File
@@ -0,0 +1,32 @@
package charts
import "ruben/inventory2/server/ui/svg"
func getChartOrientationTransform(height float64) svg.Transform {
return svg.Transform{
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
Y: -height,
},
}
}
func upsideDownTransform(x, y float64) svg.Transform {
return svg.Transform{
svg.TransformTranslate{
X: x,
Y: y,
},
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
X: -x,
Y: -y,
},
}
}
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 7.1 KiB

+425
View File
@@ -0,0 +1,425 @@
package charts
import (
"ruben/inventory2/server/ui/svg"
"slices"
"time"
)
type (
TimeLineChart struct {
width float64
height float64
foreground string
background string
values []TimeLineValue
max *float64
min *float64
}
TimeLineValue struct {
Label string
Time time.Time
Value float64
}
)
func NewTimeLineChart(width, height float64, vs ...TimeLineValue) *TimeLineChart {
return &TimeLineChart{
width: width,
height: height,
values: validValues(vs),
}
}
func validValues(vs []TimeLineValue) []TimeLineValue {
values := make([]TimeLineValue, 0, len(vs))
for _, v := range vs {
if !v.Time.IsZero() {
values = append(values, v)
}
}
return values
}
func (c *TimeLineChart) Max(n float64) *TimeLineChart {
c.max = &n
return c
}
func (c *TimeLineChart) Min(n float64) *TimeLineChart {
c.min = &n
return c
}
func (c *TimeLineChart) Foreground(fg string) *TimeLineChart {
c.foreground = fg
return c
}
func (c *TimeLineChart) Background(bg string) *TimeLineChart {
c.background = bg
return c
}
func (c *TimeLineChart) SVG() svg.MarshalerReader {
return svg.NewSVG().
Attr(
svg.NewLength(c.width).
AsWidth(),
svg.NewLength(c.height).
AsHeight(),
svg.Style{
"background": c.getOuterSVGBackground(),
},
svg.ViewBox{
X: 0,
Y: 0,
Width: c.width,
Height: c.height,
},
svg.PreserveAspectRatio{},
).
Child(
new(svg.G).
Attr(
svg.Transform{
// scale down to provide padding
svg.TransformTranslate{
X: 0.05 * c.width,
Y: 0.05 * c.height,
},
svg.TransformScale{
X: 0.9,
Y: 0.85, // a little more padding on the bottom
},
// flip
svg.TransformScale{
X: 1,
Y: -1,
},
svg.TransformTranslate{
Y: -c.height,
},
},
).
Child(c.getChildren()...),
)
}
func (c *TimeLineChart) getOuterSVGBackground() string {
if c.background != "" {
return c.background
}
return "auto"
}
func (c *TimeLineChart) getChildren() []svg.GChildren {
pts := c.getPoints()
return append(
append(
[]svg.GChildren{
new(svg.Polyline).Attr(
pts,
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
svg.Stroke(c.getStroke()),
svg.Fill("none"),
),
},
c.buildDots(pts)...,
),
c.buildLabels(pts)...,
)
}
func (c *TimeLineChart) getPoints() svg.Points {
minVal, _ := c.getMinVal()
maxVal, _ := c.getMaxVal()
minTime, maxTime := c.getMinAndMaxUnixTimes()
pts := make(svg.Points, len(c.values))
for i, p := range c.values {
pts[i] = svg.Point{
X: c.width * float64(p.Time.UnixNano()-minTime) / float64(maxTime-minTime),
Y: ((p.Value - minVal) * c.height) / (maxVal - minVal),
}
}
return pts
}
func (c *TimeLineChart) getMinVal() (float64, bool) {
if c.min != nil {
return *c.min, true
}
if values := c.values; len(values) > 0 {
return slices.MinFunc(values, compareTimeLineValues).Value, true
}
return 0, false
}
func (c *TimeLineChart) getMaxVal() (float64, bool) {
if c.max != nil {
return *c.max, true
}
if len(c.values) > 0 {
return slices.MaxFunc(c.values, compareTimeLineValues).Value, true
}
return 0, false
}
func compareTimeLineValues(a, b TimeLineValue) int {
if a.Value < b.Value {
return -1
}
if a.Value > b.Value {
return 1
}
return 0
}
func (c *TimeLineChart) getMinAndMaxUnixTimes() (minTime, maxTime int64) {
if len(c.values) > 0 {
return slices.MinFunc(c.values, compareTimeLineTimeValues).Time.UnixNano(),
slices.MaxFunc(c.values, compareTimeLineTimeValues).Time.UnixNano()
}
return 0, 0
}
func compareTimeLineTimeValues(a, b TimeLineValue) int {
at := a.Time
bt := b.Time
an := at.UnixNano()
bn := bt.UnixNano()
if at.IsZero() {
an = 0
}
if bt.IsZero() {
bn = 0
}
return int(an - bn)
}
func (c *TimeLineChart) buildDots(pts svg.Points) []svg.GChildren {
if len(c.values) == 0 {
return nil
}
chn := make([]svg.GChildren, len(c.values))
for i, p := range pts {
chn[i] = c.buildDot(p)
}
return chn
}
func (c *TimeLineChart) buildDot(p svg.Point) *svg.Circle {
return new(svg.Circle).
Attr(
svg.NewLength(p.X).
AsCX(),
svg.NewLength(p.Y).
AsCY(),
svg.NewLength(0.01*min(c.width, c.height)).
AsR(),
svg.Stroke(c.getStroke()),
svg.NewLength(2).
Unit(svg.Px).
AsStrokeWidth(),
svg.VectorEffectNonScalingStroke,
c.getDotFill(),
)
}
func (c *TimeLineChart) getDotFill() svg.Fill {
if c.background != "" {
return svg.Fill(c.background)
} else {
return svg.Fill(c.getStroke())
}
}
func (c *TimeLineChart) buildLabels(pts svg.Points) []svg.GChildren {
if len(c.values) == 0 {
return nil
}
chn := make([]svg.GChildren, len(c.values))
for i, p := range pts {
chn[i] = c.buildLabel(c.values[i], p)
}
return chn
}
func (c *TimeLineChart) buildLabel(v TimeLineValue, p svg.Point) *svg.Element[svg.SVGTag, svg.SVGAttribute, svg.SVGChildren] {
return svg.NewSVG().
Attr(
svg.NewLength(p.X).
AsX(),
svg.NewLength(p.Y).
AsY(),
svg.NewLength(24).
Unit(svg.Px).
AsWidth(),
svg.NewLength(24).
Unit(svg.Px).
AsHeight(),
svg.Class("group"),
svg.Style{
"overflow": "visible",
"fill": "var(--accent-foreground)",
},
svg.ViewBox{
X: 0,
Y: 0,
Width: 1,
Height: 1,
},
svg.PreserveAspectRatio{
Align: &svg.AlignValue{
X: svg.AlignMid,
Y: svg.AlignMid,
},
},
).
Child(
c.buildAlwaysDisplayedLabelText(v, p),
new(svg.Rect).
Attr(
svg.NewLength(0.1).
AsStrokeWidth(),
svg.Fill("var(--muted)"),
svg.Stroke("var(--accent-foreground)"),
svg.NewLength(2.5).
AsHeight(),
svg.NewLength(-5).
AsX(),
svg.NewLength(-3).
AsY(),
svg.NewLength(10).
AsWidth(),
svg.Class("not-group-hover:hidden"),
),
c.buildHoverLabelText(v, p),
)
}
func (c *TimeLineChart) buildAlwaysDisplayedLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
return c.buildLabelText(v, p)
}
func (c *TimeLineChart) buildHoverLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
labelText := c.buildLabelText(v, p).
Attr(
svg.Class("not-group-hover:hidden"),
)
if v.Time.IsZero() {
return labelText
}
return labelText.Child(
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.Style{
"font-size": "1px",
},
svg.Class("not-group-hover:hidden"),
svg.NewLength(0).
AsX(),
svg.NewLength(2.5).
AsY(),
),
)
}
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
return new(svg.G).
Attr(
svg.NewLength(0).
AsX(),
svg.NewLength(0).
AsY(),
upsideDownTransform(0, 0),
).
Child(
svg.NewText(v.Label).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.Style{
"font-size": "1px",
},
svg.NewLength(0).
AsX(),
svg.NewLength(1.25).
AsY(),
),
)
}
/*
func (c *TimeLineChart) buildLabelText(v TimeLineValue, p svg.Point) *svg.Element[svg.GTag, svg.GAttribute, svg.GChildren] {
labelText := new(svg.G).
Attr(
svg.NewLength(0).
AsX(),
svg.NewLength(0).
AsY(),
upsideDownTransform(0, 0),
).
Child(
svg.NewText(v.Label).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.Style{
"font-size": "1px",
},
svg.NewLength(0).
AsX(),
svg.NewLength(1.25).
AsY(),
),
)
if v.Time.IsZero() {
return labelText
}
return labelText.Child(
svg.NewText(v.Time.Format("Jan _2 3:04:05 PM")).
Attr(
svg.TextAnchorMiddle,
svg.DominantBaselineMiddle,
svg.Style{
"font-size": "1px",
},
svg.Class("not-group-hover:hidden"),
svg.NewLength(0).
AsX(),
svg.NewLength(2.5).
AsY(),
),
)
}
*/
func (c *TimeLineChart) getStroke() string {
if c.foreground != "" {
return c.foreground
}
return "black"
}
+68 -11
View File
@@ -16,6 +16,7 @@ import (
"ruben/inventory2/domains/accounts"
etsy_platform "ruben/inventory2/domains/platforms/etsy"
"ruben/inventory2/domains/raw_events"
"ruben/inventory2/domains/reports"
"ruben/inventory2/logging"
"ruben/inventory2/server/auth"
"ruben/inventory2/server/response"
@@ -23,12 +24,14 @@ import (
type (
webpageRouter struct {
log *logging.Logger
uiPath string
templater *templater.Templater
rawEvents *raw_events.Store
accts *accounts.Store
etsy *etsy_platform.Platform
log *logging.Logger
uiPath string
templater *templater.Templater
rawEvents *raw_events.Store
accts *accounts.Store
reports *reports.Store
etsy *etsy_platform.Platform
devAuthEnabled bool
}
// ErrTemplateNotFound is returned if the reason the template failed to compile
@@ -40,12 +43,14 @@ type (
func Routes(
logger *logging.Logger,
r gin.IRoutes,
r gin.IRouter,
uiPath string,
rawEvents *raw_events.Store,
accts *accounts.Store,
reps *reports.Store,
etsy *etsy_platform.Platform,
authenticate gin.HandlerFunc,
devAuthEnabled bool,
) {
s := &webpageRouter{
@@ -98,6 +103,39 @@ func Routes(
"multInt": func(a, b int) int {
return a * b
},
"divInt": func(a, b int) int {
return a / b
},
"intToFloat": func(n int) float64 {
return float64(n)
},
"addInt64": func(a, b int64) int64 {
return a + b
},
"subInt64": func(a, b int64) int64 {
return a - b
},
"multInt64": func(a, b int64) int64 {
return a * b
},
"divInt64": func(a, b int64) int64 {
return a / b
},
"int64ToFloat": func(n int64) float64 {
return float64(n)
},
"addFloat": func(a, b float64) float64 {
return a + b
},
"subFloat": func(a, b float64) float64 {
return a - b
},
"multFloat": func(a, b float64) float64 {
return a * b
},
"divFloat": func(a, b float64) float64 {
return a / b
},
// html
"rawHTML": func(s string) template.HTML {
@@ -141,9 +179,11 @@ func Routes(
}
},
}),
rawEvents: rawEvents,
accts: accts,
etsy: etsy,
rawEvents: rawEvents,
accts: accts,
reports: reps,
etsy: etsy,
devAuthEnabled: devAuthEnabled,
}
r.GET("", response.Handler(s.redirectToAccountsIfLoggedInWithAnAccount), response.Handler(s.serveTemplate))
@@ -170,10 +210,23 @@ func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error)
r := c.Request
ctx := r.Context()
id := auth.GetIdentity(ctx)
var mockMode bool
if id.Account != nil {
var err error
if mockMode, err = s.accts.GetMockMode(ctx, id.Account.AccountID); err != nil {
return nil, err
}
}
args := []any{
"Request",
r,
// dev mode
"DevAuthEnabled",
s.devAuthEnabled,
// add services and data here
"RawEvents",
s.rawEvents.WithContext(ctx),
@@ -181,12 +234,16 @@ func (s *webpageRouter) serveTemplate(c *gin.Context) (response.Response, error)
newURLCalculator(r.URL),
"Accounts",
s.accts.WithContext(ctx),
"Reports",
s.reports.WithContext(ctx),
"Etsy",
s.etsy.WithContext(ctx),
"MockMode",
mockMode,
// auth tooling
"Identity",
auth.GetIdentity(ctx),
id,
"Auth",
newTemplateAuthenticator(r),
}
+24
View File
@@ -0,0 +1,24 @@
package svg
import (
"fmt"
)
type (
// Attribute is the minimum method set of of an svg attribute.
Attribute interface {
// PrintKey must return the svg attribute key
PrintKey() string
// PrintVAlue must return the svg attribute value string, if applicable
PrintValue() (string, bool)
}
)
func PrintAttribute(a Attribute) string {
v, ok := a.PrintValue()
if !ok {
return a.PrintKey()
}
return fmt.Sprintf("%s=%s", a.PrintKey(), v)
}
+49
View File
@@ -0,0 +1,49 @@
package svg
type (
Circle = VoidElement[CircleTag, CircleAttribute]
CircleAttribute interface {
Attribute
IsCircleAttribute()
}
CircleTag struct{}
)
func (CircleTag) PrintTag() string {
return "circle"
}
func (CX) IsCircleAttribute() {
}
func (CXP) IsCircleAttribute() {
}
func (CY) IsCircleAttribute() {
}
func (CYP) IsCircleAttribute() {
}
func (R) IsCircleAttribute() {
}
func (RP) IsCircleAttribute() {
}
func (PathLength) IsCircleAttribute() {
}
func (Fill) IsCircleAttribute() {
}
func (Stroke) IsCircleAttribute() {
}
func (StrokeWidth) IsCircleAttribute() {
}
func (VectorEffect) IsCircleAttribute() {
}

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