From 5cbc8ea144d20559ee3032f64940dc34e7ba831f Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Thu, 8 Oct 2026 08:25:41 +0200 Subject: [PATCH 1/9] 01a117b8 - Filter map places by country, shop, blockchain, and asset (#7) * 01a117b8 - Filter map places by country, shop, blockchain, and asset GET /map/places applies a stored country, a shop name, and one blockchain and asset pair in SQL before the limit. GET /map/filters lists those values. Existing pins keep a null country until a real value is written. * Cover map filter pairs, limit order, and the SPAR shop backfill * Cover a split support pair and the row delete keeps A blockchain and asset filter matches one support row, and deleting a pin leaves a different asset on the same chain in place. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- README.md | 2 +- docs/handbook/endpoints.md | 11 +- docs/handbook/functions.md | 38 ++- docs/schema/map-place.sql | 13 + e2e/map.spec.ts | 282 ++++++++++++++++ e2e/sqlite-backfill.ts | 109 +++++++ src/__tests__/map/btcmap.test.ts | 3 + src/__tests__/map/place.test.ts | 342 +++++++++++++++++++- src/__tests__/map/routes.test.ts | 213 ++++++++++++ src/__tests__/map/store.test.ts | 223 ++++++++++++- src/lib/map/place.ts | 540 ++++++++++++++++++++++++++++++- src/lib/map/sqlite.ts | 288 +++++++++++++++-- src/lib/map/store.ts | 180 ++++++++++- src/routes/map.ts | 53 ++- src/server.ts | 2 +- 15 files changed, 2233 insertions(+), 66 deletions(-) create mode 100644 e2e/sqlite-backfill.ts diff --git a/README.md b/README.md index 83e82bd8f..52ded0019 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), blockchain is a DFX payment-network name, and asset is the DFX asset name. `GET /map/filters` lists those filter 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. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index 86e21944f..a794ef68e 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`, and `supports`. `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. 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. +- **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` or `asset` is present but not a DFX payment-network name or DFX asset 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` are the distinct support blockchains, sorted. `assets` are the distinct support assets, sorted. `?blockchain=` limits `assets` to that chain and still returns every blockchain. +- **Errors:** 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a DFX 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`. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 024b36313..8c004302d 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -1,12 +1,26 @@ # Functions +## Function: normalizePlaceOrigin + +- **Purpose:** Trim a pin origin and accept only `^[a-z][a-z0-9-]{0,31}$`. 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. +- **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: 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. @@ -16,23 +30,23 @@ ## 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. +- **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. - **Inputs:** A stored map pin. -- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, `category`, and `techProvider`. No I/O. +- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. 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, and delete rules as SQLite. A repeat of origin plus external id on insert returns the first row unchanged, including its tech provider, country, shop name, and supports. +- **Inputs:** `insertIfNew` and `upsert` take a validated pin. `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` inserts or updates country and shop name from the body (`null` when omitted) and replaces supports only when that field is sent. `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 a non-null brand other than SPAR. Blockchain and asset must occur on the same support row. `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`, and `shop_name` 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. 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. `list` takes a positive limit and an optional filter object. Predicates are AND and apply before sort and limit. SQLite binds every value. `filters` takes an optional blockchain. +- **Returns / side effects:** Opens the file, enables foreign keys, applies the schema, and implements `MapPlaceStore`. 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 +65,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. +- **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 plus distinct countries, blockchains, and assets. `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 }`. +- **Returns / side effects:** GET `/places` returns `{ places }`. GET `/filters` returns `{ shopNames, countries, blockchains, assets }`. POST and PUT return 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. DELETE returns 200 `{ deleted: true }` or `{ deleted: false }`. - **Used by:** `createApp`, mounted at `/map`. ## Function: healthRoutes diff --git a/docs/schema/map-place.sql b/docs/schema/map-place.sql index 976ec2a25..9ab57acc5 100644 --- a/docs/schema/map-place.sql +++ b/docs/schema/map-place.sql @@ -10,5 +10,18 @@ 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, 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..9cb617e8f 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,140 @@ 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 — 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'); + expect(afterFilterJson.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: 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..88624e8e3 --- /dev/null +++ b/e2e/sqlite-backfill.ts @@ -0,0 +1,109 @@ +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', + ); + seed.close(); + seed = undefined; + store = new SqliteMapPlaceStore(filename); + const listed = store.list(10); + 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'); + if ( + sparNull !== undefined && + dfxNull !== undefined && + sparMigros !== undefined && + sparNull.shopName === 'SPAR' && + dfxNull.shopName === null && + sparMigros.shopName === 'Migros' + ) { + process.stdout.write('ok\n'); + } else { + process.stdout.write( + `${JSON.stringify({ + 'spar-null': sparNull === undefined ? null : sparNull.shopName, + 'dfx-null': dfxNull === undefined ? null : dfxNull.shopName, + 'spar-migros': sparMigros === undefined ? null : sparMigros.shopName, + })}\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..1fdb893d1 100644 --- a/src/__tests__/map/btcmap.test.ts +++ b/src/__tests__/map/btcmap.test.ts @@ -12,6 +12,9 @@ const place: StoredMapPlace = { category: 'groceries', paymentMethods: 'lightning', techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], createdAt: '2026-09-26T00:00:00.000Z', }; diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index 689f2af23..aa9c22e6c 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -1,5 +1,11 @@ import { describe, expect, it } from 'vitest'; -import { normalizeMapPlace, normalizeMapPlaceKey, toPublicMapPlace } from '@/lib/map/place'; +import { + normalizeMapPlace, + normalizeMapPlaceFilter, + normalizeMapPlaceKey, + normalizePlaceOrigin, + toPublicMapPlace, +} from '@/lib/map/place'; import type { StoredMapPlace } from '@/lib/map/place'; const valid = { @@ -12,6 +18,27 @@ const valid = { paymentMethods: 'lightning', }; +describe('normalizePlaceOrigin', () => { + it('trims a valid origin', () => { + expect(normalizePlaceOrigin(' spar ')).toEqual({ ok: true, value: 'spar' }); + }); + + 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', + }); + }); +}); + describe('normalizeMapPlace', () => { it('rejects a non-object and an array', () => { expect(normalizeMapPlace(null)).toEqual({ @@ -123,6 +150,285 @@ 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' }, + }); + }); + + 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', + }); + }); }); describe('normalizeMapPlaceKey', () => { @@ -190,6 +496,9 @@ describe('toPublicMapPlace', () => { techProvider: 'DFX.swiss', id: 'id-1', createdAt: '2026-09-26T00:00:00.000Z', + country: null, + shopName: null, + supports: [], }; expect(toPublicMapPlace(stored)).toEqual({ id: 'id-1', @@ -199,6 +508,37 @@ describe('toPublicMapPlace', () => { lon: 8.54, category: 'groceries', techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], + }); + }); + + 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, + }; + const publicPlace = toPublicMapPlace(stored); + expect(publicPlace).toEqual({ + id: 'id-1', + origin: 'dfx', + name: 'SPAR', + lat: 47.37, + lon: 8.54, + category: 'groceries', + techProvider: 'DFX.swiss', + country: 'CH', + shopName: 'SPAR', + supports: [{ blockchain: 'Ethereum', asset: 'ZCHF' }], }); + 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..5ec15013d 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -39,6 +39,51 @@ 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({ + shopNames: ['SPAR', 'others'], + 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('rejects a bad limit', async () => { @@ -195,6 +240,9 @@ describe('map routes', () => { lon: 8.54, category: 'groceries', techProvider: 'DFX.swiss', + country: null, + shopName: null, + supports: [], }); }); @@ -240,6 +288,9 @@ describe('map routes', () => { lon: 8.54, category: 'groceries', techProvider: '21.gifts', + country: null, + shopName: null, + supports: [], }); }); @@ -279,4 +330,166 @@ 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({ + shopNames: ['SPAR', 'others'], + 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({ + shopNames: ['SPAR', 'others'], + countries: ['CH'], + blockchains: ['Ethereum', 'Polygon'], + assets: ['ZCHF'], + }); + const bad = await api.request('/map/filters?blockchain=ethereum'); + expect(bad.status).toBe(400); + expect(await bad.json()).toEqual({ error: 'Place support is invalid' }); + }); }); diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index be536e220..cc6d20732 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,9 @@ 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' })).toHaveLength(0); + expect(store.list(10, { asset: 'ZCHF' })).toHaveLength(0); store.close(); expect(store.list(10)).toHaveLength(0); expect(later.created).toBe(true); @@ -95,6 +101,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 +113,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 +128,223 @@ 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 others without null brands', () => { + 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(['migros']); + expect(others.every((row) => row.shopName !== null && row.shopName !== 'SPAR')).toBe(true); + }); + + 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('sets omitted country and shop name to null on upsert 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).toBeNull(); + 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); }); }); diff --git a/src/lib/map/place.ts b/src/lib/map/place.ts index bedb7d14f..253cef245 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -3,6 +3,11 @@ * Coordinates are rounded to six decimal places. */ +export type MapPlaceSupport = { + blockchain: string; + asset: string; +}; + export type MapPlaceInput = { origin: string; externalId: string; @@ -12,12 +17,21 @@ 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[]; }; /** Fields the public map may show. The caller's own id stays off this list. */ @@ -29,6 +43,17 @@ export type PublicMapPlace = { lon: number; category: string; techProvider: string; + country: string | null; + shopName: string | null; + supports: MapPlaceSupport[]; +}; + +/** 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 +62,297 @@ 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 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,7 +369,46 @@ function hasNoControls(value: string): boolean { return true; } -function normalizeOrigin(raw: unknown): { ok: true; value: string } | { ok: false; error: 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 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-z][a-z0-9-]{0,31}$/.test(raw.trim())) { return { ok: false, error: ORIGIN_ERROR }; } @@ -95,6 +447,94 @@ 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 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 +563,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 +605,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 +647,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 +672,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,6 +683,53 @@ export function normalizeMapPlaceKey( return { ok: true, value: { origin: origin.value, externalId: externalId.value } }; } +/** + * 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 = normalizeBlockchain(blockchain); + if (!parsed.ok) { + return parsed; + } + value.blockchain = parsed.value; + } + if (asset !== undefined) { + const parsed = normalizeAsset(asset); + if (!parsed.ok) { + return parsed; + } + value.asset = parsed.value; + } + return { ok: true, value }; +} + /** * Drop fields that only the ingest caller needs. * @@ -219,5 +745,11 @@ 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, + })), }; } diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index 02d86841e..0260e76b3 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -1,9 +1,9 @@ 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 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'; type MapPlaceRow = { id: string; @@ -16,9 +16,42 @@ type MapPlaceRow = { payment_methods: string | null; created_at: string; tech_provider: string; + country: string | null; + shop_name: 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 +63,80 @@ function mapRow(row: MapPlaceRow): StoredMapPlace { paymentMethods: row.payment_methods, createdAt: row.created_at, techProvider: row.tech_provider, + country: row.country, + shopName: row.shop_name, + 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; +} + +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, and delete are exercised by the HTTP end-to-end run. + * The SPAR shop-name backfill is exercised by e2e/sqlite-backfill.ts. */ export class SqliteMapPlaceStore implements MapPlaceStore { readonly #db: Database; @@ -46,13 +146,32 @@ 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'); + } + } } + this.#db.exec( + "UPDATE map_place SET shop_name = 'SPAR' WHERE origin = 'spar' AND shop_name IS NULL", + ); } /** @@ -65,11 +184,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 + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT (origin, external_id) DO NOTHING`, ) .run( @@ -83,9 +205,32 @@ export class SqliteMapPlaceStore implements MapPlaceStore { input.paymentMethods, createdAt, techProvider, + country, + shopName, ); 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, + })), + id, + createdAt, + }, + }; } const existing = this.#db .query( @@ -96,7 +241,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 +262,12 @@ export class SqliteMapPlaceStore implements MapPlaceStore { } const techProvider = input.techProvider !== undefined ? input.techProvider : existing.tech_provider; + const country = input.country ?? null; + 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,20 +277,27 @@ 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, + }), }; } @@ -162,21 +316,103 @@ 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 NOT NULL AND shop_name <> ?'); + params.push('SPAR'); + } + if (filter !== undefined && 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 !== undefined && filter.blockchain !== undefined) { + where.push('id IN (SELECT place_id FROM map_place_support WHERE blockchain = ?)'); + params.push(filter.blockchain); + } else if (filter !== undefined && 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..7b617cbaa 100644 --- a/src/lib/map/store.ts +++ b/src/lib/map/store.ts @@ -1,4 +1,9 @@ -import type { MapPlaceInput, StoredMapPlace } from '@/lib/map/place'; +import type { + MapPlaceInput, + MapPlaceQueryFilter, + MapPlaceSupport, + StoredMapPlace, +} from '@/lib/map/place'; const DEFAULT_TECH_PROVIDER = 'DFX.swiss'; @@ -14,8 +19,114 @@ 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, 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 === null || row.shopName === 'SPAR')) { + return false; + } + if (filter.blockchain !== undefined || filter.asset !== undefined) { + const match = row.supports.some( + (item) => + (filter.blockchain === undefined || item.blockchain === filter.blockchain) && + (filter.asset === undefined || item.asset === 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 ?? []), + id, + createdAt, + }; +} /** * Store for map pins. The SQLite driver is `sqlite.ts`. Tests use the memory store. @@ -24,7 +135,12 @@ export interface MapPlaceStore { insertIfNew(input: MapPlaceInput): { created: boolean; place: StoredMapPlace }; upsert(input: MapPlaceInput): { created: boolean; place: StoredMapPlace }; 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 +169,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 +194,15 @@ export class MemoryMapPlaceStore implements MapPlaceStore { existing.lon = input.lon; existing.category = input.category; existing.paymentMethods = input.paymentMethods; + existing.country = input.country ?? null; + 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) }; } /** @@ -111,10 +227,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 +241,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..5447e5b19 100644 --- a/src/routes/map.ts +++ b/src/routes/map.ts @@ -2,8 +2,14 @@ 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 { + normalizeMapPlace, + normalizeMapPlaceFilter, + normalizeMapPlaceKey, + normalizePlaceOrigin, + toPublicMapPlace, +} from '@/lib/map/place'; +import type { MapPlaceListFilter, MapPlaceStore } from '@/lib/map/store'; export type MapRouteDeps = { store: MapPlaceStore; @@ -62,10 +68,51 @@ 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 places = deps.store.list(limit, filter).map((place) => toPublicMapPlace(place)); 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({ + shopNames: ['SPAR', 'others'], + countries: values.countries, + blockchains: values.blockchains, + assets: values.assets, + }); + }); + app.post('/places', async (c) => { const auth = checkIngest(deps.ingestToken, c.req.header('authorization')); if (auth === 'unconfigured') { diff --git a/src/server.ts b/src/server.ts index d1a217535..266da93e1 100644 --- a/src/server.ts +++ b/src/server.ts @@ -13,7 +13,7 @@ export type AppDeps = { }; /** - * 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. * From 1f1e2f6f421c68df9dabdb53ee23973574942040 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Thu, 8 Oct 2026 12:03:11 +0200 Subject: [PATCH 2/9] Accept a place origin that starts with a digit (#9) A leading digit is valid, so 21gifts passes the origin check. Length, lowercase, and the ban on a leading hyphen stay the same. Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- docs/handbook/functions.md | 2 +- src/__tests__/map/place.test.ts | 29 +++++++++++++++++ src/__tests__/map/routes.test.ts | 53 ++++++++++++++++++++++++++++++++ src/lib/map/place.ts | 2 +- 4 files changed, 84 insertions(+), 2 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 8c004302d..200dc9e2f 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -2,7 +2,7 @@ ## Function: normalizePlaceOrigin -- **Purpose:** Trim a pin origin and accept only `^[a-z][a-z0-9-]{0,31}$`. Uppercase tokens such as SPAR are invalid. +- **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`. diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index aa9c22e6c..503edee21 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -23,6 +23,15 @@ describe('normalizePlaceOrigin', () => { 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, @@ -36,6 +45,26 @@ describe('normalizePlaceOrigin', () => { 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', + }); }); }); diff --git a/src/__tests__/map/routes.test.ts b/src/__tests__/map/routes.test.ts index 5ec15013d..95fddbb95 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -86,6 +86,59 @@ describe('map routes', () => { 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 () => { const api = app({}); expect((await api.request('/map/places?limit=0')).status).toBe(400); diff --git a/src/lib/map/place.ts b/src/lib/map/place.ts index 253cef245..6c2053b1a 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -409,7 +409,7 @@ function uniqueSortedSupports(items: MapPlaceSupport[]): MapPlaceSupport[] { export function normalizePlaceOrigin( raw: unknown, ): { ok: true; value: string } | { ok: false; error: string } { - if (typeof raw !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(raw.trim())) { + 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() }; From 9975efe670272fcc9afce456650ac0fc807287ab Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Thu, 8 Oct 2026 18:16:23 +0200 Subject: [PATCH 3/9] 01a0dc6b - Offer hardcoded payment-link map filters (#11) * 01a0dc6b - Offer hardcoded payment-link map filters * 01a0dc6b - Expect only stored rows for a non-catalog pair Polygon plus ZCHF is not a payment-link catalog pair, so a pin without support rows stays out of that filter. * 01a0dc6b - Ignore the unreachable support branch and align filter errors The dead support predicate is ignored through the function close, and the handbook states the same blockchain and asset rules as the query validator. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- README.md | 2 +- docs/handbook/endpoints.md | 8 +- docs/handbook/functions.md | 45 +++- e2e/map.spec.ts | 136 +++++++++- src/__tests__/map/filter-catalog.test.ts | 121 +++++++++ src/__tests__/map/place.test.ts | 16 ++ src/__tests__/map/routes.test.ts | 41 +-- src/__tests__/map/store.test.ts | 69 ++++- src/lib/map/filter-catalog.ts | 307 +++++++++++++++++++++++ src/lib/map/place.ts | 26 +- src/lib/map/sqlite.ts | 31 ++- src/lib/map/store.ts | 16 +- src/routes/map.ts | 8 +- 13 files changed, 765 insertions(+), 61 deletions(-) create mode 100644 src/__tests__/map/filter-catalog.test.ts create mode 100644 src/lib/map/filter-catalog.ts diff --git a/README.md b/README.md index 52ded0019..cc672e822 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; `?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), blockchain is a DFX payment-network name, and asset is the DFX asset name. `GET /map/filters` lists those filter values. `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. diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index a794ef68e..d50aa07f4 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -9,15 +9,15 @@ ## Endpoint: GET /map/places -- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. `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. 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. -- **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` or `asset` is present but not a DFX payment-network name or DFX asset name. +- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. `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. 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` are the distinct support blockchains, sorted. `assets` are the distinct support assets, sorted. `?blockchain=` limits `assets` to that chain and still returns every blockchain. -- **Errors:** 400 `{ error: "Place support is invalid" }` when `blockchain` is present but not a DFX payment-network name. +- **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. diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 200dc9e2f..fe850523f 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -16,11 +16,46 @@ ## 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. +- **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, USDC.e, or USDbC. 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, USDC.e, and USDbC. +- **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. @@ -39,13 +74,13 @@ - **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, country, shop name, and supports. - **Inputs:** `insertIfNew` and `upsert` take a validated pin. `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` inserts or updates country and shop name from the body (`null` when omitted) and replaces supports only when that field is sent. `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 a non-null brand other than SPAR. Blockchain and asset must occur on the same support row. `filters` returns distinct stored countries, blockchains, and assets; shop names are not read. `close` drops the rows. +- **Returns / side effects:** `insertIfNew` reports whether it created the row. `upsert` inserts or updates country and shop name from the body (`null` when omitted) and replaces supports only when that field is sent. `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`, `country`, and `shop_name` 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. 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. `list` takes a positive limit and an optional filter object. Predicates are AND and apply before sort and limit. SQLite binds every value. `filters` takes an optional blockchain. +- **Inputs:** Constructor takes a SQLite filename or `:memory:`. `upsert` 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`. 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. @@ -65,9 +100,9 @@ ## Function: mapRoutes -- **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 plus distinct countries, blockchains, and assets. `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. +- **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. - **Inputs:** Store, optional ingest token, environment, and fetch. -- **Returns / side effects:** GET `/places` returns `{ places }`. GET `/filters` returns `{ shopNames, countries, blockchains, assets }`. POST and PUT return 201 `{ created: true, id, btcmap }` or 200 `{ created: false, id, btcmap: "skipped" }`. DELETE returns 200 `{ deleted: true }` or `{ deleted: false }`. +- **Returns / side effects:** GET `/places` returns `{ places }`. 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 }`. - **Used by:** `createApp`, mounted at `/map`. ## Function: healthRoutes diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 9cb617e8f..73156a8fc 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -410,7 +410,10 @@ test('Function: SqliteMapPlaceStore — delete removes the pin and its support f assets: string[]; }; expect(afterFilterJson.blockchains).not.toContain('Plasma'); - expect(afterFilterJson.assets).not.toContain('ONDO'); + 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 }>; @@ -425,6 +428,137 @@ test('Function: SqliteMapPlaceStore — delete removes the pin and its support f 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=USDC.e')).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).toContain('USDC.e'); + expect(json.assets).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).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(200); + expect((await request.get('/map/places?asset=USDbC')).status()).toBe(200); + expect((await request.get('/map/places?asset=Ethereum/ZCHF')).status()).toBe(400); +}); + 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/src/__tests__/map/filter-catalog.test.ts b/src/__tests__/map/filter-catalog.test.ts new file mode 100644 index 000000000..40e518850 --- /dev/null +++ b/src/__tests__/map/filter-catalog.test.ts @@ -0,0 +1,121 @@ +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('USDC.e')).toBe(true); + expect(isCatalogAsset('USDbC')).toBe(true); + expect(isCatalogAsset('dEURO')).toBe(true); + 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', '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).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).not.toContainEqual({ blockchain: 'Ethereum', asset: 'BTC' }); + 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 503edee21..28fbac008 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -430,6 +430,22 @@ describe('normalizeMapPlaceFilter', () => { ok: true, value: { shopName: 'others' }, }); + expect(normalizeMapPlaceFilter(undefined, undefined, ' BinancePay ', ' ckBTC ')).toEqual({ + ok: true, + value: { blockchain: 'BinancePay', asset: 'ckBTC' }, + }); + expect(normalizeMapPlaceFilter(undefined, undefined, 'KucoinPay', 'USDC.e')).toEqual({ + ok: true, + value: { blockchain: 'KucoinPay', asset: 'USDC.e' }, + }); + expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'USDbC')).toEqual({ + ok: true, + value: { asset: 'USDbC' }, + }); + expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'ZCHF')).toEqual({ + ok: true, + value: { asset: 'ZCHF' }, + }); }); it('rejects a bad country, shop name, blockchain, or asset query', () => { diff --git a/src/__tests__/map/routes.test.ts b/src/__tests__/map/routes.test.ts index 95fddbb95..04f07b308 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', @@ -41,12 +42,9 @@ describe('map routes', () => { expect(one.status).toBe(200); const filters = await api.request('/map/filters'); expect(filters.status).toBe(200); - expect(await filters.json()).toEqual({ - shopNames: ['SPAR', 'others'], - countries: [], - blockchains: [], - assets: [], - }); + expect(await filters.json()).toEqual( + publicFilterResponse({ countries: [], blockchains: [], assets: [] }), + ); }); it('filters GET /map/places by origin and rejects SPAR', async () => { @@ -527,20 +525,25 @@ describe('map routes', () => { expect(created.status).toBe(201); const filters = await api.request('/map/filters'); expect(filters.status).toBe(200); - expect(await filters.json()).toEqual({ - shopNames: ['SPAR', 'others'], - countries: ['CH'], - blockchains: ['Ethereum', 'Polygon'], - assets: ['ETH', 'ZCHF'], - }); + 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({ - shopNames: ['SPAR', 'others'], - countries: ['CH'], - blockchains: ['Ethereum', 'Polygon'], - assets: ['ZCHF'], - }); + 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' }); diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index cc6d20732..493f320c9 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -46,8 +46,14 @@ describe('MemoryMapPlaceStore', () => { 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' })).toHaveLength(0); - expect(store.list(10, { asset: 'ZCHF' })).toHaveLength(0); + 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); @@ -151,7 +157,7 @@ describe('MemoryMapPlaceStore', () => { expect(listed[0]?.country).toBe('CH'); }); - it('lists SPAR shop names and others without null brands', () => { + 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' }); @@ -160,8 +166,61 @@ describe('MemoryMapPlaceStore', () => { 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(['migros']); - expect(others.every((row) => row.shopName !== null && row.shopName !== 'SPAR')).toBe(true); + 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' })).toEqual(['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', () => { diff --git a/src/lib/map/filter-catalog.ts b/src/lib/map/filter-catalog.ts new file mode 100644 index 000000000..a0fe73d2e --- /dev/null +++ b/src/lib/map/filter-catalog.ts @@ -0,0 +1,307 @@ +/** + * Temporary payment-link filter catalog. + * Networks are those offered for payment links, without test networks and + * without Plasma. Assets are the sellable names on those networks. + * 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: 'AAVE' }, + { blockchain: 'Arbitrum', asset: 'ARB' }, + { blockchain: 'Arbitrum', asset: 'ETH' }, + { blockchain: 'Arbitrum', asset: 'GMX' }, + { blockchain: 'Arbitrum', asset: 'LINK' }, + { blockchain: 'Arbitrum', asset: 'UNI' }, + { blockchain: 'Arbitrum', asset: 'USDC' }, + { blockchain: 'Arbitrum', asset: 'USDC.e' }, + { blockchain: 'Arbitrum', asset: 'USDT' }, + { blockchain: 'Arbitrum', asset: 'WBTC' }, + { blockchain: 'Arbitrum', asset: 'dEURO' }, + { blockchain: 'Base', asset: 'DEPS' }, + { blockchain: 'Base', asset: 'ETH' }, + { blockchain: 'Base', asset: 'USDC' }, + { blockchain: 'Base', asset: 'USDbC' }, + { blockchain: 'Base', asset: 'dEURO' }, + { blockchain: 'BinanceSmartChain', asset: 'AAVE' }, + { blockchain: 'BinanceSmartChain', asset: 'BNB' }, + { blockchain: 'BinanceSmartChain', asset: 'BTCB' }, + { blockchain: 'BinanceSmartChain', asset: 'ETH' }, + { blockchain: 'BinanceSmartChain', asset: 'USDC' }, + { blockchain: 'BinanceSmartChain', asset: 'USDT' }, + { blockchain: 'Bitcoin', asset: 'BTC' }, + { blockchain: 'Cardano', asset: 'ADA' }, + { blockchain: 'Ethereum', asset: 'AAVE' }, + { blockchain: 'Ethereum', asset: 'APE' }, + { blockchain: 'Ethereum', asset: 'ARB' }, + { blockchain: 'Ethereum', asset: 'BBTC' }, + { blockchain: 'Ethereum', asset: 'CRV' }, + { blockchain: 'Ethereum', asset: 'DEPS' }, + { blockchain: 'Ethereum', asset: 'EDLC' }, + { blockchain: 'Ethereum', asset: 'ETH' }, + { blockchain: 'Ethereum', asset: 'EURC' }, + { blockchain: 'Ethereum', asset: 'FPS' }, + { blockchain: 'Ethereum', asset: 'LINK' }, + { blockchain: 'Ethereum', asset: 'ONDO' }, + { blockchain: 'Ethereum', asset: 'POL' }, + { blockchain: 'Ethereum', asset: 'QNT' }, + { blockchain: 'Ethereum', asset: 'REALU' }, + { blockchain: 'Ethereum', asset: 'SNX' }, + { blockchain: 'Ethereum', asset: 'USDC' }, + { blockchain: 'Ethereum', asset: 'USDT' }, + { blockchain: 'Ethereum', asset: 'WBTC' }, + { blockchain: 'Ethereum', asset: 'WFPS' }, + { blockchain: 'Ethereum', asset: 'ZCHF' }, + { blockchain: 'Ethereum', asset: 'dEURO' }, + { blockchain: 'Ethereum', asset: 'kBTC' }, + { blockchain: 'Ethereum', asset: 'nDEPS' }, + { blockchain: 'Firo', asset: 'FIRO' }, + { blockchain: 'Gnosis', asset: 'xDAI' }, + { blockchain: 'InternetComputer', asset: 'ICP' }, + { blockchain: 'InternetComputer', asset: 'VCHF' }, + { blockchain: 'InternetComputer', asset: 'VEUR' }, + { blockchain: 'InternetComputer', asset: 'ckBTC' }, + { 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: 'DEPS' }, + { blockchain: 'Polygon', asset: 'DGC' }, + { blockchain: 'Polygon', asset: 'DSC' }, + { blockchain: 'Polygon', asset: 'EURS' }, + { blockchain: 'Polygon', asset: 'POL' }, + { blockchain: 'Polygon', asset: 'SAND' }, + { blockchain: 'Polygon', asset: 'USDC' }, + { blockchain: 'Polygon', asset: 'USDC.e' }, + { blockchain: 'Polygon', asset: 'USDT' }, + { blockchain: 'Polygon', asset: 'WBTC' }, + { blockchain: 'Polygon', asset: 'WFPS' }, + { 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, USDC.e, and USDbC. + */ +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 6c2053b1a..06e2bf098 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -3,6 +3,8 @@ * Coordinates are rounded to six decimal places. */ +import { isCatalogAsset, isCatalogBlockchain } from '@/lib/map/filter-catalog'; + export type MapPlaceSupport = { blockchain: string; asset: string; @@ -507,6 +509,26 @@ function normalizeAsset(raw: unknown): { ok: true; value: string } | { ok: false 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 } { @@ -714,14 +736,14 @@ export function normalizeMapPlaceFilter( value.shopName = parsed.value; } if (blockchain !== undefined) { - const parsed = normalizeBlockchain(blockchain); + const parsed = normalizeQueryBlockchain(blockchain); if (!parsed.ok) { return parsed; } value.blockchain = parsed.value; } if (asset !== undefined) { - const parsed = normalizeAsset(asset); + const parsed = normalizeQueryAsset(asset); if (!parsed.ok) { return parsed; } diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index 0260e76b3..9fa5fb836 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -1,4 +1,5 @@ import { Database } from 'bun:sqlite'; +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'; @@ -337,20 +338,26 @@ export class SqliteMapPlaceStore implements MapPlaceStore { where.push('shop_name = ?'); params.push('SPAR'); } else if (filter?.shopName === 'others') { - where.push('shop_name IS NOT NULL AND shop_name <> ?'); + where.push('(shop_name IS NULL OR shop_name <> ?)'); params.push('SPAR'); } - if (filter !== undefined && 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 !== undefined && filter.blockchain !== undefined) { - where.push('id IN (SELECT place_id FROM map_place_support WHERE blockchain = ?)'); - params.push(filter.blockchain); - } else if (filter !== undefined && filter.asset !== undefined) { - where.push('id IN (SELECT place_id FROM map_place_support WHERE asset = ?)'); - params.push(filter.asset); + 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 diff --git a/src/lib/map/store.ts b/src/lib/map/store.ts index 7b617cbaa..2b2dbac4e 100644 --- a/src/lib/map/store.ts +++ b/src/lib/map/store.ts @@ -1,3 +1,4 @@ +import { matchesUnstatedPayment } from '@/lib/map/filter-catalog'; import type { MapPlaceInput, MapPlaceQueryFilter, @@ -94,15 +95,18 @@ function matchesFilter(row: StoredMapPlace, filter?: MapPlaceListFilter): boolea if (filter.shopName === 'SPAR' && row.shopName !== 'SPAR') { return false; } - if (filter.shopName === 'others' && (row.shopName === null || row.shopName === 'SPAR')) { + if (filter.shopName === 'others' && row.shopName === 'SPAR') { return false; } if (filter.blockchain !== undefined || filter.asset !== undefined) { - const match = row.supports.some( - (item) => - (filter.blockchain === undefined || item.blockchain === filter.blockchain) && - (filter.asset === undefined || item.asset === filter.asset), - ); + 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; } diff --git a/src/routes/map.ts b/src/routes/map.ts index 5447e5b19..bbf323250 100644 --- a/src/routes/map.ts +++ b/src/routes/map.ts @@ -2,6 +2,7 @@ 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 { publicFilterResponse } from '@/lib/map/filter-catalog'; import { normalizeMapPlace, normalizeMapPlaceFilter, @@ -105,12 +106,7 @@ export function mapRoutes(deps: MapRouteDeps): Hono { return c.json({ error: parsed.error }, 400); } const values = deps.store.filters(parsed.value.blockchain); - return c.json({ - shopNames: ['SPAR', 'others'], - countries: values.countries, - blockchains: values.blockchains, - assets: values.assets, - }); + return c.json(publicFilterResponse(values, parsed.value.blockchain)); }); app.post('/places', async (c) => { From 2b5c297567d4555d47b4eb9c34a93c5f53edb033 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sat, 10 Oct 2026 08:33:08 +0200 Subject: [PATCH 4/9] Record whether a shop took a payment recently (#10) Public places include activity: within the last 7 days, within the last 30 days, or none. A provider records the time on an existing pin. An older or unreadable time does not replace a newer one. Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- docs/handbook/endpoints.md | 9 +- docs/handbook/functions.md | 40 ++++--- docs/schema/map-place.sql | 1 + e2e/map.spec.ts | 49 ++++++++ src/__tests__/map/btcmap.test.ts | 1 + src/__tests__/map/place.test.ts | 140 ++++++++++++++++++++++- src/__tests__/map/routes.test.ts | 190 ++++++++++++++++++++++++++++++- src/__tests__/map/store.test.ts | 39 +++++++ src/lib/map/place.ts | 83 +++++++++++++- src/lib/map/sqlite.ts | 56 ++++++++- src/lib/map/store.ts | 41 +++++++ src/routes/map.ts | 34 +++++- src/server.ts | 5 +- 13 files changed, 663 insertions(+), 25 deletions(-) diff --git a/docs/handbook/endpoints.md b/docs/handbook/endpoints.md index d50aa07f4..ef916757c 100644 --- a/docs/handbook/endpoints.md +++ b/docs/handbook/endpoints.md @@ -9,7 +9,7 @@ ## Endpoint: GET /map/places -- **Purpose:** Public map list, newest first. Each item is `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. `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. 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. +- **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. @@ -41,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 fe850523f..4e50034e4 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -63,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, country, shop name, and supports stay on the public row. -- **Inputs:** A stored map pin. -- **Returns / side effects:** `id`, `origin`, `name`, `lat`, `lon`, `category`, `techProvider`, `country`, `shopName`, and `supports`. 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, country, shop name, and supports. -- **Inputs:** `insertIfNew` and `upsert` take a validated pin. `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` inserts or updates country and shop name from the body (`null` when omitted) and replaces supports only when that field is sent. `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. +- **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` inserts or updates country and shop name from the body (`null` when omitted) and 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`, `country`, and `shop_name` 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. 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. `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`. Present filter fields are AND-ed before the sort and the limit. Deleting a pin deletes its support rows. `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. 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 @@ -100,9 +114,9 @@ ## Function: mapRoutes -- **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. -- **Inputs:** Store, optional ingest token, environment, and fetch. -- **Returns / side effects:** GET `/places` returns `{ places }`. 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 }`. +- **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 @@ -115,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/schema/map-place.sql b/docs/schema/map-place.sql index 9ab57acc5..82f55cf56 100644 --- a/docs/schema/map-place.sql +++ b/docs/schema/map-place.sql @@ -12,6 +12,7 @@ CREATE TABLE IF NOT EXISTS map_place ( 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 ( diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 73156a8fc..da492fd59 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -559,6 +559,55 @@ test('Function: isCatalogAsset — catalog asset names are filter queries', asyn 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/src/__tests__/map/btcmap.test.ts b/src/__tests__/map/btcmap.test.ts index 1fdb893d1..cdb737889 100644 --- a/src/__tests__/map/btcmap.test.ts +++ b/src/__tests__/map/btcmap.test.ts @@ -15,6 +15,7 @@ const place: StoredMapPlace = { country: null, shopName: null, supports: [], + lastTransactionAt: null, createdAt: '2026-09-26T00:00:00.000Z', }; diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index 28fbac008..4d9033f95 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -4,6 +4,8 @@ import { normalizeMapPlaceFilter, normalizeMapPlaceKey, normalizePlaceOrigin, + normalizePlaceTransaction, + placeActivity, toPublicMapPlace, } from '@/lib/map/place'; import type { StoredMapPlace } from '@/lib/map/place'; @@ -534,7 +536,137 @@ 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, @@ -544,8 +676,9 @@ describe('toPublicMapPlace', () => { country: null, shopName: null, supports: [], + lastTransactionAt: null, }; - expect(toPublicMapPlace(stored)).toEqual({ + expect(toPublicMapPlace(stored, nowMs)).toEqual({ id: 'id-1', origin: 'dfx', name: 'SPAR', @@ -556,6 +689,7 @@ describe('toPublicMapPlace', () => { country: null, shopName: null, supports: [], + activity: 'none', }); }); @@ -569,8 +703,9 @@ describe('toPublicMapPlace', () => { country: 'CH', shopName: 'SPAR', supports, + lastTransactionAt: null, }; - const publicPlace = toPublicMapPlace(stored); + const publicPlace = toPublicMapPlace(stored, nowMs); expect(publicPlace).toEqual({ id: 'id-1', origin: 'dfx', @@ -582,6 +717,7 @@ describe('toPublicMapPlace', () => { 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 04f07b308..a076376b5 100644 --- a/src/__tests__/map/routes.test.ts +++ b/src/__tests__/map/routes.test.ts @@ -22,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 }), }); } @@ -294,6 +297,7 @@ describe('map routes', () => { country: null, shopName: null, supports: [], + activity: 'none', }); }); @@ -342,6 +346,7 @@ describe('map routes', () => { country: null, shopName: null, supports: [], + activity: 'none', }); }); @@ -548,4 +553,187 @@ describe('map routes', () => { 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 493f320c9..009a7a4a9 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -406,4 +406,43 @@ describe('MemoryMapPlaceStore', () => { 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/place.ts b/src/lib/map/place.ts index 06e2bf098..d969cede3 100644 --- a/src/lib/map/place.ts +++ b/src/lib/map/place.ts @@ -34,6 +34,7 @@ export type StoredMapPlace = Omit< 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. */ @@ -48,6 +49,7 @@ export type PublicMapPlace = { country: string | null; shopName: string | null; supports: MapPlaceSupport[]; + activity: 'within7Days' | 'within30Days' | 'none'; }; /** Query fields that restrict GET /map/places, all optional. */ @@ -67,6 +69,7 @@ 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}$/; @@ -705,6 +708,80 @@ 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. * @@ -756,9 +833,10 @@ export function normalizeMapPlaceFilter( * 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, @@ -773,5 +851,6 @@ export function toPublicMapPlace(place: StoredMapPlace): PublicMapPlace { 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 9fa5fb836..c00ae65d5 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -4,7 +4,7 @@ import type { MapPlaceInput, MapPlaceSupport, StoredMapPlace } from '@/lib/map/p 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, country, shop_name'; + 'id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider, country, shop_name, last_transaction_at'; type MapPlaceRow = { id: string; @@ -19,6 +19,7 @@ type MapPlaceRow = { tech_provider: string; country: string | null; shop_name: string | null; + last_transaction_at: string | null; }; function compareSupport(a: MapPlaceSupport, b: MapPlaceSupport): number { @@ -66,6 +67,7 @@ function mapRow(row: MapPlaceRow, supports: MapPlaceSupport[]): StoredMapPlace { 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 })), }; } @@ -168,6 +170,9 @@ export class SqliteMapPlaceStore implements MapPlaceStore { 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( @@ -191,8 +196,8 @@ export class SqliteMapPlaceStore implements MapPlaceStore { const result = this.#db .query( `INSERT INTO map_place ( - id, origin, external_id, name, lat, lon, category, payment_methods, created_at, tech_provider, country, shop_name - ) 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( @@ -208,6 +213,7 @@ export class SqliteMapPlaceStore implements MapPlaceStore { techProvider, country, shopName, + null, ); if (result.changes === 1) { insertSupportRows(this.#db, id, supports); @@ -228,6 +234,7 @@ export class SqliteMapPlaceStore implements MapPlaceStore { blockchain: item.blockchain, asset: item.asset, })), + lastTransactionAt: null, id, createdAt, }, @@ -302,6 +309,49 @@ export class SqliteMapPlaceStore implements MapPlaceStore { }; } + /** + * 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. * diff --git a/src/lib/map/store.ts b/src/lib/map/store.ts index 2b2dbac4e..556457d5d 100644 --- a/src/lib/map/store.ts +++ b/src/lib/map/store.ts @@ -22,6 +22,7 @@ export const MAP_PLACE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS map_place ( 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 ( @@ -127,6 +128,7 @@ function toStoredPlace(input: MapPlaceInput, id: string, createdAt: string): Sto country: input.country ?? null, shopName: input.shopName ?? null, supports: storedSupports(input.supports ?? []), + lastTransactionAt: null, id, createdAt, }; @@ -138,6 +140,11 @@ function toStoredPlace(input: MapPlaceInput, id: string, createdAt: string): Sto 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, filter?: MapPlaceListFilter): StoredMapPlace[]; filters(blockchain?: string | undefined): { @@ -209,6 +216,40 @@ export class MemoryMapPlaceStore implements MapPlaceStore { 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); + } + /** * Remove the pin for this origin and external id. * diff --git a/src/routes/map.ts b/src/routes/map.ts index bbf323250..188af178f 100644 --- a/src/routes/map.ts +++ b/src/routes/map.ts @@ -8,6 +8,8 @@ import { normalizeMapPlaceFilter, normalizeMapPlaceKey, normalizePlaceOrigin, + normalizePlaceTransaction, + placeActivity, toPublicMapPlace, } from '@/lib/map/place'; import type { MapPlaceListFilter, MapPlaceStore } from '@/lib/map/store'; @@ -18,6 +20,7 @@ export type MapRouteDeps = { ingestToken?: string; env: Record; fetchImpl: FetchLike; + now?: () => number; }; function checkIngest( @@ -43,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( '*', @@ -91,7 +95,8 @@ export function mapRoutes(deps: MapRouteDeps): Hono { if (origin !== undefined) { filter.origin = origin; } - const places = deps.store.list(limit, filter).map((place) => toPublicMapPlace(place)); + const nowMs = now(); + const places = deps.store.list(limit, filter).map((place) => toPublicMapPlace(place, nowMs)); return c.json({ places }); }); @@ -168,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 266da93e1..55c3018e5 100644 --- a/src/server.ts +++ b/src/server.ts @@ -10,6 +10,8 @@ export type AppDeps = { ingestToken?: string; env: Record; fetchImpl: FetchLike; + /** Request clock. Unset means `Date.now`. */ + now?: () => number; }; /** @@ -17,7 +19,7 @@ export type AppDeps = { * `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; From 56f2615ab347a0b05030940830d8bcdb85cab966 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Sat, 10 Oct 2026 15:46:20 +0200 Subject: [PATCH 5/9] 01a0dc6b - Use payment-link assets and fill a missing country (#12) The map catalog lists the payment-enabled pairs instead of the wider sellable names. Opening the place database sets a missing 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. Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- docs/handbook/functions.md | 6 +- e2e/map.spec.ts | 13 +- e2e/sqlite-backfill.ts | 168 ++++++++++++++++++++++- src/__tests__/map/filter-catalog.test.ts | 13 +- src/__tests__/map/place.test.ts | 16 +-- src/__tests__/map/store.test.ts | 2 +- src/lib/map/filter-catalog.ts | 45 +----- src/lib/map/sqlite.ts | 76 +++++++++- 8 files changed, 274 insertions(+), 65 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 4e50034e4..568ca4fea 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -16,7 +16,7 @@ ## 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, USDC.e, or USDbC. A support body still uses the stricter stored-pair rules. +- **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`. @@ -30,7 +30,7 @@ ## 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, USDC.e, and USDbC. +- **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. @@ -93,7 +93,7 @@ ## Function: SqliteMapPlaceStore -- **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. 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. +- **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. 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. diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index da492fd59..14e4dfea2 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -483,7 +483,8 @@ test('Function: unstatedPaymentSql — a pin without supports uses the payment-l 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=USDC.e')).toContain('Catalog DFX 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'); @@ -522,11 +523,12 @@ test('Function: publicFilterResponse — filters include the payment-link catalo expect(json.blockchains).toContain('BinancePay'); expect(json.blockchains).not.toContain('Sepolia'); expect(json.assets).toContain('ckBTC'); - expect(json.assets).toContain('USDC.e'); - expect(json.assets).toContain('USDbC'); + 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({ @@ -554,8 +556,9 @@ test('Function: isCatalogBlockchain — BinancePay is a filter and Kraken is not 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(200); - expect((await request.get('/map/places?asset=USDbC')).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); }); diff --git a/e2e/sqlite-backfill.ts b/e2e/sqlite-backfill.ts index 88624e8e3..ddff96d9b 100644 --- a/e2e/sqlite-backfill.ts +++ b/e2e/sqlite-backfill.ts @@ -64,28 +64,186 @@ try { 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(10); + 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 && - sparMigros.shopName === 'Migros' + 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 : sparNull.shopName, - 'dfx-null': dfxNull === undefined ? null : dfxNull.shopName, - 'spar-migros': sparMigros === undefined ? null : sparMigros.shopName, + '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; diff --git a/src/__tests__/map/filter-catalog.test.ts b/src/__tests__/map/filter-catalog.test.ts index 40e518850..bb155e9fb 100644 --- a/src/__tests__/map/filter-catalog.test.ts +++ b/src/__tests__/map/filter-catalog.test.ts @@ -21,9 +21,10 @@ describe('payment-link filter catalog', () => { expect(isCatalogAsset('ZCHF')).toBe(true); expect(isCatalogAsset('ckBTC')).toBe(true); - expect(isCatalogAsset('USDC.e')).toBe(true); - expect(isCatalogAsset('USDbC')).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); }); @@ -33,6 +34,9 @@ describe('payment-link filter catalog', () => { 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); @@ -91,7 +95,7 @@ describe('payment-link filter catalog', () => { ); expect(ethereum.assets).toContain('AAAA'); expect(ethereum.assets).toContain('ZCHF'); - expect(ethereum.assets).toContain('ONDO'); + expect(ethereum.assets).not.toContain('ONDO'); expect(ethereum.assets).not.toContain('BTC'); expect(ethereum.blockchains).toContain('Plasma'); @@ -109,7 +113,10 @@ describe('payment-link filter catalog', () => { 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', diff --git a/src/__tests__/map/place.test.ts b/src/__tests__/map/place.test.ts index 4d9033f95..a44a0d46c 100644 --- a/src/__tests__/map/place.test.ts +++ b/src/__tests__/map/place.test.ts @@ -436,14 +436,6 @@ describe('normalizeMapPlaceFilter', () => { ok: true, value: { blockchain: 'BinancePay', asset: 'ckBTC' }, }); - expect(normalizeMapPlaceFilter(undefined, undefined, 'KucoinPay', 'USDC.e')).toEqual({ - ok: true, - value: { blockchain: 'KucoinPay', asset: 'USDC.e' }, - }); - expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'USDbC')).toEqual({ - ok: true, - value: { asset: 'USDbC' }, - }); expect(normalizeMapPlaceFilter(undefined, undefined, undefined, 'ZCHF')).toEqual({ ok: true, value: { asset: 'ZCHF' }, @@ -475,6 +467,14 @@ describe('normalizeMapPlaceFilter', () => { 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', + }); }); }); diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index 009a7a4a9..2bbd562cd 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -212,7 +212,7 @@ describe('MemoryMapPlaceStore', () => { 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' })).toEqual(['stated']); + expect(ids({ blockchain: 'Polygon', asset: 'ZCHF' }).sort()).toEqual(['dfx-open', 'stated']); expect(ids({ shopName: 'SPAR' })).toEqual([]); expect(ids({ shopName: 'others' }).sort()).toEqual([ 'dfx-open', diff --git a/src/lib/map/filter-catalog.ts b/src/lib/map/filter-catalog.ts index a0fe73d2e..20ff08653 100644 --- a/src/lib/map/filter-catalog.ts +++ b/src/lib/map/filter-catalog.ts @@ -1,7 +1,8 @@ /** * Temporary payment-link filter catalog. * Networks are those offered for payment links, without test networks and - * without Plasma. Assets are the sellable names on those networks. + * 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. */ @@ -34,60 +35,32 @@ const DFX_BLOCKCHAINS: readonly string[] = [ ]; const DFX_PAIRS: readonly { blockchain: string; asset: string }[] = [ - { blockchain: 'Arbitrum', asset: 'AAVE' }, - { blockchain: 'Arbitrum', asset: 'ARB' }, { blockchain: 'Arbitrum', asset: 'ETH' }, - { blockchain: 'Arbitrum', asset: 'GMX' }, - { blockchain: 'Arbitrum', asset: 'LINK' }, - { blockchain: 'Arbitrum', asset: 'UNI' }, { blockchain: 'Arbitrum', asset: 'USDC' }, - { blockchain: 'Arbitrum', asset: 'USDC.e' }, { blockchain: 'Arbitrum', asset: 'USDT' }, { blockchain: 'Arbitrum', asset: 'WBTC' }, { blockchain: 'Arbitrum', asset: 'dEURO' }, - { blockchain: 'Base', asset: 'DEPS' }, { blockchain: 'Base', asset: 'ETH' }, { blockchain: 'Base', asset: 'USDC' }, - { blockchain: 'Base', asset: 'USDbC' }, { blockchain: 'Base', asset: 'dEURO' }, - { blockchain: 'BinanceSmartChain', asset: 'AAVE' }, + { blockchain: 'BinancePay', asset: 'USDT' }, { blockchain: 'BinanceSmartChain', asset: 'BNB' }, - { blockchain: 'BinanceSmartChain', asset: 'BTCB' }, - { blockchain: 'BinanceSmartChain', asset: 'ETH' }, { blockchain: 'BinanceSmartChain', asset: 'USDC' }, { blockchain: 'BinanceSmartChain', asset: 'USDT' }, { blockchain: 'Bitcoin', asset: 'BTC' }, { blockchain: 'Cardano', asset: 'ADA' }, - { blockchain: 'Ethereum', asset: 'AAVE' }, - { blockchain: 'Ethereum', asset: 'APE' }, - { blockchain: 'Ethereum', asset: 'ARB' }, - { blockchain: 'Ethereum', asset: 'BBTC' }, - { blockchain: 'Ethereum', asset: 'CRV' }, - { blockchain: 'Ethereum', asset: 'DEPS' }, - { blockchain: 'Ethereum', asset: 'EDLC' }, { blockchain: 'Ethereum', asset: 'ETH' }, - { blockchain: 'Ethereum', asset: 'EURC' }, - { blockchain: 'Ethereum', asset: 'FPS' }, - { blockchain: 'Ethereum', asset: 'LINK' }, - { blockchain: 'Ethereum', asset: 'ONDO' }, - { blockchain: 'Ethereum', asset: 'POL' }, - { blockchain: 'Ethereum', asset: 'QNT' }, - { blockchain: 'Ethereum', asset: 'REALU' }, - { blockchain: 'Ethereum', asset: 'SNX' }, { blockchain: 'Ethereum', asset: 'USDC' }, { blockchain: 'Ethereum', asset: 'USDT' }, { blockchain: 'Ethereum', asset: 'WBTC' }, - { blockchain: 'Ethereum', asset: 'WFPS' }, { blockchain: 'Ethereum', asset: 'ZCHF' }, { blockchain: 'Ethereum', asset: 'dEURO' }, - { blockchain: 'Ethereum', asset: 'kBTC' }, - { blockchain: 'Ethereum', asset: 'nDEPS' }, { blockchain: 'Firo', asset: 'FIRO' }, - { blockchain: 'Gnosis', asset: 'xDAI' }, { 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' }, @@ -95,17 +68,11 @@ const DFX_PAIRS: readonly { blockchain: string; asset: string }[] = [ { blockchain: 'Optimism', asset: 'USDT' }, { blockchain: 'Optimism', asset: 'WBTC' }, { blockchain: 'Optimism', asset: 'dEURO' }, - { blockchain: 'Polygon', asset: 'DEPS' }, - { blockchain: 'Polygon', asset: 'DGC' }, - { blockchain: 'Polygon', asset: 'DSC' }, - { blockchain: 'Polygon', asset: 'EURS' }, { blockchain: 'Polygon', asset: 'POL' }, - { blockchain: 'Polygon', asset: 'SAND' }, { blockchain: 'Polygon', asset: 'USDC' }, - { blockchain: 'Polygon', asset: 'USDC.e' }, { blockchain: 'Polygon', asset: 'USDT' }, { blockchain: 'Polygon', asset: 'WBTC' }, - { blockchain: 'Polygon', asset: 'WFPS' }, + { blockchain: 'Polygon', asset: 'ZCHF' }, { blockchain: 'Polygon', asset: 'dEURO' }, { blockchain: 'Solana', asset: 'SOL' }, { blockchain: 'Solana', asset: 'USDC' }, @@ -213,7 +180,7 @@ export function isCatalogBlockchain(value: string): boolean { * Whether a query asset is in the hardcoded payment-link catalog. * * @param value - Trimmed asset query. - * @returns True for a catalog asset, including ckBTC, USDC.e, and USDbC. + * @returns True for a catalog asset, including ckBTC and dEURO. */ export function isCatalogAsset(value: string): boolean { return ASSET_SET.has(value); diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index c00ae65d5..22c97ec18 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -122,6 +122,78 @@ function mapOne(db: Database, row: MapPlaceRow): StoredMapPlace { 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 (?, ?, ?)', @@ -139,7 +211,8 @@ function replaceSupportRows(db: Database, placeId: string, supports: MapPlaceSup /** * SQLite {@link MapPlaceStore}. Opened by the process entrypoint. * Insert, list, and delete are exercised by the HTTP end-to-end run. - * The SPAR shop-name backfill is exercised by e2e/sqlite-backfill.ts. + * 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; @@ -178,6 +251,7 @@ export class SqliteMapPlaceStore implements MapPlaceStore { this.#db.exec( "UPDATE map_place SET shop_name = 'SPAR' WHERE origin = 'spar' AND shop_name IS NULL", ); + backfillMissingCountry(this.#db); } /** From e757f1c3afd9f160df025fb095954552dab43a2a Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Mon, 12 Oct 2026 10:47:22 +0200 Subject: [PATCH 6/9] Document where place maps are shown (#14) Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- README.md | 4 +++ docs/product/README.md | 8 ++++++ docs/product/maps.md | 59 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 71 insertions(+) create mode 100644 docs/product/README.md create mode 100644 docs/product/maps.md diff --git a/README.md b/README.md index cc672e822..cfb5ec5ed 100644 --- a/README.md +++ b/README.md @@ -25,3 +25,7 @@ Pull requests target `develop`. `main` moves only through the release pull reque ## 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/product/README.md b/docs/product/README.md new file mode 100644 index 000000000..3989394d7 --- /dev/null +++ b/docs/product/README.md @@ -0,0 +1,8 @@ +# 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 | diff --git a/docs/product/maps.md b/docs/product/maps.md new file mode 100644 index 000000000..667dd7678 --- /dev/null +++ b/docs/product/maps.md @@ -0,0 +1,59 @@ +# 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. + +## 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. From 556df31ccd1a4d3256789cb32e5840375af4d824 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Mon, 12 Oct 2026 11:09:45 +0200 Subject: [PATCH 7/9] 01a12757 - Publish the Docker image from this repository (#13) * Publish the image from this repository develop pushes dfxswiss/opencryptopay-api:beta. main pushes :latest. The workflows only push the image. * Format the publish workflows and contributing notes Prettier rejected the quote style and the table alignment. * Let the image publish write its cache and match the docs The build cache needs actions write. Markdown-only pushes do not publish, and a manual run publishes only on its own branch. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- .github/workflows/publish-dev.yaml | 59 ++++++++++++++++++++++++++++++ .github/workflows/publish-prd.yaml | 59 ++++++++++++++++++++++++++++++ CONTRIBUTING.md | 31 +++++++++++----- Dockerfile | 4 +- README.md | 2 +- 5 files changed, 142 insertions(+), 13 deletions(-) create mode 100644 .github/workflows/publish-dev.yaml create mode 100644 .github/workflows/publish-prd.yaml 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 cfb5ec5ed..44202c8ee 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ 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 From 832cfc759342b1a18038d51797c4657384419cb2 Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Tue, 13 Oct 2026 14:27:21 +0200 Subject: [PATCH 8/9] 01a117b8 - Compare the place-map implementations (#15) * Document how the place-map implementations differ Record what the five known maps share and where the street map, the RealUnit outline, and the 21.gifts Google map diverge. * Correct the shared map claims The five maps do not share one camera frame, and the shop name sits in a popup or under the map, not in a list beside it. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- docs/product/README.md | 7 +- docs/product/map-comparison.md | 206 +++++++++++++++++++++++++++++++++ docs/product/maps.md | 3 +- 3 files changed, 212 insertions(+), 4 deletions(-) create mode 100644 docs/product/map-comparison.md diff --git a/docs/product/README.md b/docs/product/README.md index 3989394d7..6eb93da30 100644 --- a/docs/product/README.md +++ b/docs/product/README.md @@ -3,6 +3,7 @@ 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 | +| 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 index 667dd7678..6dc61ff94 100644 --- a/docs/product/maps.md +++ b/docs/product/maps.md @@ -5,7 +5,8 @@ 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. +are different maps and are named as such. How the implementations compare is +[map-comparison.md](map-comparison.md). ## Street map From 55c4afd5cf7533b1d4ebccf5405015d17b91c98c Mon Sep 17 00:00:00 2001 From: TaprootFreakAI Date: Tue, 13 Oct 2026 21:17:15 +0200 Subject: [PATCH 9/9] 01a117b8 - Keep a stored place country when an update omits it (#16) * Keep a stored place country when an update omits it A shop push that does not send a country was storing null and undoing the startup backfill, so those places never joined the country filter. * Prove an omitted country on the SQLite upsert The HTTP end-to-end run stores a country, updates the pin without sending one, and checks that the stored country remains. A later update that sends a country still replaces it. --------- Co-authored-by: TaprootFreakAI <315477232+TaprootFreakAI@users.noreply.github.com> --- docs/handbook/functions.md | 4 +- e2e/map.spec.ts | 90 +++++++++++++++++++++++++++++++++ src/__tests__/map/store.test.ts | 4 +- src/lib/map/sqlite.ts | 5 +- src/lib/map/store.ts | 4 +- 5 files changed, 100 insertions(+), 7 deletions(-) diff --git a/docs/handbook/functions.md b/docs/handbook/functions.md index 568ca4fea..bcfa50cea 100644 --- a/docs/handbook/functions.md +++ b/docs/handbook/functions.md @@ -88,12 +88,12 @@ - **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` inserts or updates country and shop name from the body (`null` when omitted) and 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. +- **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`, `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. 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. +- **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. diff --git a/e2e/map.spec.ts b/e2e/map.spec.ts index 14e4dfea2..070696568 100644 --- a/e2e/map.spec.ts +++ b/e2e/map.spec.ts @@ -293,6 +293,96 @@ test('Function: normalizeMapPlaceKey — PUT updates a pin and DELETE removes it 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, }) => { diff --git a/src/__tests__/map/store.test.ts b/src/__tests__/map/store.test.ts index 2bbd562cd..bfcd150f6 100644 --- a/src/__tests__/map/store.test.ts +++ b/src/__tests__/map/store.test.ts @@ -293,7 +293,7 @@ describe('MemoryMapPlaceStore', () => { expect(again.place.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); }); - it('sets omitted country and shop name to null on upsert and leaves supports', () => { + 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, @@ -303,7 +303,7 @@ describe('MemoryMapPlaceStore', () => { }); const omitted = store.upsert({ ...input, name: 'Other' }); expect(omitted.place.name).toBe('Other'); - expect(omitted.place.country).toBeNull(); + expect(omitted.place.country).toBe('CH'); expect(omitted.place.shopName).toBeNull(); expect(omitted.place.supports).toEqual([{ blockchain: 'Ethereum', asset: 'ZCHF' }]); }); diff --git a/src/lib/map/sqlite.ts b/src/lib/map/sqlite.ts index 22c97ec18..344ac3108 100644 --- a/src/lib/map/sqlite.ts +++ b/src/lib/map/sqlite.ts @@ -210,7 +210,8 @@ function replaceSupportRows(db: Database, placeId: string, supports: MapPlaceSup /** * SQLite {@link MapPlaceStore}. Opened by the process entrypoint. - * Insert, list, and delete are 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. */ @@ -344,7 +345,7 @@ export class SqliteMapPlaceStore implements MapPlaceStore { } const techProvider = input.techProvider !== undefined ? input.techProvider : existing.tech_provider; - const country = input.country ?? null; + const country = input.country !== undefined ? input.country : existing.country; const shopName = input.shopName ?? null; this.#db .query( diff --git a/src/lib/map/store.ts b/src/lib/map/store.ts index 556457d5d..6580ff310 100644 --- a/src/lib/map/store.ts +++ b/src/lib/map/store.ts @@ -205,7 +205,9 @@ export class MemoryMapPlaceStore implements MapPlaceStore { existing.lon = input.lon; existing.category = input.category; existing.paymentMethods = input.paymentMethods; - existing.country = input.country ?? null; + if (input.country !== undefined) { + existing.country = input.country; + } existing.shopName = input.shopName ?? null; if (input.techProvider !== undefined) { existing.techProvider = input.techProvider;