diff --git a/.github/workflows/publish-dev.yaml b/.github/workflows/publish-dev.yaml new file mode 100644 index 000000000..22dded252 --- /dev/null +++ b/.github/workflows/publish-dev.yaml @@ -0,0 +1,59 @@ +name: Publish DEV image + +on: + push: + branches: [develop] + paths-ignore: + - '**.md' + workflow_dispatch: + +permissions: + contents: read + actions: write + +concurrency: + group: publish-dev + cancel-in-progress: false + +env: + DOCKER_TAGS: dfxswiss/opencryptopay-api:beta + +jobs: + publish: + name: Build and push beta + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + steps: + - name: Require develop + if: github.ref != 'refs/heads/develop' + run: | + echo "::error::This workflow only publishes develop as beta" + exit 1 + + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/arm64 + tags: ${{ env.DOCKER_TAGS }} + provenance: false + sbom: false + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=https://github.com/openCryptoPay/api + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/publish-prd.yaml b/.github/workflows/publish-prd.yaml new file mode 100644 index 000000000..45867c8dd --- /dev/null +++ b/.github/workflows/publish-prd.yaml @@ -0,0 +1,59 @@ +name: Publish PRD image + +on: + push: + branches: [main] + paths-ignore: + - '**.md' + workflow_dispatch: + +permissions: + contents: read + actions: write + +concurrency: + group: publish-prd + cancel-in-progress: false + +env: + DOCKER_TAGS: dfxswiss/opencryptopay-api:latest + +jobs: + publish: + name: Build and push latest + runs-on: ubuntu-24.04-arm + timeout-minutes: 30 + steps: + - name: Require main + if: github.ref != 'refs/heads/main' + run: | + echo "::error::This workflow only publishes main as latest" + exit 1 + + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log in to Docker Hub + uses: docker/login-action@v4 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build and push + uses: docker/build-push-action@v7 + with: + context: . + file: ./Dockerfile + push: true + platforms: linux/arm64 + tags: ${{ env.DOCKER_TAGS }} + provenance: false + sbom: false + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.source=https://github.com/openCryptoPay/api + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2bb40348f..3230fc5c3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -4,14 +4,14 @@ The map API keeps the same gates as the 21.gifts API. ## Branches -| Branch | Purpose | -| --------- | -------------------------------------------------------- | -| `develop` | Integration branch. Open pull requests here. | -| `main` | Production. Moves only through the release pull request. | +| Branch | Purpose | Image | +| --------- | -------------------------------------------------------- | ----------------------------------- | +| `develop` | Integration branch. Open pull requests here. | `dfxswiss/opencryptopay-api:beta` | +| `main` | Production. Moves only through the release pull request. | `dfxswiss/opencryptopay-api:latest` | - Land work on `develop` through a feature branch and a pull request. Do not push `develop` or `main` directly. -- A push to `develop` runs CI on that commit. The release pull request shows that same CI run. -- This repository does not publish a container image. +- A push to `develop` publishes `dfxswiss/opencryptopay-api:beta`, except a push that changes only Markdown, and runs CI on that commit. The release pull request shows that same CI run. A manual run publishes only when it is started on `develop`. +- A push to `main` publishes `dfxswiss/opencryptopay-api:latest`, except a push that changes only Markdown. A manual run publishes only when it is started on `main`. - `auto-release-pr.yaml` opens `Release: develop -> main` when `develop` is ahead of `main`. - Never force-push, and never amend a published commit. @@ -35,7 +35,18 @@ Do not lower a coverage threshold to land a change. A genuinely unreachable bran ## CI / CD -| Workflow | Trigger | Action | -| ---------------------- | ----------------------------------- | ----------------------------------------------------------------------- | -| `ci.yaml` | Pull request, and push to `develop` | Typecheck, lint, handbook, e2e-check, test (100% coverage), build, e2e | -| `auto-release-pr.yaml` | Push to `develop` | Open the release pull request (`develop` → `main`) when there is a diff | +| Workflow | Trigger | Action | +| ---------------------- | --------------------------------------------------------------------- | ----------------------------------------------------------------------- | +| `ci.yaml` | Pull request, and push to `develop` | Typecheck, lint, handbook, e2e-check, test (100% coverage), build, e2e | +| `publish-dev.yaml` | Push to `develop`, except Markdown-only, or a manual run on `develop` | Build and push `dfxswiss/opencryptopay-api:beta` | +| `publish-prd.yaml` | Push to `main`, except Markdown-only, or a manual run on `main` | Build and push `dfxswiss/opencryptopay-api:latest` | +| `auto-release-pr.yaml` | Push to `develop` | Open the release pull request (`develop` → `main`) when there is a diff | + +Images are `linux/arm64`. The publish workflows push the image and do not deploy it. + +Publish workflows need these Actions secrets: + +| Secret | Purpose | +| ----------------- | --------------------------------------------------------------------------- | +| `DOCKER_USERNAME` | Docker Hub username for the push | +| `DOCKER_PASSWORD` | Docker Hub access token with read and write on `dfxswiss/opencryptopay-api` | diff --git a/Dockerfile b/Dockerfile index e9ff9abde..8b7ce0092 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,8 @@ # OpenCryptoPay place API. # # Build: -# docker build -t opencryptopay/api:beta . -# docker build -t opencryptopay/api:latest . +# docker build -t dfxswiss/opencryptopay-api:beta . +# docker build -t dfxswiss/opencryptopay-api:latest . FROM oven/bun:1.3-alpine AS deps WORKDIR /app diff --git a/README.md b/README.md index 83e82bd8f..44202c8ee 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # OpenCryptoPay API -Map API. `GET /map/places` is the public list of places, each with a tech provider. `POST /map/places` adds a place once. `PUT /map/places` updates a place. `DELETE /map/places` removes a place. +Map API. `GET /map/places` is the public list of places, each with a tech provider; `?origin=` limits that list to one origin. The list can also be limited by country, shopName (`SPAR` or `others`), blockchain, and asset; country is a stored column (the DFX country symbol). A pin with support rows matches only those rows. A pin with none uses a hardcoded payment-link catalog: `21.gifts` is Lightning and BTC, and other pins use the DFX payment-link networks and assets. `GET /map/filters` lists shop names, stored countries, and that catalog plus any stored values. `POST /map/places` adds a place once. `PUT /map/places` updates a place. `DELETE /map/places` removes a place. The same `origin` and `externalId` returns the first pin and does not submit it again. When `BTCMAP_ACCESS_TOKEN` is set, a new pin is posted once to `https://api.btcmap.org/v4/place-submissions`. A missing token stores the pin and skips that call. @@ -20,8 +20,12 @@ The process listens on port `3000` unless `PORT` is set. The database file is `d ## Branches -Pull requests target `develop`. `main` moves only through the release pull request from `develop`. This repository does not publish a container image. +Pull requests target `develop`. A push there publishes `dfxswiss/opencryptopay-api:beta`, except a push that changes only Markdown. `main` publishes `dfxswiss/opencryptopay-api:latest` the same way and moves only through the release pull request from `develop`. A manual run publishes only when it is started on that branch. ## Checks `bun run typecheck`, `bun run lint`, `bun run handbook:check`, `bun run e2e:check`, `bun run test:coverage`, `bun run build`, and `bun run e2e`. Coverage on `src` is 100 percent. `src/index.ts` is the process entry and is exercised by the HTTP end-to-end run. + +## Product notes + +Notes that are not specific to this API are in [docs/product](docs/product/README.md). [Where place maps are shown](docs/product/maps.md) is the first of those notes. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 86e21944f..ef916757c 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -9,11 +9,18 @@ ## Endpoint: GET /map/places -- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, and `techProvider`. The caller id and payment methods are not included. -- **Errors:** 400 `{ error: "Invalid limit" }` when `limit` is not an integer from 1 to 1000. Omitted `limit` means 1000. +- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, `supports`, and `activity`. `country` is the stored DFX country symbol or null. `shopName` is the stored brand or null. `supports` is the stored blockchain and asset pairs, sorted. `activity` is `within7Days` when the stored instant is at most 7×24h before the request clock (exactly 7 days stays in this bucket; a future instant is `within7Days`), `within30Days` when it is older than 7×24h and at most 30×24h (exactly 30 days stays in this bucket; one millisecond later is `none`), or `none` when there is no stored instant, the instant is unparseable, or it is older than 30×24h. The caller id and payment methods are not included. Omitted `origin` is every pin. A present `origin` must be the same token as a stored origin. Optional `country`, `shopName` (`SPAR` or `others`), `blockchain`, and `asset` further restrict the store read. All predicates are AND and run before `limit`. `country` is a stored column, not inferred from an address. `others` includes a pin with no brand. A pin that already has support rows matches only those rows. A pin with none matches the hardcoded payment-link catalog: origin `21gifts` or tech provider `21.gifts` matches Lightning and BTC only, and every other pin matches a catalog network, asset, or pair. +- **Errors:** 400 `{ error: "Invalid limit" }` when `limit` is not an integer from 1 to 1000. Omitted `limit` means 1000. 400 `{ error: "Place origin is invalid" }` when `origin` is present but not a valid origin token. 400 `{ error: "Place country is invalid" }` when `country` is present but not an assigned ISO 3166-1 alpha-2 DFX country symbol. 400 `{ error: "Place shop name is invalid" }` when `shopName` is present and not `SPAR` or `others`. 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a payment-link or stored payment-network name, or when `asset` is present but not a ticker, a prefixed ticker, or a payment-link catalog name. - **Used by:** The OpenCryptoPay map. - **Auth:** none. Browser calls are allowed from any origin. +## Endpoint: GET /map/filters + +- **Purpose:** Public list of filter values for the map. `shopNames` is always exactly `["SPAR", "others"]`. `countries` are the distinct stored ISO country codes, sorted. `blockchains` and `assets` are the hardcoded payment-link catalog plus distinct stored values, sorted. `?blockchain=` limits the top-level `assets` to that chain and still returns every blockchain. `techProviders` lists the full hardcoded offer for `DFX.swiss` and for `21.gifts` (Lightning and BTC only) and is not narrowed by `?blockchain=`. +- **Errors:** 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a payment-link or stored payment-network name. +- **Used by:** The OpenCryptoPay map filter UI. +- **Auth:** none. Browser calls are allowed from any origin. + ## Endpoint: POST /map/places - **Purpose:** Create one map pin. The same `origin` and `externalId` returns the first row, including its tech provider, and does not call BTC Map again. A new row is posted once when `BTCMAP_ACCESS_TOKEN` is set. An omitted tech provider is stored as `DFX.swiss`. @@ -34,3 +41,10 @@ - **Errors:** 503 `{ error: "Place ingest is not configured" }` when the ingest token is unset or blank. 401 `{ error: "Unauthorized" }` when the bearer does not match. 400 with the validator message for a bad body. - **Used by:** Trusted callers that take a shop off the public map. - **Auth:** `Authorization: Bearer` must equal `OCP_PLACE_INGEST_TOKEN`. The JSON body is `origin` and `externalId`. + +## Endpoint: POST /map/places/transactions + +- **Purpose:** Record a transaction time on an existing pin. The JSON body is `origin`, `externalId`, and optional `occurredAt`. The response is `{ activity }` classified at the request clock, not at `occurredAt`. This call does not create a pin and does not call BTC Map. An older or equal stored instant is kept. Browser CORS `allowMethods` stays `GET` and `OPTIONS`; this call is server-to-server. +- **Errors:** 503 `{ error: "Place ingest is not configured" }` when the ingest token is unset or blank. 401 `{ error: "Unauthorized" }` when the bearer does not match. 400 with the validator message for a bad body, including `Place transaction time is invalid`. 404 `{ error: "Place not found" }` when the pair is missing. +- **Used by:** Trusted callers that report a shop transaction. +- **Auth:** `Authorization: Bearer` must equal `OCP_PLACE_INGEST_TOKEN`. Browser CORS `allowMethods` stays GET and OPTIONS. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 024b36313..bcfa50cea 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -1,12 +1,61 @@ # Functions +## Function: normalizePlaceOrigin + +- **Purpose:** Trim a pin origin and accept only `^[a-z0-9][a-z0-9-]{0,31}$`. A leading digit is valid. Uppercase tokens such as SPAR are invalid. +- **Inputs:** Unknown value, typically a JSON field or the `origin` query string. +- **Returns / side effects:** `{ ok: true, value }` with the trimmed origin, or `{ ok: false, error: "Place origin is invalid" }`. No I/O. +- **Used by:** `normalizeMapPlace`, `normalizeMapPlaceKey`, and `mapRoutes` on `GET /map/places`. + ## Function: normalizeMapPlace -- **Purpose:** Validate a JSON body for one map pin. Coordinates must be finite and in range, then they are rounded to six decimals. An unknown payment list becomes null instead of an error. An omitted tech provider is left unset so the store can default it. -- **Inputs:** Unknown JSON. Required fields are `origin`, `externalId`, `name`, `lat`, `lon`, and `category`. `techProvider` is optional. +- **Purpose:** Validate a JSON body for one map pin. Coordinates must be finite and in range, then they are rounded to six decimals. An unknown payment list becomes null instead of an error. An omitted tech provider is left unset so the store can default it. Optional `country` is the DFX country symbol (ISO 3166-1 alpha-2), trimmed and uppercased, or omitted. Optional `shopName` is a brand of 1 to 40 characters, not casefolded. Optional `supports` is a list of DFX payment-network and asset-name pairs; omitted leaves the field unset so an upsert can keep the stored rows. +- **Inputs:** Unknown JSON. Required fields are `origin`, `externalId`, `name`, `lat`, `lon`, and `category`. `techProvider`, `country`, `shopName`, and `supports` are optional. - **Returns / side effects:** `{ ok: true, value }` or `{ ok: false, error }` with a fixed English message. No I/O. - **Used by:** `mapRoutes` on `POST /map/places` and `PUT /map/places`. +## Function: normalizeMapPlaceFilter + +- **Purpose:** Validate the optional `country`, `shopName`, `blockchain`, and `asset` query strings for the public map. Omitted parameters stay off the filter. `country` is trimmed and uppercased. `shopName` is trimmed and must be `SPAR` or `others`. `blockchain` and `asset` are trimmed and not casefolded. A filter blockchain may be a stored payment-network name or a payment-link network such as BinancePay or KucoinPay. A filter asset may be a ticker, a prefixed ticker, or a catalog name such as ckBTC or dEURO. A support body still uses the stricter stored-pair rules. +- **Inputs:** Four optional raw query strings. +- **Returns / side effects:** `{ ok: true, value }` with the present filter fields, or `{ ok: false, error }` with `Place country is invalid`, `Place shop name is invalid`, or `Place support is invalid`. No I/O. +- **Used by:** `mapRoutes` on `GET /map/places` and `GET /map/filters`. + +## Function: isCatalogBlockchain + +- **Purpose:** Report whether a trimmed blockchain query is in the hardcoded payment-link catalog. Test networks, Plasma, and names that are not payment-link networks are excluded. BinancePay and KucoinPay are included. +- **Inputs:** A trimmed blockchain string. +- **Returns / side effects:** True or false. No I/O. +- **Used by:** `normalizeMapPlaceFilter` when a blockchain query is not a stored payment-network name. + +## Function: isCatalogAsset + +- **Purpose:** Report whether a trimmed asset query is in the hardcoded payment-link catalog. This includes names that are not plain tickers, such as ckBTC and dEURO. +- **Inputs:** A trimmed asset string. +- **Returns / side effects:** True or false. No I/O. +- **Used by:** `normalizeMapPlaceFilter` when an asset query is not a ticker or a prefixed ticker. + +## Function: matchesUnstatedPayment + +- **Purpose:** Decide whether a pin with no stored support rows matches a blockchain or asset filter. Origin `21gifts` or tech provider `21.gifts` matches Lightning and BTC only. Every other pin matches a catalog network, a catalog asset, or a catalog pair. An omitted filter matches. +- **Inputs:** Stored origin, stored tech provider, and the optional blockchain and asset from the query. +- **Returns / side effects:** True when the pin stays in the list. No I/O. +- **Used by:** `MemoryMapPlaceStore.list`. The SQLite driver uses `unstatedPaymentSql` for the same rule. + +## Function: unstatedPaymentSql + +- **Purpose:** Build the SQLite predicate for a blockchain or asset filter. Stored support rows still match. A pin with no support rows matches only when the hardcoded catalog offers that network or asset for its origin and tech provider. A request outside the catalog returns null so only stored rows match. +- **Inputs:** Optional blockchain and asset query values. +- **Returns / side effects:** A parenthesised SQL fragment and its bound parameters, or null. No I/O. +- **Used by:** `SqliteMapPlaceStore.list`. + +## Function: publicFilterResponse + +- **Purpose:** Merge distinct stored filter values with the hardcoded payment-link catalog for `GET /map/filters`. `?blockchain=` limits the top-level asset list. `techProviders` always contains the full `DFX.swiss` offer and the `21.gifts` offer of Lightning and BTC. +- **Inputs:** Stored countries, blockchains, and assets, plus an optional blockchain query. +- **Returns / side effects:** `{ shopNames, countries, blockchains, assets, techProviders }`. No I/O. +- **Used by:** `mapRoutes` on `GET /map/filters`. + ## Function: normalizeMapPlaceKey - **Purpose:** Validate origin and external id for deleting a map pin. Extra JSON fields are ignored. The body does not need coordinates or a name. @@ -14,25 +63,39 @@ - **Returns / side effects:** `{ ok: true, value: { origin, externalId } }` or `{ ok: false, error }` with the same English messages as create. No I/O. - **Used by:** `mapRoutes` on `DELETE /map/places`. +## Function: placeActivity + +- **Purpose:** Classify a stored transaction instant into `within7Days`, `within30Days`, or `none` relative to the request clock. `null` or an unparseable instant is `none`. Age is `nowMs` minus the parsed epoch. A future instant (negative age) is `within7Days`. Age at most `7 * 24 * 60 * 60 * 1000` ms is `within7Days`; one millisecond later through `30 * 24 * 60 * 60 * 1000` ms is `within30Days`; older than that is `none`. Exactly 7 days is `within7Days`. Exactly 30 days is `within30Days`. +- **Inputs:** `lastTransactionAt` (`string | null`) and `nowMs` (request clock in milliseconds). +- **Returns / side effects:** One of the three mutually exclusive activity strings. No I/O. +- **Used by:** `toPublicMapPlace` on `GET /map/places` and `mapRoutes` on `POST /map/places/transactions`. + +## Function: normalizePlaceTransaction + +- **Purpose:** Validate a JSON body that records a shop transaction. `origin` and `externalId` use `normalizeMapPlaceKey` and the same error strings. Extra JSON fields are ignored. Omitted `occurredAt` becomes `new Date(nowMs).toISOString()`. A present `occurredAt` must be a parseable string at most 120 seconds after `nowMs`; exactly `nowMs + 120_000` is valid and one millisecond later is not. The returned instant is canonical ISO (`new Date(parsed).toISOString()`), not the raw string. +- **Inputs:** Unknown JSON and `nowMs`. Required fields are `origin` and `externalId`. `occurredAt` is optional. +- **Returns / side effects:** `{ ok: true, value: { origin, externalId, occurredAt } }` or `{ ok: false, error }` including `Place transaction time is invalid`. No I/O. +- **Used by:** `mapRoutes` on `POST /map/places/transactions`. + ## Function: toPublicMapPlace -- **Purpose:** Strip the caller id and payment methods before a pin is shown on the public map. The tech provider stays on the public row. -- **Inputs:** A stored map pin. -- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, `category`, and `techProvider`. No I/O. +- **Purpose:** Strip the caller id and payment methods before a pin is shown on the public map. The tech provider, country, shop name, and supports stay on the public row. `activity` is set from `placeActivity(place.lastTransactionAt, nowMs)`. +- **Inputs:** A stored map pin and `nowMs` (request clock in milliseconds). +- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, `supports`, and `activity`. No I/O. - **Used by:** `mapRoutes` on `GET /map/places`. ## Function: MemoryMapPlaceStore -- **Purpose:** In-memory map store with the same insert-once, upsert, and delete rules as SQLite. A repeat of origin plus external id on insert returns the first row unchanged, including its tech provider. -- **Inputs:** `insertIfNew` and `upsert` take a validated pin. `deleteByKey` takes origin and external id. `list` takes a positive limit. -- **Returns / side effects:** `insertIfNew` reports whether it created the row. `upsert` inserts or updates. `deleteByKey` is true only when a row was removed. `list` is newest first, then id descending. `close` drops the rows. +- **Purpose:** In-memory map store with the same insert-once, upsert, delete, and `recordTransaction` rules as SQLite. New rows store `lastTransactionAt` null. A repeat of origin plus external id on insert returns the first row unchanged, including its tech provider, country, shop name, supports, and last transaction instant. An upsert updates the fields it already updates and does not clear the timestamp. +- **Inputs:** `insertIfNew` and `upsert` take a validated pin. `recordTransaction` takes origin, external id, and an occurred-at string. `deleteByKey` takes origin and external id. `list` takes a positive limit and an optional filter object (`origin`, `country`, `shopName`, `blockchain`, `asset`). `filters` takes an optional blockchain. +- **Returns / side effects:** `insertIfNew` reports whether it created the row. `upsert` stores a sent country and keeps the stored country when `country` is omitted. An omitted shop name is stored as null. It replaces supports only when that field is sent. `recordTransaction` returns a copy or undefined when the pair is missing. An unparseable `occurredAt` does not change the row. A written instant is `new Date(newMs).toISOString()`. It does not insert, does not move an equal or newer stored instant backward, and replaces a null or unparseable stored value. `deleteByKey` is true only when a row was removed. `list` is newest first, then id descending. Predicates are AND and apply before sort and limit. `shopName` `SPAR` matches that stored brand; `others` matches every other pin, including a missing brand. A pin with support rows matches a blockchain or asset only on those rows, and a pair must be the same row. A pin with no support rows matches the hardcoded payment-link catalog. `filters` returns distinct stored countries, blockchains, and assets; shop names are not read. `close` drops the rows. - **Used by:** Unit tests. The process uses `SqliteMapPlaceStore`. ## Function: SqliteMapPlaceStore -- **Purpose:** SQLite driver for map pins. The schema is `MAP_PLACE_SCHEMA_SQL`. Opening an older file adds `tech_provider` when that column is missing. A conflicting insert does not overwrite the first row. -- **Inputs:** Constructor takes a SQLite filename or `:memory:`. `upsert` and `deleteByKey` use the same origin and external id pair as insert. -- **Returns / side effects:** Opens the file, applies the schema, and implements `MapPlaceStore`. `close` closes the handle. +- **Purpose:** SQLite driver for map pins. The schema is `MAP_PLACE_SCHEMA_SQL`. Opening an older file adds `tech_provider`, `country`, `shop_name`, and `last_transaction_at` when those columns are missing, creates `map_place_support` and its indexes if missing, and backfills `shop_name` to SPAR for origin `spar` when that column is null. It also sets a null country from the last four-digit word in a SPAR name, or from four known shop names, and does not replace a country that is already stored. An upsert that omits `country` keeps the stored country. New rows store `last_transaction_at` null. An upsert and a repeat insert do not clear the timestamp. A conflicting insert does not overwrite the first row. +- **Inputs:** Constructor takes a SQLite filename or `:memory:`. `upsert`, `recordTransaction`, and `deleteByKey` use the same origin and external id pair as insert. `list` takes a positive limit and an optional filter object. Predicates are AND and apply before sort and limit. A pin with no support rows uses the same hardcoded payment-link catalog as the memory store. SQLite binds every value. `filters` takes an optional blockchain. +- **Returns / side effects:** Opens the file, enables foreign keys, applies the schema, and implements `MapPlaceStore`. `recordTransaction` updates only `last_transaction_at` with the same rules as the memory store. An unparseable `occurredAt` does not change the row. A written instant is `new Date(newMs).toISOString()`. Present filter fields are AND-ed before the sort and the limit. Deleting a pin deletes its support rows. `close` closes the handle. - **Used by:** The process entrypoint. The HTTP end-to-end run exercises it. ## Function: mapSubmissionBody @@ -51,9 +114,9 @@ ## Function: mapRoutes -- **Purpose:** `GET /map/places` is public. `POST /map/places` requires the ingest bearer and creates a pin at most once. `PUT /map/places` upserts a pin. `DELETE /map/places` removes a pin by origin and external id. -- **Inputs:** Store, optional ingest token, environment, and fetch. -- **Returns / side effects:** GET returns `{ places }`. POST and PUT return 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. DELETE returns 200 `{ deleted: true }` or `{ deleted: false }`. +- **Purpose:** `GET /map/places` is public and may take `origin`, `country`, `shopName`, `blockchain`, and `asset` to restrict the list. `GET /map/filters` is public and returns the shop name tokens, distinct countries, the payment-link catalog merged with stored blockchains and assets, and `techProviders`. `POST /map/places` requires the ingest bearer and creates a pin at most once. `PUT /map/places` upserts a pin. `DELETE /map/places` removes a pin by origin and external id. `POST /map/places/transactions` records a transaction time on an existing pin and does not call BTC Map. +- **Inputs:** Store, optional ingest token, environment, fetch, and optional `now` (defaults to `Date.now`). +- **Returns / side effects:** GET `/places` returns `{ places }` with `activity` from the request clock. GET `/filters` returns `{ shopNames, countries, blockchains, assets, techProviders }`. POST and PUT return 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. DELETE returns 200 `{ deleted: true }` or `{ deleted: false }`. POST `/places/transactions` returns 200 `{ activity }` or 404 `{ error: "Place not found" }` and does not call BTC Map. - **Used by:** `createApp`, mounted at `/map`. ## Function: healthRoutes @@ -66,6 +129,6 @@ ## Function: createApp - **Purpose:** Build the HTTP application: health and the map routes. -- **Inputs:** Store, optional ingest token, environment, and fetch. +- **Inputs:** Store, optional ingest token, environment, fetch, and an optional clock. When the clock is omitted the routes use `Date.now`. - **Returns / side effects:** A Hono app. It does not listen and does not open a second database. - **Used by:** The process entrypoint and the tests. diff --git a/docs/product/README.md b/docs/product/README.md new file mode 100644 index 000000000..6eb93da30 --- /dev/null +++ b/docs/product/README.md @@ -0,0 +1,9 @@ +# Product notes + +This directory is for OpenCryptoPay as a product. It is not the API reference. +HTTP routes and handlers stay in the [handbook](../handbook/endpoints.md). + +| Note | What it records | +| ----------------------------------- | ------------------------------------------------------- | +| [Maps](maps.md) | Where a place map is known to be shown | +| [Map comparison](map-comparison.md) | What those implementations share, and where they differ | diff --git a/docs/product/map-comparison.md b/docs/product/map-comparison.md new file mode 100644 index 000000000..aaf422903 --- /dev/null +++ b/docs/product/map-comparison.md @@ -0,0 +1,206 @@ +# How the place maps differ + +What the five known place maps share, and where they diverge. Where each one +is shown is [maps.md](maps.md). This is a product note, not the API reference. + +Compared on 2026-10-12 from the `develop` commits below. A later commit can +move a client without this note changing. + +| Client | Repository | Commit | +| --------------------- | --------------------------- | ------------------------------------------ | +| OpenCryptoPay website | `openCryptoPay/landingPage` | `4fcfdc8edda1869e5949ab56c94c9bc35172a856` | +| RealUnit website | `RealUnitCH/web` | `ddd2d12b3cb10c28efafbc68e5b2f36a765be0d4` | +| DFX app, SPAR payment | `DFXswiss/app` | `b9e403131a1c4090bdb59ecf5212870adfe2e1b8` | +| RealUnit app | `RealUnitCH/app` | `3a330fcbc61099d04e7bd05540776e29fd59d61a` | +| 21.gifts | `21gifts/app` | `518a7844ad9128e290320be330840454efd9ba96` | + +Files: `js/ocp-places.js` and `css/ocp-places.css`; `public/pay/pay.js`, +`public/js/lib/pay-places-core.js`, and the pin style in `public/pay/index.html`; +`src/components/payment/spar-place-map.tsx`; `lib/screens/pay/pay_locations_page.dart` +and `lib/screens/pay/pay_locations.dart`; `src/components/PlacesMapScreen.tsx`. + +## Release branches + +`main` of the website (`f5b2316540cb59f37ee1f5b8ed7cd300865a875a`) is the same +street map and the same hostname gate, with only a tech-provider control built +from the loaded places. It does not request `/map/filters` and has no country, +name, blockchain, or asset control. Those controls are on `develop`. + +The RealUnit website pages are not on `main` (`ad4271104bfa402cadb995f1160a5c0d0de7b660` +has no `public/pay/pay.js`). The DFX SPAR map is not on `main`. The RealUnit +app screen is not on `main`. The two app files above match on `staging` +(`713bc0ffd6bc0f5bc89b9111bbe46af71d48a97d`) and on `develop`. The 21.gifts +file has the same contents on `main` and on `develop`. + +## What all five share + +Each one draws places as points. The shop name can be read once a place is +chosen. Where that name sits is not the same: a map popup on the OpenCryptoPay +website and on the DFX page, a card under the map on the RealUnit website, the +outline and the list under the map in the RealUnit app, and the list under the +map on 21.gifts. + +That is the whole overlap. They do not share a renderer, a tile source, a +place list, a pin, a filter, or a camera frame. + +Three of them are the OpenCryptoPay street map. The RealUnit app is an outline +of Switzerland. 21.gifts is a Google map of its own member shops. + +## What the three street maps share + +The OpenCryptoPay website, the RealUnit website, and the DFX SPAR payment page: + +- Draw OpenFreeMap Liberty with MapLibre. The style URL is + `https://tiles.openfreemap.org/styles/liberty`. +- Open at longitude 8.23, latitude 46.8, zoom 7, with the zoom control at the + top right. +- Read the OpenCryptoPay place list with `credentials: 'omit'`. The body is an + object with a `places` array. A point needs a finite latitude and longitude. +- Draw one marker per kept place. None of them draws a numbered cluster. +- Show the place name, and the category when the place has one, after the + visitor selects a point. +- Frame several places with `fitBounds` and a maximum zoom of 12. One place is + shown at zoom 12. + +The website on `opencryptopay.io` and `www.opencryptopay.io` calls +`https://api.opencryptopay.io`. On `dev.opencryptopay.io` and +`www.dev.opencryptopay.io` it calls `https://dev-api.opencryptopay.io`. The +RealUnit website, the RealUnit app, and the DFX app always call +`https://api.opencryptopay.io`. Any other hostname on the OpenCryptoPay website +keeps the embedded Google My Map and does not draw the street map. + +## Where they differ + +### Which places are requested + +| Client | Request | +| --------------------- | ----------------------------------------------------------------- | +| OpenCryptoPay website | `GET /map/places` plus the selected query, and `GET /map/filters` | +| RealUnit website | `GET /map/places?blockchain=Ethereum&asset=ZCHF` only | +| RealUnit app | the same Ethereum and ZCHF URL as the RealUnit website | +| DFX SPAR page | `GET /map/places?shopName=SPAR&country=CH` only | +| 21.gifts | its own signed-in shop list, not the OpenCryptoPay place list | + +The RealUnit pages and the DFX page do not call `/map/filters`. The DFX page +mounts the map only when the payment merchant is SPAR. 21.gifts loads a Google +Maps key from its own `GET /maps/key`. + +The website then drops a returned place that does not match the selection. A +missing country is not the selected country. A missing shop name is not SPAR. +The name value `others` keeps every place that is not SPAR. A blank +`techProvider` is treated as `DFX.swiss`. The tech-provider control starts at +All and only shows or hides markers already drawn. Country, name, blockchain, +and asset each load the list again. + +### Payment rows + +On the website, payment is filtered only when a blockchain or an asset is +selected. A non-empty `supports` array must contain a matching pair. A value +that is present and not an array is dropped. A missing `supports` value, and +an empty array, follow the catalog `/map/filters` published for that provider +(`21.gifts`, otherwise `DFX.swiss`). A missing catalog is not a match. + +On the RealUnit website and in the RealUnit app the rule is different. A +missing `supports` value is kept. A present list must include blockchain +`Ethereum` and asset `ZCHF` exactly. An empty list, a non-array, and any other +pair are dropped. Country is not read and is not inferred. The app applies the +same keep rule as the website, then a text search over the places already +loaded. + +The DFX page does not read `supports`. It keeps a place only when `shopName` +is `SPAR`, `country` is `CH`, and both coordinates are finite. It does not +require a name. + +21.gifts does not read this place list. + +### Pins and the shop name + +| Client | Pin | Where the name appears | +| --------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- | +| OpenCryptoPay website | 14px circle, `#d23b3b`, 2px white border | MapLibre popup, offset 16. No selected ring. | +| RealUnit website | the same 14px circle, anchor center | Card under the map. A selected pin keeps the red fill and gains a blue ring. | +| DFX SPAR page | 28×36 teardrop, fill `#C8102E`, white inner circle, anchor bottom | MapLibre popup, offset 18, with a close button. | +| RealUnit app | red circle, 16px, 2px white border | On the outline and in the shop list under the map. | +| 21.gifts | default Google marker | In the list. The marker has no popup. | + +The website marker uses the library default anchor. A place with no name is +labeled `Location` and still drawn. The RealUnit website drops a place with no +name. Its selected pin adds a blue ring (`#1988C6`) and does not open a card +until a pin is clicked. The live list does not select the first pin. + +The RealUnit app takes the selected shop out of the cluster and draws it at +18px with a 3px white border. One loaded shop starts selected. Tapping a shop +in the list or on the map selects it. The list splits a name on its last +comma into the street and the place, and shows the street only when that +comma is there. The map and the list share the flexible height, three parts +to two, with the search field and the count between them. The search keeps +pins whose name contains the query. + +21.gifts keeps the list when the map is drawn. A moderator can edit a shop +pin. A chosen `pin` query bolds that row. + +### Clusters + +Only the RealUnit app groups nearby shops. A group of two or more is a +numbered dark-blue circle, 34px, with a white border. Opening it frames that +group at a maximum zoom of 14. The three street maps and the 21.gifts map draw +every kept place as its own marker. + +### Camera + +The street maps share the start view and the zoom cap of 12 described above. +Padding differs. The website and the RealUnit website use 48 on every side. +The DFX page uses 48 on the top and right and 40 on the bottom and left. The +website leaves the fit animation on. The RealUnit website and the DFX page +turn it off. One place on the website and on the DFX page is centered and set +to zoom 12. One place on the RealUnit website uses `jumpTo` at zoom 12. + +The RealUnit app starts from the same point and zoom, then fits the country +outline with 28px of padding. Zoom stays between 5 and 16. A search with +several matches fits those matches at a maximum zoom of 12 and 36px of +padding. A search that leaves one shop uses zoom 12. A single loaded shop +stays on the country view until a search or a tap. A tap uses zoom 13. The +country fill is white. Lakes use the map background color. There are no +street tiles. City names are drawn at zoom 9 and when zoomed further out. + +21.gifts, with more than one shop and no chosen pin, starts at zoom 2, fits +the shops with 32px of padding, and if that fit is closer than zoom 14 sets +the zoom back to 14. One shop, or the shop named by the `pin` query, is shown +at zoom 14. + +### When the list does not load + +| Client | Failed list | Empty list | +| --------------------- | --------------------------------------------- | --------------------------------------------- | +| OpenCryptoPay website | the street map stays, with a note on the map | the street map stays, with a note | +| RealUnit website | the map is hidden and an error state is shown | the map is hidden and an empty state is shown | +| DFX SPAR page | the map is replaced by an error message | the street map stays, with a note over it | +| RealUnit app | an error message, no outline | the outline stays, with a card over it | +| 21.gifts | an error message and a retry, no map | text only, no map | + +The RealUnit website aborts the request after 15 seconds. The RealUnit app +aborts after 20 seconds. The website and the DFX page set no timeout in the +page. A failed map script on the website sets the same note. On 21.gifts a +failed map script leaves the list usable, and a missing key leaves the list +without a map. + +The RealUnit website does not treat a MapLibre error as a failed page. That +event also fires when one tile fails and the map is still drawn. The page +already has an error state for a place list that did not load. + +### Controls and language + +The RealUnit website is the only street map that sets a MapLibre locale. The +German page names the controls Karte, Vergrössern, Verkleinern, Norden +ausrichten, and Quellen einblenden. The English page sets the library's own +English strings. The OpenCryptoPay website and the DFX page pass no locale, so +the controls stay the library English defaults. + +The RealUnit website and the DFX page set `fadeDuration` to 0 and +`preserveDrawingBuffer`. The OpenCryptoPay website sets neither. The DFX page +sets the MapLibre worker URL because its build would otherwise point the +worker at a file that never loads. The two websites load MapLibre as a page +script and do not set a worker URL. + +The RealUnit app draws its own zoom-in and zoom-out buttons. It has no +north control. 21.gifts leaves the default Google Maps controls in place. diff --git a/docs/product/maps.md b/docs/product/maps.md new file mode 100644 index 000000000..6dc61ff94 --- /dev/null +++ b/docs/product/maps.md @@ -0,0 +1,60 @@ +# Place maps + +Where a map of places is known to be shown, checked on 2026-10-12. This is an +inventory of clients, not a proof that no other client exists, and not the API +reference. Branch facts below are the GitHub branches on that date. + +The street map is OpenFreeMap Liberty drawn with MapLibre. The other sections +are different maps and are named as such. How the implementations compare is +[map-comparison.md](map-comparison.md). + +## Street map + +### OpenCryptoPay website + +Repository: `openCryptoPay/landingPage`. + +On `opencryptopay.io`, `www.opencryptopay.io`, `dev.opencryptopay.io`, and +`www.dev.opencryptopay.io`, the homepage replaces the embedded Google My Map +with the street map of the published place list. Any other hostname keeps the +Google My Map. That behavior is on `main` and on `develop`. + +### RealUnit website + +Repository: `RealUnitCH/web`. + +`/pay/` is the German page and `/pay/en/` is the English page. Both draw the +street map of the published shops. The homepage does not link to either page. +The pages are on `develop`. They are not on `main`. + +### DFX app + +Repository: `DFXswiss/app`. + +The SPAR payment page draws the street map when the merchant is SPAR. The pins +are SPAR places in Switzerland. The map component is on `develop`. It is not +on `main`. + +## Outline map + +### RealUnit app + +Repository: `RealUnitCH/app`. + +The pay-info screen links to `/payLocations`. That screen draws a white outline +of Switzerland, with lakes and pins. It does not use the street map. The screen +is on `develop`. It is not on `main`. + +## Google Maps + +### 21.gifts + +Repository: `21gifts/app`. + +`/shops#map` draws member shops with Google Maps. It does not use the +OpenCryptoPay street map. The same file is on `develop` and on `main`. + +## Not a drawn map + +This repository, `openCryptoPay/api`, stores and serves the place list. It does +not draw a map. diff --git a/docs/schema/map-place.sql b/docs/schema/map-place.sql index 976ec2a25..82f55cf56 100644 --- a/docs/schema/map-place.sql +++ b/docs/schema/map-place.sql @@ -10,5 +10,19 @@ CREATE TABLE IF NOT EXISTS map_place ( payment_methods TEXT, created_at TEXT NOT NULL, tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss', + country TEXT, + shop_name TEXT, + last_transaction_at TEXT, UNIQUE (origin, external_id) ); +CREATE TABLE IF NOT EXISTS map_place_support ( + place_id TEXT NOT NULL, + blockchain TEXT NOT NULL, + asset TEXT NOT NULL, + PRIMARY KEY (place_id, blockchain, asset), + FOREIGN KEY (place_id) REFERENCES map_place(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS map_place_country ON map_place(country); +CREATE INDEX IF NOT EXISTS map_place_shop_name ON map_place(shop_name); +CREATE INDEX IF NOT EXISTS map_place_support_lookup + ON map_place_support(blockchain, asset, place_id); diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 290737abe..070696568 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -1,3 +1,4 @@ +import { execFileSync } from 'node:child_process'; import { expect, test } from '@playwright/test'; const body = { @@ -23,6 +24,54 @@ test('Function: mapRoutes — GET /map/places is public', async ({ request }) => expect(Array.isArray(json.places)).toBe(true); }); +test('Function: normalizePlaceOrigin — GET /map/places?origin=spar filters the list', async ({ + request, +}) => { + const sparPin = { + origin: 'spar', + externalId: 'e2e-origin-spar', + name: 'Origin SPAR Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + }; + const otherPin = { + origin: 'dfx', + externalId: 'e2e-origin-other', + name: 'Origin Other Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: sparPin, + }) + ).status(), + ).toBe(201); + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: otherPin, + }) + ).status(), + ).toBe(201); + + const filtered = await request.get('/map/places?origin=spar'); + expect(filtered.status()).toBe(200); + const json = (await filtered.json()) as { places: Array<{ origin: string; name: string }> }; + expect(json.places.some((row) => row.name === 'Origin SPAR Pin')).toBe(true); + expect(json.places.some((row) => row.name === 'Origin Other Pin')).toBe(false); + expect(json.places.every((row) => row.origin === 'spar')).toBe(true); + + const bad = await request.get('/map/places?origin=SPAR'); + expect(bad.status()).toBe(400); + expect(await bad.json()).toEqual({ error: 'Place origin is invalid' }); +}); + test('Function: createApp — POST /map/places without a bearer is 401', async ({ request }) => { const res = await request.post('/map/places', { data: body }); expect(res.status()).toBe(401); @@ -82,6 +131,102 @@ test('Function: pushMapPlace — default boot does not require a map token', asy expect(res.status()).toBe(200); }); +test('Function: normalizeMapPlaceFilter — filters one row', async ({ request }) => { + const sparPin = { + origin: 'dfx', + externalId: 'e2e-filter-spar-ch', + name: 'Filter SPAR CH', + lat: 47.37, + lon: 8.54, + category: 'groceries', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }; + const otherPin = { + origin: 'dfx', + externalId: 'e2e-filter-other', + name: 'Filter Other Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + country: 'DE', + shopName: 'Migros', + supports: [{ blockchain: 'Bitcoin', asset: 'BTC' }], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: sparPin, + }) + ).status(), + ).toBe(201); + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: otherPin, + }) + ).status(), + ).toBe(201); + + const filtered = await request.get( + '/map/places?shopName=SPAR&country=CH&blockchain=Ethereum&asset=ZCHF', + ); + expect(filtered.status()).toBe(200); + const listed = (await filtered.json()) as { places: Array<{ name: string }> }; + expect(listed.places.some((row) => row.name === 'Filter SPAR CH')).toBe(true); + expect(listed.places.some((row) => row.name === 'Filter Other Pin')).toBe(false); + + const filters = await request.get('/map/filters'); + expect(filters.status()).toBe(200); + const filterJson = (await filters.json()) as { + shopNames: string[]; + countries: string[]; + blockchains: string[]; + assets: string[]; + }; + expect(filterJson.shopNames).toEqual(['SPAR', 'others']); + expect(filterJson.countries).toContain('CH'); + expect(filterJson.blockchains).toContain('Ethereum'); + expect(filterJson.assets).toContain('ZCHF'); +}); + +test('Function: SqliteMapPlaceStore — a pair is one support row', async ({ request }) => { + const pin = { + origin: 'dfx', + externalId: 'e2e-split-pair', + name: 'Split Pair Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + ], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: pin, + }) + ).status(), + ).toBe(201); + + const names = async (query: string): Promise => { + const response = await request.get(`/map/places?${query}`); + expect(response.status()).toBe(200); + const json = (await response.json()) as { places: Array<{ name: string }> }; + return json.places.map((row) => row.name); + }; + + expect(await names('blockchain=Ethereum&asset=ZCHF')).not.toContain('Split Pair Pin'); + expect(await names('blockchain=Ethereum&asset=ETH')).toContain('Split Pair Pin'); + expect(await names('blockchain=Polygon&asset=ZCHF')).toContain('Split Pair Pin'); +}); + test('Function: normalizeMapPlaceKey — PUT updates a pin and DELETE removes it', async ({ request, }) => { @@ -147,3 +292,416 @@ test('Function: normalizeMapPlaceKey — PUT updates a pin and DELETE removes it expect(again.status()).toBe(200); expect(await again.json()).toEqual({ deleted: false }); }); + +test('Function: SqliteMapPlaceStore — an upsert that omits country keeps it', async ({ + request, +}) => { + const pin = { + origin: 'dfx', + externalId: 'e2e-keep-country', + name: 'Keep Country Pin', + lat: 47.37, + lon: 8.54, + category: 'cafe', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: pin, + }) + ).status(), + ).toBe(201); + + const createdList = (await (await request.get('/map/places')).json()) as { + places: Array<{ + name: string; + country: string | null; + shopName: string | null; + supports: Array<{ blockchain: string; asset: string }>; + }>; + }; + const created = createdList.places.find((row) => row.name === 'Keep Country Pin'); + expect(created?.country).toBe('CH'); + expect(created?.shopName).toBe('SPAR'); + + const renamed = await request.put('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { + origin: 'dfx', + externalId: 'e2e-keep-country', + name: 'Keep Country Renamed', + lat: 47.37, + lon: 8.54, + category: 'cafe', + }, + }); + expect(renamed.status()).toBe(200); + + const omittedList = (await (await request.get('/map/places')).json()) as { + places: Array<{ + name: string; + country: string | null; + shopName: string | null; + supports: Array<{ blockchain: string; asset: string }>; + }>; + }; + const omitted = omittedList.places.find((row) => row.name === 'Keep Country Renamed'); + expect(omitted?.country).toBe('CH'); + expect(omitted?.shopName).toBeNull(); + expect(omitted?.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); + + const replaced = await request.put('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { + origin: 'dfx', + externalId: 'e2e-keep-country', + name: 'Keep Country Renamed', + lat: 47.37, + lon: 8.54, + category: 'cafe', + country: 'DE', + }, + }); + expect(replaced.status()).toBe(200); + + const replacedList = (await (await request.get('/map/places')).json()) as { + places: Array<{ name: string; country: string | null; shopName: string | null }>; + }; + const updated = replacedList.places.find((row) => row.name === 'Keep Country Renamed'); + expect(updated?.country).toBe('DE'); + expect(updated?.shopName).toBeNull(); + + const deleted = await request.delete('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { origin: 'dfx', externalId: 'e2e-keep-country' }, + }); + expect(deleted.status()).toBe(200); + expect(await deleted.json()).toEqual({ deleted: true }); +}); + +test('Function: SqliteMapPlaceStore — limit is applied after the country filter', async ({ + request, +}) => { + const older = { + origin: 'dfx', + externalId: 'e2e-limit-ch', + name: 'Limit Older CH', + lat: 47.37, + lon: 8.54, + category: 'groceries', + country: 'CH', + }; + const newer = { + origin: 'dfx', + externalId: 'e2e-limit-de', + name: 'Limit Newer DE', + lat: 47.37, + lon: 8.54, + category: 'groceries', + country: 'DE', + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: older, + }) + ).status(), + ).toBe(201); + await new Promise((resolve) => setTimeout(resolve, 30)); + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: newer, + }) + ).status(), + ).toBe(201); + + const newest = await request.get('/map/places?limit=1'); + expect(newest.status()).toBe(200); + const newestJson = (await newest.json()) as { places: Array<{ name: string }> }; + expect(newestJson.places).toHaveLength(1); + expect(newestJson.places[0]?.name).toBe('Limit Newer DE'); + + const ch = await request.get('/map/places?limit=1&country=CH'); + expect(ch.status()).toBe(200); + const chJson = (await ch.json()) as { places: Array<{ name: string }> }; + expect(chJson.places).toHaveLength(1); + expect(chJson.places[0]?.name).toBe('Limit Older CH'); +}); + +test('Function: SqliteMapPlaceStore — delete removes the pin and its support from filters', async ({ + request, +}) => { + const pin = { + origin: 'dfx', + externalId: 'e2e-support-delete', + name: 'Support Delete Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + supports: [{ blockchain: 'Plasma', asset: 'ONDO' }], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: pin, + }) + ).status(), + ).toBe(201); + const keep = { + origin: 'dfx', + externalId: 'e2e-support-keep', + name: 'Support Keep Pin', + lat: 46.95, + lon: 7.44, + category: 'groceries', + supports: [{ blockchain: 'Ethereum', asset: 'dEURO' }], + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: keep, + }) + ).status(), + ).toBe(201); + + const filters = await request.get('/map/filters'); + expect(filters.status()).toBe(200); + const filterJson = (await filters.json()) as { + shopNames: string[]; + countries: string[]; + blockchains: string[]; + assets: string[]; + }; + expect(filterJson.blockchains).toContain('Plasma'); + expect(filterJson.assets).toContain('ONDO'); + + const deleted = await request.delete('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { origin: 'dfx', externalId: 'e2e-support-delete' }, + }); + expect(deleted.status()).toBe(200); + expect(await deleted.json()).toEqual({ deleted: true }); + + const afterFilters = await request.get('/map/filters'); + expect(afterFilters.status()).toBe(200); + const afterFilterJson = (await afterFilters.json()) as { + shopNames: string[]; + countries: string[]; + blockchains: string[]; + assets: string[]; + }; + expect(afterFilterJson.blockchains).not.toContain('Plasma'); + const plasmaFilters = await request.get('/map/filters?blockchain=Plasma'); + expect(plasmaFilters.status()).toBe(200); + const plasmaJson = (await plasmaFilters.json()) as { assets: string[] }; + expect(plasmaJson.assets).not.toContain('ONDO'); + + const afterDelete = (await (await request.get('/map/places')).json()) as { + places: Array<{ name: string }>; + }; + expect(afterDelete.places.some((row) => row.name === 'Support Delete Pin')).toBe(false); + expect(afterDelete.places.some((row) => row.name === 'Support Keep Pin')).toBe(true); + + const kept = await request.get('/map/places?blockchain=Ethereum&asset=dEURO'); + expect(kept.status()).toBe(200); + const keptJson = (await kept.json()) as { places: Array<{ name: string }> }; + expect(keptJson.places.some((row) => row.name === 'Support Keep Pin')).toBe(true); + expect(keptJson.places.some((row) => row.name === 'Support Delete Pin')).toBe(false); +}); + +test('Function: unstatedPaymentSql — a pin without supports uses the payment-link catalog', async ({ + request, +}) => { + const dfx = { + origin: 'dfx', + externalId: 'e2e-catalog-dfx', + name: 'Catalog DFX Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + }; + const gifts = { + origin: '21gifts', + externalId: 'e2e-catalog-gifts', + name: 'Catalog Gifts Pin', + lat: 14.62, + lon: 120.96, + category: 'shopping', + techProvider: '21.gifts', + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: dfx, + }) + ).status(), + ).toBe(201); + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: gifts, + }) + ).status(), + ).toBe(201); + + const names = async (query: string): Promise => { + const response = await request.get(`/map/places?${query}`); + expect(response.status()).toBe(200); + const json = (await response.json()) as { places: Array<{ name: string }> }; + return json.places.map((row) => row.name); + }; + + expect(await names('blockchain=Ethereum')).toContain('Catalog DFX Pin'); + expect(await names('blockchain=Ethereum')).not.toContain('Catalog Gifts Pin'); + expect(await names('blockchain=Lightning&asset=BTC')).toEqual( + expect.arrayContaining(['Catalog DFX Pin', 'Catalog Gifts Pin']), + ); + expect(await names('blockchain=Ethereum&asset=BTC')).not.toContain('Catalog DFX Pin'); + expect(await names('blockchain=Ethereum&asset=BTC')).not.toContain('Catalog Gifts Pin'); + expect(await names('blockchain=BinancePay')).toContain('Catalog DFX Pin'); + expect(await names('blockchain=BinancePay')).not.toContain('Catalog Gifts Pin'); + expect(await names('asset=ckBTC')).toContain('Catalog DFX Pin'); + expect(await names('asset=ckBTC')).not.toContain('Catalog Gifts Pin'); + expect(await names('asset=dEURO')).toContain('Catalog DFX Pin'); + expect(await names('asset=LINK')).not.toContain('Catalog DFX Pin'); + expect(await names('blockchain=Plasma')).not.toContain('Catalog DFX Pin'); + expect(await names('shopName=others')).toContain('Catalog DFX Pin'); + expect(await names('shopName=others')).toContain('Catalog Gifts Pin'); + expect(await names('shopName=SPAR')).not.toContain('Catalog DFX Pin'); +}); + +test('Function: matchesUnstatedPayment — 21.gifts stays on Lightning and BTC', async ({ + request, +}) => { + const listed = await request.get('/map/places?blockchain=Lightning&asset=BTC'); + expect(listed.status()).toBe(200); + const json = (await listed.json()) as { places: Array<{ name: string }> }; + expect(json.places.some((row) => row.name === 'Catalog Gifts Pin')).toBe(true); + const hidden = await request.get('/map/places?asset=ZCHF'); + expect(hidden.status()).toBe(200); + const hiddenJson = (await hidden.json()) as { places: Array<{ name: string }> }; + expect(hiddenJson.places.some((row) => row.name === 'Catalog Gifts Pin')).toBe(false); +}); + +test('Function: publicFilterResponse — filters include the payment-link catalog', async ({ + request, +}) => { + const filters = await request.get('/map/filters'); + expect(filters.status()).toBe(200); + const json = (await filters.json()) as { + blockchains: string[]; + assets: string[]; + techProviders: Array<{ + name: string; + blockchains: string[]; + assets: string[]; + pairs: Array<{ blockchain: string; asset: string }>; + }>; + }; + expect(json.blockchains).toContain('Lightning'); + expect(json.blockchains).toContain('BinancePay'); + expect(json.blockchains).not.toContain('Sepolia'); + expect(json.assets).toContain('ckBTC'); + expect(json.assets).not.toContain('USDC.e'); + expect(json.assets).not.toContain('USDbC'); + const dfx = json.techProviders.find((provider) => provider.name === 'DFX.swiss'); + const gifts = json.techProviders.find((provider) => provider.name === '21.gifts'); + expect(dfx?.pairs).toContainEqual({ blockchain: 'Ethereum', asset: 'ZCHF' }); + expect(dfx?.pairs).toContainEqual({ blockchain: 'Polygon', asset: 'ZCHF' }); + expect(dfx?.pairs).not.toContainEqual({ blockchain: 'Ethereum', asset: 'BTC' }); + expect(dfx?.blockchains).not.toContain('Plasma'); + expect(gifts).toEqual({ + name: '21.gifts', + blockchains: ['Lightning'], + assets: ['BTC'], + pairs: [{ blockchain: 'Lightning', asset: 'BTC' }], + }); + const ethereum = await request.get('/map/filters?blockchain=Ethereum'); + expect(ethereum.status()).toBe(200); + const ethereumJson = (await ethereum.json()) as { assets: string[]; blockchains: string[] }; + expect(ethereumJson.assets).toContain('ZCHF'); + expect(ethereumJson.assets).not.toContain('BTC'); + expect(ethereumJson.blockchains).toContain('Polygon'); +}); + +test('Function: isCatalogBlockchain — BinancePay is a filter and Kraken is not', async ({ + request, +}) => { + expect((await request.get('/map/places?blockchain=BinancePay')).status()).toBe(200); + expect((await request.get('/map/places?blockchain=Kraken')).status()).toBe(400); + expect((await request.get('/map/filters?blockchain=KucoinPay')).status()).toBe(200); + expect((await request.get('/map/filters?blockchain=Frick')).status()).toBe(400); +}); + +test('Function: isCatalogAsset — catalog asset names are filter queries', async ({ request }) => { + expect((await request.get('/map/places?asset=ckBTC')).status()).toBe(200); + expect((await request.get('/map/places?asset=USDC.e')).status()).toBe(400); + expect((await request.get('/map/places?asset=USDbC')).status()).toBe(400); + expect((await request.get('/map/places?asset=dEURO')).status()).toBe(200); + expect((await request.get('/map/places?asset=Ethereum/ZCHF')).status()).toBe(400); +}); + +test('Function: placeActivity — a recorded transaction is within7Days', async ({ request }) => { + const pin = { + origin: 'dfx', + externalId: 'e2e-activity-recent', + name: 'Activity Recent Pin', + lat: 47.37, + lon: 8.54, + category: 'groceries', + }; + expect( + ( + await request.post('/map/places', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: pin, + }) + ).status(), + ).toBe(201); + const recorded = await request.post('/map/places/transactions', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { origin: 'dfx', externalId: 'e2e-activity-recent' }, + }); + expect(recorded.status()).toBe(200); + expect(await recorded.json()).toEqual({ activity: 'within7Days' }); + const listed = await request.get('/map/places'); + const json = (await listed.json()) as { places: Array<{ name: string; activity: string }> }; + expect( + json.places.some((row) => row.name === 'Activity Recent Pin' && row.activity === 'within7Days'), + ).toBe(true); + + const missing = await request.post('/map/places/transactions', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { origin: 'dfx', externalId: 'e2e-activity-missing' }, + }); + expect(missing.status()).toBe(404); + expect(await missing.json()).toEqual({ error: 'Place not found' }); +}); + +test('Function: normalizePlaceTransaction — a future occurredAt beyond skew is 400', async ({ + request, +}) => { + const occurredAt = new Date(Date.now() + 3 * 60 * 1000).toISOString(); + const res = await request.post('/map/places/transactions', { + headers: { Authorization: 'Bearer e2e-ingest' }, + data: { origin: 'dfx', externalId: 'e2e-activity-recent', occurredAt }, + }); + expect(res.status()).toBe(400); + expect(await res.json()).toEqual({ error: 'Place transaction time is invalid' }); +}); + +test('Function: SqliteMapPlaceStore — opening backfills only a null SPAR shop name', () => { + const stdout = execFileSync('bun', ['e2e/sqlite-backfill.ts'], { encoding: 'utf8' }); + expect(stdout.trim()).toBe('ok'); +}); diff --git a/e2e/sqlite-backfill.ts b/e2e/sqlite-backfill.ts new file mode 100644 index 000000000..ddff96d9b --- /dev/null +++ b/e2e/sqlite-backfill.ts @@ -0,0 +1,267 @@ +import { Database } from 'bun:sqlite'; +import { mkdtempSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { SqliteMapPlaceStore } from '../src/lib/map/sqlite'; +import { MAP_PLACE_SCHEMA_SQL } from '../src/lib/map/store'; + +const dir = mkdtempSync(join(tmpdir(), 'sqlite-backfill-')); +const filename = join(dir, 'map.sqlite'); +let seed: Database | undefined; +let store: SqliteMapPlaceStore | undefined; +try { + seed = new Database(filename); + const statements = MAP_PLACE_SCHEMA_SQL.split(';') + .map((part) => part.trim()) + .filter((part) => part !== ''); + for (const sql of statements) { + seed.exec(sql); + } + const insert = seed.query( + `INSERT INTO map_place ( + id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider, country, shop_name + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ); + insert.run( + 'spar-null-row', + 'spar', + 'spar-null', + 'Pin', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-01T00:00:00.000Z', + 'DFX.swiss', + null, + null, + ); + insert.run( + 'dfx-null-row', + 'dfx', + 'dfx-null', + 'Pin', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-02T00:00:00.000Z', + 'DFX.swiss', + null, + null, + ); + insert.run( + 'spar-migros-row', + 'spar', + 'spar-migros', + 'Pin', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-03T00:00:00.000Z', + 'DFX.swiss', + null, + 'Migros', + ); + insert.run( + 'spar-ch-1713-row', + 'spar', + 'spar-ch-1713', + 'SPAR 1713', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-04T00:00:00.000Z', + 'DFX.swiss', + null, + null, + ); + insert.run( + 'spar-li-9495-row', + 'spar', + 'spar-li-9495', + 'SPAR 9495 Triesen', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-05T00:00:00.000Z', + 'DFX.swiss', + null, + null, + ); + insert.run( + 'spar-de-keep-row', + 'spar', + 'spar-de-keep', + 'SPAR 8001', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-06T00:00:00.000Z', + 'DFX.swiss', + 'DE', + null, + ); + insert.run( + 'gifts-ph-happyland-row', + '21gifts', + 'gifts-ph-happyland', + 'Happyland Court Barangay 105 Tondo, Manila', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-07T00:00:00.000Z', + '21.gifts', + null, + null, + ); + insert.run( + 'gifts-ph-rose-row', + '21gifts', + 'gifts-ph-rose', + 'Rose st.Happyland Barangay 105 Tondo,Manila', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-08T00:00:00.000Z', + '21.gifts', + null, + null, + ); + insert.run( + 'gifts-ke-machakos-row', + '21gifts', + 'gifts-ke-machakos', + 'Machakos Bitcoin Academy', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-09T00:00:00.000Z', + '21.gifts', + null, + null, + ); + insert.run( + 'gifts-ke-makueni-row', + '21gifts', + 'gifts-ke-makueni', + 'bitcoinmakueni', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-10T00:00:00.000Z', + '21.gifts', + null, + null, + ); + insert.run( + 'gifts-other-row', + '21gifts', + 'gifts-other', + 'Other shop', + 47.37, + 8.54, + 'groceries', + null, + '2026-01-11T00:00:00.000Z', + '21.gifts', + null, + null, + ); + seed.close(); + seed = undefined; + store = new SqliteMapPlaceStore(filename); + const listed = store.list(20); + const sparNull = listed.find((row) => row.externalId === 'spar-null'); + const dfxNull = listed.find((row) => row.externalId === 'dfx-null'); + const sparMigros = listed.find((row) => row.externalId === 'spar-migros'); + const sparCh1713 = listed.find((row) => row.externalId === 'spar-ch-1713'); + const sparLi9495 = listed.find((row) => row.externalId === 'spar-li-9495'); + const sparDeKeep = listed.find((row) => row.externalId === 'spar-de-keep'); + const giftsPhHappyland = listed.find((row) => row.externalId === 'gifts-ph-happyland'); + const giftsPhRose = listed.find((row) => row.externalId === 'gifts-ph-rose'); + const giftsKeMachakos = listed.find((row) => row.externalId === 'gifts-ke-machakos'); + const giftsKeMakueni = listed.find((row) => row.externalId === 'gifts-ke-makueni'); + const giftsOther = listed.find((row) => row.externalId === 'gifts-other'); + if ( + sparNull !== undefined && + dfxNull !== undefined && + sparMigros !== undefined && + sparCh1713 !== undefined && + sparLi9495 !== undefined && + sparDeKeep !== undefined && + giftsPhHappyland !== undefined && + giftsPhRose !== undefined && + giftsKeMachakos !== undefined && + giftsKeMakueni !== undefined && + giftsOther !== undefined && + sparNull.shopName === 'SPAR' && + sparNull.country === null && + dfxNull.shopName === null && + dfxNull.country === null && + sparMigros.shopName === 'Migros' && + sparMigros.country === null && + sparCh1713.country === 'CH' && + sparCh1713.shopName === 'SPAR' && + sparLi9495.country === 'LI' && + sparDeKeep.country === 'DE' && + giftsPhHappyland.country === 'PH' && + giftsPhRose.country === 'PH' && + giftsKeMachakos.country === 'KE' && + giftsKeMakueni.country === 'KE' && + giftsOther.country === null + ) { + process.stdout.write('ok\n'); + } else { + process.stdout.write( + `${JSON.stringify({ + 'spar-null': + sparNull === undefined + ? null + : { shopName: sparNull.shopName, country: sparNull.country }, + 'dfx-null': + dfxNull === undefined ? null : { shopName: dfxNull.shopName, country: dfxNull.country }, + 'spar-migros': + sparMigros === undefined + ? null + : { shopName: sparMigros.shopName, country: sparMigros.country }, + 'spar-ch-1713': + sparCh1713 === undefined + ? null + : { shopName: sparCh1713.shopName, country: sparCh1713.country }, + 'spar-li-9495': sparLi9495 === undefined ? null : sparLi9495.country, + 'spar-de-keep': sparDeKeep === undefined ? null : sparDeKeep.country, + 'gifts-ph-happyland': giftsPhHappyland === undefined ? null : giftsPhHappyland.country, + 'gifts-ph-rose': giftsPhRose === undefined ? null : giftsPhRose.country, + 'gifts-ke-machakos': giftsKeMachakos === undefined ? null : giftsKeMachakos.country, + 'gifts-ke-makueni': giftsKeMakueni === undefined ? null : giftsKeMakueni.country, + 'gifts-other': giftsOther === undefined ? null : giftsOther.country, + })}\n`, + ); + process.exitCode = 1; + } +} catch (error) { + process.stdout.write( + `${JSON.stringify({ error: error instanceof Error ? error.message : error })}\n`, + ); + process.exitCode = 1; +} finally { + try { + if (seed !== undefined) { + seed.close(); + } + if (store !== undefined) { + store.close(); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} diff --git a/src/__tests__/map/btcmap.test.ts b/src/__tests__/map/btcmap.test.ts index f0edb8725..cdb737889 100644 --- a/src/__tests__/map/btcmap.test.ts +++ b/src/__tests__/map/btcmap.test.ts @@ -12,6 +12,10 @@ const place: StoredMapPlace = { category: 'groceries', paymentMethods: 'lightning', techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], + lastTransactionAt: null, createdAt: '2026-09-26T00:00:00.000Z', }; diff --git a/src/__tests__/map/filter-catalog.test.ts b/src/__tests__/map/filter-catalog.test.ts new file mode 100644 index 000000000..bb155e9fb --- /dev/null +++ b/src/__tests__/map/filter-catalog.test.ts @@ -0,0 +1,128 @@ +import { describe, expect, it } from 'vitest'; +import { + isCatalogAsset, + isCatalogBlockchain, + matchesUnstatedPayment, + publicFilterResponse, + unstatedPaymentSql, +} from '@/lib/map/filter-catalog'; + +describe('payment-link filter catalog', () => { + it('names the payment-link networks and assets, not test or inactive ones', () => { + expect(isCatalogBlockchain('Ethereum')).toBe(true); + expect(isCatalogBlockchain('BinancePay')).toBe(true); + expect(isCatalogBlockchain('KucoinPay')).toBe(true); + expect(isCatalogBlockchain('Zano')).toBe(true); + expect(isCatalogBlockchain('Lightning')).toBe(true); + expect(isCatalogBlockchain('Plasma')).toBe(false); + expect(isCatalogBlockchain('Sepolia')).toBe(false); + expect(isCatalogBlockchain('Arkade')).toBe(false); + expect(isCatalogBlockchain('Kraken')).toBe(false); + + expect(isCatalogAsset('ZCHF')).toBe(true); + expect(isCatalogAsset('ckBTC')).toBe(true); + expect(isCatalogAsset('dEURO')).toBe(true); + expect(isCatalogAsset('USDC.e')).toBe(false); + expect(isCatalogAsset('USDbC')).toBe(false); + expect(isCatalogAsset('AAVE')).toBe(false); + expect(isCatalogAsset('XPL')).toBe(false); + expect(isCatalogAsset('ckbtc')).toBe(false); + }); + + it('matches an unstated pin from the catalog for its tech provider', () => { + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', undefined, undefined)).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Ethereum', undefined)).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Ethereum', 'ZCHF')).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Ethereum', 'BTC')).toBe(false); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Polygon', 'ZCHF')).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Arbitrum', 'AAVE')).toBe(false); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Gnosis', 'xDAI')).toBe(false); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Plasma', undefined)).toBe(false); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'BinancePay', undefined)).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', undefined, 'ckBTC')).toBe(true); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', undefined, 'AAAA')).toBe(false); + expect(matchesUnstatedPayment('dfx', 'DFX.swiss', 'Lightning', 'BTC')).toBe(true); + + expect(matchesUnstatedPayment('21gifts', 'DFX.swiss', 'Ethereum', undefined)).toBe(false); + expect(matchesUnstatedPayment('dfx', '21.gifts', 'Ethereum', undefined)).toBe(false); + expect(matchesUnstatedPayment('21gifts', '21.gifts', 'Lightning', undefined)).toBe(true); + expect(matchesUnstatedPayment('21gifts', '21.gifts', undefined, 'BTC')).toBe(true); + expect(matchesUnstatedPayment('21gifts', '21.gifts', 'Lightning', 'BTC')).toBe(true); + expect(matchesUnstatedPayment('21gifts', '21.gifts', 'Lightning', 'ZCHF')).toBe(false); + expect(matchesUnstatedPayment('21gifts', '21.gifts', undefined, 'ZCHF')).toBe(false); + expect(matchesUnstatedPayment('21gifts', '21.gifts', undefined, 'ckBTC')).toBe(false); + }); + + it('builds a stored-row predicate only when the catalog cannot match', () => { + expect(unstatedPaymentSql()).toBeNull(); + expect(unstatedPaymentSql('Plasma')).toBeNull(); + expect(unstatedPaymentSql('Ethereum', 'BTC')).toBeNull(); + expect(unstatedPaymentSql(undefined, 'AAAA')).toBeNull(); + + const chain = unstatedPaymentSql('Ethereum'); + expect(chain?.params).toEqual(['Ethereum', '0', '1']); + expect(chain?.sql).toContain('NOT EXISTS'); + expect(chain?.sql).toContain("tech_provider = '21.gifts'"); + expect(chain?.sql).toContain("origin = '21gifts'"); + + expect(unstatedPaymentSql('Lightning', 'BTC')?.params).toEqual(['Lightning', 'BTC', '1', '1']); + expect(unstatedPaymentSql(undefined, 'ckBTC')?.params).toEqual(['ckBTC', '0', '1']); + expect(unstatedPaymentSql(undefined, 'BTC')?.params).toEqual(['BTC', '1', '1']); + expect(unstatedPaymentSql('BinancePay')?.params).toEqual(['BinancePay', '0', '1']); + expect(unstatedPaymentSql('Ethereum', 'ZCHF')?.params).toEqual(['Ethereum', 'ZCHF', '0', '1']); + }); + + it('unions stored filter values with the catalog and keeps provider offers whole', () => { + const merged = publicFilterResponse({ + countries: ['CH'], + blockchains: ['Plasma'], + assets: ['ONDO', 'ZCHF'], + }); + expect(merged.shopNames).toEqual(['SPAR', 'others']); + expect(merged.countries).toEqual(['CH']); + expect(merged.blockchains).toContain('Plasma'); + expect(merged.blockchains).toContain('Lightning'); + expect(merged.blockchains).not.toContain('Sepolia'); + expect(merged.blockchains[0]).toBe('Arbitrum'); + expect(merged.assets.filter((asset) => asset === 'ZCHF')).toHaveLength(1); + expect(merged.assets).toContain('ONDO'); + expect(merged.assets).toContain('BTC'); + expect(merged.assets).toContain('ckBTC'); + + const ethereum = publicFilterResponse( + { countries: [], blockchains: ['Plasma', 'Ethereum'], assets: ['AAAA'] }, + 'Ethereum', + ); + expect(ethereum.assets).toContain('AAAA'); + expect(ethereum.assets).toContain('ZCHF'); + expect(ethereum.assets).not.toContain('ONDO'); + expect(ethereum.assets).not.toContain('BTC'); + expect(ethereum.blockchains).toContain('Plasma'); + + const plasma = publicFilterResponse( + { countries: [], blockchains: ['Plasma'], assets: ['ONDO'] }, + 'Plasma', + ); + expect(plasma.assets).toEqual(['ONDO']); + + const dfx = merged.techProviders.find((provider) => provider.name === 'DFX.swiss'); + const gifts = merged.techProviders.find((provider) => provider.name === '21.gifts'); + expect(dfx?.blockchains).not.toContain('Plasma'); + expect(dfx?.blockchains).not.toContain('Sepolia'); + expect(dfx?.blockchains).toContain('BinancePay'); + expect(dfx?.blockchains).toContain('Zano'); + expect(dfx?.assets).not.toContain('XPL'); + expect(dfx?.pairs).toContainEqual({ blockchain: 'Ethereum', asset: 'ZCHF' }); + expect(dfx?.pairs).toContainEqual({ blockchain: 'Polygon', asset: 'ZCHF' }); + expect(dfx?.pairs).not.toContainEqual({ blockchain: 'Ethereum', asset: 'BTC' }); + expect(dfx?.pairs).not.toContainEqual({ blockchain: 'Arbitrum', asset: 'AAVE' }); + expect(dfx?.pairs).not.toContainEqual({ blockchain: 'Gnosis', asset: 'xDAI' }); + expect(dfx?.pairs).not.toContainEqual({ blockchain: 'Arkade', asset: 'BTC' }); + expect(gifts).toEqual({ + name: '21.gifts', + blockchains: ['Lightning'], + assets: ['BTC'], + pairs: [{ blockchain: 'Lightning', asset: 'BTC' }], + }); + }); +}); diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index 689f2af23..a44a0d46c 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -1,5 +1,13 @@ import { describe, expect, it } from 'vitest'; -import { normalizeMapPlace, normalizeMapPlaceKey, toPublicMapPlace } from '@/lib/map/place'; +import { + normalizeMapPlace, + normalizeMapPlaceFilter, + normalizeMapPlaceKey, + normalizePlaceOrigin, + normalizePlaceTransaction, + placeActivity, + toPublicMapPlace, +} from '@/lib/map/place'; import type { StoredMapPlace } from '@/lib/map/place'; const valid = { @@ -12,6 +20,56 @@ const valid = { paymentMethods: 'lightning', }; +describe('normalizePlaceOrigin', () => { + it('trims a valid origin', () => { + expect(normalizePlaceOrigin(' spar ')).toEqual({ ok: true, value: 'spar' }); + }); + + it('trims an origin that starts with a digit', () => { + expect(normalizePlaceOrigin(' 21gifts ')).toEqual({ ok: true, value: '21gifts' }); + }); + + it('accepts a 32-character origin that starts with a digit', () => { + const origin = '2' + 'a'.repeat(31); + expect(normalizePlaceOrigin(origin)).toEqual({ ok: true, value: origin }); + }); + + it('rejects SPAR and any other invalid value', () => { + expect(normalizePlaceOrigin('SPAR')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin(1)).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('2' + 'a'.repeat(32))).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('-a')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('21.gifts')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin('21Gifts')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceOrigin(' ')).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + }); +}); + describe('normalizeMapPlace', () => { it('rejects a non-object and an array', () => { expect(normalizeMapPlace(null)).toEqual({ @@ -123,6 +181,301 @@ describe('normalizeMapPlace', () => { error: 'Place tech provider is invalid', }); }); + + it('omits an absent or null country and stores a trimmed lowercase code as uppercase', () => { + const absent = normalizeMapPlace(valid); + expect(absent.ok && !('country' in absent.value)).toBe(true); + const nulled = normalizeMapPlace({ ...valid, country: null }); + expect(nulled.ok && !('country' in nulled.value)).toBe(true); + const trimmed = normalizeMapPlace({ ...valid, country: ' ch ' }); + expect(trimmed).toEqual({ ok: true, value: { ...valid, country: 'CH' } }); + for (const country of ['CH', 'LI', 'DE', 'AT', 'FR', 'IT', 'PH']) { + const result = normalizeMapPlace({ ...valid, country }); + expect(result).toEqual({ ok: true, value: { ...valid, country } }); + } + }); + + it('rejects Switzerland, C, empty, CHE, ZZ, and a non-string country', () => { + expect(normalizeMapPlace({ ...valid, country: 'Switzerland' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: 'C' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: '' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: 'CHE' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: 'ZZ' })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlace({ ...valid, country: 1 })).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + }); + + it('omits an absent or null shop name and trims a present brand', () => { + const absent = normalizeMapPlace(valid); + expect(absent.ok && !('shopName' in absent.value)).toBe(true); + const nulled = normalizeMapPlace({ ...valid, shopName: null }); + expect(nulled.ok && !('shopName' in nulled.value)).toBe(true); + const trimmed = normalizeMapPlace({ ...valid, shopName: ' SPAR ' }); + expect(trimmed).toEqual({ ok: true, value: { ...valid, shopName: 'SPAR' } }); + const other = normalizeMapPlace({ ...valid, shopName: 'Migros' }); + expect(other.ok && other.value.shopName).toBe('Migros'); + const max = normalizeMapPlace({ ...valid, shopName: 'x'.repeat(40) }); + expect(max.ok && max.value.shopName).toBe('x'.repeat(40)); + }); + + it('rejects an empty, too long, control, or non-string shop name', () => { + expect(normalizeMapPlace({ ...valid, shopName: '' })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlace({ ...valid, shopName: ' ' })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlace({ ...valid, shopName: 'x'.repeat(41) })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlace({ ...valid, shopName: 'bad\nname' })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlace({ ...valid, shopName: 1 })).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + }); + + it('omits supports when absent and stores an empty list when sent', () => { + const absent = normalizeMapPlace(valid); + expect(absent.ok && !('supports' in absent.value)).toBe(true); + const empty = normalizeMapPlace({ ...valid, supports: [] }); + expect(empty).toEqual({ ok: true, value: { ...valid, supports: [] } }); + }); + + it('stores a duplicate support pair once and sorts by blockchain then asset', () => { + const result = normalizeMapPlace({ + ...valid, + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + ], + }); + expect(result).toEqual({ + ok: true, + value: { + ...valid, + supports: [ + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'ZCHF' }, + ], + }, + }); + }); + + it('stores a trimmed dEURO asset unchanged', () => { + const result = normalizeMapPlace({ + ...valid, + supports: [{ blockchain: ' Ethereum ', asset: ' dEURO ' }], + }); + expect(result).toEqual({ + ok: true, + value: { ...valid, supports: [{ blockchain: 'Ethereum', asset: 'dEURO' }] }, + }); + }); + + it('rejects too many support items, a non-array, and a bad item', () => { + const many = Array.from({ length: 33 }, (_, i) => ({ + blockchain: 'Ethereum', + asset: `A${String(i).padStart(2, '0')}`, + })); + expect(normalizeMapPlace({ ...valid, supports: many })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + const thirtyTwo = many.slice(0, 32); + expect(normalizeMapPlace({ ...valid, supports: thirtyTwo }).ok).toBe(true); + expect(normalizeMapPlace({ ...valid, supports: null })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: 'Ethereum' })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [null] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [[]] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [1] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [{ blockchain: 'Ethereum' }] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlace({ ...valid, supports: [{ asset: 'ZCHF' }] })).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + }); + + it('rejects payment providers, exchanges, banks, and a manual position as blockchain', () => { + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'BinancePay', asset: 'USDT' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Kraken', asset: 'BTC' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'PLoan', asset: 'CHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'ethereum', asset: 'ZCHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'ETHEREUM', asset: 'ZCHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Frick', asset: 'ZCHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + }); + + it('rejects lowercase, uniqueName, and invalid assets', () => { + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Ethereum', asset: 'zchf' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Ethereum', asset: 'deuro' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Spark', asset: 'Spark/BTC' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'Ethereum', asset: 'Ethereum/ZCHF' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + expect( + normalizeMapPlace({ + ...valid, + supports: [{ blockchain: 'BinanceSmartChain', asset: 'opBNB' }], + }), + ).toEqual({ ok: false, error: 'Place support is invalid' }); + }); +}); + +describe('normalizeMapPlaceFilter', () => { + it('returns an empty filter when every query is omitted', () => { + expect(normalizeMapPlaceFilter()).toEqual({ ok: true, value: {} }); + }); + + it('uppercases a trimmed country and accepts SPAR or others as shopName', () => { + expect(normalizeMapPlaceFilter(' ch ', ' SPAR ', ' Ethereum ', ' dEURO ')).toEqual({ + ok: true, + value: { + country: 'CH', + shopName: 'SPAR', + blockchain: 'Ethereum', + asset: 'dEURO', + }, + }); + expect(normalizeMapPlaceFilter(undefined, ' others ')).toEqual({ + ok: true, + value: { shopName: 'others' }, + }); + expect(normalizeMapPlaceFilter(undefined, undefined, ' BinancePay ', ' ckBTC ')).toEqual({ + ok: true, + value: { blockchain: 'BinancePay', asset: 'ckBTC' }, + }); + expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'ZCHF')).toEqual({ + ok: true, + value: { asset: 'ZCHF' }, + }); + }); + + it('rejects a bad country, shop name, blockchain, or asset query', () => { + expect(normalizeMapPlaceFilter('ZZ')).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlaceFilter('CHE')).toEqual({ + ok: false, + error: 'Place country is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, 'Migros')).toEqual({ + ok: false, + error: 'Place shop name is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, undefined, 'Frick')).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, undefined, 'ethereum')).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'Ethereum/ZCHF')).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, undefined, 'KucoinPay', 'USDC.e')).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'USDbC')).toEqual({ + ok: false, + error: 'Place support is invalid', + }); + }); }); describe('normalizeMapPlaceKey', () => { @@ -183,15 +536,177 @@ describe('normalizeMapPlaceKey', () => { }); }); +describe('placeActivity', () => { + const nowMs = Date.parse('2026-10-08T00:00:00.000Z'); + + it('classifies null, unparseable, future, and the 7-day and 30-day bounds', () => { + expect(placeActivity(null, nowMs)).toBe('none'); + expect(placeActivity('not-a-date', nowMs)).toBe('none'); + expect(placeActivity(new Date(nowMs + 1).toISOString(), nowMs)).toBe('within7Days'); + expect(placeActivity(new Date(nowMs - 3 * 24 * 60 * 60 * 1000).toISOString(), nowMs)).toBe( + 'within7Days', + ); + expect(placeActivity(new Date(nowMs - 7 * 24 * 60 * 60 * 1000).toISOString(), nowMs)).toBe( + 'within7Days', + ); + expect( + placeActivity(new Date(nowMs - (7 * 24 * 60 * 60 * 1000 + 1)).toISOString(), nowMs), + ).toBe('within30Days'); + expect(placeActivity(new Date(nowMs - 15 * 24 * 60 * 60 * 1000).toISOString(), nowMs)).toBe( + 'within30Days', + ); + expect(placeActivity(new Date(nowMs - 30 * 24 * 60 * 60 * 1000).toISOString(), nowMs)).toBe( + 'within30Days', + ); + expect( + placeActivity(new Date(nowMs - (30 * 24 * 60 * 60 * 1000 + 1)).toISOString(), nowMs), + ).toBe('none'); + }); +}); + +describe('normalizePlaceTransaction', () => { + const nowMs = Date.parse('2026-10-08T00:00:00.000Z'); + const key = { origin: 'dfx', externalId: 'store-1' }; + + it('rejects a non-object and an array with the key error', () => { + expect(normalizePlaceTransaction(null, nowMs)).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceTransaction([], nowMs)).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceTransaction('x', nowMs)).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + }); + + it('rejects a bad origin and external id with the key error strings', () => { + expect(normalizePlaceTransaction({ origin: 1, externalId: 'store-1' }, nowMs)).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceTransaction({ origin: 'DFX', externalId: 'store-1' }, nowMs)).toEqual({ + ok: false, + error: 'Place origin is invalid', + }); + expect(normalizePlaceTransaction({ origin: 'dfx', externalId: 1 }, nowMs)).toEqual({ + ok: false, + error: 'Place external id is required', + }); + expect(normalizePlaceTransaction({ origin: 'dfx', externalId: '' }, nowMs)).toEqual({ + ok: false, + error: 'Place external id is required', + }); + }); + + it('defaults omitted occurredAt and canonicalizes a parseable instant', () => { + expect(normalizePlaceTransaction(key, nowMs)).toEqual({ + ok: true, + value: { ...key, occurredAt: new Date(nowMs).toISOString() }, + }); + expect( + normalizePlaceTransaction({ ...key, occurredAt: '2026-10-01T00:00:00Z' }, nowMs), + ).toEqual({ + ok: true, + value: { ...key, occurredAt: '2026-10-01T00:00:00.000Z' }, + }); + expect( + normalizePlaceTransaction({ ...key, occurredAt: '2026-10-01T02:00:00+02:00' }, nowMs), + ).toEqual({ + ok: true, + value: { ...key, occurredAt: '2026-10-01T00:00:00.000Z' }, + }); + }); + + it('rejects a null, number, blank, whitespace, or unparseable occurredAt', () => { + expect(normalizePlaceTransaction({ ...key, occurredAt: null }, nowMs)).toEqual({ + ok: false, + error: 'Place transaction time is invalid', + }); + expect(normalizePlaceTransaction({ ...key, occurredAt: 1 }, nowMs)).toEqual({ + ok: false, + error: 'Place transaction time is invalid', + }); + expect(normalizePlaceTransaction({ ...key, occurredAt: '' }, nowMs)).toEqual({ + ok: false, + error: 'Place transaction time is invalid', + }); + expect(normalizePlaceTransaction({ ...key, occurredAt: ' ' }, nowMs)).toEqual({ + ok: false, + error: 'Place transaction time is invalid', + }); + expect(normalizePlaceTransaction({ ...key, occurredAt: 'not-a-date' }, nowMs)).toEqual({ + ok: false, + error: 'Place transaction time is invalid', + }); + }); + + it('accepts exactly 120 seconds of future skew and rejects one millisecond later', () => { + const atSkew = nowMs + 120_000; + expect( + normalizePlaceTransaction({ ...key, occurredAt: new Date(atSkew).toISOString() }, nowMs), + ).toEqual({ + ok: true, + value: { ...key, occurredAt: new Date(atSkew).toISOString() }, + }); + expect( + normalizePlaceTransaction( + { ...key, occurredAt: new Date(nowMs + 120_001).toISOString() }, + nowMs, + ), + ).toEqual({ + ok: false, + error: 'Place transaction time is invalid', + }); + }); +}); + describe('toPublicMapPlace', () => { + const nowMs = Date.parse('2026-09-26T00:00:00.000Z'); + it('omits the caller id and payment methods', () => { const stored: StoredMapPlace = { ...valid, techProvider: 'DFX.swiss', id: 'id-1', createdAt: '2026-09-26T00:00:00.000Z', + country: null, + shopName: null, + supports: [], + lastTransactionAt: null, + }; + expect(toPublicMapPlace(stored, nowMs)).toEqual({ + id: 'id-1', + origin: 'dfx', + name: 'SPAR', + lat: 47.37, + lon: 8.54, + category: 'groceries', + techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], + activity: 'none', + }); + }); + + it('returns country, shop name, and a copy of supports', () => { + const supports = [{ blockchain: 'Ethereum', asset: 'ZCHF' }]; + const stored: StoredMapPlace = { + ...valid, + techProvider: 'DFX.swiss', + id: 'id-1', + createdAt: '2026-09-26T00:00:00.000Z', + country: 'CH', + shopName: 'SPAR', + supports, + lastTransactionAt: null, }; - expect(toPublicMapPlace(stored)).toEqual({ + const publicPlace = toPublicMapPlace(stored, nowMs); + expect(publicPlace).toEqual({ id: 'id-1', origin: 'dfx', name: 'SPAR', @@ -199,6 +714,12 @@ describe('toPublicMapPlace', () => { lon: 8.54, category: 'groceries', techProvider: 'DFX.swiss', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + activity: 'none', }); + publicPlace.supports.push({ blockchain: 'Polygon', asset: 'ETH' }); + expect(stored.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); }); }); diff --git a/src/__tests__/map/routes.test.ts b/src/__tests__/map/routes.test.ts index f0b74c881..a076376b5 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; -import { createApp } from '@/server'; +import { publicFilterResponse } from '@/lib/map/filter-catalog'; import { MemoryMapPlaceStore } from '@/lib/map/store'; +import { createApp } from '@/server'; const body = { origin: 'dfx', @@ -21,12 +22,15 @@ function app(opts: { token?: string; env?: Record; fetchImpl?: (input: string, init: RequestInit) => Promise; + store?: MemoryMapPlaceStore; + now?: () => number; }) { return createApp({ - store: new MemoryMapPlaceStore(), + store: opts.store ?? new MemoryMapPlaceStore(), env: opts.env ?? {}, fetchImpl: opts.fetchImpl ?? (async () => new Response('{}', { status: 201 })), ...(opts.token === undefined ? {} : { ingestToken: opts.token }), + ...(opts.now === undefined ? {} : { now: opts.now }), }); } @@ -39,6 +43,101 @@ describe('map routes', () => { expect(await list.json()).toEqual({ places: [] }); const one = await api.request('/map/places?limit=1'); expect(one.status).toBe(200); + const filters = await api.request('/map/filters'); + expect(filters.status).toBe(200); + expect(await filters.json()).toEqual( + publicFilterResponse({ countries: [], blockchains: [], assets: [] }), + ); + }); + + it('filters GET /map/places by origin and rejects SPAR', async () => { + const api = app({ token: 'secret' }); + const dfx = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(body), + }); + expect(dfx.status).toBe(201); + const spar = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + origin: 'spar', + externalId: 'spar-1', + name: 'SPAR Shop', + }), + }); + expect(spar.status).toBe(201); + const filtered = await api.request('/map/places?origin=spar'); + expect(filtered.status).toBe(200); + const filteredJson = (await filtered.json()) as { + places: Array<{ origin: string; name: string }>; + }; + expect(filteredJson.places).toHaveLength(1); + expect(filteredJson.places[0]?.origin).toBe('spar'); + expect(filteredJson.places[0]?.name).toBe('SPAR Shop'); + const bad = await api.request('/map/places?origin=SPAR'); + expect(bad.status).toBe(400); + expect(await bad.json()).toEqual({ error: 'Place origin is invalid' }); + const all = (await (await api.request('/map/places')).json()) as { + places: Array<{ origin: string }>; + }; + expect(all.places).toHaveLength(2); + expect(all.places.map((row) => row.origin).sort()).toEqual(['dfx', 'spar']); + }); + + it('accepts origin 21gifts on PUT and GET and rejects a leading hyphen or uppercase', async () => { + const api = app({ token: 'secret' }); + const created = await api.request('/map/places', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ + origin: '21gifts', + externalId: 'shop-1', + name: 'Shop', + lat: 14.620311, + lon: 120.959626, + category: 'shopping', + paymentMethods: 'lightning', + techProvider: '21.gifts', + }), + }); + expect(created.status).toBe(201); + const createdJson = (await created.json()) as { created: boolean }; + expect(createdJson.created).toBe(true); + const listed = await api.request('/map/places?origin=21gifts'); + expect(listed.status).toBe(200); + const listedJson = (await listed.json()) as { + places: Array<{ origin: string; name: string; category: string; techProvider: string }>; + }; + expect(listedJson.places).toHaveLength(1); + expect(listedJson.places[0]?.origin).toBe('21gifts'); + expect(listedJson.places[0]?.name).toBe('Shop'); + expect(listedJson.places[0]?.category).toBe('shopping'); + expect(listedJson.places[0]?.techProvider).toBe('21.gifts'); + const hyphen = await api.request('/map/places?origin=-21gifts'); + expect(hyphen.status).toBe(400); + expect(await hyphen.json()).toEqual({ error: 'Place origin is invalid' }); + const uppercase = await api.request('/map/places?origin=21Gifts'); + expect(uppercase.status).toBe(400); + expect(await uppercase.json()).toEqual({ error: 'Place origin is invalid' }); + const putHyphen = await api.request('/map/places', { + method: 'PUT', + headers: jsonHeaders, + body: JSON.stringify({ + origin: '-21gifts', + externalId: 'shop-2', + name: 'Shop', + lat: 14.620311, + lon: 120.959626, + category: 'shopping', + paymentMethods: 'lightning', + techProvider: '21.gifts', + }), + }); + expect(putHyphen.status).toBe(400); + expect(await putHyphen.json()).toEqual({ error: 'Place origin is invalid' }); }); it('rejects a bad limit', async () => { @@ -195,6 +294,10 @@ describe('map routes', () => { lon: 8.54, category: 'groceries', techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], + activity: 'none', }); }); @@ -240,6 +343,10 @@ describe('map routes', () => { lon: 8.54, category: 'groceries', techProvider: '21.gifts', + country: null, + shopName: null, + supports: [], + activity: 'none', }); }); @@ -279,4 +386,354 @@ describe('map routes', () => { expect(await missing.json()).toEqual({ deleted: false }); expect(calls).toHaveLength(1); }); + + it('filters GET /map/places by shop name, country, blockchain, and asset', async () => { + const api = app({ token: 'secret' }); + const match = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + externalId: 'match', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }), + }); + expect(match.status).toBe(201); + const other = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + externalId: 'other', + country: 'DE', + shopName: 'Migros', + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + ], + }), + }); + expect(other.status).toBe(201); + const filtered = await api.request( + '/map/places?shopName=SPAR&country=CH&blockchain=Ethereum&asset=ZCHF', + ); + expect(filtered.status).toBe(200); + const filteredJson = (await filtered.json()) as { + places: Array<{ name: string; country: string | null; shopName: string | null }>; + }; + expect(filteredJson.places).toHaveLength(1); + expect(filteredJson.places[0]?.shopName).toBe('SPAR'); + expect(filteredJson.places[0]?.country).toBe('CH'); + const others = await api.request('/map/places?shopName=others'); + const othersJson = (await others.json()) as { places: Array<{ shopName: string | null }> }; + expect(othersJson.places).toHaveLength(1); + expect(othersJson.places[0]?.shopName).toBe('Migros'); + expect((await api.request('/map/places?shopName=Migros')).status).toBe(400); + expect(await (await api.request('/map/places?shopName=Migros')).json()).toEqual({ + error: 'Place shop name is invalid', + }); + expect((await api.request('/map/places?country=ZZ')).status).toBe(400); + expect(await (await api.request('/map/places?country=ZZ')).json()).toEqual({ + error: 'Place country is invalid', + }); + expect((await api.request('/map/places?country=CHE')).status).toBe(400); + expect(await (await api.request('/map/places?country=CHE')).json()).toEqual({ + error: 'Place country is invalid', + }); + expect((await api.request('/map/places?country=PH')).status).toBe(200); + expect((await api.request('/map/places?blockchain=ethereum')).status).toBe(400); + expect(await (await api.request('/map/places?blockchain=ethereum')).json()).toEqual({ + error: 'Place support is invalid', + }); + expect((await api.request('/map/places?blockchain=Frick')).status).toBe(400); + expect(await (await api.request('/map/places?blockchain=Frick')).json()).toEqual({ + error: 'Place support is invalid', + }); + expect((await api.request('/map/places?asset=zchf')).status).toBe(400); + expect(await (await api.request('/map/places?asset=zchf')).json()).toEqual({ + error: 'Place support is invalid', + }); + expect((await api.request('/map/places?asset=Ethereum/ZCHF')).status).toBe(400); + expect(await (await api.request('/map/places?asset=Ethereum/ZCHF')).json()).toEqual({ + error: 'Place support is invalid', + }); + }); + + it('does not treat blockchain and asset on different support rows as one pair', async () => { + const api = app({ token: 'secret' }); + const created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + externalId: 'split-pair', + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + ], + }), + }); + expect(created.status).toBe(201); + const mismatch = await api.request('/map/places?blockchain=Ethereum&asset=ZCHF'); + expect(mismatch.status).toBe(200); + const mismatchJson = (await mismatch.json()) as { places: unknown[] }; + expect(mismatchJson.places).toHaveLength(0); + const eth = await api.request('/map/places?blockchain=Ethereum&asset=ETH'); + expect(eth.status).toBe(200); + const ethJson = (await eth.json()) as { places: Array<{ name: string }> }; + expect(ethJson.places).toHaveLength(1); + expect(ethJson.places[0]?.name).toBe(body.name); + const polygon = await api.request('/map/places?blockchain=Polygon&asset=ZCHF'); + expect(polygon.status).toBe(200); + const polygonJson = (await polygon.json()) as { places: unknown[] }; + expect(polygonJson.places).toHaveLength(1); + }); + + it('returns a stored dEURO asset unchanged', async () => { + const api = app({ token: 'secret' }); + const created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + externalId: 'deuro', + supports: [{ blockchain: 'Ethereum', asset: 'dEURO' }], + }), + }); + expect(created.status).toBe(201); + const listed = await api.request('/map/places?asset=dEURO'); + expect(listed.status).toBe(200); + const json = (await listed.json()) as { + places: Array<{ supports: Array<{ blockchain: string; asset: string }> }>; + }; + expect(json.places).toHaveLength(1); + expect(json.places[0]?.supports).toEqual([{ blockchain: 'Ethereum', asset: 'dEURO' }]); + }); + + it('returns GET /map/filters from stored rows and rejects a bad blockchain', async () => { + const api = app({ token: 'secret' }); + const created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + ...body, + country: 'CH', + shopName: 'SPAR', + supports: [ + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'ETH' }, + ], + }), + }); + expect(created.status).toBe(201); + const filters = await api.request('/map/filters'); + expect(filters.status).toBe(200); + expect(await filters.json()).toEqual( + publicFilterResponse({ + countries: ['CH'], + blockchains: ['Ethereum', 'Polygon'], + assets: ['ETH', 'ZCHF'], + }), + ); + const ethereum = await api.request('/map/filters?blockchain=Ethereum'); + expect(ethereum.status).toBe(200); + expect(await ethereum.json()).toEqual( + publicFilterResponse( + { + countries: ['CH'], + blockchains: ['Ethereum', 'Polygon'], + assets: ['ZCHF'], + }, + 'Ethereum', + ), + ); + const bad = await api.request('/map/filters?blockchain=ethereum'); + expect(bad.status).toBe(400); + expect(await bad.json()).toEqual({ error: 'Place support is invalid' }); + }); + + it('refuses place transactions until a token is configured', async () => { + const tx = { origin: 'dfx', externalId: 'store-1' }; + const missing = await app({}).request('/map/places/transactions', { + method: 'POST', + body: JSON.stringify(tx), + }); + expect(missing.status).toBe(503); + expect(await missing.json()).toEqual({ error: 'Place ingest is not configured' }); + const blank = await app({ token: ' ' }).request('/map/places/transactions', { + method: 'POST', + headers: { Authorization: 'Bearer secret' }, + body: JSON.stringify(tx), + }); + expect(blank.status).toBe(503); + expect(await blank.json()).toEqual({ error: 'Place ingest is not configured' }); + }); + + it('rejects a missing or wrong bearer for place transactions', async () => { + const api = app({ token: 'secret' }); + const tx = { origin: 'dfx', externalId: 'store-1' }; + const noHeader = await api.request('/map/places/transactions', { + method: 'POST', + body: JSON.stringify(tx), + }); + expect(noHeader.status).toBe(401); + expect(await noHeader.json()).toEqual({ error: 'Unauthorized' }); + const wrong = await api.request('/map/places/transactions', { + method: 'POST', + headers: { Authorization: 'Bearer secreX' }, + body: JSON.stringify(tx), + }); + expect(wrong.status).toBe(401); + expect(await wrong.json()).toEqual({ error: 'Unauthorized' }); + }); + + it('rejects invalid JSON, a bad origin, and a future occurredAt', async () => { + const api = app({ token: 'secret' }); + const badJson = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: '{', + }); + expect(badJson.status).toBe(400); + const badOrigin = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'DFX', externalId: 'store-1' }), + }); + expect(badOrigin.status).toBe(400); + expect(await badOrigin.json()).toEqual({ error: 'Place origin is invalid' }); + const future = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ + origin: 'dfx', + externalId: 'store-1', + occurredAt: new Date(Date.now() + 3 * 60 * 1000).toISOString(), + }), + }); + expect(future.status).toBe(400); + expect(await future.json()).toEqual({ error: 'Place transaction time is invalid' }); + }); + + it('returns 404 for an unknown pair and does not call fetchImpl', async () => { + const calls: string[] = []; + const api = app({ + token: 'secret', + env: { BTCMAP_ACCESS_TOKEN: 'map-token' }, + fetchImpl: async (input) => { + calls.push(input); + return new Response('{}', { status: 201 }); + }, + }); + const missing = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1' }), + }); + expect(missing.status).toBe(404); + expect(await missing.json()).toEqual({ error: 'Place not found' }); + expect(calls).toHaveLength(0); + }); + + it('records a transaction and classifies activity at the request clock', async () => { + const store = new MemoryMapPlaceStore(); + const calls: string[] = []; + const api = app({ + token: 'secret', + store, + env: { BTCMAP_ACCESS_TOKEN: 'map-token' }, + fetchImpl: async (input) => { + calls.push(input); + return new Response('{}', { status: 201 }); + }, + }); + const created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify(body), + }); + expect(created.status).toBe(201); + expect(calls).toHaveLength(1); + const omitted = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1' }), + }); + expect(omitted.status).toBe(200); + expect(await omitted.json()).toEqual({ activity: 'within7Days' }); + expect(calls).toHaveLength(1); + const listed = (await (await api.request('/map/places')).json()) as { + places: Array<{ activity: string; externalId?: string }>; + }; + expect(listed.places[0]?.activity).toBe('within7Days'); + const oldIso = new Date(Date.now() - 40 * 24 * 60 * 60 * 1000).toISOString(); + const older = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'dfx', externalId: 'store-1', occurredAt: oldIso }), + }); + expect(older.status).toBe(200); + expect(await older.json()).toEqual({ activity: 'within7Days' }); + expect(store.list(1)[0]?.lastTransactionAt).not.toBe(oldIso); + const afterOlder = (await (await api.request('/map/places')).json()) as { + places: Array<{ activity: string }>; + }; + expect(afterOlder.places[0]?.activity).toBe('within7Days'); + const other = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ ...body, externalId: 'store-old' }), + }); + expect(other.status).toBe(201); + const noneRes = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'dfx', externalId: 'store-old', occurredAt: oldIso }), + }); + expect(noneRes.status).toBe(200); + expect(await noneRes.json()).toEqual({ activity: 'none' }); + expect(store.list(10).find((row) => row.externalId === 'store-old')?.lastTransactionAt).toBe( + oldIso, + ); + expect(calls).toHaveLength(2); + }); + + it('classifies activity from the injected clock', async () => { + const nowMs = Date.parse('2026-10-08T00:00:00.000Z'); + const api = app({ token: 'secret', now: () => nowMs }); + const created = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ ...body, externalId: 'ten-days', name: 'Ten Days' }), + }); + expect(created.status).toBe(201); + const tenDays = new Date(nowMs - 10 * 24 * 60 * 60 * 1000).toISOString(); + const older = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'dfx', externalId: 'ten-days', occurredAt: tenDays }), + }); + expect(older.status).toBe(200); + expect(await older.json()).toEqual({ activity: 'within30Days' }); + const listed = (await (await api.request('/map/places')).json()) as { + places: Array<{ name: string; activity: string }>; + }; + expect(listed.places.find((row) => row.name === 'Ten Days')?.activity).toBe('within30Days'); + const seven = await api.request('/map/places', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ ...body, externalId: 'seven-days', name: 'Seven Days' }), + }); + expect(seven.status).toBe(201); + const sevenDays = new Date(nowMs - 7 * 24 * 60 * 60 * 1000).toISOString(); + const recent = await api.request('/map/places/transactions', { + method: 'POST', + headers: jsonHeaders, + body: JSON.stringify({ origin: 'dfx', externalId: 'seven-days', occurredAt: sevenDays }), + }); + expect(recent.status).toBe(200); + expect(await recent.json()).toEqual({ activity: 'within7Days' }); + }); }); diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index be536e220..bfcd150f6 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -34,6 +34,9 @@ describe('MemoryMapPlaceStore', () => { const first = store.insertIfNew(input); expect(first.created).toBe(true); expect(first.place.techProvider).toBe('DFX.swiss'); + expect(first.place.country).toBeNull(); + expect(first.place.shopName).toBeNull(); + expect(first.place.supports).toEqual([]); const second = store.insertIfNew({ ...input, name: 'Other', lat: 1 }); expect(second.created).toBe(false); expect(second.place.name).toBe('SPAR'); @@ -42,6 +45,15 @@ describe('MemoryMapPlaceStore', () => { const listed = store.list(10); expect(listed.map((row) => row.externalId)).toEqual(['store-2', 'store-1']); expect(store.list(1)).toHaveLength(1); + expect(store.list(10, {}).map((row) => row.externalId)).toEqual(['store-2', 'store-1']); + expect(store.list(10, { blockchain: 'Ethereum' }).map((row) => row.externalId)).toEqual([ + 'store-2', + 'store-1', + ]); + expect(store.list(10, { asset: 'ZCHF' }).map((row) => row.externalId)).toEqual([ + 'store-2', + 'store-1', + ]); store.close(); expect(store.list(10)).toHaveLength(0); expect(later.created).toBe(true); @@ -95,6 +107,8 @@ describe('MemoryMapPlaceStore', () => { lon: 2, category: 'cafe', paymentMethods: 'onchain', + country: 'DE', + shopName: 'Migros', }); expect(updated.created).toBe(false); expect(updated.place.id).toBe(created.place.id); @@ -105,6 +119,8 @@ describe('MemoryMapPlaceStore', () => { expect(updated.place.category).toBe('cafe'); expect(updated.place.paymentMethods).toBe('onchain'); expect(updated.place.techProvider).toBe('DFX.swiss'); + expect(updated.place.country).toBe('DE'); + expect(updated.place.shopName).toBe('Migros'); }); it('keeps techProvider on upsert when omitted and changes it when sent', () => { @@ -118,12 +134,315 @@ describe('MemoryMapPlaceStore', () => { expect(changed.place.techProvider).toBe('DFX.swiss'); }); + it('applies the limit after filtering by origin', () => { + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); + store.insertIfNew({ ...input, origin: 'spar', externalId: 'spar-1' }); + store.insertIfNew({ ...input, origin: 'dfx', externalId: 'dfx-1' }); + const listed = store.list(1, { origin: 'spar' }); + expect(listed).toHaveLength(1); + expect(listed[0]?.origin).toBe('spar'); + expect(listed[0]?.externalId).toBe('spar-1'); + }); + + it('returns the older CH pin when a newer pin has another country', () => { + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); + store.insertIfNew({ ...input, externalId: 'ch-old', country: 'CH' }); + store.insertIfNew({ ...input, externalId: 'de-new', country: 'DE' }); + expect(store.list(1).map((row) => row.externalId)).toEqual(['de-new']); + const listed = store.list(1, { country: 'CH' }); + expect(listed).toHaveLength(1); + expect(listed[0]?.externalId).toBe('ch-old'); + expect(listed[0]?.country).toBe('CH'); + }); + + it('lists SPAR shop names and treats a missing brand as others', () => { + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); + store.insertIfNew({ ...input, externalId: 'spar-brand', shopName: 'SPAR' }); + store.insertIfNew({ ...input, externalId: 'migros', shopName: 'Migros' }); + store.insertIfNew({ ...input, externalId: 'none' }); + const spar = store.list(10, { shopName: 'SPAR' }); + expect(spar.map((row) => row.externalId)).toEqual(['spar-brand']); + const others = store.list(10, { shopName: 'others' }); + expect(others.map((row) => row.externalId)).toEqual(['none', 'migros']); + expect(others.every((row) => row.shopName !== 'SPAR')).toBe(true); + }); + + it('uses the payment-link catalog when a pin has no support rows', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.insertIfNew({ ...input, externalId: 'dfx-open' }); + store.insertIfNew({ + ...input, + origin: '21gifts', + externalId: 'gifts-open', + techProvider: '21.gifts', + }); + store.insertIfNew({ + ...input, + origin: '21gifts', + externalId: 'origin-only', + }); + store.insertIfNew({ + ...input, + externalId: 'label-only', + techProvider: '21.gifts', + }); + store.insertIfNew({ + ...input, + externalId: 'stated', + supports: [{ blockchain: 'Polygon', asset: 'ZCHF' }], + }); + const ids = (filter: { + blockchain?: string; + asset?: string; + shopName?: 'SPAR' | 'others'; + }): string[] => store.list(10, filter).map((row) => row.externalId); + + expect(ids({ blockchain: 'Ethereum' }).sort()).toEqual(['dfx-open']); + expect(ids({ blockchain: 'Lightning', asset: 'BTC' }).sort()).toEqual([ + 'dfx-open', + 'gifts-open', + 'label-only', + 'origin-only', + ]); + expect(ids({ blockchain: 'Ethereum', asset: 'BTC' })).toEqual([]); + expect(ids({ blockchain: 'Plasma' })).toEqual([]); + expect(ids({ blockchain: 'BinancePay' }).sort()).toEqual(['dfx-open']); + expect(ids({ asset: 'ckBTC' }).sort()).toEqual(['dfx-open']); + expect(ids({ blockchain: 'Bitcoin' }).sort()).toEqual(['dfx-open']); + expect(ids({ blockchain: 'Polygon', asset: 'ZCHF' }).sort()).toEqual(['dfx-open', 'stated']); + expect(ids({ shopName: 'SPAR' })).toEqual([]); + expect(ids({ shopName: 'others' }).sort()).toEqual([ + 'dfx-open', + 'gifts-open', + 'label-only', + 'origin-only', + 'stated', + ]); + }); + + it('requires blockchain and asset to match the same support row', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.insertIfNew({ + ...input, + supports: [ + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + ], + }); + expect(store.list(10, { blockchain: 'Ethereum', asset: 'ZCHF' })).toHaveLength(0); + expect(store.list(10, { blockchain: 'Bitcoin' })).toHaveLength(0); + expect(store.list(10, { asset: 'BTC' })).toHaveLength(0); + expect(store.list(10, { blockchain: 'Ethereum' }).map((row) => row.externalId)).toEqual([ + 'store-1', + ]); + expect(store.list(10, { asset: 'ZCHF' }).map((row) => row.externalId)).toEqual(['store-1']); + expect( + store.list(10, { blockchain: 'Polygon', asset: 'ZCHF' }).map((row) => row.externalId), + ).toEqual(['store-1']); + expect( + store.list(10, { blockchain: 'Ethereum', asset: 'ETH' }).map((row) => row.externalId), + ).toEqual(['store-1']); + }); + + it('sorts supports and copies them so a caller cannot mutate the store', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + const created = store.insertIfNew({ + ...input, + supports: [ + { blockchain: 'Polygon', asset: 'USDT' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + ], + }); + expect(created.place.supports).toEqual([ + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'USDT' }, + ]); + created.place.supports.push({ blockchain: 'Bitcoin', asset: 'BTC' }); + expect(store.list(1)[0]?.supports).toEqual([ + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'USDT' }, + ]); + }); + + it('does not replace country, shop name, or supports on a repeated insert', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.insertIfNew({ + ...input, + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + const again = store.insertIfNew({ + ...input, + name: 'Other', + country: 'DE', + shopName: 'Migros', + supports: [{ blockchain: 'Bitcoin', asset: 'BTC' }], + }); + expect(again.created).toBe(false); + expect(again.place.name).toBe('SPAR'); + expect(again.place.country).toBe('CH'); + expect(again.place.shopName).toBe('SPAR'); + expect(again.place.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); + }); + + it('keeps an omitted country on upsert, clears an omitted shop name, and leaves supports', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.upsert({ + ...input, + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + const omitted = store.upsert({ ...input, name: 'Other' }); + expect(omitted.place.name).toBe('Other'); + expect(omitted.place.country).toBe('CH'); + expect(omitted.place.shopName).toBeNull(); + expect(omitted.place.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); + }); + + it('clears supports on upsert when the body sends an empty list', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.upsert({ + ...input, + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + const emptied = store.upsert({ + ...input, + country: 'DE', + shopName: 'Migros', + supports: [], + }); + expect(emptied.place.country).toBe('DE'); + expect(emptied.place.shopName).toBe('Migros'); + expect(emptied.place.supports).toEqual([]); + }); + + it('returns distinct sorted filter values and restricts assets by blockchain', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + expect(store.filters()).toEqual({ countries: [], blockchains: [], assets: [] }); + store.insertIfNew({ + ...input, + country: 'CH', + shopName: 'SPAR', + supports: [ + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'USDT' }, + ], + }); + store.insertIfNew({ + ...input, + externalId: 'store-2', + country: 'DE', + supports: [{ blockchain: 'Bitcoin', asset: 'BTC' }], + }); + store.insertIfNew({ ...input, externalId: 'none' }); + expect(store.filters()).toEqual({ + countries: ['CH', 'DE'], + blockchains: ['Bitcoin', 'Ethereum', 'Polygon'], + assets: ['BTC', 'USDT', 'ZCHF'], + }); + expect(store.filters('Ethereum')).toEqual({ + countries: ['CH', 'DE'], + blockchains: ['Bitcoin', 'Ethereum', 'Polygon'], + assets: ['ZCHF'], + }); + expect(store.filters('Sepolia')).toEqual({ + countries: ['CH', 'DE'], + blockchains: ['Bitcoin', 'Ethereum', 'Polygon'], + assets: [], + }); + }); + + it('applies AND predicates before the limit', () => { + let tick = 0; + const store = new MemoryMapPlaceStore(() => new Date(Date.UTC(2026, 0, 1, 0, 0, tick++))); + store.insertIfNew({ + ...input, + origin: 'spar', + externalId: 'match', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + store.insertIfNew({ + ...input, + origin: 'spar', + externalId: 'miss', + country: 'DE', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); + const listed = store.list(1, { + origin: 'spar', + country: 'CH', + shopName: 'SPAR', + blockchain: 'Ethereum', + asset: 'ZCHF', + }); + expect(listed).toHaveLength(1); + expect(listed[0]?.externalId).toBe('match'); + }); + it('deletes by key and reports a miss', () => { const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); expect(store.deleteByKey('dfx', 'store-1')).toBe(false); - store.insertIfNew(input); + store.insertIfNew({ + ...input, + country: 'CH', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], + }); expect(store.deleteByKey('dfx', 'store-1')).toBe(true); expect(store.list(10)).toHaveLength(0); + expect(store.filters()).toEqual({ countries: [], blockchains: [], assets: [] }); expect(store.deleteByKey('dfx', 'store-1')).toBe(false); }); + + it('records a newer transaction instant and does not move backward', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + expect(store.recordTransaction('dfx', 'store-1', '2026-09-26T00:00:00.000Z')).toBeUndefined(); + expect(store.list(10)).toHaveLength(0); + const created = store.insertIfNew(input); + expect(created.place.lastTransactionAt).toBeNull(); + const moved = store.recordTransaction('dfx', 'store-1', '2026-09-26T12:00:00Z'); + expect(moved?.lastTransactionAt).toBe('2026-09-26T12:00:00.000Z'); + const newer = '2026-09-26T18:00:00.000Z'; + const forwarded = store.recordTransaction('dfx', 'store-1', newer); + expect(forwarded?.lastTransactionAt).toBe(newer); + const older = store.recordTransaction('dfx', 'store-1', '2026-09-26T06:00:00.000Z'); + expect(older?.lastTransactionAt).toBe(newer); + const equal = store.recordTransaction('dfx', 'store-1', '2026-09-26T18:00:00Z'); + expect(equal?.lastTransactionAt).toBe(newer); + }); + + it('ignores an unparseable argument and keeps the timestamp on insert and upsert', () => { + const store = new MemoryMapPlaceStore(() => new Date('2026-09-26T00:00:00.000Z')); + store.insertIfNew(input); + const seeded = store.recordTransaction('dfx', 'store-1', 'not-a-date'); + expect(seeded?.lastTransactionAt).toBeNull(); + const iso = '2026-09-26T00:00:00.000Z'; + const replaced = store.recordTransaction('dfx', 'store-1', iso); + expect(replaced?.lastTransactionAt).toBe(iso); + const ignored = store.recordTransaction('dfx', 'store-1', 'not-a-date'); + expect(ignored?.lastTransactionAt).toBe(iso); + const again = store.insertIfNew({ ...input, name: 'Other' }); + expect(again.created).toBe(false); + expect(again.place.lastTransactionAt).toBe(iso); + const updated = store.upsert({ ...input, name: 'Renamed' }); + expect(updated.place.name).toBe('Renamed'); + expect(updated.place.lastTransactionAt).toBe(iso); + if (replaced !== undefined) { + replaced.lastTransactionAt = 'mutated'; + } + expect(store.list(1)[0]?.lastTransactionAt).toBe(iso); + }); }); diff --git a/src/lib/map/filter-catalog.ts b/src/lib/map/filter-catalog.ts new file mode 100644 index 000000000..20ff08653 --- /dev/null +++ b/src/lib/map/filter-catalog.ts @@ -0,0 +1,274 @@ +/** + * Temporary payment-link filter catalog. + * Networks are those offered for payment links, without test networks and + * without Plasma. Assets are the payment-enabled names on those networks, + * not the sellable names. + * 21.gifts is Lightning and BTC only. Replace this module when the offer + * is read from the payment service. + */ + +const GIFTS_ORIGIN = '21gifts'; +const GIFTS_TECH_PROVIDER = '21.gifts'; +const GIFTS_BLOCKCHAIN = 'Lightning'; +const GIFTS_ASSET = 'BTC'; + +const DFX_BLOCKCHAINS: readonly string[] = [ + 'Arbitrum', + 'Base', + 'BinancePay', + 'BinanceSmartChain', + 'Bitcoin', + 'Cardano', + 'Ethereum', + 'Firo', + 'Gnosis', + 'InternetComputer', + 'KucoinPay', + 'Lightning', + 'Monero', + 'Optimism', + 'Polygon', + 'Solana', + 'Spark', + 'Tron', + 'Zano', +]; + +const DFX_PAIRS: readonly { blockchain: string; asset: string }[] = [ + { blockchain: 'Arbitrum', asset: 'ETH' }, + { blockchain: 'Arbitrum', asset: 'USDC' }, + { blockchain: 'Arbitrum', asset: 'USDT' }, + { blockchain: 'Arbitrum', asset: 'WBTC' }, + { blockchain: 'Arbitrum', asset: 'dEURO' }, + { blockchain: 'Base', asset: 'ETH' }, + { blockchain: 'Base', asset: 'USDC' }, + { blockchain: 'Base', asset: 'dEURO' }, + { blockchain: 'BinancePay', asset: 'USDT' }, + { blockchain: 'BinanceSmartChain', asset: 'BNB' }, + { blockchain: 'BinanceSmartChain', asset: 'USDC' }, + { blockchain: 'BinanceSmartChain', asset: 'USDT' }, + { blockchain: 'Bitcoin', asset: 'BTC' }, + { blockchain: 'Cardano', asset: 'ADA' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'USDC' }, + { blockchain: 'Ethereum', asset: 'USDT' }, + { blockchain: 'Ethereum', asset: 'WBTC' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'dEURO' }, + { blockchain: 'Firo', asset: 'FIRO' }, + { blockchain: 'InternetComputer', asset: 'ICP' }, + { blockchain: 'InternetComputer', asset: 'VCHF' }, + { blockchain: 'InternetComputer', asset: 'VEUR' }, + { blockchain: 'InternetComputer', asset: 'ckBTC' }, + { blockchain: 'KucoinPay', asset: 'USDT' }, + { blockchain: 'Lightning', asset: 'BTC' }, + { blockchain: 'Monero', asset: 'XMR' }, + { blockchain: 'Optimism', asset: 'ETH' }, + { blockchain: 'Optimism', asset: 'USDC' }, + { blockchain: 'Optimism', asset: 'USDT' }, + { blockchain: 'Optimism', asset: 'WBTC' }, + { blockchain: 'Optimism', asset: 'dEURO' }, + { blockchain: 'Polygon', asset: 'POL' }, + { blockchain: 'Polygon', asset: 'USDC' }, + { blockchain: 'Polygon', asset: 'USDT' }, + { blockchain: 'Polygon', asset: 'WBTC' }, + { blockchain: 'Polygon', asset: 'ZCHF' }, + { blockchain: 'Polygon', asset: 'dEURO' }, + { blockchain: 'Solana', asset: 'SOL' }, + { blockchain: 'Solana', asset: 'USDC' }, + { blockchain: 'Solana', asset: 'USDT' }, + { blockchain: 'Spark', asset: 'BTC' }, + { blockchain: 'Tron', asset: 'TRX' }, + { blockchain: 'Tron', asset: 'USDT' }, +]; + +const CHAIN_SET: ReadonlySet = new Set(DFX_BLOCKCHAINS); +const ASSET_SET: ReadonlySet = new Set(DFX_PAIRS.map((pair) => pair.asset)); +const PAIR_KEYS: ReadonlySet = new Set( + DFX_PAIRS.map((pair) => `${pair.blockchain}\0${pair.asset}`), +); + +export type MapFilterTechProvider = { + name: string; + blockchains: string[]; + assets: string[]; + pairs: { blockchain: string; asset: string }[]; +}; + +export type PublicMapFilters = { + shopNames: ['SPAR', 'others']; + countries: string[]; + blockchains: string[]; + assets: string[]; + techProviders: MapFilterTechProvider[]; +}; + +function uniqueSorted(values: readonly string[]): string[] { + return [...new Set(values)].sort(); +} + +function offerFlags( + blockchain: string | undefined, + asset: string | undefined, +): { gifts: boolean; dfx: boolean } { + const gifts = + (blockchain === undefined || blockchain === GIFTS_BLOCKCHAIN) && + (asset === undefined || asset === GIFTS_ASSET) && + (blockchain !== undefined || asset !== undefined); + let dfx = false; + if (blockchain !== undefined && asset !== undefined) { + dfx = PAIR_KEYS.has(`${blockchain}\0${asset}`); + } else if (blockchain !== undefined) { + dfx = CHAIN_SET.has(blockchain); + } else if (asset !== undefined) { + dfx = ASSET_SET.has(asset); + } + return { gifts, dfx }; +} + +function supportSql( + blockchain: string | undefined, + asset: string | undefined, +): { sql: string; params: string[] } { + if (blockchain !== undefined && asset !== undefined) { + return { + sql: 'id IN (SELECT place_id FROM map_place_support WHERE blockchain = ? AND asset = ?)', + params: [blockchain, asset], + }; + } + if (blockchain !== undefined) { + return { + sql: 'id IN (SELECT place_id FROM map_place_support WHERE blockchain = ?)', + params: [blockchain], + }; + } + /* v8 ignore start -- @preserve a matching offer always names a blockchain or an asset */ + if (asset === undefined) { + return { sql: '0', params: [] }; + } + /* v8 ignore stop -- @preserve */ + return { + sql: 'id IN (SELECT place_id FROM map_place_support WHERE asset = ?)', + params: [asset], + }; +} + +function assetsFor(blockchain: string | undefined): string[] { + if (blockchain === undefined) { + return [...ASSET_SET]; + } + const values: string[] = []; + for (const pair of DFX_PAIRS) { + if (pair.blockchain === blockchain) { + values.push(pair.asset); + } + } + return values; +} + +/** + * Whether a query network is in the hardcoded payment-link catalog. + * + * @param value - Trimmed blockchain query. + * @returns True for a catalog network, including BinancePay and KucoinPay. + */ +export function isCatalogBlockchain(value: string): boolean { + return CHAIN_SET.has(value); +} + +/** + * Whether a query asset is in the hardcoded payment-link catalog. + * + * @param value - Trimmed asset query. + * @returns True for a catalog asset, including ckBTC and dEURO. + */ +export function isCatalogAsset(value: string): boolean { + return ASSET_SET.has(value); +} + +/** + * Whether a pin with no support rows matches a payment filter. + * + * @param origin - Stored origin. + * @param techProvider - Stored tech provider. + * @param blockchain - Requested network, if any. + * @param asset - Requested asset, if any. + * @returns True when the hardcoded offer for that pin includes the request. + */ +export function matchesUnstatedPayment( + origin: string, + techProvider: string, + blockchain: string | undefined, + asset: string | undefined, +): boolean { + if (blockchain === undefined && asset === undefined) { + return true; + } + const offer = offerFlags(blockchain, asset); + if (origin === GIFTS_ORIGIN || techProvider === GIFTS_TECH_PROVIDER) { + return offer.gifts; + } + return offer.dfx; +} + +/** + * SQL for a blockchain or asset filter, including pins with no support rows. + * + * @param blockchain - Requested network, if any. + * @param asset - Requested asset, if any. + * @returns A parenthesised predicate and its bound parameters, or null when + * only stored support rows can match. + */ +export function unstatedPaymentSql( + blockchain?: string | undefined, + asset?: string | undefined, +): { sql: string; params: string[] } | null { + const offer = offerFlags(blockchain, asset); + if (!offer.gifts && !offer.dfx) { + return null; + } + const support = supportSql(blockchain, asset); + const giftsFlag = offer.gifts ? '1' : '0'; + let dfxFlag = '1'; + /* v8 ignore next 3 -- @preserve gifts offers are a subset of the DFX catalog */ + if (!offer.dfx) { + dfxFlag = '0'; + } + return { + sql: `(${support.sql} OR (NOT EXISTS (SELECT 1 FROM map_place_support WHERE place_id = map_place.id) AND (((tech_provider = '21.gifts' OR origin = '21gifts') AND ? = '1') OR (NOT (tech_provider = '21.gifts' OR origin = '21gifts') AND ? = '1'))))`, + params: [...support.params, giftsFlag, dfxFlag], + }; +} + +/** + * Public filter payload: stored values plus the hardcoded payment-link offer. + * + * @param stored - Distinct countries, blockchains, and assets already stored. + * @param blockchain - When set, top-level assets are limited to that network. + * @returns Shop-name tokens, countries, networks, assets, and per-provider offers. + */ +export function publicFilterResponse( + stored: { countries: string[]; blockchains: string[]; assets: string[] }, + blockchain?: string | undefined, +): PublicMapFilters { + return { + shopNames: ['SPAR', 'others'], + countries: stored.countries, + blockchains: uniqueSorted([...DFX_BLOCKCHAINS, ...stored.blockchains]), + assets: uniqueSorted([...assetsFor(blockchain), ...stored.assets]), + techProviders: [ + { + name: 'DFX.swiss', + blockchains: [...DFX_BLOCKCHAINS], + assets: [...ASSET_SET].sort(), + pairs: DFX_PAIRS.map((pair) => ({ blockchain: pair.blockchain, asset: pair.asset })), + }, + { + name: GIFTS_TECH_PROVIDER, + blockchains: [GIFTS_BLOCKCHAIN], + assets: [GIFTS_ASSET], + pairs: [{ blockchain: GIFTS_BLOCKCHAIN, asset: GIFTS_ASSET }], + }, + ], + }; +} diff --git a/src/lib/map/place.ts b/src/lib/map/place.ts index bedb7d14f..d969cede3 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -3,6 +3,13 @@ * Coordinates are rounded to six decimal places. */ +import { isCatalogAsset, isCatalogBlockchain } from '@/lib/map/filter-catalog'; + +export type MapPlaceSupport = { + blockchain: string; + asset: string; +}; + export type MapPlaceInput = { origin: string; externalId: string; @@ -12,12 +19,22 @@ export type MapPlaceInput = { category: string; paymentMethods: string | null; techProvider?: string; + country?: string; + shopName?: string; + supports?: MapPlaceSupport[]; }; -export type StoredMapPlace = MapPlaceInput & { +export type StoredMapPlace = Omit< + MapPlaceInput, + 'techProvider' | 'country' | 'shopName' | 'supports' +> & { id: string; createdAt: string; techProvider: string; + country: string | null; + shopName: string | null; + supports: MapPlaceSupport[]; + lastTransactionAt: string | null; }; /** Fields the public map may show. The caller's own id stays off this list. */ @@ -29,6 +46,18 @@ export type PublicMapPlace = { lon: number; category: string; techProvider: string; + country: string | null; + shopName: string | null; + supports: MapPlaceSupport[]; + activity: 'within7Days' | 'within30Days' | 'none'; +}; + +/** Query fields that restrict GET /map/places, all optional. */ +export type MapPlaceQueryFilter = { + country?: string; + shopName?: 'SPAR' | 'others'; + blockchain?: string; + asset?: string; }; const COORD_ERROR = 'Place must be a latitude and longitude'; @@ -37,9 +66,298 @@ const EXTERNAL_ID_ERROR = 'Place external id is required'; const NAME_ERROR = 'Place name is required'; const CATEGORY_ERROR = 'Place category is required'; const TECH_PROVIDER_ERROR = 'Place tech provider is invalid'; +const COUNTRY_ERROR = 'Place country is invalid'; +const SHOP_NAME_ERROR = 'Place shop name is invalid'; +const SUPPORT_ERROR = 'Place support is invalid'; +const TRANSACTION_TIME_ERROR = 'Place transaction time is invalid'; const PAYMENT_METHODS = /^(onchain|lightning|nfc)(,(onchain|lightning|nfc))*$/; const TECH_PROVIDER = /^[A-Za-z0-9][A-Za-z0-9.-]{0,39}$/; +const ASSET_TICKER = /^[A-Z][A-Z0-9]{1,31}$/; +const ASSET_PREFIXED = /^[a-z][A-Z][A-Z0-9]{1,30}$/; + +const ISO_COUNTRIES: ReadonlySet = new Set([ + 'AD', + 'AE', + 'AF', + 'AG', + 'AI', + 'AL', + 'AM', + 'AO', + 'AQ', + 'AR', + 'AS', + 'AT', + 'AU', + 'AW', + 'AX', + 'AZ', + 'BA', + 'BB', + 'BD', + 'BE', + 'BF', + 'BG', + 'BH', + 'BI', + 'BJ', + 'BL', + 'BM', + 'BN', + 'BO', + 'BQ', + 'BR', + 'BS', + 'BT', + 'BV', + 'BW', + 'BY', + 'BZ', + 'CA', + 'CC', + 'CD', + 'CF', + 'CG', + 'CH', + 'CI', + 'CK', + 'CL', + 'CM', + 'CN', + 'CO', + 'CR', + 'CU', + 'CV', + 'CW', + 'CX', + 'CY', + 'CZ', + 'DE', + 'DJ', + 'DK', + 'DM', + 'DO', + 'DZ', + 'EC', + 'EE', + 'EG', + 'EH', + 'ER', + 'ES', + 'ET', + 'FI', + 'FJ', + 'FK', + 'FM', + 'FO', + 'FR', + 'GA', + 'GB', + 'GD', + 'GE', + 'GF', + 'GG', + 'GH', + 'GI', + 'GL', + 'GM', + 'GN', + 'GP', + 'GQ', + 'GR', + 'GS', + 'GT', + 'GU', + 'GW', + 'GY', + 'HK', + 'HM', + 'HN', + 'HR', + 'HT', + 'HU', + 'ID', + 'IE', + 'IL', + 'IM', + 'IN', + 'IO', + 'IQ', + 'IR', + 'IS', + 'IT', + 'JE', + 'JM', + 'JO', + 'JP', + 'KE', + 'KG', + 'KH', + 'KI', + 'KM', + 'KN', + 'KP', + 'KR', + 'KW', + 'KY', + 'KZ', + 'LA', + 'LB', + 'LC', + 'LI', + 'LK', + 'LR', + 'LS', + 'LT', + 'LU', + 'LV', + 'LY', + 'MA', + 'MC', + 'MD', + 'ME', + 'MF', + 'MG', + 'MH', + 'MK', + 'ML', + 'MM', + 'MN', + 'MO', + 'MP', + 'MQ', + 'MR', + 'MS', + 'MT', + 'MU', + 'MV', + 'MW', + 'MX', + 'MY', + 'MZ', + 'NA', + 'NC', + 'NE', + 'NF', + 'NG', + 'NI', + 'NL', + 'NO', + 'NP', + 'NR', + 'NU', + 'NZ', + 'OM', + 'PA', + 'PE', + 'PF', + 'PG', + 'PH', + 'PK', + 'PL', + 'PM', + 'PN', + 'PR', + 'PS', + 'PT', + 'PW', + 'PY', + 'QA', + 'RE', + 'RO', + 'RS', + 'RU', + 'RW', + 'SA', + 'SB', + 'SC', + 'SD', + 'SE', + 'SG', + 'SH', + 'SI', + 'SJ', + 'SK', + 'SL', + 'SM', + 'SN', + 'SO', + 'SR', + 'SS', + 'ST', + 'SV', + 'SX', + 'SY', + 'SZ', + 'TC', + 'TD', + 'TF', + 'TG', + 'TH', + 'TJ', + 'TK', + 'TL', + 'TM', + 'TN', + 'TO', + 'TR', + 'TT', + 'TV', + 'TW', + 'TZ', + 'UA', + 'UG', + 'UM', + 'US', + 'UY', + 'UZ', + 'VA', + 'VC', + 'VE', + 'VG', + 'VI', + 'VN', + 'VU', + 'WF', + 'WS', + 'YE', + 'YT', + 'ZA', + 'ZM', + 'ZW', +]); + +const PAYMENT_BLOCKCHAINS: ReadonlySet = new Set([ + 'Bitcoin', + 'Lightning', + 'Spark', + 'Arkade', + 'Firo', + 'Monero', + 'Zano', + 'Ethereum', + 'Sepolia', + 'BinanceSmartChain', + 'Optimism', + 'Arbitrum', + 'Polygon', + 'Base', + 'Haqq', + 'Liquid', + 'Arweave', + 'Cardano', + 'InternetComputer', + 'DeFiChain', + 'Railgun', + 'Solana', + 'Gnosis', + 'Plasma', + 'Tron', + 'Citrea', + 'CitreaTestnet', + 'BitcoinTestnet4', +]); function roundCoord(n: number): number { const rounded = Math.round(n * 1e6) / 1e6; @@ -56,8 +374,47 @@ function hasNoControls(value: string): boolean { return true; } -function normalizeOrigin(raw: unknown): { ok: true; value: string } | { ok: false; error: string } { - if (typeof raw !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(raw.trim())) { +function compareSupport(a: MapPlaceSupport, b: MapPlaceSupport): number { + if (a.blockchain < b.blockchain) { + return -1; + } + if (a.blockchain > b.blockchain) { + return 1; + } + if (a.asset < b.asset) { + return -1; + } + if (a.asset > b.asset) { + return 1; + } + return 0; +} + +function uniqueSortedSupports(items: MapPlaceSupport[]): MapPlaceSupport[] { + const sorted = [...items] + .map((item) => ({ blockchain: item.blockchain, asset: item.asset })) + .sort(compareSupport); + const unique: MapPlaceSupport[] = []; + for (const item of sorted) { + const prev = unique[unique.length - 1]; + if (prev !== undefined && prev.blockchain === item.blockchain && prev.asset === item.asset) { + continue; + } + unique.push(item); + } + return unique; +} + +/** + * Validate a pin origin. + * + * @param raw - Query string or JSON value. + * @returns The trimmed origin, or a fixed error message. + */ +export function normalizePlaceOrigin( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string' || !/^[a-z0-9][a-z0-9-]{0,31}$/.test(raw.trim())) { return { ok: false, error: ORIGIN_ERROR }; } return { ok: true, value: raw.trim() }; @@ -95,6 +452,114 @@ function normalizeTechProvider( return { ok: true, value: trimmed }; } +function normalizeCountry( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: COUNTRY_ERROR }; + } + const country = raw.trim().toUpperCase(); + if (!ISO_COUNTRIES.has(country)) { + return { ok: false, error: COUNTRY_ERROR }; + } + return { ok: true, value: country }; +} + +function normalizeShopName( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: SHOP_NAME_ERROR }; + } + const shopName = raw.trim(); + if (shopName.length < 1 || shopName.length > 40 || !hasNoControls(shopName)) { + return { ok: false, error: SHOP_NAME_ERROR }; + } + return { ok: true, value: shopName }; +} + +function normalizeQueryShopName( + raw: string, +): { ok: true; value: 'SPAR' | 'others' } | { ok: false; error: string } { + const shopName = raw.trim(); + if (shopName !== 'SPAR' && shopName !== 'others') { + return { ok: false, error: SHOP_NAME_ERROR }; + } + return { ok: true, value: shopName }; +} + +function normalizeBlockchain( + raw: unknown, +): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: SUPPORT_ERROR }; + } + const blockchain = raw.trim(); + if (!PAYMENT_BLOCKCHAINS.has(blockchain)) { + return { ok: false, error: SUPPORT_ERROR }; + } + return { ok: true, value: blockchain }; +} + +function normalizeAsset(raw: unknown): { ok: true; value: string } | { ok: false; error: string } { + if (typeof raw !== 'string') { + return { ok: false, error: SUPPORT_ERROR }; + } + const asset = raw.trim(); + if (!ASSET_TICKER.test(asset) && !ASSET_PREFIXED.test(asset)) { + return { ok: false, error: SUPPORT_ERROR }; + } + return { ok: true, value: asset }; +} + +function normalizeQueryBlockchain( + raw: string, +): { ok: true; value: string } | { ok: false; error: string } { + const blockchain = raw.trim(); + if (!PAYMENT_BLOCKCHAINS.has(blockchain) && !isCatalogBlockchain(blockchain)) { + return { ok: false, error: SUPPORT_ERROR }; + } + return { ok: true, value: blockchain }; +} + +function normalizeQueryAsset( + raw: string, +): { ok: true; value: string } | { ok: false; error: string } { + const asset = raw.trim(); + if (!ASSET_TICKER.test(asset) && !ASSET_PREFIXED.test(asset) && !isCatalogAsset(asset)) { + return { ok: false, error: SUPPORT_ERROR }; + } + return { ok: true, value: asset }; +} + +function normalizeSupports( + raw: unknown, +): { ok: true; value: MapPlaceSupport[] } | { ok: false; error: string } { + if (!Array.isArray(raw)) { + return { ok: false, error: SUPPORT_ERROR }; + } + if (raw.length > 32) { + return { ok: false, error: SUPPORT_ERROR }; + } + const items: MapPlaceSupport[] = []; + for (const item of raw) { + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + return { ok: false, error: SUPPORT_ERROR }; + } + const rec = item as Record; + const blockchain = normalizeBlockchain(rec['blockchain']); + if (!blockchain.ok) { + return blockchain; + } + const asset = normalizeAsset(rec['asset']); + if (!asset.ok) { + return asset; + } + items.push({ blockchain: blockchain.value, asset: asset.value }); + } + return { ok: true, value: uniqueSortedSupports(items) }; +} + /** * Validate a JSON body for one map pin. * @@ -123,7 +588,7 @@ export function normalizeMapPlace( return { ok: false, error: COORD_ERROR }; } - const origin = normalizeOrigin(rec['origin']); + const origin = normalizePlaceOrigin(rec['origin']); if (!origin.ok) { return origin; } @@ -165,6 +630,36 @@ export function normalizeMapPlace( return techProvider; } + const rawCountry = rec['country']; + let country: string | undefined; + if (rawCountry !== undefined && rawCountry !== null) { + const parsedCountry = normalizeCountry(rawCountry); + if (!parsedCountry.ok) { + return parsedCountry; + } + country = parsedCountry.value; + } + + const rawShopName = rec['shopName']; + let shopName: string | undefined; + if (rawShopName !== undefined && rawShopName !== null) { + const parsedShopName = normalizeShopName(rawShopName); + if (!parsedShopName.ok) { + return parsedShopName; + } + shopName = parsedShopName.value; + } + + const rawSupports = rec['supports']; + let supports: MapPlaceSupport[] | undefined; + if (rawSupports !== undefined) { + const parsedSupports = normalizeSupports(rawSupports); + if (!parsedSupports.ok) { + return parsedSupports; + } + supports = parsedSupports.value; + } + const value: MapPlaceInput = { origin: origin.value, externalId: externalId.value, @@ -177,6 +672,15 @@ export function normalizeMapPlace( if (techProvider.value !== undefined) { value.techProvider = techProvider.value; } + if (country !== undefined) { + value.country = country; + } + if (shopName !== undefined) { + value.shopName = shopName; + } + if (supports !== undefined) { + value.supports = supports; + } return { ok: true, value }; } @@ -193,7 +697,7 @@ export function normalizeMapPlaceKey( return { ok: false, error: ORIGIN_ERROR }; } const rec = input as Record; - const origin = normalizeOrigin(rec['origin']); + const origin = normalizePlaceOrigin(rec['origin']); if (!origin.ok) { return origin; } @@ -204,13 +708,135 @@ export function normalizeMapPlaceKey( return { ok: true, value: { origin: origin.value, externalId: externalId.value } }; } +/** + * Classify a stored transaction instant relative to the request clock. + * + * @param lastTransactionAt - Stored instant, or null when none. + * @param nowMs - Request clock in milliseconds. + * @returns One of the three mutually exclusive activity buckets. + */ +export function placeActivity( + lastTransactionAt: string | null, + nowMs: number, +): 'within7Days' | 'within30Days' | 'none' { + if (lastTransactionAt === null) { + return 'none'; + } + const parsed = Date.parse(lastTransactionAt); + if (!Number.isFinite(parsed)) { + return 'none'; + } + const age = nowMs - parsed; + if (age <= 7 * 24 * 60 * 60 * 1000) { + return 'within7Days'; + } + if (age <= 30 * 24 * 60 * 60 * 1000) { + return 'within30Days'; + } + return 'none'; +} + +/** + * Validate a JSON body that records a shop transaction. + * + * @param input - Request JSON. + * @param nowMs - Request clock in milliseconds. + * @returns The key and a canonical instant, or a fixed error message. + */ +export function normalizePlaceTransaction( + input: unknown, + nowMs: number, +): + | { ok: true; value: { origin: string; externalId: string; occurredAt: string } } + | { ok: false; error: string } { + const key = normalizeMapPlaceKey(input); + if (!key.ok) { + return key; + } + const rec = input as Record; + const rawOccurredAt = rec['occurredAt']; + if (rawOccurredAt === undefined) { + return { + ok: true, + value: { + origin: key.value.origin, + externalId: key.value.externalId, + occurredAt: new Date(nowMs).toISOString(), + }, + }; + } + if (typeof rawOccurredAt !== 'string' || rawOccurredAt.trim() === '') { + return { ok: false, error: TRANSACTION_TIME_ERROR }; + } + const parsed = Date.parse(rawOccurredAt); + if (!Number.isFinite(parsed) || parsed > nowMs + 120_000) { + return { ok: false, error: TRANSACTION_TIME_ERROR }; + } + return { + ok: true, + value: { + origin: key.value.origin, + externalId: key.value.externalId, + occurredAt: new Date(parsed).toISOString(), + }, + }; +} + +/** + * Validate optional public map query fields. + * + * @param country - Optional ISO country query. + * @param shopName - Optional SPAR or others query. + * @param blockchain - Optional payment network query. + * @param asset - Optional asset name query. + * @returns The present filter fields, or a fixed error message. + */ +export function normalizeMapPlaceFilter( + country?: string | undefined, + shopName?: string | undefined, + blockchain?: string | undefined, + asset?: string | undefined, +): { ok: true; value: MapPlaceQueryFilter } | { ok: false; error: string } { + const value: MapPlaceQueryFilter = {}; + if (country !== undefined) { + const parsed = normalizeCountry(country); + if (!parsed.ok) { + return parsed; + } + value.country = parsed.value; + } + if (shopName !== undefined) { + const parsed = normalizeQueryShopName(shopName); + if (!parsed.ok) { + return parsed; + } + value.shopName = parsed.value; + } + if (blockchain !== undefined) { + const parsed = normalizeQueryBlockchain(blockchain); + if (!parsed.ok) { + return parsed; + } + value.blockchain = parsed.value; + } + if (asset !== undefined) { + const parsed = normalizeQueryAsset(asset); + if (!parsed.ok) { + return parsed; + } + value.asset = parsed.value; + } + return { ok: true, value }; +} + /** * Drop fields that only the ingest caller needs. * * @param place - Stored pin. - * @returns The public map row. + * @param nowMs - Request clock in milliseconds. + * @returns The public map row, including activity. */ -export function toPublicMapPlace(place: StoredMapPlace): PublicMapPlace { +export function toPublicMapPlace(place: StoredMapPlace, nowMs: number): PublicMapPlace { return { id: place.id, origin: place.origin, @@ -219,5 +845,12 @@ export function toPublicMapPlace(place: StoredMapPlace): PublicMapPlace { lon: place.lon, category: place.category, techProvider: place.techProvider, + country: place.country, + shopName: place.shopName, + supports: place.supports.map((item) => ({ + blockchain: item.blockchain, + asset: item.asset, + })), + activity: placeActivity(place.lastTransactionAt, nowMs), }; } diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index 02d86841e..344ac3108 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -1,9 +1,10 @@ import { Database } from 'bun:sqlite'; -import type { MapPlaceInput, StoredMapPlace } from '@/lib/map/place'; -import { MAP_PLACE_SCHEMA_SQL, type MapPlaceStore } from '@/lib/map/store'; +import { unstatedPaymentSql } from '@/lib/map/filter-catalog'; +import type { MapPlaceInput, MapPlaceSupport, StoredMapPlace } from '@/lib/map/place'; +import { MAP_PLACE_SCHEMA_SQL, type MapPlaceListFilter, type MapPlaceStore } from '@/lib/map/store'; const MAP_PLACE_COLUMNS = - 'id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider'; + 'id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider, country, shop_name, last_transaction_at'; type MapPlaceRow = { id: string; @@ -16,9 +17,43 @@ type MapPlaceRow = { payment_methods: string | null; created_at: string; tech_provider: string; + country: string | null; + shop_name: string | null; + last_transaction_at: string | null; }; -function mapRow(row: MapPlaceRow): StoredMapPlace { +function compareSupport(a: MapPlaceSupport, b: MapPlaceSupport): number { + if (a.blockchain < b.blockchain) { + return -1; + } + if (a.blockchain > b.blockchain) { + return 1; + } + if (a.asset < b.asset) { + return -1; + } + if (a.asset > b.asset) { + return 1; + } + return 0; +} + +function storedSupports(items: MapPlaceSupport[]): MapPlaceSupport[] { + const sorted = [...items] + .map((item) => ({ blockchain: item.blockchain, asset: item.asset })) + .sort(compareSupport); + const unique: MapPlaceSupport[] = []; + for (const item of sorted) { + const prev = unique[unique.length - 1]; + if (prev !== undefined && prev.blockchain === item.blockchain && prev.asset === item.asset) { + continue; + } + unique.push(item); + } + return unique; +} + +function mapRow(row: MapPlaceRow, supports: MapPlaceSupport[]): StoredMapPlace { return { id: row.id, origin: row.origin, @@ -30,13 +65,155 @@ function mapRow(row: MapPlaceRow): StoredMapPlace { paymentMethods: row.payment_methods, createdAt: row.created_at, techProvider: row.tech_provider, + country: row.country, + shopName: row.shop_name, + lastTransactionAt: row.last_transaction_at, + supports: supports.map((item) => ({ blockchain: item.blockchain, asset: item.asset })), }; } +function loadSupportsByPlaceId(db: Database, ids: string[]): Map { + const supportsById = new Map(); + for (const id of ids) { + supportsById.set(id, []); + } + if (ids.length === 0) { + return supportsById; + } + const placeholders = ids.map(() => '?').join(', '); + const rows = db + .query( + `SELECT place_id, blockchain, asset + FROM map_place_support + WHERE place_id IN (${placeholders}) + ORDER BY blockchain, asset`, + ) + .all(...ids) as Array<{ place_id: string; blockchain: string; asset: string }>; + for (const row of rows) { + const list = supportsById.get(row.place_id); + if (list === undefined) { + throw new Error('map place support for unknown id'); + } + list.push({ blockchain: row.blockchain, asset: row.asset }); + } + return supportsById; +} + +function mapRows(db: Database, rows: MapPlaceRow[]): StoredMapPlace[] { + const supportsById = loadSupportsByPlaceId( + db, + rows.map((row) => row.id), + ); + return rows.map((row) => { + const supports = supportsById.get(row.id); + if (supports === undefined) { + throw new Error('map place supports missing'); + } + return mapRow(row, supports); + }); +} + +function mapOne(db: Database, row: MapPlaceRow): StoredMapPlace { + const mapped = mapRows(db, [row]); + const place = mapped[0]; + if (place === undefined) { + throw new Error('map place row missing after map'); + } + return place; +} + +const LI_POSTAL_CODES: ReadonlySet = new Set([ + '9485', + '9486', + '9487', + '9488', + '9490', + '9491', + '9492', + '9493', + '9494', + '9495', + '9496', + '9497', + '9498', +]); + +const GIFTS_COUNTRY_BY_EXACT_NAME: ReadonlyMap = new Map([ + ['Happyland Court Barangay 105 Tondo, Manila', 'PH'], + ['Rose st.Happyland Barangay 105 Tondo,Manila', 'PH'], + ['Machakos Bitcoin Academy', 'KE'], + ['bitcoinmakueni', 'KE'], +]); + +function lastFourDigitWord(name: string): string | undefined { + let found: string | undefined; + for (const word of name.split(/\s+/)) { + if (/^[0-9]{4}$/.test(word)) { + found = word; + } + } + return found; +} + +function countryFromSparName(name: string): 'LI' | 'CH' | undefined { + const code = lastFourDigitWord(name); + if (code === undefined) { + return undefined; + } + if (LI_POSTAL_CODES.has(code)) { + return 'LI'; + } + const numeric = Number(code); + if (numeric >= 1000 && numeric <= 9999) { + return 'CH'; + } + return undefined; +} + +function backfillMissingCountry(db: Database): void { + const rows = db + .query( + `SELECT id, origin, name + FROM map_place + WHERE country IS NULL AND (origin = 'spar' OR origin = '21gifts')`, + ) + .all() as Array<{ id: string; origin: string; name: string }>; + const update = db.query('UPDATE map_place SET country = ? WHERE id = ? AND country IS NULL'); + for (const row of rows) { + let country: string | undefined; + if (row.origin === 'spar') { + country = countryFromSparName(row.name); + } else if (row.origin === '21gifts') { + country = GIFTS_COUNTRY_BY_EXACT_NAME.get(row.name); + } else { + throw new Error('country backfill unexpected origin'); + } + if (country !== undefined) { + update.run(country, row.id); + } + } +} + +function insertSupportRows(db: Database, placeId: string, supports: MapPlaceSupport[]): void { + const insert = db.query( + 'INSERT INTO map_place_support (place_id, blockchain, asset) VALUES (?, ?, ?)', + ); + for (const item of supports) { + insert.run(placeId, item.blockchain, item.asset); + } +} + +function replaceSupportRows(db: Database, placeId: string, supports: MapPlaceSupport[]): void { + db.query('DELETE FROM map_place_support WHERE place_id = ?').run(placeId); + insertSupportRows(db, placeId, supports); +} + /** * SQLite {@link MapPlaceStore}. Opened by the process entrypoint. - * Unit tests use {@link MemoryMapPlaceStore}; this driver is exercised by - * the HTTP end-to-end run. + * Insert, list, delete, and an upsert that omits or replaces country + * are exercised by the HTTP end-to-end run. + * The SPAR shop-name backfill and the country backfill (null country only) + * are exercised by e2e/sqlite-backfill.ts. */ export class SqliteMapPlaceStore implements MapPlaceStore { readonly #db: Database; @@ -46,13 +223,36 @@ export class SqliteMapPlaceStore implements MapPlaceStore { */ constructor(filename: string) { this.#db = new Database(filename); - this.#db.exec(MAP_PLACE_SCHEMA_SQL); - const columns = this.#db.query('PRAGMA table_info(map_place)').all() as Array<{ name: string }>; - if (!columns.some((column) => column.name === 'tech_provider')) { - this.#db.exec( - "ALTER TABLE map_place ADD COLUMN tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss'", - ); + this.#db.exec('PRAGMA foreign_keys = ON'); + const statements = MAP_PLACE_SCHEMA_SQL.split(';') + .map((part) => part.trim()) + .filter((part) => part !== ''); + for (const [index, sql] of statements.entries()) { + this.#db.exec(sql); + if (index === 0) { + const columns = this.#db.query('PRAGMA table_info(map_place)').all() as Array<{ + name: string; + }>; + if (!columns.some((column) => column.name === 'tech_provider')) { + this.#db.exec( + "ALTER TABLE map_place ADD COLUMN tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss'", + ); + } + if (!columns.some((column) => column.name === 'country')) { + this.#db.exec('ALTER TABLE map_place ADD COLUMN country TEXT'); + } + if (!columns.some((column) => column.name === 'shop_name')) { + this.#db.exec('ALTER TABLE map_place ADD COLUMN shop_name TEXT'); + } + if (!columns.some((column) => column.name === 'last_transaction_at')) { + this.#db.exec('ALTER TABLE map_place ADD COLUMN last_transaction_at TEXT'); + } + } } + this.#db.exec( + "UPDATE map_place SET shop_name = 'SPAR' WHERE origin = 'spar' AND shop_name IS NULL", + ); + backfillMissingCountry(this.#db); } /** @@ -65,11 +265,14 @@ export class SqliteMapPlaceStore implements MapPlaceStore { const id = crypto.randomUUID(); const createdAt = new Date().toISOString(); const techProvider = input.techProvider ?? 'DFX.swiss'; + const country = input.country ?? null; + const shopName = input.shopName ?? null; + const supports = storedSupports(input.supports ?? []); const result = this.#db .query( `INSERT INTO map_place ( - id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider, country, shop_name, last_transaction_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (origin, external_id) DO NOTHING`, ) .run( @@ -83,9 +286,34 @@ export class SqliteMapPlaceStore implements MapPlaceStore { input.paymentMethods, createdAt, techProvider, + country, + shopName, + null, ); if (result.changes === 1) { - return { created: true, place: { ...input, techProvider, id, createdAt } }; + insertSupportRows(this.#db, id, supports); + return { + created: true, + place: { + origin: input.origin, + externalId: input.externalId, + name: input.name, + lat: input.lat, + lon: input.lon, + category: input.category, + paymentMethods: input.paymentMethods, + techProvider, + country, + shopName, + supports: supports.map((item) => ({ + blockchain: item.blockchain, + asset: item.asset, + })), + lastTransactionAt: null, + id, + createdAt, + }, + }; } const existing = this.#db .query( @@ -96,7 +324,7 @@ export class SqliteMapPlaceStore implements MapPlaceStore { if (existing === null) { throw new Error('map place insert conflict missing row'); } - return { created: false, place: mapRow(existing) }; + return { created: false, place: mapOne(this.#db, existing) }; } /** @@ -117,10 +345,12 @@ export class SqliteMapPlaceStore implements MapPlaceStore { } const techProvider = input.techProvider !== undefined ? input.techProvider : existing.tech_provider; + const country = input.country !== undefined ? input.country : existing.country; + const shopName = input.shopName ?? null; this.#db .query( `UPDATE map_place - SET name = ?, lat = ?, lon = ?, category = ?, payment_methods = ?, tech_provider = ? + SET name = ?, lat = ?, lon = ?, category = ?, payment_methods = ?, tech_provider = ?, country = ?, shop_name = ? WHERE origin = ? AND external_id = ?`, ) .run( @@ -130,23 +360,73 @@ export class SqliteMapPlaceStore implements MapPlaceStore { input.category, input.paymentMethods, techProvider, + country, + shopName, input.origin, input.externalId, ); + if (input.supports !== undefined) { + replaceSupportRows(this.#db, existing.id, storedSupports(input.supports)); + } return { created: false, - place: { - ...mapRow(existing), + place: mapOne(this.#db, { + ...existing, name: input.name, lat: input.lat, lon: input.lon, category: input.category, - paymentMethods: input.paymentMethods, - techProvider, - }, + payment_methods: input.paymentMethods, + tech_provider: techProvider, + country, + shop_name: shopName, + }), }; } + /** + * Store a newer transaction instant for an existing pin. + * + * @param origin - Pin origin. + * @param externalId - Caller id. + * @param occurredAt - Instant to store. An unparseable argument is ignored. + * @returns A copy of the row, or undefined when the pair is missing. A written instant is + * canonical ISO. + */ + recordTransaction( + origin: string, + externalId: string, + occurredAt: string, + ): StoredMapPlace | undefined { + const existing = this.#db + .query( + `SELECT ${MAP_PLACE_COLUMNS} + FROM map_place WHERE origin = ? AND external_id = ?`, + ) + .get(origin, externalId) as MapPlaceRow | null; + if (existing === null) { + return undefined; + } + const newMs = Date.parse(occurredAt); + if (!Number.isFinite(newMs)) { + return mapOne(this.#db, existing); + } + const canonical = new Date(newMs).toISOString(); + const storedMs = + existing.last_transaction_at === null ? Number.NaN : Date.parse(existing.last_transaction_at); + if (Number.isFinite(storedMs) && storedMs >= newMs) { + return mapOne(this.#db, existing); + } + this.#db + .query( + `UPDATE map_place + SET last_transaction_at = ? + WHERE origin = ? AND external_id = ?`, + ) + .run(canonical, origin, externalId); + return mapOne(this.#db, { ...existing, last_transaction_at: canonical }); + } + /** * Remove the pin for this origin and external id. * @@ -162,21 +442,109 @@ export class SqliteMapPlaceStore implements MapPlaceStore { } /** - * Newest pins first. + * Newest pins first, then id descending. * * @param limit - Maximum rows. + * @param filter - Optional AND predicates, applied before sort and limit. * @returns Stored pins. */ - list(limit: number): StoredMapPlace[] { + list(limit: number, filter?: MapPlaceListFilter): StoredMapPlace[] { + const where: string[] = []; + const params: Array = []; + if (filter?.origin !== undefined) { + where.push('origin = ?'); + params.push(filter.origin); + } + if (filter?.country !== undefined) { + where.push('country = ?'); + params.push(filter.country); + } + if (filter?.shopName === 'SPAR') { + where.push('shop_name = ?'); + params.push('SPAR'); + } else if (filter?.shopName === 'others') { + where.push('(shop_name IS NULL OR shop_name <> ?)'); + params.push('SPAR'); + } + if (filter !== undefined && (filter.blockchain !== undefined || filter.asset !== undefined)) { + const payment = unstatedPaymentSql(filter.blockchain, filter.asset); + if (payment !== null) { + where.push(payment.sql); + params.push(...payment.params); + } else if (filter.blockchain !== undefined && filter.asset !== undefined) { + where.push( + 'id IN (SELECT place_id FROM map_place_support WHERE blockchain = ? AND asset = ?)', + ); + params.push(filter.blockchain, filter.asset); + } else if (filter.blockchain !== undefined) { + where.push('id IN (SELECT place_id FROM map_place_support WHERE blockchain = ?)'); + params.push(filter.blockchain); + } else if (filter.asset !== undefined) { + where.push('id IN (SELECT place_id FROM map_place_support WHERE asset = ?)'); + params.push(filter.asset); + } + } + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; const rows = this.#db .query( `SELECT ${MAP_PLACE_COLUMNS} FROM map_place + ${whereSql} ORDER BY created_at DESC, id DESC LIMIT ?`, ) - .all(limit) as MapPlaceRow[]; - return rows.map((row) => mapRow(row)); + .all(...params, limit) as MapPlaceRow[]; + return mapRows(this.#db, rows); + } + + /** + * Distinct stored filter values, sorted. + * + * @param blockchain - When set, only assets stored on that network. + * @returns Countries, blockchains, and assets. Shop names are not read. + */ + filters(blockchain?: string | undefined): { + countries: string[]; + blockchains: string[]; + assets: string[]; + } { + const countryRows = this.#db + .query( + `SELECT DISTINCT country + FROM map_place + WHERE country IS NOT NULL + ORDER BY country`, + ) + .all() as Array<{ country: string }>; + const blockchainRows = this.#db + .query( + `SELECT DISTINCT blockchain + FROM map_place_support + ORDER BY blockchain`, + ) + .all() as Array<{ blockchain: string }>; + const assetRows = + blockchain === undefined + ? (this.#db + .query( + `SELECT DISTINCT asset + FROM map_place_support + ORDER BY asset`, + ) + .all() as Array<{ asset: string }>) + : (this.#db + .query( + `SELECT DISTINCT asset + FROM map_place_support + WHERE blockchain = ? + ORDER BY asset`, + ) + .all(blockchain) as Array<{ asset: string }>); + return { + countries: countryRows.map((row) => row.country), + blockchains: blockchainRows.map((row) => row.blockchain), + assets: assetRows.map((row) => row.asset), + }; } /** Close the database. */ diff --git a/src/lib/map/store.ts b/src/lib/map/store.ts index be9944486..6580ff310 100644 --- a/src/lib/map/store.ts +++ b/src/lib/map/store.ts @@ -1,4 +1,10 @@ -import type { MapPlaceInput, StoredMapPlace } from '@/lib/map/place'; +import { matchesUnstatedPayment } from '@/lib/map/filter-catalog'; +import type { + MapPlaceInput, + MapPlaceQueryFilter, + MapPlaceSupport, + StoredMapPlace, +} from '@/lib/map/place'; const DEFAULT_TECH_PROVIDER = 'DFX.swiss'; @@ -14,8 +20,119 @@ export const MAP_PLACE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS map_place ( payment_methods TEXT, created_at TEXT NOT NULL, tech_provider TEXT NOT NULL DEFAULT 'DFX.swiss', + country TEXT, + shop_name TEXT, + last_transaction_at TEXT, UNIQUE (origin, external_id) -)`; +); +CREATE TABLE IF NOT EXISTS map_place_support ( + place_id TEXT NOT NULL, + blockchain TEXT NOT NULL, + asset TEXT NOT NULL, + PRIMARY KEY (place_id, blockchain, asset), + FOREIGN KEY (place_id) REFERENCES map_place(id) ON DELETE CASCADE +); +CREATE INDEX IF NOT EXISTS map_place_country ON map_place(country); +CREATE INDEX IF NOT EXISTS map_place_shop_name ON map_place(shop_name); +CREATE INDEX IF NOT EXISTS map_place_support_lookup + ON map_place_support(blockchain, asset, place_id)`; + +/** Optional AND predicates for `list`, applied before sort and limit. */ +export type MapPlaceListFilter = MapPlaceQueryFilter & { + origin?: string; +}; + +function compareSupport(a: MapPlaceSupport, b: MapPlaceSupport): number { + if (a.blockchain < b.blockchain) { + return -1; + } + if (a.blockchain > b.blockchain) { + return 1; + } + if (a.asset < b.asset) { + return -1; + } + if (a.asset > b.asset) { + return 1; + } + return 0; +} + +function storedSupports(items: MapPlaceSupport[]): MapPlaceSupport[] { + const sorted = [...items] + .map((item) => ({ blockchain: item.blockchain, asset: item.asset })) + .sort(compareSupport); + const unique: MapPlaceSupport[] = []; + for (const item of sorted) { + const prev = unique[unique.length - 1]; + if (prev !== undefined && prev.blockchain === item.blockchain && prev.asset === item.asset) { + continue; + } + unique.push(item); + } + return unique; +} + +function copyStoredPlace(place: StoredMapPlace): StoredMapPlace { + return { + ...place, + supports: place.supports.map((item) => ({ + blockchain: item.blockchain, + asset: item.asset, + })), + }; +} + +function matchesFilter(row: StoredMapPlace, filter?: MapPlaceListFilter): boolean { + if (filter === undefined) { + return true; + } + if (filter.origin !== undefined && row.origin !== filter.origin) { + return false; + } + if (filter.country !== undefined && row.country !== filter.country) { + return false; + } + if (filter.shopName === 'SPAR' && row.shopName !== 'SPAR') { + return false; + } + if (filter.shopName === 'others' && row.shopName === 'SPAR') { + return false; + } + if (filter.blockchain !== undefined || filter.asset !== undefined) { + const match = + row.supports.length > 0 + ? row.supports.some( + (item) => + (filter.blockchain === undefined || item.blockchain === filter.blockchain) && + (filter.asset === undefined || item.asset === filter.asset), + ) + : matchesUnstatedPayment(row.origin, row.techProvider, filter.blockchain, filter.asset); + if (!match) { + return false; + } + } + return true; +} + +function toStoredPlace(input: MapPlaceInput, id: string, createdAt: string): StoredMapPlace { + return { + origin: input.origin, + externalId: input.externalId, + name: input.name, + lat: input.lat, + lon: input.lon, + category: input.category, + paymentMethods: input.paymentMethods, + techProvider: input.techProvider ?? DEFAULT_TECH_PROVIDER, + country: input.country ?? null, + shopName: input.shopName ?? null, + supports: storedSupports(input.supports ?? []), + lastTransactionAt: null, + id, + createdAt, + }; +} /** * Store for map pins. The SQLite driver is `sqlite.ts`. Tests use the memory store. @@ -23,8 +140,18 @@ export const MAP_PLACE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS map_place ( export interface MapPlaceStore { insertIfNew(input: MapPlaceInput): { created: boolean; place: StoredMapPlace }; upsert(input: MapPlaceInput): { created: boolean; place: StoredMapPlace }; + recordTransaction( + origin: string, + externalId: string, + occurredAt: string, + ): StoredMapPlace | undefined; deleteByKey(origin: string, externalId: string): boolean; - list(limit: number): StoredMapPlace[]; + list(limit: number, filter?: MapPlaceListFilter): StoredMapPlace[]; + filters(blockchain?: string | undefined): { + countries: string[]; + blockchains: string[]; + assets: string[]; + }; close(): void; } @@ -53,16 +180,11 @@ export class MemoryMapPlaceStore implements MapPlaceStore { (row) => row.origin === input.origin && row.externalId === input.externalId, ); if (existing !== undefined) { - return { created: false, place: { ...existing } }; + return { created: false, place: copyStoredPlace(existing) }; } - const place: StoredMapPlace = { - ...input, - techProvider: input.techProvider ?? DEFAULT_TECH_PROVIDER, - id: crypto.randomUUID(), - createdAt: this.#now().toISOString(), - }; + const place = toStoredPlace(input, crypto.randomUUID(), this.#now().toISOString()); this.#rows.push(place); - return { created: true, place: { ...place } }; + return { created: true, place: copyStoredPlace(place) }; } /** @@ -83,10 +205,51 @@ export class MemoryMapPlaceStore implements MapPlaceStore { existing.lon = input.lon; existing.category = input.category; existing.paymentMethods = input.paymentMethods; + if (input.country !== undefined) { + existing.country = input.country; + } + existing.shopName = input.shopName ?? null; if (input.techProvider !== undefined) { existing.techProvider = input.techProvider; } - return { created: false, place: { ...existing } }; + if (input.supports !== undefined) { + existing.supports = storedSupports(input.supports); + } + return { created: false, place: copyStoredPlace(existing) }; + } + + /** + * Store a newer transaction instant for an existing pin. + * + * @param origin - Pin origin. + * @param externalId - Caller id. + * @param occurredAt - Instant to store. An unparseable argument is ignored. + * @returns A copy of the row, or undefined when the pair is missing. A written instant is + * canonical ISO. + */ + recordTransaction( + origin: string, + externalId: string, + occurredAt: string, + ): StoredMapPlace | undefined { + const existing = this.#rows.find( + (row) => row.origin === origin && row.externalId === externalId, + ); + if (existing === undefined) { + return undefined; + } + const newMs = Date.parse(occurredAt); + if (!Number.isFinite(newMs)) { + return copyStoredPlace(existing); + } + const canonical = new Date(newMs).toISOString(); + const storedMs = + existing.lastTransactionAt === null ? Number.NaN : Date.parse(existing.lastTransactionAt); + if (Number.isFinite(storedMs) && storedMs >= newMs) { + return copyStoredPlace(existing); + } + existing.lastTransactionAt = canonical; + return copyStoredPlace(existing); } /** @@ -111,10 +274,12 @@ export class MemoryMapPlaceStore implements MapPlaceStore { * Newest pins first, then id descending. * * @param limit - Maximum rows. + * @param filter - Optional AND predicates, applied before sort and limit. * @returns A copy of the stored pins. */ - list(limit: number): StoredMapPlace[] { - return [...this.#rows] + list(limit: number, filter?: MapPlaceListFilter): StoredMapPlace[] { + const rows = this.#rows.filter((row) => matchesFilter(row, filter)); + return [...rows] .sort((a, b) => { const byTime = b.createdAt.localeCompare(a.createdAt); if (byTime !== 0) { @@ -123,7 +288,39 @@ export class MemoryMapPlaceStore implements MapPlaceStore { return b.id.localeCompare(a.id); }) .slice(0, limit) - .map((row) => ({ ...row })); + .map((row) => copyStoredPlace(row)); + } + + /** + * Distinct stored filter values, sorted. + * + * @param blockchain - When set, only assets stored on that network. + * @returns Countries, blockchains, and assets. Shop names are not read. + */ + filters(blockchain?: string | undefined): { + countries: string[]; + blockchains: string[]; + assets: string[]; + } { + const countrySet = new Set(); + const blockchainSet = new Set(); + const assetSet = new Set(); + for (const row of this.#rows) { + if (row.country !== null) { + countrySet.add(row.country); + } + for (const item of row.supports) { + blockchainSet.add(item.blockchain); + if (blockchain === undefined || item.blockchain === blockchain) { + assetSet.add(item.asset); + } + } + } + return { + countries: [...countrySet].sort(), + blockchains: [...blockchainSet].sort(), + assets: [...assetSet].sort(), + }; } /** Nothing to close. */ diff --git a/src/routes/map.ts b/src/routes/map.ts index 105fa2f2f..188af178f 100644 --- a/src/routes/map.ts +++ b/src/routes/map.ts @@ -2,8 +2,17 @@ import { timingSafeEqual } from 'node:crypto'; import { Hono } from 'hono'; import { cors } from 'hono/cors'; import { pushMapPlace, type FetchLike, type MapPushResult } from '@/lib/map/btcmap'; -import { normalizeMapPlace, normalizeMapPlaceKey, toPublicMapPlace } from '@/lib/map/place'; -import type { MapPlaceStore } from '@/lib/map/store'; +import { publicFilterResponse } from '@/lib/map/filter-catalog'; +import { + normalizeMapPlace, + normalizeMapPlaceFilter, + normalizeMapPlaceKey, + normalizePlaceOrigin, + normalizePlaceTransaction, + placeActivity, + toPublicMapPlace, +} from '@/lib/map/place'; +import type { MapPlaceListFilter, MapPlaceStore } from '@/lib/map/store'; export type MapRouteDeps = { store: MapPlaceStore; @@ -11,6 +20,7 @@ export type MapRouteDeps = { ingestToken?: string; env: Record; fetchImpl: FetchLike; + now?: () => number; }; function checkIngest( @@ -36,10 +46,11 @@ function checkIngest( /** * Public map list and ingest. Mounted at `/map`. * - * @param deps - Store, ingest token, and the environment used for BTC Map. + * @param deps - Store, ingest token, BTC Map environment, and an optional clock. * @returns The map route group. */ export function mapRoutes(deps: MapRouteDeps): Hono { + const now = deps.now ?? (() => Date.now()); const app = new Hono(); app.use( '*', @@ -62,10 +73,47 @@ export function mapRoutes(deps: MapRouteDeps): Hono { } limit = n; } - const places = deps.store.list(limit).map((place) => toPublicMapPlace(place)); + const originQuery = c.req.query('origin'); + let origin: string | undefined; + if (originQuery !== undefined) { + const parsedOrigin = normalizePlaceOrigin(originQuery); + if (!parsedOrigin.ok) { + return c.json({ error: parsedOrigin.error }, 400); + } + origin = parsedOrigin.value; + } + const parsedFilter = normalizeMapPlaceFilter( + c.req.query('country'), + c.req.query('shopName'), + c.req.query('blockchain'), + c.req.query('asset'), + ); + if (!parsedFilter.ok) { + return c.json({ error: parsedFilter.error }, 400); + } + const filter: MapPlaceListFilter = { ...parsedFilter.value }; + if (origin !== undefined) { + filter.origin = origin; + } + const nowMs = now(); + const places = deps.store.list(limit, filter).map((place) => toPublicMapPlace(place, nowMs)); return c.json({ places }); }); + app.get('/filters', (c) => { + const parsed = normalizeMapPlaceFilter( + undefined, + undefined, + c.req.query('blockchain'), + undefined, + ); + if (!parsed.ok) { + return c.json({ error: parsed.error }, 400); + } + const values = deps.store.filters(parsed.value.blockchain); + return c.json(publicFilterResponse(values, parsed.value.blockchain)); + }); + app.post('/places', async (c) => { const auth = checkIngest(deps.ingestToken, c.req.header('authorization')); if (auth === 'unconfigured') { @@ -125,5 +173,30 @@ export function mapRoutes(deps: MapRouteDeps): Hono { return c.json({ deleted }); }); + app.post('/places/transactions', async (c) => { + const auth = checkIngest(deps.ingestToken, c.req.header('authorization')); + if (auth === 'unconfigured') { + return c.json({ error: 'Place ingest is not configured' }, 503); + } + if (auth === 'unauthorized') { + return c.json({ error: 'Unauthorized' }, 401); + } + const nowMs = now(); + const raw: unknown = await c.req.json().catch(() => null); + const parsed = normalizePlaceTransaction(raw, nowMs); + if (!parsed.ok) { + return c.json({ error: parsed.error }, 400); + } + const place = deps.store.recordTransaction( + parsed.value.origin, + parsed.value.externalId, + parsed.value.occurredAt, + ); + if (place === undefined) { + return c.json({ error: 'Place not found' }, 404); + } + return c.json({ activity: placeActivity(place.lastTransactionAt, nowMs) }); + }); + return app; } diff --git a/src/server.ts b/src/server.ts index d1a217535..55c3018e5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,14 +10,16 @@ export type AppDeps = { ingestToken?: string; env: Record; fetchImpl: FetchLike; + /** Request clock. Unset means `Date.now`. */ + now?: () => number; }; /** - * HTTP API. `GET /healthz` and `GET /map/places` are public. + * HTTP API. `GET /healthz`, `GET /map/places`, and `GET /map/filters` are public. * `POST /map/places` creates a pin once. `PUT /map/places` updates a pin. * `DELETE /map/places` removes a pin. * - * @param deps - Store, ingest token, and the BTC Map environment. + * @param deps - Store, ingest token, the BTC Map environment, and an optional clock. * @returns The application. */ export function createApp(deps: AppDeps): Hono { @@ -30,6 +32,7 @@ export function createApp(deps: AppDeps): Hono { env: deps.env, fetchImpl: deps.fetchImpl, ...(deps.ingestToken === undefined ? {} : { ingestToken: deps.ingestToken }), + ...(deps.now === undefined ? {} : { now: deps.now }), }), ); return app;